Limit only new ip when TimeWindow + Fix WorkingDirectory behavior + Atomically updates with Includes + Expose tls_domains links as domain-link pairs + TLS Fetcher on multiple tls_domains: merge pull request #751 from telemt/flow

Limit only new ip when TimeWindow + Fix WorkingDirectory behavior + Atomically updates with Includes + Expose tls_domains links as domain-link pairs + TLS Fetcher on multiple tls_domains
This commit is contained in:
Alexey
2026-04-29 16:04:36 +03:00
committed by GitHub
11 changed files with 496 additions and 26 deletions

2
Cargo.lock generated
View File

@@ -2791,7 +2791,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "telemt" name = "telemt"
version = "3.4.8" version = "3.4.9"
dependencies = [ dependencies = [
"aes", "aes",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "telemt" name = "telemt"
version = "3.4.8" version = "3.4.9"
edition = "2024" edition = "2024"
[features] [features]

View File

@@ -82,6 +82,7 @@ pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyCon
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e))) .map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
} }
#[allow(dead_code)]
pub(super) async fn save_config_to_disk( pub(super) async fn save_config_to_disk(
config_path: &Path, config_path: &Path,
cfg: &ProxyConfig, cfg: &ProxyConfig,
@@ -106,6 +107,12 @@ pub(super) async fn save_access_sections_to_disk(
if applied.contains(section) { if applied.contains(section) {
continue; continue;
} }
if find_toml_table_bounds(&content, section.table_name()).is_none()
&& access_section_is_empty(cfg, *section)
{
applied.push(*section);
continue;
}
let rendered = render_access_section(cfg, *section)?; let rendered = render_access_section(cfg, *section)?;
content = upsert_toml_table(&content, section.table_name(), &rendered); content = upsert_toml_table(&content, section.table_name(), &rendered);
applied.push(*section); applied.push(*section);
@@ -183,6 +190,17 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
Ok(out) Ok(out)
} }
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> 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<T: Serialize>(value: &T) -> Result<String, ApiFailure> { fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
toml::to_string(value) toml::to_string(value)
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e))) .map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))

View File

@@ -456,6 +456,13 @@ pub(super) struct UserLinks {
pub(super) classic: Vec<String>, pub(super) classic: Vec<String>,
pub(super) secure: Vec<String>, pub(super) secure: Vec<String>,
pub(super) tls: Vec<String>, pub(super) tls: Vec<String>,
pub(super) tls_domains: Vec<TlsDomainLink>,
}
#[derive(Serialize)]
pub(super) struct TlsDomainLink {
pub(super) domain: String,
pub(super) link: String,
} }
#[derive(Serialize)] #[derive(Serialize)]

View File

@@ -8,12 +8,12 @@ use crate::stats::Stats;
use super::ApiShared; use super::ApiShared;
use super::config_store::{ use super::config_store::{
AccessSection, ensure_expected_revision, load_config_from_disk, save_access_sections_to_disk, AccessSection, current_revision, ensure_expected_revision, load_config_from_disk,
save_config_to_disk, save_access_sections_to_disk,
}; };
use super::model::{ use super::model::{
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest, 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, parse_optional_expiration, parse_patch_expiration, random_user_secret,
}; };
use super::patch::Patch; use super::patch::Patch;
@@ -176,6 +176,13 @@ pub(super) async fn patch_user(
expected_revision: Option<String>, expected_revision: Option<String>,
shared: &ApiShared, shared: &ApiShared,
) -> Result<(UserInfo, String), ApiFailure> { ) -> 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() if let Some(secret) = body.secret.as_ref()
&& !is_valid_user_secret(secret) && !is_valid_user_secret(secret)
{ {
@@ -265,7 +272,31 @@ pub(super) async fn patch_user(
cfg.validate() cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; .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); drop(_guard);
match max_unique_ips_change { match max_unique_ips_change {
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await, Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
@@ -438,6 +469,7 @@ pub(super) async fn users_from_config(
classic: Vec::new(), classic: Vec::new(),
secure: Vec::new(), secure: Vec::new(),
tls: Vec::new(), tls: Vec::new(),
tls_domains: Vec::new(),
}); });
users.push(UserInfo { users.push(UserInfo {
in_runtime: runtime_cfg in_runtime: runtime_cfg
@@ -492,10 +524,12 @@ fn build_user_links(
.public_port .public_port
.unwrap_or(resolve_default_link_port(cfg)); .unwrap_or(resolve_default_link_port(cfg));
let tls_domains = resolve_tls_domains(cfg); let tls_domains = resolve_tls_domains(cfg);
let extra_tls_domains = resolve_extra_tls_domains(cfg);
let mut classic = Vec::new(); let mut classic = Vec::new();
let mut secure = Vec::new(); let mut secure = Vec::new();
let mut tls = Vec::new(); let mut tls = Vec::new();
let mut tls_domain_links = Vec::new();
for host in &hosts { for host in &hosts {
if cfg.general.modes.classic { if cfg.general.modes.classic {
@@ -518,6 +552,17 @@ fn build_user_links(
host, port, secret, domain_hex 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,
});
}
} }
} }
@@ -525,6 +570,7 @@ fn build_user_links(
classic, classic,
secure, secure,
tls, tls,
tls_domains: tls_domain_links,
} }
} }
@@ -641,6 +687,19 @@ fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
domains 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -730,4 +789,80 @@ mod tests {
assert!(alice.in_runtime); assert!(alice.in_runtime);
assert!(!bob.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")
);
}
} }

View File

@@ -102,7 +102,7 @@ pub(crate) fn default_fake_cert_len() -> usize {
} }
pub(crate) fn default_tls_front_dir() -> String { pub(crate) fn default_tls_front_dir() -> String {
"/etc/telemt/tlsfront".to_string() "tlsfront".to_string()
} }
pub(crate) fn default_replay_check_len() -> usize { 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 { 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 { pub(crate) fn default_tls_new_session_tickets() -> u8 {

View File

@@ -278,9 +278,11 @@ impl UserIpTracker {
return Ok(()); return Ok(());
} }
let is_new_ip = !user_recent.contains_key(&ip);
if let Some(limit) = limit { if let Some(limit) = limit {
let active_limit_reached = user_active.len() >= 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 { let deny = match mode {
UserMaxUniqueIpsMode::ActiveWindow => active_limit_reached, UserMaxUniqueIpsMode::ActiveWindow => active_limit_reached,
UserMaxUniqueIpsMode::TimeWindow => recent_limit_reached, UserMaxUniqueIpsMode::TimeWindow => recent_limit_reached,
@@ -860,4 +862,19 @@ mod tests {
.unwrap_or(false); .unwrap_or(false);
assert!(!stale_exists); 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());
}
} }

View File

@@ -1,6 +1,6 @@
#![allow(clippy::items_after_test_module)] #![allow(clippy::items_after_test_module)]
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
@@ -17,7 +17,7 @@ use crate::transport::middle_proxy::{
pub(crate) fn resolve_runtime_config_path( pub(crate) fn resolve_runtime_config_path(
config_path_cli: &str, config_path_cli: &str,
startup_cwd: &std::path::Path, startup_cwd: &Path,
config_path_explicit: bool, config_path_explicit: bool,
) -> PathBuf { ) -> PathBuf {
if config_path_explicit { if config_path_explicit {
@@ -46,6 +46,39 @@ pub(crate) fn resolve_runtime_config_path(
startup_cwd.join("config.toml") 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. /// Parsed CLI arguments.
pub(crate) struct CliArgs { pub(crate) struct CliArgs {
pub config_path: String, pub config_path: String,
@@ -231,9 +264,11 @@ fn print_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::path::{Path, PathBuf};
use super::{ use super::{
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, 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}; use crate::error::{ProxyError, StreamError};
@@ -304,6 +339,91 @@ mod tests {
let _ = std::fs::remove_dir(&startup_cwd); 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] #[test]
fn expected_handshake_eof_matches_connection_reset() { fn expected_handshake_eof_matches_connection_reset() {
let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));

View File

@@ -47,7 +47,7 @@ use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool; use crate::stream::BufferPool;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool; 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)] #[cfg(unix)]
use crate::daemon::{DaemonOptions, PidFile, drop_privileges}; use crate::daemon::{DaemonOptions, PidFile, drop_privileges};
@@ -112,8 +112,51 @@ async fn run_telemt_core(
std::process::exit(1); 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 = let mut config_path =
resolve_runtime_config_path(&config_path_cli, &startup_cwd, config_path_explicit); 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) { let mut config = match ProxyConfig::load(&config_path) {
Ok(c) => c, Ok(c) => c,
@@ -156,16 +199,15 @@ async fn run_telemt_core(
); );
} }
} else { } else {
let system_dir = std::path::Path::new("/etc/telemt"); let runtime_config_path = runtime_base_dir.join("telemt.toml");
let system_config_path = system_dir.join("telemt.toml"); let fallback_config_path = runtime_base_dir.join("config.toml");
let startup_config_path = startup_cwd.join("config.toml");
let mut persisted = false; let mut persisted = false;
if let Some(serialized) = serialized.as_ref() { if let Some(serialized) = serialized.as_ref() {
match std::fs::create_dir_all(system_dir) { match std::fs::create_dir_all(&runtime_base_dir) {
Ok(()) => match std::fs::write(&system_config_path, serialized) { Ok(()) => match std::fs::write(&runtime_config_path, serialized) {
Ok(()) => { Ok(()) => {
config_path = system_config_path; config_path = runtime_config_path;
eprintln!( eprintln!(
"[telemt] Created default config at {}", "[telemt] Created default config at {}",
config_path.display() config_path.display()
@@ -175,7 +217,7 @@ async fn run_telemt_core(
Err(write_error) => { Err(write_error) => {
eprintln!( eprintln!(
"[telemt] Warning: failed to write default config at {}: {}", "[telemt] Warning: failed to write default config at {}: {}",
system_config_path.display(), runtime_config_path.display(),
write_error write_error
); );
} }
@@ -183,16 +225,16 @@ async fn run_telemt_core(
Err(create_error) => { Err(create_error) => {
eprintln!( eprintln!(
"[telemt] Warning: failed to create {}: {}", "[telemt] Warning: failed to create {}: {}",
system_dir.display(), runtime_base_dir.display(),
create_error create_error
); );
} }
} }
if !persisted { if !persisted {
match std::fs::write(&startup_config_path, serialized) { match std::fs::write(&fallback_config_path, serialized) {
Ok(()) => { Ok(()) => {
config_path = startup_config_path; config_path = fallback_config_path;
eprintln!( eprintln!(
"[telemt] Created default config at {}", "[telemt] Created default config at {}",
config_path.display() config_path.display()
@@ -202,7 +244,7 @@ async fn run_telemt_core(
Err(write_error) => { Err(write_error) => {
eprintln!( eprintln!(
"[telemt] Warning: failed to write default config at {}: {}", "[telemt] Warning: failed to write default config at {}: {}",
startup_config_path.display(), fallback_config_path.display(),
write_error write_error
); );
} }

View File

@@ -10,6 +10,14 @@ use crate::tls_front::TlsFrontCache;
use crate::tls_front::fetcher::TlsFetchStrategy; use crate::tls_front::fetcher::TlsFetchStrategy;
use crate::transport::UpstreamManager; 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( pub(crate) async fn bootstrap_tls_front(
config: &ProxyConfig, config: &ProxyConfig,
tls_domains: &[String], tls_domains: &[String],
@@ -56,6 +64,7 @@ pub(crate) async fn bootstrap_tls_front(
let cache_initial = cache.clone(); let cache_initial = cache.clone();
let domains_initial = tls_domains.to_vec(); let domains_initial = tls_domains.to_vec();
let host_initial = mask_host.clone(); let host_initial = mask_host.clone();
let primary_initial = config.censorship.tls_domain.clone();
let unix_sock_initial = mask_unix_sock.clone(); let unix_sock_initial = mask_unix_sock.clone();
let scope_initial = tls_fetch_scope.clone(); let scope_initial = tls_fetch_scope.clone();
let upstream_initial = upstream_manager.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(); let mut join = tokio::task::JoinSet::new();
for domain in domains_initial { for domain in domains_initial {
let cache_domain = cache_initial.clone(); 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 unix_sock_domain = unix_sock_initial.clone();
let scope_domain = scope_initial.clone(); let scope_domain = scope_initial.clone();
let upstream_domain = upstream_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 cache_refresh = cache.clone();
let domains_refresh = tls_domains.to_vec(); let domains_refresh = tls_domains.to_vec();
let host_refresh = mask_host.clone(); let host_refresh = mask_host.clone();
let primary_refresh = config.censorship.tls_domain.clone();
let unix_sock_refresh = mask_unix_sock.clone(); let unix_sock_refresh = mask_unix_sock.clone();
let scope_refresh = tls_fetch_scope.clone(); let scope_refresh = tls_fetch_scope.clone();
let upstream_refresh = upstream_manager.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(); let mut join = tokio::task::JoinSet::new();
for domain in domains_refresh.clone() { for domain in domains_refresh.clone() {
let cache_domain = cache_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 unix_sock_domain = unix_sock_refresh.clone();
let scope_domain = scope_refresh.clone(); let scope_domain = scope_refresh.clone();
let upstream_domain = upstream_refresh.clone(); let upstream_domain = upstream_refresh.clone();
@@ -186,3 +198,24 @@ pub(crate) async fn bootstrap_tls_front(
tls_cache 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"
);
}
}

View File

@@ -130,6 +130,14 @@ impl TlsFrontCache {
warn!(file = %name, "Skipping TLS cache entry with invalid domain"); warn!(file = %name, "Skipping TLS cache entry with invalid domain");
continue; 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. // fetched_at is skipped during deserialization; approximate with file mtime if available.
if let Ok(meta) = entry.metadata().await if let Ok(meta) = entry.metadata().await
&& let Ok(modified) = meta.modified() && 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn test_take_full_cert_budget_for_ip_uses_ttl() { async fn test_take_full_cert_budget_for_ip_uses_ttl() {
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache"); let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");