From d567dfe40bb9557f673f7adb49fc2a411d48a660 Mon Sep 17 00:00:00 2001 From: sanekb Date: Sat, 25 Apr 2026 14:36:43 +0300 Subject: [PATCH 1/7] fix: limit only new ip when TimeWindow mode enabled --- src/ip_tracker.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ip_tracker.rs b/src/ip_tracker.rs index b4d934f..a5219fa 100644 --- a/src/ip_tracker.rs +++ b/src/ip_tracker.rs @@ -269,9 +269,11 @@ impl UserIpTracker { return Ok(()); } + let is_new_ip = !user_recent.contains_key(&ip); + if let Some(limit) = limit { let active_limit_reached = user_active.len() >= limit; - let recent_limit_reached = user_recent.len() >= limit; + let recent_limit_reached = user_recent.len() >= limit && is_new_ip; let deny = match mode { UserMaxUniqueIpsMode::ActiveWindow => active_limit_reached, UserMaxUniqueIpsMode::TimeWindow => recent_limit_reached, @@ -851,4 +853,19 @@ mod tests { .unwrap_or(false); assert!(!stale_exists); } + + #[tokio::test] + async fn test_time_window_allows_same_ip_reconnect() { + let tracker = UserIpTracker::new(); + tracker.set_user_limit("test_user", 1).await; + tracker + .set_limit_policy(UserMaxUniqueIpsMode::TimeWindow, 1) + .await; + + let ip1 = test_ipv4(10, 4, 0, 1); + + assert!(tracker.check_and_add("test_user", ip1).await.is_ok()); + tracker.remove_ip("test_user", ip1).await; + assert!(tracker.check_and_add("test_user", ip1).await.is_ok()); + } } From 8ef5263fce85f11771880cccec820da2a7641acd Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:30:27 +0300 Subject: [PATCH 2/7] Fix WorkingDirectory behavior Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> Co-Authored-By: mikhailnov Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> --- src/config/defaults.rs | 4 +- src/maestro/helpers.rs | 127 ++++++++++++++++++++++++++++++++++++++++- src/maestro/mod.rs | 66 +++++++++++++++++---- 3 files changed, 180 insertions(+), 17 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 64fa2ac..da9e472 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -102,7 +102,7 @@ pub(crate) fn default_fake_cert_len() -> usize { } pub(crate) fn default_tls_front_dir() -> String { - "/etc/telemt/tlsfront".to_string() + "tlsfront".to_string() } pub(crate) fn default_replay_check_len() -> usize { @@ -568,7 +568,7 @@ pub(crate) fn default_beobachten_flush_secs() -> u64 { } pub(crate) fn default_beobachten_file() -> String { - "/etc/telemt/beobachten.txt".to_string() + "beobachten.txt".to_string() } pub(crate) fn default_tls_new_session_tickets() -> u8 { diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index b888fb4..01b0d46 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -1,6 +1,6 @@ #![allow(clippy::items_after_test_module)] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::sync::watch; @@ -17,7 +17,7 @@ use crate::transport::middle_proxy::{ pub(crate) fn resolve_runtime_config_path( config_path_cli: &str, - startup_cwd: &std::path::Path, + startup_cwd: &Path, config_path_explicit: bool, ) -> PathBuf { if config_path_explicit { @@ -46,6 +46,39 @@ pub(crate) fn resolve_runtime_config_path( startup_cwd.join("config.toml") } +pub(crate) fn resolve_runtime_base_dir( + config_path: &Path, + startup_cwd: &Path, + config_path_explicit: bool, + data_path: Option<&Path>, +) -> PathBuf { + if let Some(path) = data_path { + return normalize_runtime_dir(path, startup_cwd); + } + + if startup_cwd != Path::new("/") { + return normalize_runtime_dir(startup_cwd, startup_cwd); + } + + if config_path_explicit + && let Some(parent) = config_path.parent() + && !parent.as_os_str().is_empty() + { + return normalize_runtime_dir(parent, startup_cwd); + } + + PathBuf::from("/etc/telemt") +} + +fn normalize_runtime_dir(path: &Path, startup_cwd: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + startup_cwd.join(path) + }; + absolute.canonicalize().unwrap_or(absolute) +} + /// Parsed CLI arguments. pub(crate) struct CliArgs { pub config_path: String, @@ -231,9 +264,11 @@ fn print_help() { #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; + use super::{ expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, - resolve_runtime_config_path, + resolve_runtime_base_dir, resolve_runtime_config_path, }; use crate::error::{ProxyError, StreamError}; @@ -304,6 +339,92 @@ mod tests { let _ = std::fs::remove_dir(&startup_cwd); } + #[test] + fn resolve_runtime_base_dir_prefers_cli_data_path() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_cwd_{nonce}")); + let data_path = std::env::temp_dir().join(format!("telemt_runtime_base_data_{nonce}")); + std::fs::create_dir_all(&startup_cwd).unwrap(); + std::fs::create_dir_all(&data_path).unwrap(); + + let resolved = resolve_runtime_base_dir( + &startup_cwd.join("config.toml"), + &startup_cwd, + true, + Some(&data_path), + ); + assert_eq!(resolved, data_path.canonicalize().unwrap()); + + let _ = std::fs::remove_dir(&data_path); + let _ = std::fs::remove_dir(&startup_cwd); + } + + #[test] + fn resolve_runtime_base_dir_uses_working_directory_before_explicit_config_parent() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_start_{nonce}")); + let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_cfg_{nonce}")); + std::fs::create_dir_all(&startup_cwd).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + + let resolved = + resolve_runtime_base_dir(&config_dir.join("telemt.toml"), &startup_cwd, true, None); + assert_eq!(resolved, startup_cwd.canonicalize().unwrap()); + + let _ = std::fs::remove_dir(&config_dir); + let _ = std::fs::remove_dir(&startup_cwd); + } + + #[test] + fn resolve_runtime_base_dir_uses_explicit_config_parent_from_root() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_root_cfg_{nonce}")); + std::fs::create_dir_all(&config_dir).unwrap(); + + let resolved = + resolve_runtime_base_dir(&config_dir.join("telemt.toml"), Path::new("/"), true, None); + assert_eq!(resolved, config_dir.canonicalize().unwrap()); + + let _ = std::fs::remove_dir(&config_dir); + } + + #[test] + fn resolve_runtime_base_dir_uses_systemd_working_directory_before_etc() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let startup_cwd = + std::env::temp_dir().join(format!("telemt_runtime_base_systemd_{nonce}")); + std::fs::create_dir_all(&startup_cwd).unwrap(); + + let resolved = + resolve_runtime_base_dir(&startup_cwd.join("config.toml"), &startup_cwd, false, None); + assert_eq!(resolved, startup_cwd.canonicalize().unwrap()); + + let _ = std::fs::remove_dir(&startup_cwd); + } + + #[test] + fn resolve_runtime_base_dir_falls_back_to_etc_from_root() { + let resolved = resolve_runtime_base_dir( + Path::new("/etc/telemt/config.toml"), + Path::new("/"), + false, + None, + ); + assert_eq!(resolved, PathBuf::from("/etc/telemt")); + } + #[test] fn expected_handshake_eof_matches_connection_reset() { let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 4c5b98a..3cce3dc 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -47,7 +47,7 @@ use crate::stats::{ReplayChecker, Stats}; use crate::stream::BufferPool; use crate::transport::UpstreamManager; use crate::transport::middle_proxy::MePool; -use helpers::{parse_cli, resolve_runtime_config_path}; +use helpers::{parse_cli, resolve_runtime_base_dir, resolve_runtime_config_path}; #[cfg(unix)] use crate::daemon::{DaemonOptions, PidFile, drop_privileges}; @@ -112,8 +112,51 @@ async fn run_telemt_core( std::process::exit(1); } }; + if let Some(ref data_path) = data_path + && !data_path.is_absolute() + { + eprintln!( + "[telemt] data_path must be absolute: {}", + data_path.display() + ); + std::process::exit(1); + } let mut config_path = resolve_runtime_config_path(&config_path_cli, &startup_cwd, config_path_explicit); + let runtime_base_dir = resolve_runtime_base_dir( + &config_path, + &startup_cwd, + config_path_explicit, + data_path.as_deref(), + ); + + if !runtime_base_dir.exists() + && let Err(e) = std::fs::create_dir_all(&runtime_base_dir) + { + eprintln!( + "[telemt] Can't create runtime directory {}: {}", + runtime_base_dir.display(), + e + ); + std::process::exit(1); + } + + if !runtime_base_dir.is_dir() { + eprintln!( + "[telemt] Runtime path exists but is not a directory: {}", + runtime_base_dir.display() + ); + std::process::exit(1); + } + + if let Err(e) = std::env::set_current_dir(&runtime_base_dir) { + eprintln!( + "[telemt] Can't use runtime directory {}: {}", + runtime_base_dir.display(), + e + ); + std::process::exit(1); + } let mut config = match ProxyConfig::load(&config_path) { Ok(c) => c, @@ -156,16 +199,15 @@ async fn run_telemt_core( ); } } else { - let system_dir = std::path::Path::new("/etc/telemt"); - let system_config_path = system_dir.join("telemt.toml"); - let startup_config_path = startup_cwd.join("config.toml"); + let runtime_config_path = runtime_base_dir.join("telemt.toml"); + let fallback_config_path = runtime_base_dir.join("config.toml"); let mut persisted = false; if let Some(serialized) = serialized.as_ref() { - match std::fs::create_dir_all(system_dir) { - Ok(()) => match std::fs::write(&system_config_path, serialized) { + match std::fs::create_dir_all(&runtime_base_dir) { + Ok(()) => match std::fs::write(&runtime_config_path, serialized) { Ok(()) => { - config_path = system_config_path; + config_path = runtime_config_path; eprintln!( "[telemt] Created default config at {}", config_path.display() @@ -175,7 +217,7 @@ async fn run_telemt_core( Err(write_error) => { eprintln!( "[telemt] Warning: failed to write default config at {}: {}", - system_config_path.display(), + runtime_config_path.display(), write_error ); } @@ -183,16 +225,16 @@ async fn run_telemt_core( Err(create_error) => { eprintln!( "[telemt] Warning: failed to create {}: {}", - system_dir.display(), + runtime_base_dir.display(), create_error ); } } if !persisted { - match std::fs::write(&startup_config_path, serialized) { + match std::fs::write(&fallback_config_path, serialized) { Ok(()) => { - config_path = startup_config_path; + config_path = fallback_config_path; eprintln!( "[telemt] Created default config at {}", config_path.display() @@ -202,7 +244,7 @@ async fn run_telemt_core( Err(write_error) => { eprintln!( "[telemt] Warning: failed to write default config at {}: {}", - startup_config_path.display(), + fallback_config_path.display(), write_error ); } From 236bbb4970d6112e86889cfed51aa86989b22364 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:00:13 +0300 Subject: [PATCH 3/7] Atomically updates with Includes Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> --- src/api/config_store.rs | 18 ++++++++++++++++++ src/api/users.rs | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/api/config_store.rs b/src/api/config_store.rs index f0da554..63b062e 100644 --- a/src/api/config_store.rs +++ b/src/api/config_store.rs @@ -82,6 +82,7 @@ pub(super) async fn load_config_from_disk(config_path: &Path) -> Result Result bool { + match section { + AccessSection::Users => cfg.access.users.is_empty(), + AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(), + AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(), + AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(), + AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(), + AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(), + } +} + fn serialize_table_body(value: &T) -> Result { toml::to_string(value) .map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e))) diff --git a/src/api/users.rs b/src/api/users.rs index ef8f10a..df89273 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -8,8 +8,8 @@ use crate::stats::Stats; use super::ApiShared; use super::config_store::{ - AccessSection, ensure_expected_revision, load_config_from_disk, save_access_sections_to_disk, - save_config_to_disk, + AccessSection, current_revision, ensure_expected_revision, load_config_from_disk, + save_access_sections_to_disk, }; use super::model::{ ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest, @@ -176,6 +176,13 @@ pub(super) async fn patch_user( expected_revision: Option, shared: &ApiShared, ) -> Result<(UserInfo, String), ApiFailure> { + let touches_users = body.secret.is_some(); + let touches_user_ad_tags = !matches!(&body.user_ad_tag, Patch::Unchanged); + let touches_user_max_tcp_conns = !matches!(&body.max_tcp_conns, Patch::Unchanged); + let touches_user_expirations = !matches!(&body.expiration_rfc3339, Patch::Unchanged); + let touches_user_data_quota = !matches!(&body.data_quota_bytes, Patch::Unchanged); + let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged); + if let Some(secret) = body.secret.as_ref() && !is_valid_user_secret(secret) { @@ -265,7 +272,31 @@ pub(super) async fn patch_user( cfg.validate() .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; - let revision = save_config_to_disk(&shared.config_path, &cfg).await?; + let mut touched_sections = Vec::new(); + if touches_users { + touched_sections.push(AccessSection::Users); + } + if touches_user_ad_tags { + touched_sections.push(AccessSection::UserAdTags); + } + if touches_user_max_tcp_conns { + touched_sections.push(AccessSection::UserMaxTcpConns); + } + if touches_user_expirations { + touched_sections.push(AccessSection::UserExpirations); + } + if touches_user_data_quota { + touched_sections.push(AccessSection::UserDataQuota); + } + if touches_user_max_unique_ips { + touched_sections.push(AccessSection::UserMaxUniqueIps); + } + + let revision = if touched_sections.is_empty() { + current_revision(&shared.config_path).await? + } else { + save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await? + }; drop(_guard); match max_unique_ips_change { Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await, From f0e1a6cf1c491b55b53061e89027d1f29385e216 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:34:47 +0300 Subject: [PATCH 4/7] Expose tls_domains links as domain-link pairs Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> --- src/api/model.rs | 7 ++++ src/api/users.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/api/model.rs b/src/api/model.rs index 1ca9f33..1604491 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -456,6 +456,13 @@ pub(super) struct UserLinks { pub(super) classic: Vec, pub(super) secure: Vec, pub(super) tls: Vec, + pub(super) tls_domains: Vec, +} + +#[derive(Serialize)] +pub(super) struct TlsDomainLink { + pub(super) domain: String, + pub(super) link: String, } #[derive(Serialize)] diff --git a/src/api/users.rs b/src/api/users.rs index df89273..cb0ffb0 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -13,7 +13,7 @@ use super::config_store::{ }; use super::model::{ ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest, - UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username, + TlsDomainLink, UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username, parse_optional_expiration, parse_patch_expiration, random_user_secret, }; use super::patch::Patch; @@ -469,6 +469,7 @@ pub(super) async fn users_from_config( classic: Vec::new(), secure: Vec::new(), tls: Vec::new(), + tls_domains: Vec::new(), }); users.push(UserInfo { in_runtime: runtime_cfg @@ -523,10 +524,12 @@ fn build_user_links( .public_port .unwrap_or(resolve_default_link_port(cfg)); let tls_domains = resolve_tls_domains(cfg); + let extra_tls_domains = resolve_extra_tls_domains(cfg); let mut classic = Vec::new(); let mut secure = Vec::new(); let mut tls = Vec::new(); + let mut tls_domain_links = Vec::new(); for host in &hosts { if cfg.general.modes.classic { @@ -549,6 +552,17 @@ fn build_user_links( host, port, secret, domain_hex )); } + for domain in &extra_tls_domains { + let domain_hex = hex::encode(domain); + let link = format!( + "tg://proxy?server={}&port={}&secret=ee{}{}", + host, port, secret, domain_hex + ); + tls_domain_links.push(TlsDomainLink { + domain: (*domain).to_string(), + link, + }); + } } } @@ -556,6 +570,7 @@ fn build_user_links( classic, secure, tls, + tls_domains: tls_domain_links, } } @@ -672,6 +687,19 @@ fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> { domains } +fn resolve_extra_tls_domains(cfg: &ProxyConfig) -> Vec<&str> { + let mut domains = Vec::with_capacity(cfg.censorship.tls_domains.len()); + let primary = cfg.censorship.tls_domain.as_str(); + for domain in &cfg.censorship.tls_domains { + let value = domain.as_str(); + if value.is_empty() || value == primary || domains.contains(&value) { + continue; + } + domains.push(value); + } + domains +} + #[cfg(test)] mod tests { use super::*; @@ -761,4 +789,80 @@ mod tests { assert!(alice.in_runtime); assert!(!bob.in_runtime); } + + #[tokio::test] + async fn users_from_config_returns_tls_link_for_each_tls_domain() { + let mut cfg = ProxyConfig::default(); + cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.general.modes.classic = false; + cfg.general.modes.secure = false; + cfg.general.modes.tls = true; + cfg.general.links.public_host = Some("proxy.example.net".to_string()); + cfg.general.links.public_port = Some(443); + cfg.censorship.tls_domain = "front-a.example.com".to_string(); + cfg.censorship.tls_domains = vec![ + "front-b.example.com".to_string(), + "front-c.example.com".to_string(), + "front-b.example.com".to_string(), + "front-a.example.com".to_string(), + ]; + + let stats = Stats::new(); + let tracker = UserIpTracker::new(); + let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; + let alice = users + .iter() + .find(|entry| entry.username == "alice") + .expect("alice must be present"); + + assert_eq!(alice.links.tls.len(), 3); + assert!( + alice + .links + .tls + .iter() + .any(|link| link.ends_with(&hex::encode("front-a.example.com"))) + ); + assert!( + alice + .links + .tls + .iter() + .any(|link| link.ends_with(&hex::encode("front-b.example.com"))) + ); + assert!( + alice + .links + .tls + .iter() + .any(|link| link.ends_with(&hex::encode("front-c.example.com"))) + ); + assert_eq!(alice.links.tls_domains.len(), 2); + assert!( + alice + .links + .tls_domains + .iter() + .any(|entry| entry.domain == "front-b.example.com" + && entry.link.ends_with(&hex::encode("front-b.example.com"))) + ); + assert!( + alice + .links + .tls_domains + .iter() + .any(|entry| entry.domain == "front-c.example.com" + && entry.link.ends_with(&hex::encode("front-c.example.com"))) + ); + assert!( + !alice + .links + .tls_domains + .iter() + .any(|entry| entry.domain == "front-a.example.com") + ); + } } From 065786b839910e16b9abbed6c73dc1ff132f02d9 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:47:42 +0300 Subject: [PATCH 5/7] TLS Fetcher on multiple tls_domains by #750 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> --- src/maestro/tls_bootstrap.rs | 37 +++++++++++++- src/tls_front/cache.rs | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/maestro/tls_bootstrap.rs b/src/maestro/tls_bootstrap.rs index 7cf3039..4412723 100644 --- a/src/maestro/tls_bootstrap.rs +++ b/src/maestro/tls_bootstrap.rs @@ -10,6 +10,14 @@ use crate::tls_front::TlsFrontCache; use crate::tls_front::fetcher::TlsFetchStrategy; use crate::transport::UpstreamManager; +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() + } +} + pub(crate) async fn bootstrap_tls_front( config: &ProxyConfig, tls_domains: &[String], @@ -56,6 +64,7 @@ pub(crate) async fn bootstrap_tls_front( let cache_initial = cache.clone(); let domains_initial = tls_domains.to_vec(); let host_initial = mask_host.clone(); + let primary_initial = config.censorship.tls_domain.clone(); let unix_sock_initial = mask_unix_sock.clone(); let scope_initial = tls_fetch_scope.clone(); let upstream_initial = upstream_manager.clone(); @@ -64,7 +73,8 @@ pub(crate) async fn bootstrap_tls_front( let mut join = tokio::task::JoinSet::new(); for domain in domains_initial { let cache_domain = cache_initial.clone(); - let host_domain = host_initial.clone(); + let host_domain = + tls_fetch_host_for_domain(&host_initial, &primary_initial, &domain); let unix_sock_domain = unix_sock_initial.clone(); let scope_domain = scope_initial.clone(); let upstream_domain = upstream_initial.clone(); @@ -117,6 +127,7 @@ pub(crate) async fn bootstrap_tls_front( let cache_refresh = cache.clone(); let domains_refresh = tls_domains.to_vec(); let host_refresh = mask_host.clone(); + let primary_refresh = config.censorship.tls_domain.clone(); let unix_sock_refresh = mask_unix_sock.clone(); let scope_refresh = tls_fetch_scope.clone(); let upstream_refresh = upstream_manager.clone(); @@ -130,7 +141,8 @@ pub(crate) async fn bootstrap_tls_front( let mut join = tokio::task::JoinSet::new(); for domain in domains_refresh.clone() { let cache_domain = cache_refresh.clone(); - let host_domain = host_refresh.clone(); + let host_domain = + tls_fetch_host_for_domain(&host_refresh, &primary_refresh, &domain); let unix_sock_domain = unix_sock_refresh.clone(); let scope_domain = scope_refresh.clone(); let upstream_domain = upstream_refresh.clone(); @@ -186,3 +198,24 @@ pub(crate) async fn bootstrap_tls_front( tls_cache } + +#[cfg(test)] +mod tests { + use super::tls_fetch_host_for_domain; + + #[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" + ); + } +} diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index af8addf..4f71f5a 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -130,6 +130,14 @@ impl TlsFrontCache { warn!(file = %name, "Skipping TLS cache entry with invalid domain"); continue; } + if !cert_info_matches_domain(&cached) { + warn!( + file = %name, + domain = %cached.domain, + "Skipping TLS cache entry with mismatched certificate metadata" + ); + continue; + } // fetched_at is skipped during deserialization; approximate with file mtime if available. if let Ok(meta) = entry.metadata().await && let Ok(modified) = meta.modified() @@ -209,10 +217,100 @@ impl TlsFrontCache { } } +fn cert_info_matches_domain(cached: &CachedTlsData) -> bool { + let Some(cert_info) = cached.cert_info.as_ref() else { + return true; + }; + if !cert_info.san_names.is_empty() { + return cert_info + .san_names + .iter() + .any(|name| dns_name_matches_domain(name, &cached.domain)); + } + cert_info + .subject_cn + .as_deref() + .map_or(true, |name| dns_name_matches_domain(name, &cached.domain)) +} + +fn dns_name_matches_domain(pattern: &str, domain: &str) -> bool { + let pattern = normalize_dns_name(pattern); + let domain = normalize_dns_name(domain); + if pattern == domain { + return true; + } + + let Some(suffix) = pattern.strip_prefix("*.") else { + return false; + }; + let Some(prefix) = domain.strip_suffix(suffix) else { + return false; + }; + prefix.ends_with('.') && !prefix[..prefix.len() - 1].contains('.') +} + +fn normalize_dns_name(value: &str) -> String { + value.trim().trim_end_matches('.').to_ascii_lowercase() +} + #[cfg(test)] mod tests { use super::*; + fn cached_with_cert_info( + domain: &str, + subject_cn: Option<&str>, + san_names: Vec<&str>, + ) -> CachedTlsData { + CachedTlsData { + server_hello_template: ParsedServerHello { + version: [0x03, 0x03], + random: [0u8; 32], + session_id: Vec::new(), + cipher_suite: [0x13, 0x01], + compression: 0, + extensions: Vec::new(), + }, + cert_info: Some(crate::tls_front::types::ParsedCertificateInfo { + not_after_unix: None, + not_before_unix: None, + issuer_cn: None, + subject_cn: subject_cn.map(str::to_string), + san_names: san_names.into_iter().map(str::to_string).collect(), + }), + cert_payload: None, + app_data_records_sizes: vec![1024], + total_app_data_len: 1024, + behavior_profile: TlsBehaviorProfile::default(), + fetched_at: SystemTime::now(), + domain: domain.to_string(), + } + } + + #[test] + fn cert_info_domain_match_accepts_exact_san() { + let cached = cached_with_cert_info("b.com", Some("a.com"), vec!["b.com"]); + assert!(cert_info_matches_domain(&cached)); + } + + #[test] + fn cert_info_domain_match_rejects_wrong_san() { + let cached = cached_with_cert_info("b.com", Some("b.com"), vec!["a.com"]); + assert!(!cert_info_matches_domain(&cached)); + } + + #[test] + fn cert_info_domain_match_accepts_single_label_wildcard_san() { + let cached = cached_with_cert_info("api.b.com", None, vec!["*.b.com"]); + assert!(cert_info_matches_domain(&cached)); + } + + #[test] + fn cert_info_domain_match_rejects_multi_label_wildcard_san() { + let cached = cached_with_cert_info("deep.api.b.com", None, vec!["*.b.com"]); + assert!(!cert_info_matches_domain(&cached)); + } + #[tokio::test] async fn test_take_full_cert_budget_for_ip_uses_ttl() { let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache"); From 8520955a5f229460909ad560e4a7946cdc8998fb Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:53:27 +0300 Subject: [PATCH 6/7] Update helpers.rs Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> --- src/maestro/helpers.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 01b0d46..89f4f59 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -403,8 +403,7 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); - let startup_cwd = - std::env::temp_dir().join(format!("telemt_runtime_base_systemd_{nonce}")); + let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_systemd_{nonce}")); std::fs::create_dir_all(&startup_cwd).unwrap(); let resolved = From cfe01dced29a5add230c809474a117ec76417c1b Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:54:22 +0300 Subject: [PATCH 7/7] Bump Signed-off-by: Alexey <247128645+axkurcom@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f225ff..20b840b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2791,7 +2791,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.8" +version = "3.4.9" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 36d4091..7688264 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.8" +version = "3.4.9" edition = "2024" [features]