diff --git a/src/api/users.rs b/src/api/users.rs index 39820f6..5228277 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -20,1141 +20,17 @@ use super::model::{ }; use super::patch::Patch; -pub(super) async fn create_user( - body: CreateUserRequest, - expected_revision: Option, - shared: &ApiShared, -) -> Result<(CreateUserResponse, String), ApiFailure> { - let touches_user_ad_tags = body.user_ad_tag.is_some(); - let touches_user_max_tcp_conns = body.max_tcp_conns.is_some(); - let touches_user_expirations = body.expiration_rfc3339.is_some(); - let touches_user_data_quota = body.data_quota_bytes.is_some(); - let touches_user_rate_limits = - body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some(); - let touches_user_max_unique_ips = body.max_unique_ips.is_some(); - let touches_user_enabled = matches!(body.enabled, Some(false)); +mod create; +mod lifecycle; +mod links; +mod update; +mod view; - if !is_valid_username(&body.username) { - return Err(ApiFailure::bad_request( - "username must match [A-Za-z0-9_.-] and be 1..64 chars", - )); - } - - let secret = match body.secret { - Some(secret) => { - if !is_valid_user_secret(&secret) { - return Err(ApiFailure::bad_request( - "secret must be exactly 32 hex characters", - )); - } - secret - } - None => random_user_secret(), - }; - - if let Some(ad_tag) = body.user_ad_tag.as_ref() - && !is_valid_ad_tag(ad_tag) - { - return Err(ApiFailure::bad_request( - "user_ad_tag must be exactly 32 hex characters", - )); - } - - let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?; - let _guard = shared.mutation_lock.lock().await; - let mut cfg = load_config_from_disk(&shared.config_path).await?; - ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; - - if cfg.access.users.contains_key(&body.username) { - return Err(ApiFailure::new( - StatusCode::CONFLICT, - "user_exists", - "User already exists", - )); - } - - cfg.access - .users - .insert(body.username.clone(), secret.clone()); - if let Some(ad_tag) = body.user_ad_tag { - cfg.access - .user_ad_tags - .insert(body.username.clone(), ad_tag); - } - if let Some(limit) = body.max_tcp_conns { - cfg.access - .user_max_tcp_conns - .insert(body.username.clone(), limit); - } - if let Some(expiration) = expiration { - cfg.access - .user_expirations - .insert(body.username.clone(), expiration); - } - if let Some(quota) = body.data_quota_bytes { - cfg.access - .user_data_quota - .insert(body.username.clone(), quota); - } - if touches_user_rate_limits { - cfg.access.user_rate_limits.insert( - body.username.clone(), - RateLimitBps { - up_bps: body.rate_limit_up_bps.unwrap_or(0), - down_bps: body.rate_limit_down_bps.unwrap_or(0), - }, - ); - } - - let updated_limit = body.max_unique_ips; - if let Some(limit) = updated_limit { - cfg.access - .user_max_unique_ips - .insert(body.username.clone(), limit); - } - if matches!(body.enabled, Some(false)) { - cfg.access.user_enabled.insert(body.username.clone(), false); - } - - cfg.validate() - .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; - - let mut touched_sections = vec![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_rate_limits { - touched_sections.push(AccessSection::UserRateLimits); - } - if touches_user_max_unique_ips { - touched_sections.push(AccessSection::UserMaxUniqueIps); - } - if touches_user_enabled { - touched_sections.push(AccessSection::UserEnabled); - } - - let revision = - save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?; - drop(_guard); - - if let Some(limit) = updated_limit { - shared - .ip_tracker - .set_user_limit(&body.username, limit) - .await; - } - let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); - - let users = users_from_config( - &cfg, - &shared.stats, - &shared.ip_tracker, - detected_ip_v4, - detected_ip_v6, - None, - ) - .await; - let user = users - .into_iter() - .find(|entry| entry.username == body.username) - .unwrap_or(UserInfo { - username: body.username.clone(), - enabled: cfg.access.is_user_enabled(&body.username), - in_runtime: false, - user_ad_tag: None, - max_tcp_conns: cfg - .access - .user_max_tcp_conns - .get(&body.username) - .copied() - .filter(|limit| *limit > 0) - .or((cfg.access.user_max_tcp_conns_global_each > 0) - .then_some(cfg.access.user_max_tcp_conns_global_each)), - expiration_rfc3339: None, - data_quota_bytes: None, - rate_limit_up_bps: body.rate_limit_up_bps.filter(|limit| *limit > 0), - rate_limit_down_bps: body.rate_limit_down_bps.filter(|limit| *limit > 0), - max_unique_ips: updated_limit, - current_connections: 0, - active_unique_ips: 0, - active_unique_ips_list: Vec::new(), - recent_unique_ips: 0, - recent_unique_ips_list: Vec::new(), - total_octets: 0, - links: build_user_links(&cfg, &secret, detected_ip_v4, detected_ip_v6), - }); - - Ok((CreateUserResponse { user, secret }, revision)) -} - -pub(super) async fn patch_user( - user: &str, - body: PatchUserRequest, - 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_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged) - || !matches!(&body.rate_limit_down_bps, Patch::Unchanged); - let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged); - let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged); - - if let Some(secret) = body.secret.as_ref() - && !is_valid_user_secret(secret) - { - return Err(ApiFailure::bad_request( - "secret must be exactly 32 hex characters", - )); - } - if let Patch::Set(ad_tag) = &body.user_ad_tag - && !is_valid_ad_tag(ad_tag) - { - return Err(ApiFailure::bad_request( - "user_ad_tag must be exactly 32 hex characters", - )); - } - let expiration = parse_patch_expiration(&body.expiration_rfc3339)?; - let _guard = shared.mutation_lock.lock().await; - let mut cfg = load_config_from_disk(&shared.config_path).await?; - ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; - - if !cfg.access.users.contains_key(user) { - return Err(ApiFailure::new( - StatusCode::NOT_FOUND, - "not_found", - "User not found", - )); - } - - if let Some(secret) = body.secret { - cfg.access.users.insert(user.to_string(), secret); - } - match body.user_ad_tag { - Patch::Unchanged => {} - Patch::Remove => { - cfg.access.user_ad_tags.remove(user); - } - Patch::Set(ad_tag) => { - cfg.access.user_ad_tags.insert(user.to_string(), ad_tag); - } - } - match body.max_tcp_conns { - Patch::Unchanged => {} - Patch::Remove => { - cfg.access.user_max_tcp_conns.remove(user); - } - Patch::Set(limit) => { - cfg.access - .user_max_tcp_conns - .insert(user.to_string(), limit); - } - } - match expiration { - Patch::Unchanged => {} - Patch::Remove => { - cfg.access.user_expirations.remove(user); - } - Patch::Set(expiration) => { - cfg.access - .user_expirations - .insert(user.to_string(), expiration); - } - } - match body.data_quota_bytes { - Patch::Unchanged => {} - Patch::Remove => { - cfg.access.user_data_quota.remove(user); - } - Patch::Set(quota) => { - cfg.access.user_data_quota.insert(user.to_string(), quota); - } - } - if touches_user_rate_limits { - let mut rate_limit = cfg - .access - .user_rate_limits - .get(user) - .copied() - .unwrap_or_default(); - match body.rate_limit_up_bps { - Patch::Unchanged => {} - Patch::Remove => rate_limit.up_bps = 0, - Patch::Set(limit) => rate_limit.up_bps = limit, - } - match body.rate_limit_down_bps { - Patch::Unchanged => {} - Patch::Remove => rate_limit.down_bps = 0, - Patch::Set(limit) => rate_limit.down_bps = limit, - } - if rate_limit.up_bps == 0 && rate_limit.down_bps == 0 { - cfg.access.user_rate_limits.remove(user); - } else { - cfg.access - .user_rate_limits - .insert(user.to_string(), rate_limit); - } - } - // Capture how the per-user IP limit changed, so the in-memory ip_tracker - // can be synced (set or removed) after the config is persisted. - let max_unique_ips_change = match body.max_unique_ips { - Patch::Unchanged => None, - Patch::Remove => { - cfg.access.user_max_unique_ips.remove(user); - Some(None) - } - Patch::Set(limit) => { - cfg.access - .user_max_unique_ips - .insert(user.to_string(), limit); - Some(Some(limit)) - } - }; - match body.enabled { - Patch::Unchanged => {} - Patch::Remove | Patch::Set(true) => { - cfg.access.user_enabled.remove(user); - } - Patch::Set(false) => { - cfg.access.user_enabled.insert(user.to_string(), false); - } - } - - cfg.validate() - .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; - - 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_rate_limits { - touched_sections.push(AccessSection::UserRateLimits); - } - if touches_user_max_unique_ips { - touched_sections.push(AccessSection::UserMaxUniqueIps); - } - if touches_user_enabled { - touched_sections.push(AccessSection::UserEnabled); - } - - 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, - Some(None) => shared.ip_tracker.remove_user_limit(user).await, - None => {} - } - let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); - let users = users_from_config( - &cfg, - &shared.stats, - &shared.ip_tracker, - detected_ip_v4, - detected_ip_v6, - None, - ) - .await; - let user_info = users - .into_iter() - .find(|entry| entry.username == user) - .ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?; - - Ok((user_info, revision)) -} - -pub(super) async fn rotate_secret( - user: &str, - body: RotateSecretRequest, - expected_revision: Option, - shared: &ApiShared, -) -> Result<(CreateUserResponse, String), ApiFailure> { - let secret = body.secret.unwrap_or_else(random_user_secret); - if !is_valid_user_secret(&secret) { - return Err(ApiFailure::bad_request( - "secret must be exactly 32 hex characters", - )); - } - - let _guard = shared.mutation_lock.lock().await; - let mut cfg = load_config_from_disk(&shared.config_path).await?; - ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; - - if !cfg.access.users.contains_key(user) { - return Err(ApiFailure::new( - StatusCode::NOT_FOUND, - "not_found", - "User not found", - )); - } - - cfg.access.users.insert(user.to_string(), secret.clone()); - cfg.validate() - .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; - let revision = - save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?; - drop(_guard); - - let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); - let users = users_from_config( - &cfg, - &shared.stats, - &shared.ip_tracker, - detected_ip_v4, - detected_ip_v6, - None, - ) - .await; - let user_info = users - .into_iter() - .find(|entry| entry.username == user) - .ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?; - - Ok(( - CreateUserResponse { - user: user_info, - secret, - }, - revision, - )) -} - -pub(super) async fn set_user_enabled( - user: &str, - enabled: bool, - expected_revision: Option, - shared: &ApiShared, -) -> Result<(UserInfo, String), ApiFailure> { - let _guard = shared.mutation_lock.lock().await; - let mut cfg = load_config_from_disk(&shared.config_path).await?; - ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; - - if !cfg.access.users.contains_key(user) { - return Err(ApiFailure::new( - StatusCode::NOT_FOUND, - "not_found", - "User not found", - )); - } - - if enabled { - cfg.access.user_enabled.remove(user); - } else { - cfg.access.user_enabled.insert(user.to_string(), false); - } - - cfg.validate() - .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; - let revision = - save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled]) - .await?; - drop(_guard); - - let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); - let users = users_from_config( - &cfg, - &shared.stats, - &shared.ip_tracker, - detected_ip_v4, - detected_ip_v6, - None, - ) - .await; - let user_info = users - .into_iter() - .find(|entry| entry.username == user) - .ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?; - - Ok((user_info, revision)) -} - -pub(super) async fn delete_user( - user: &str, - expected_revision: Option, - shared: &ApiShared, -) -> Result<(String, String), ApiFailure> { - let _guard = shared.mutation_lock.lock().await; - let mut cfg = load_config_from_disk(&shared.config_path).await?; - ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; - - if !cfg.access.users.contains_key(user) { - return Err(ApiFailure::new( - StatusCode::NOT_FOUND, - "not_found", - "User not found", - )); - } - if cfg.access.users.len() <= 1 { - return Err(ApiFailure::new( - StatusCode::CONFLICT, - "last_user_forbidden", - "Cannot delete the last configured user", - )); - } - - let mut touched_sections = vec![AccessSection::Users]; - cfg.access.users.remove(user); - if cfg.access.user_enabled.remove(user).is_some() { - touched_sections.push(AccessSection::UserEnabled); - } - if cfg.access.user_ad_tags.remove(user).is_some() { - touched_sections.push(AccessSection::UserAdTags); - } - if cfg.access.user_max_tcp_conns.remove(user).is_some() { - touched_sections.push(AccessSection::UserMaxTcpConns); - } - if cfg.access.user_expirations.remove(user).is_some() { - touched_sections.push(AccessSection::UserExpirations); - } - if cfg.access.user_data_quota.remove(user).is_some() { - touched_sections.push(AccessSection::UserDataQuota); - } - if cfg.access.user_rate_limits.remove(user).is_some() { - touched_sections.push(AccessSection::UserRateLimits); - } - if cfg.access.user_max_unique_ips.remove(user).is_some() { - touched_sections.push(AccessSection::UserMaxUniqueIps); - } - - cfg.validate() - .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; - let revision = - save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?; - drop(_guard); - shared.ip_tracker.remove_user_limit(user).await; - shared.ip_tracker.clear_user_ips(user).await; - - Ok((user.to_string(), revision)) -} - -pub(super) async fn users_from_config( - cfg: &ProxyConfig, - stats: &Stats, - ip_tracker: &UserIpTracker, - startup_detected_ip_v4: Option, - startup_detected_ip_v6: Option, - runtime_cfg: Option<&ProxyConfig>, -) -> Vec { - let mut names = cfg.access.users.keys().cloned().collect::>(); - names.sort(); - let active_ip_lists = ip_tracker.get_active_ips_for_users(&names).await; - let recent_ip_lists = ip_tracker.get_recent_ips_for_users(&names).await; - - let mut users = Vec::with_capacity(names.len()); - for username in names { - let active_ip_list = active_ip_lists - .get(&username) - .cloned() - .unwrap_or_else(Vec::new); - let recent_ip_list = recent_ip_lists - .get(&username) - .cloned() - .unwrap_or_else(Vec::new); - let links = cfg - .access - .users - .get(&username) - .map(|secret| { - build_user_links(cfg, secret, startup_detected_ip_v4, startup_detected_ip_v6) - }) - .unwrap_or_else(empty_user_links); - users.push(UserInfo { - enabled: cfg.access.is_user_enabled(&username), - in_runtime: runtime_cfg - .map(|runtime| runtime.access.users.contains_key(&username)) - .unwrap_or(false), - user_ad_tag: cfg.access.user_ad_tags.get(&username).cloned(), - max_tcp_conns: cfg - .access - .user_max_tcp_conns - .get(&username) - .copied() - .filter(|limit| *limit > 0) - .or((cfg.access.user_max_tcp_conns_global_each > 0) - .then_some(cfg.access.user_max_tcp_conns_global_each)), - expiration_rfc3339: cfg - .access - .user_expirations - .get(&username) - .map(chrono::DateTime::::to_rfc3339), - data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(), - rate_limit_up_bps: cfg - .access - .user_rate_limits - .get(&username) - .map(|limit| limit.up_bps) - .filter(|limit| *limit > 0), - rate_limit_down_bps: cfg - .access - .user_rate_limits - .get(&username) - .map(|limit| limit.down_bps) - .filter(|limit| *limit > 0), - max_unique_ips: cfg - .access - .user_max_unique_ips - .get(&username) - .copied() - .filter(|limit| *limit > 0) - .or((cfg.access.user_max_unique_ips_global_each > 0) - .then_some(cfg.access.user_max_unique_ips_global_each)), - current_connections: stats.get_user_curr_connects(&username), - active_unique_ips: active_ip_list.len(), - active_unique_ips_list: active_ip_list, - recent_unique_ips: recent_ip_list.len(), - recent_unique_ips_list: recent_ip_list, - total_octets: stats.get_user_total_octets(&username), - links, - username, - }); - } - users -} - -pub(super) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData { - let mut names = cfg.access.users.keys().cloned().collect::>(); - names.sort(); - - let snapshot = stats.user_quota_snapshot(); - let mut users = Vec::with_capacity(names.len()); - for username in names { - let Some(&data_quota_bytes) = cfg.access.user_data_quota.get(&username) else { - continue; - }; - if data_quota_bytes == 0 { - continue; - } - let (used_bytes, last_reset_epoch_secs) = snapshot - .get(&username) - .map(|entry| (entry.used_bytes, entry.last_reset_epoch_secs)) - .unwrap_or((0, 0)); - users.push(UserQuotaEntry { - username, - data_quota_bytes, - used_bytes, - last_reset_epoch_secs, - }); - } - UserQuotaListData { users } -} - -fn empty_user_links() -> UserLinks { - UserLinks { - classic: Vec::new(), - secure: Vec::new(), - tls: Vec::new(), - tls_domains: Vec::new(), - } -} - -fn build_user_links( - cfg: &ProxyConfig, - secret: &str, - startup_detected_ip_v4: Option, - startup_detected_ip_v6: Option, -) -> UserLinks { - let hosts = resolve_link_hosts(cfg, startup_detected_ip_v4, startup_detected_ip_v6); - let port = cfg - .general - .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 { - classic.push(format!( - "tg://proxy?server={}&port={}&secret={}", - host, port, secret - )); - } - if cfg.general.modes.secure { - secure.push(format!( - "tg://proxy?server={}&port={}&secret=dd{}", - host, port, secret - )); - } - if cfg.general.modes.tls { - for domain in &tls_domains { - let domain_hex = hex::encode(domain); - tls.push(format!( - "tg://proxy?server={}&port={}&secret=ee{}{}", - 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, - }); - } - } - } - - UserLinks { - classic, - secure, - tls, - tls_domains: tls_domain_links, - } -} - -fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 { - cfg.server - .listeners - .first() - .and_then(|listener| listener.port) - .unwrap_or(cfg.server.port) -} - -fn resolve_link_hosts( - cfg: &ProxyConfig, - startup_detected_ip_v4: Option, - startup_detected_ip_v6: Option, -) -> Vec { - if let Some(host) = cfg - .general - .links - .public_host - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return vec![host.to_string()]; - } - - let mut hosts = Vec::new(); - for listener in &cfg.server.listeners { - if let Some(host) = listener - .announce - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - push_unique_host(&mut hosts, host); - continue; - } - if let Some(ip) = listener.announce_ip - && !ip.is_unspecified() - { - push_unique_host(&mut hosts, &ip.to_string()); - continue; - } - if listener.ip.is_unspecified() { - let detected_ip = if listener.ip.is_ipv4() { - startup_detected_ip_v4 - } else { - startup_detected_ip_v6 - }; - if let Some(ip) = detected_ip { - push_unique_host(&mut hosts, &ip.to_string()); - } else { - push_unique_host(&mut hosts, &listener.ip.to_string()); - } - continue; - } - push_unique_host(&mut hosts, &listener.ip.to_string()); - } - - if !hosts.is_empty() { - return hosts; - } - - if let Some(ip) = startup_detected_ip_v4.or(startup_detected_ip_v6) { - return vec![ip.to_string()]; - } - - if let Some(host) = cfg.server.listen_addr_ipv4.as_deref() { - push_host_from_legacy_listen(&mut hosts, host); - } - if let Some(host) = cfg.server.listen_addr_ipv6.as_deref() { - push_host_from_legacy_listen(&mut hosts, host); - } - if !hosts.is_empty() { - return hosts; - } - - vec!["UNKNOWN".to_string()] -} - -fn push_host_from_legacy_listen(hosts: &mut Vec, raw: &str) { - let candidate = raw.trim(); - if candidate.is_empty() { - return; - } - - match candidate.parse::() { - Ok(ip) if ip.is_unspecified() => {} - Ok(ip) => push_unique_host(hosts, &ip.to_string()), - Err(_) => push_unique_host(hosts, candidate), - } -} - -fn push_unique_host(hosts: &mut Vec, candidate: &str) { - if !hosts.iter().any(|existing| existing == candidate) { - hosts.push(candidate.to_string()); - } -} - -fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> { - let mut domains = Vec::with_capacity(1 + cfg.censorship.tls_domains.len()); - let primary = cfg.censorship.tls_domain.as_str(); - if !primary.is_empty() { - domains.push(primary); - } - for domain in &cfg.censorship.tls_domains { - let value = domain.as_str(); - if value.is_empty() || domains.contains(&value) { - continue; - } - domains.push(value); - } - 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 -} +pub(super) use create::create_user; +pub(super) use lifecycle::{delete_user, rotate_secret}; +use links::{build_user_links, empty_user_links}; +pub(super) use update::{patch_user, set_user_enabled}; +pub(super) use view::{build_user_quota_list, users_from_config}; #[cfg(test)] -mod tests { - use super::*; - use crate::ip_tracker::UserIpTracker; - use crate::stats::Stats; - - #[tokio::test] - async fn users_from_config_reports_effective_tcp_limit_with_global_fallback() { - let mut cfg = ProxyConfig::default(); - cfg.access.users.insert( - "alice".to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - cfg.access.user_max_tcp_conns_global_each = 7; - - 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!(!alice.in_runtime); - assert_eq!(alice.max_tcp_conns, Some(7)); - - cfg.access.user_max_tcp_conns.insert("alice".to_string(), 5); - 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!(!alice.in_runtime); - assert_eq!(alice.max_tcp_conns, Some(5)); - - cfg.access.user_max_tcp_conns.insert("alice".to_string(), 0); - 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!(!alice.in_runtime); - assert_eq!(alice.max_tcp_conns, Some(7)); - - cfg.access.user_max_tcp_conns_global_each = 0; - 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!(!alice.in_runtime); - assert_eq!(alice.max_tcp_conns, None); - } - - #[tokio::test] - async fn users_from_config_reports_user_rate_limits() { - let mut cfg = ProxyConfig::default(); - cfg.access.users.insert( - "alice".to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - cfg.access.user_rate_limits.insert( - "alice".to_string(), - RateLimitBps { - up_bps: 1024, - down_bps: 0, - }, - ); - - 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.rate_limit_up_bps, Some(1024)); - assert_eq!(alice.rate_limit_down_bps, None); - } - - #[tokio::test] - async fn users_from_config_reports_user_enabled_default_and_override() { - let mut cfg = ProxyConfig::default(); - cfg.access.users.insert( - "alice".to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - cfg.access.users.insert( - "bob".to_string(), - "fedcba9876543210fedcba9876543210".to_string(), - ); - cfg.access.user_enabled.insert("bob".to_string(), false); - - 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"); - let bob = users - .iter() - .find(|entry| entry.username == "bob") - .expect("bob must be present"); - - assert!(alice.enabled); - assert!(!bob.enabled); - - cfg.access.user_enabled.insert("bob".to_string(), true); - let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; - let bob = users - .iter() - .find(|entry| entry.username == "bob") - .expect("bob must be present"); - assert!(bob.enabled); - } - - #[tokio::test] - async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() { - let mut disk_cfg = ProxyConfig::default(); - disk_cfg.access.users.insert( - "alice".to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - disk_cfg.access.users.insert( - "bob".to_string(), - "fedcba9876543210fedcba9876543210".to_string(), - ); - - let mut runtime_cfg = ProxyConfig::default(); - runtime_cfg.access.users.insert( - "alice".to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - - let stats = Stats::new(); - let tracker = UserIpTracker::new(); - let users = - users_from_config(&disk_cfg, &stats, &tracker, None, None, Some(&runtime_cfg)).await; - - let alice = users - .iter() - .find(|entry| entry.username == "alice") - .expect("alice must be present"); - let bob = users - .iter() - .find(|entry| entry.username == "bob") - .expect("bob must be present"); - - 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") - ); - } - - #[test] - fn build_user_quota_list_skips_users_without_positive_quota_and_sorts_by_username() { - let mut cfg = ProxyConfig::default(); - cfg.access.users.insert( - "alice".to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - cfg.access.users.insert( - "bob".to_string(), - "fedcba9876543210fedcba9876543210".to_string(), - ); - cfg.access.users.insert( - "carol".to_string(), - "aaaabbbbccccddddeeeeffff00001111".to_string(), - ); - // alice has a positive quota and should be listed. - cfg.access - .user_data_quota - .insert("alice".to_string(), 1 << 20); - // bob has no quota entry at all (None) — should be skipped. - // carol has an explicit zero quota — should be skipped. - cfg.access.user_data_quota.insert("carol".to_string(), 0); - - let stats = Stats::new(); - // Charge some traffic against alice; carol gets traffic too but should - // still be filtered out by the quota check. - let alice_stats = stats.get_or_create_user_stats_handle("alice"); - stats.quota_charge_post_write(&alice_stats, 4096); - let carol_stats = stats.get_or_create_user_stats_handle("carol"); - stats.quota_charge_post_write(&carol_stats, 99); - - let data = build_user_quota_list(&cfg, &stats); - - assert_eq!(data.users.len(), 1); - let entry = &data.users[0]; - assert_eq!(entry.username, "alice"); - assert_eq!(entry.data_quota_bytes, 1 << 20); - assert_eq!(entry.used_bytes, 4096); - assert_eq!(entry.last_reset_epoch_secs, 0); - } - - #[test] - fn build_user_quota_list_orders_multiple_users_by_username_ascending() { - let mut cfg = ProxyConfig::default(); - for name in ["charlie", "alice", "bob"] { - cfg.access.users.insert( - name.to_string(), - "0123456789abcdef0123456789abcdef".to_string(), - ); - cfg.access.user_data_quota.insert(name.to_string(), 1 << 30); - } - - let stats = Stats::new(); - let data = build_user_quota_list(&cfg, &stats); - - let names: Vec<&str> = data.users.iter().map(|e| e.username.as_str()).collect(); - assert_eq!(names, vec!["alice", "bob", "charlie"]); - for entry in &data.users { - assert_eq!(entry.used_bytes, 0); - assert_eq!(entry.last_reset_epoch_secs, 0); - assert_eq!(entry.data_quota_bytes, 1 << 30); - } - } -} +mod tests; diff --git a/src/api/users/create.rs b/src/api/users/create.rs new file mode 100644 index 0000000..e123b4b --- /dev/null +++ b/src/api/users/create.rs @@ -0,0 +1,178 @@ +use super::*; + +pub(in crate::api) async fn create_user( + body: CreateUserRequest, + expected_revision: Option, + shared: &ApiShared, +) -> Result<(CreateUserResponse, String), ApiFailure> { + let touches_user_ad_tags = body.user_ad_tag.is_some(); + let touches_user_max_tcp_conns = body.max_tcp_conns.is_some(); + let touches_user_expirations = body.expiration_rfc3339.is_some(); + let touches_user_data_quota = body.data_quota_bytes.is_some(); + let touches_user_rate_limits = + body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some(); + let touches_user_max_unique_ips = body.max_unique_ips.is_some(); + let touches_user_enabled = matches!(body.enabled, Some(false)); + + if !is_valid_username(&body.username) { + return Err(ApiFailure::bad_request( + "username must match [A-Za-z0-9_.-] and be 1..64 chars", + )); + } + + let secret = match body.secret { + Some(secret) => { + if !is_valid_user_secret(&secret) { + return Err(ApiFailure::bad_request( + "secret must be exactly 32 hex characters", + )); + } + secret + } + None => random_user_secret(), + }; + + if let Some(ad_tag) = body.user_ad_tag.as_ref() + && !is_valid_ad_tag(ad_tag) + { + return Err(ApiFailure::bad_request( + "user_ad_tag must be exactly 32 hex characters", + )); + } + + let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?; + let _guard = shared.mutation_lock.lock().await; + let mut cfg = load_config_from_disk(&shared.config_path).await?; + ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; + + if cfg.access.users.contains_key(&body.username) { + return Err(ApiFailure::new( + StatusCode::CONFLICT, + "user_exists", + "User already exists", + )); + } + + cfg.access + .users + .insert(body.username.clone(), secret.clone()); + if let Some(ad_tag) = body.user_ad_tag { + cfg.access + .user_ad_tags + .insert(body.username.clone(), ad_tag); + } + if let Some(limit) = body.max_tcp_conns { + cfg.access + .user_max_tcp_conns + .insert(body.username.clone(), limit); + } + if let Some(expiration) = expiration { + cfg.access + .user_expirations + .insert(body.username.clone(), expiration); + } + if let Some(quota) = body.data_quota_bytes { + cfg.access + .user_data_quota + .insert(body.username.clone(), quota); + } + if touches_user_rate_limits { + cfg.access.user_rate_limits.insert( + body.username.clone(), + RateLimitBps { + up_bps: body.rate_limit_up_bps.unwrap_or(0), + down_bps: body.rate_limit_down_bps.unwrap_or(0), + }, + ); + } + + let updated_limit = body.max_unique_ips; + if let Some(limit) = updated_limit { + cfg.access + .user_max_unique_ips + .insert(body.username.clone(), limit); + } + if matches!(body.enabled, Some(false)) { + cfg.access.user_enabled.insert(body.username.clone(), false); + } + + cfg.validate() + .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; + + let mut touched_sections = vec![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_rate_limits { + touched_sections.push(AccessSection::UserRateLimits); + } + if touches_user_max_unique_ips { + touched_sections.push(AccessSection::UserMaxUniqueIps); + } + if touches_user_enabled { + touched_sections.push(AccessSection::UserEnabled); + } + + let revision = + save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?; + drop(_guard); + + if let Some(limit) = updated_limit { + shared + .ip_tracker + .set_user_limit(&body.username, limit) + .await; + } + let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); + + let users = users_from_config( + &cfg, + &shared.stats, + &shared.ip_tracker, + detected_ip_v4, + detected_ip_v6, + None, + ) + .await; + let user = users + .into_iter() + .find(|entry| entry.username == body.username) + .unwrap_or(UserInfo { + username: body.username.clone(), + enabled: cfg.access.is_user_enabled(&body.username), + in_runtime: false, + user_ad_tag: None, + max_tcp_conns: cfg + .access + .user_max_tcp_conns + .get(&body.username) + .copied() + .filter(|limit| *limit > 0) + .or((cfg.access.user_max_tcp_conns_global_each > 0) + .then_some(cfg.access.user_max_tcp_conns_global_each)), + expiration_rfc3339: None, + data_quota_bytes: None, + rate_limit_up_bps: body.rate_limit_up_bps.filter(|limit| *limit > 0), + rate_limit_down_bps: body.rate_limit_down_bps.filter(|limit| *limit > 0), + max_unique_ips: updated_limit, + current_connections: 0, + active_unique_ips: 0, + active_unique_ips_list: Vec::new(), + recent_unique_ips: 0, + recent_unique_ips_list: Vec::new(), + total_octets: 0, + links: build_user_links(&cfg, &secret, detected_ip_v4, detected_ip_v6), + }); + + Ok((CreateUserResponse { user, secret }, revision)) +} + diff --git a/src/api/users/lifecycle.rs b/src/api/users/lifecycle.rs new file mode 100644 index 0000000..1866e86 --- /dev/null +++ b/src/api/users/lifecycle.rs @@ -0,0 +1,118 @@ +use super::*; + +pub(in crate::api) async fn rotate_secret( + user: &str, + body: RotateSecretRequest, + expected_revision: Option, + shared: &ApiShared, +) -> Result<(CreateUserResponse, String), ApiFailure> { + let secret = body.secret.unwrap_or_else(random_user_secret); + if !is_valid_user_secret(&secret) { + return Err(ApiFailure::bad_request( + "secret must be exactly 32 hex characters", + )); + } + + let _guard = shared.mutation_lock.lock().await; + let mut cfg = load_config_from_disk(&shared.config_path).await?; + ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; + + if !cfg.access.users.contains_key(user) { + return Err(ApiFailure::new( + StatusCode::NOT_FOUND, + "not_found", + "User not found", + )); + } + + cfg.access.users.insert(user.to_string(), secret.clone()); + cfg.validate() + .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; + let revision = + save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?; + drop(_guard); + + let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); + let users = users_from_config( + &cfg, + &shared.stats, + &shared.ip_tracker, + detected_ip_v4, + detected_ip_v6, + None, + ) + .await; + let user_info = users + .into_iter() + .find(|entry| entry.username == user) + .ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?; + + Ok(( + CreateUserResponse { + user: user_info, + secret, + }, + revision, + )) +} + + +pub(in crate::api) async fn delete_user( + user: &str, + expected_revision: Option, + shared: &ApiShared, +) -> Result<(String, String), ApiFailure> { + let _guard = shared.mutation_lock.lock().await; + let mut cfg = load_config_from_disk(&shared.config_path).await?; + ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; + + if !cfg.access.users.contains_key(user) { + return Err(ApiFailure::new( + StatusCode::NOT_FOUND, + "not_found", + "User not found", + )); + } + if cfg.access.users.len() <= 1 { + return Err(ApiFailure::new( + StatusCode::CONFLICT, + "last_user_forbidden", + "Cannot delete the last configured user", + )); + } + + let mut touched_sections = vec![AccessSection::Users]; + cfg.access.users.remove(user); + if cfg.access.user_enabled.remove(user).is_some() { + touched_sections.push(AccessSection::UserEnabled); + } + if cfg.access.user_ad_tags.remove(user).is_some() { + touched_sections.push(AccessSection::UserAdTags); + } + if cfg.access.user_max_tcp_conns.remove(user).is_some() { + touched_sections.push(AccessSection::UserMaxTcpConns); + } + if cfg.access.user_expirations.remove(user).is_some() { + touched_sections.push(AccessSection::UserExpirations); + } + if cfg.access.user_data_quota.remove(user).is_some() { + touched_sections.push(AccessSection::UserDataQuota); + } + if cfg.access.user_rate_limits.remove(user).is_some() { + touched_sections.push(AccessSection::UserRateLimits); + } + if cfg.access.user_max_unique_ips.remove(user).is_some() { + touched_sections.push(AccessSection::UserMaxUniqueIps); + } + + cfg.validate() + .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; + let revision = + save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?; + drop(_guard); + shared.ip_tracker.remove_user_limit(user).await; + shared.ip_tracker.clear_user_ips(user).await; + + Ok((user.to_string(), revision)) +} + diff --git a/src/api/users/links.rs b/src/api/users/links.rs new file mode 100644 index 0000000..2d2cb96 --- /dev/null +++ b/src/api/users/links.rs @@ -0,0 +1,200 @@ +use super::*; + +pub(in crate::api::users) fn empty_user_links() -> UserLinks { + UserLinks { + classic: Vec::new(), + secure: Vec::new(), + tls: Vec::new(), + tls_domains: Vec::new(), + } +} + +pub(in crate::api::users) fn build_user_links( + cfg: &ProxyConfig, + secret: &str, + startup_detected_ip_v4: Option, + startup_detected_ip_v6: Option, +) -> UserLinks { + let hosts = resolve_link_hosts(cfg, startup_detected_ip_v4, startup_detected_ip_v6); + let port = cfg + .general + .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 { + classic.push(format!( + "tg://proxy?server={}&port={}&secret={}", + host, port, secret + )); + } + if cfg.general.modes.secure { + secure.push(format!( + "tg://proxy?server={}&port={}&secret=dd{}", + host, port, secret + )); + } + if cfg.general.modes.tls { + for domain in &tls_domains { + let domain_hex = hex::encode(domain); + tls.push(format!( + "tg://proxy?server={}&port={}&secret=ee{}{}", + 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, + }); + } + } + } + + UserLinks { + classic, + secure, + tls, + tls_domains: tls_domain_links, + } +} + +fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 { + cfg.server + .listeners + .first() + .and_then(|listener| listener.port) + .unwrap_or(cfg.server.port) +} + +fn resolve_link_hosts( + cfg: &ProxyConfig, + startup_detected_ip_v4: Option, + startup_detected_ip_v6: Option, +) -> Vec { + if let Some(host) = cfg + .general + .links + .public_host + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return vec![host.to_string()]; + } + + let mut hosts = Vec::new(); + for listener in &cfg.server.listeners { + if let Some(host) = listener + .announce + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + push_unique_host(&mut hosts, host); + continue; + } + if let Some(ip) = listener.announce_ip + && !ip.is_unspecified() + { + push_unique_host(&mut hosts, &ip.to_string()); + continue; + } + if listener.ip.is_unspecified() { + let detected_ip = if listener.ip.is_ipv4() { + startup_detected_ip_v4 + } else { + startup_detected_ip_v6 + }; + if let Some(ip) = detected_ip { + push_unique_host(&mut hosts, &ip.to_string()); + } else { + push_unique_host(&mut hosts, &listener.ip.to_string()); + } + continue; + } + push_unique_host(&mut hosts, &listener.ip.to_string()); + } + + if !hosts.is_empty() { + return hosts; + } + + if let Some(ip) = startup_detected_ip_v4.or(startup_detected_ip_v6) { + return vec![ip.to_string()]; + } + + if let Some(host) = cfg.server.listen_addr_ipv4.as_deref() { + push_host_from_legacy_listen(&mut hosts, host); + } + if let Some(host) = cfg.server.listen_addr_ipv6.as_deref() { + push_host_from_legacy_listen(&mut hosts, host); + } + if !hosts.is_empty() { + return hosts; + } + + vec!["UNKNOWN".to_string()] +} + +fn push_host_from_legacy_listen(hosts: &mut Vec, raw: &str) { + let candidate = raw.trim(); + if candidate.is_empty() { + return; + } + + match candidate.parse::() { + Ok(ip) if ip.is_unspecified() => {} + Ok(ip) => push_unique_host(hosts, &ip.to_string()), + Err(_) => push_unique_host(hosts, candidate), + } +} + +fn push_unique_host(hosts: &mut Vec, candidate: &str) { + if !hosts.iter().any(|existing| existing == candidate) { + hosts.push(candidate.to_string()); + } +} + +fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> { + let mut domains = Vec::with_capacity(1 + cfg.censorship.tls_domains.len()); + let primary = cfg.censorship.tls_domain.as_str(); + if !primary.is_empty() { + domains.push(primary); + } + for domain in &cfg.censorship.tls_domains { + let value = domain.as_str(); + if value.is_empty() || domains.contains(&value) { + continue; + } + domains.push(value); + } + 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 +} + diff --git a/src/api/users/tests.rs b/src/api/users/tests.rs new file mode 100644 index 0000000..45e2ec5 --- /dev/null +++ b/src/api/users/tests.rs @@ -0,0 +1,292 @@ + use super::*; + use crate::ip_tracker::UserIpTracker; + use crate::stats::Stats; + + #[tokio::test] + async fn users_from_config_reports_effective_tcp_limit_with_global_fallback() { + let mut cfg = ProxyConfig::default(); + cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.user_max_tcp_conns_global_each = 7; + + 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!(!alice.in_runtime); + assert_eq!(alice.max_tcp_conns, Some(7)); + + cfg.access.user_max_tcp_conns.insert("alice".to_string(), 5); + 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!(!alice.in_runtime); + assert_eq!(alice.max_tcp_conns, Some(5)); + + cfg.access.user_max_tcp_conns.insert("alice".to_string(), 0); + 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!(!alice.in_runtime); + assert_eq!(alice.max_tcp_conns, Some(7)); + + cfg.access.user_max_tcp_conns_global_each = 0; + 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!(!alice.in_runtime); + assert_eq!(alice.max_tcp_conns, None); + } + + #[tokio::test] + async fn users_from_config_reports_user_rate_limits() { + let mut cfg = ProxyConfig::default(); + cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.user_rate_limits.insert( + "alice".to_string(), + RateLimitBps { + up_bps: 1024, + down_bps: 0, + }, + ); + + 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.rate_limit_up_bps, Some(1024)); + assert_eq!(alice.rate_limit_down_bps, None); + } + + #[tokio::test] + async fn users_from_config_reports_user_enabled_default_and_override() { + let mut cfg = ProxyConfig::default(); + cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.users.insert( + "bob".to_string(), + "fedcba9876543210fedcba9876543210".to_string(), + ); + cfg.access.user_enabled.insert("bob".to_string(), false); + + 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"); + let bob = users + .iter() + .find(|entry| entry.username == "bob") + .expect("bob must be present"); + + assert!(alice.enabled); + assert!(!bob.enabled); + + cfg.access.user_enabled.insert("bob".to_string(), true); + let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; + let bob = users + .iter() + .find(|entry| entry.username == "bob") + .expect("bob must be present"); + assert!(bob.enabled); + } + + #[tokio::test] + async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() { + let mut disk_cfg = ProxyConfig::default(); + disk_cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + disk_cfg.access.users.insert( + "bob".to_string(), + "fedcba9876543210fedcba9876543210".to_string(), + ); + + let mut runtime_cfg = ProxyConfig::default(); + runtime_cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + + let stats = Stats::new(); + let tracker = UserIpTracker::new(); + let users = + users_from_config(&disk_cfg, &stats, &tracker, None, None, Some(&runtime_cfg)).await; + + let alice = users + .iter() + .find(|entry| entry.username == "alice") + .expect("alice must be present"); + let bob = users + .iter() + .find(|entry| entry.username == "bob") + .expect("bob must be present"); + + 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") + ); + } + + #[test] + fn build_user_quota_list_skips_users_without_positive_quota_and_sorts_by_username() { + let mut cfg = ProxyConfig::default(); + cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.users.insert( + "bob".to_string(), + "fedcba9876543210fedcba9876543210".to_string(), + ); + cfg.access.users.insert( + "carol".to_string(), + "aaaabbbbccccddddeeeeffff00001111".to_string(), + ); + // alice has a positive quota and should be listed. + cfg.access + .user_data_quota + .insert("alice".to_string(), 1 << 20); + // bob has no quota entry at all (None) — should be skipped. + // carol has an explicit zero quota — should be skipped. + cfg.access.user_data_quota.insert("carol".to_string(), 0); + + let stats = Stats::new(); + // Charge some traffic against alice; carol gets traffic too but should + // still be filtered out by the quota check. + let alice_stats = stats.get_or_create_user_stats_handle("alice"); + stats.quota_charge_post_write(&alice_stats, 4096); + let carol_stats = stats.get_or_create_user_stats_handle("carol"); + stats.quota_charge_post_write(&carol_stats, 99); + + let data = build_user_quota_list(&cfg, &stats); + + assert_eq!(data.users.len(), 1); + let entry = &data.users[0]; + assert_eq!(entry.username, "alice"); + assert_eq!(entry.data_quota_bytes, 1 << 20); + assert_eq!(entry.used_bytes, 4096); + assert_eq!(entry.last_reset_epoch_secs, 0); + } + + #[test] + fn build_user_quota_list_orders_multiple_users_by_username_ascending() { + let mut cfg = ProxyConfig::default(); + for name in ["charlie", "alice", "bob"] { + cfg.access.users.insert( + name.to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.user_data_quota.insert(name.to_string(), 1 << 30); + } + + let stats = Stats::new(); + let data = build_user_quota_list(&cfg, &stats); + + let names: Vec<&str> = data.users.iter().map(|e| e.username.as_str()).collect(); + assert_eq!(names, vec!["alice", "bob", "charlie"]); + for entry in &data.users { + assert_eq!(entry.used_bytes, 0); + assert_eq!(entry.last_reset_epoch_secs, 0); + assert_eq!(entry.data_quota_bytes, 1 << 30); + } + } diff --git a/src/api/users/update.rs b/src/api/users/update.rs new file mode 100644 index 0000000..06164da --- /dev/null +++ b/src/api/users/update.rs @@ -0,0 +1,246 @@ +use super::*; + +pub(in crate::api) async fn patch_user( + user: &str, + body: PatchUserRequest, + 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_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged) + || !matches!(&body.rate_limit_down_bps, Patch::Unchanged); + let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged); + let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged); + + if let Some(secret) = body.secret.as_ref() + && !is_valid_user_secret(secret) + { + return Err(ApiFailure::bad_request( + "secret must be exactly 32 hex characters", + )); + } + if let Patch::Set(ad_tag) = &body.user_ad_tag + && !is_valid_ad_tag(ad_tag) + { + return Err(ApiFailure::bad_request( + "user_ad_tag must be exactly 32 hex characters", + )); + } + let expiration = parse_patch_expiration(&body.expiration_rfc3339)?; + let _guard = shared.mutation_lock.lock().await; + let mut cfg = load_config_from_disk(&shared.config_path).await?; + ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; + + if !cfg.access.users.contains_key(user) { + return Err(ApiFailure::new( + StatusCode::NOT_FOUND, + "not_found", + "User not found", + )); + } + + if let Some(secret) = body.secret { + cfg.access.users.insert(user.to_string(), secret); + } + match body.user_ad_tag { + Patch::Unchanged => {} + Patch::Remove => { + cfg.access.user_ad_tags.remove(user); + } + Patch::Set(ad_tag) => { + cfg.access.user_ad_tags.insert(user.to_string(), ad_tag); + } + } + match body.max_tcp_conns { + Patch::Unchanged => {} + Patch::Remove => { + cfg.access.user_max_tcp_conns.remove(user); + } + Patch::Set(limit) => { + cfg.access + .user_max_tcp_conns + .insert(user.to_string(), limit); + } + } + match expiration { + Patch::Unchanged => {} + Patch::Remove => { + cfg.access.user_expirations.remove(user); + } + Patch::Set(expiration) => { + cfg.access + .user_expirations + .insert(user.to_string(), expiration); + } + } + match body.data_quota_bytes { + Patch::Unchanged => {} + Patch::Remove => { + cfg.access.user_data_quota.remove(user); + } + Patch::Set(quota) => { + cfg.access.user_data_quota.insert(user.to_string(), quota); + } + } + if touches_user_rate_limits { + let mut rate_limit = cfg + .access + .user_rate_limits + .get(user) + .copied() + .unwrap_or_default(); + match body.rate_limit_up_bps { + Patch::Unchanged => {} + Patch::Remove => rate_limit.up_bps = 0, + Patch::Set(limit) => rate_limit.up_bps = limit, + } + match body.rate_limit_down_bps { + Patch::Unchanged => {} + Patch::Remove => rate_limit.down_bps = 0, + Patch::Set(limit) => rate_limit.down_bps = limit, + } + if rate_limit.up_bps == 0 && rate_limit.down_bps == 0 { + cfg.access.user_rate_limits.remove(user); + } else { + cfg.access + .user_rate_limits + .insert(user.to_string(), rate_limit); + } + } + // Capture how the per-user IP limit changed, so the in-memory ip_tracker + // can be synced (set or removed) after the config is persisted. + let max_unique_ips_change = match body.max_unique_ips { + Patch::Unchanged => None, + Patch::Remove => { + cfg.access.user_max_unique_ips.remove(user); + Some(None) + } + Patch::Set(limit) => { + cfg.access + .user_max_unique_ips + .insert(user.to_string(), limit); + Some(Some(limit)) + } + }; + match body.enabled { + Patch::Unchanged => {} + Patch::Remove | Patch::Set(true) => { + cfg.access.user_enabled.remove(user); + } + Patch::Set(false) => { + cfg.access.user_enabled.insert(user.to_string(), false); + } + } + + cfg.validate() + .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; + + 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_rate_limits { + touched_sections.push(AccessSection::UserRateLimits); + } + if touches_user_max_unique_ips { + touched_sections.push(AccessSection::UserMaxUniqueIps); + } + if touches_user_enabled { + touched_sections.push(AccessSection::UserEnabled); + } + + 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, + Some(None) => shared.ip_tracker.remove_user_limit(user).await, + None => {} + } + let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); + let users = users_from_config( + &cfg, + &shared.stats, + &shared.ip_tracker, + detected_ip_v4, + detected_ip_v6, + None, + ) + .await; + let user_info = users + .into_iter() + .find(|entry| entry.username == user) + .ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?; + + Ok((user_info, revision)) +} + + +pub(in crate::api) async fn set_user_enabled( + user: &str, + enabled: bool, + expected_revision: Option, + shared: &ApiShared, +) -> Result<(UserInfo, String), ApiFailure> { + let _guard = shared.mutation_lock.lock().await; + let mut cfg = load_config_from_disk(&shared.config_path).await?; + ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?; + + if !cfg.access.users.contains_key(user) { + return Err(ApiFailure::new( + StatusCode::NOT_FOUND, + "not_found", + "User not found", + )); + } + + if enabled { + cfg.access.user_enabled.remove(user); + } else { + cfg.access.user_enabled.insert(user.to_string(), false); + } + + cfg.validate() + .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; + let revision = + save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled]) + .await?; + drop(_guard); + + let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); + let users = users_from_config( + &cfg, + &shared.stats, + &shared.ip_tracker, + detected_ip_v4, + detected_ip_v6, + None, + ) + .await; + let user_info = users + .into_iter() + .find(|entry| entry.username == user) + .ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?; + + Ok((user_info, revision)) +} + diff --git a/src/api/users/view.rs b/src/api/users/view.rs new file mode 100644 index 0000000..d37a039 --- /dev/null +++ b/src/api/users/view.rs @@ -0,0 +1,113 @@ +use super::*; + +pub(in crate::api) async fn users_from_config( + cfg: &ProxyConfig, + stats: &Stats, + ip_tracker: &UserIpTracker, + startup_detected_ip_v4: Option, + startup_detected_ip_v6: Option, + runtime_cfg: Option<&ProxyConfig>, +) -> Vec { + let mut names = cfg.access.users.keys().cloned().collect::>(); + names.sort(); + let active_ip_lists = ip_tracker.get_active_ips_for_users(&names).await; + let recent_ip_lists = ip_tracker.get_recent_ips_for_users(&names).await; + + let mut users = Vec::with_capacity(names.len()); + for username in names { + let active_ip_list = active_ip_lists + .get(&username) + .cloned() + .unwrap_or_else(Vec::new); + let recent_ip_list = recent_ip_lists + .get(&username) + .cloned() + .unwrap_or_else(Vec::new); + let links = cfg + .access + .users + .get(&username) + .map(|secret| { + build_user_links(cfg, secret, startup_detected_ip_v4, startup_detected_ip_v6) + }) + .unwrap_or_else(empty_user_links); + users.push(UserInfo { + enabled: cfg.access.is_user_enabled(&username), + in_runtime: runtime_cfg + .map(|runtime| runtime.access.users.contains_key(&username)) + .unwrap_or(false), + user_ad_tag: cfg.access.user_ad_tags.get(&username).cloned(), + max_tcp_conns: cfg + .access + .user_max_tcp_conns + .get(&username) + .copied() + .filter(|limit| *limit > 0) + .or((cfg.access.user_max_tcp_conns_global_each > 0) + .then_some(cfg.access.user_max_tcp_conns_global_each)), + expiration_rfc3339: cfg + .access + .user_expirations + .get(&username) + .map(chrono::DateTime::::to_rfc3339), + data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(), + rate_limit_up_bps: cfg + .access + .user_rate_limits + .get(&username) + .map(|limit| limit.up_bps) + .filter(|limit| *limit > 0), + rate_limit_down_bps: cfg + .access + .user_rate_limits + .get(&username) + .map(|limit| limit.down_bps) + .filter(|limit| *limit > 0), + max_unique_ips: cfg + .access + .user_max_unique_ips + .get(&username) + .copied() + .filter(|limit| *limit > 0) + .or((cfg.access.user_max_unique_ips_global_each > 0) + .then_some(cfg.access.user_max_unique_ips_global_each)), + current_connections: stats.get_user_curr_connects(&username), + active_unique_ips: active_ip_list.len(), + active_unique_ips_list: active_ip_list, + recent_unique_ips: recent_ip_list.len(), + recent_unique_ips_list: recent_ip_list, + total_octets: stats.get_user_total_octets(&username), + links, + username, + }); + } + users +} + +pub(in crate::api) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData { + let mut names = cfg.access.users.keys().cloned().collect::>(); + names.sort(); + + let snapshot = stats.user_quota_snapshot(); + let mut users = Vec::with_capacity(names.len()); + for username in names { + let Some(&data_quota_bytes) = cfg.access.user_data_quota.get(&username) else { + continue; + }; + if data_quota_bytes == 0 { + continue; + } + let (used_bytes, last_reset_epoch_secs) = snapshot + .get(&username) + .map(|entry| (entry.used_bytes, entry.last_reset_epoch_secs)) + .unwrap_or((0, 0)); + users.push(UserQuotaEntry { + username, + data_quota_bytes, + used_bytes, + last_reset_epoch_secs, + }); + } + UserQuotaListData { users } +} + diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index cdd6975..f2e90a0 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -33,6 +33,7 @@ use tokio::sync::{mpsc, watch}; use tracing::{error, info, warn}; use super::load::{LoadedConfig, ProxyConfig}; +#[allow(unused_imports)] use crate::config::{ CidrRateLimitKey, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode, @@ -42,1777 +43,21 @@ use crate::config::{ListenerConfig, SynLimitMode}; const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50); -// ── Hot fields ──────────────────────────────────────────────────────────────── - -/// Fields that are safe to swap without restarting listeners. -#[derive(Debug, Clone, PartialEq)] -pub struct HotFields { - pub log_level: LogLevel, - pub ad_tag: Option, - pub dns_overrides: Vec, - pub desync_all_full: bool, - pub update_every_secs: u64, - pub me_reinit_every_secs: u64, - pub me_reinit_singleflight: bool, - pub me_reinit_coalesce_window_ms: u64, - pub hardswap: bool, - pub me_pool_drain_ttl_secs: u64, - pub me_instadrain: bool, - pub me_pool_drain_threshold: u64, - pub me_pool_min_fresh_ratio: f32, - pub me_reinit_drain_timeout_secs: u64, - pub me_hardswap_warmup_delay_min_ms: u64, - pub me_hardswap_warmup_delay_max_ms: u64, - pub me_hardswap_warmup_extra_passes: u8, - pub me_hardswap_warmup_pass_backoff_base_ms: u64, - pub me_bind_stale_mode: MeBindStaleMode, - pub me_bind_stale_ttl_secs: u64, - pub me_secret_atomic_snapshot: bool, - pub me_deterministic_writer_sort: bool, - pub me_writer_pick_mode: MeWriterPickMode, - pub me_writer_pick_sample_size: u8, - pub me_single_endpoint_shadow_writers: u8, - pub me_single_endpoint_outage_mode_enabled: bool, - pub me_single_endpoint_outage_disable_quarantine: bool, - pub me_single_endpoint_outage_backoff_min_ms: u64, - pub me_single_endpoint_outage_backoff_max_ms: u64, - pub me_single_endpoint_shadow_rotate_every_secs: u64, - pub me_config_stable_snapshots: u8, - pub me_config_apply_cooldown_secs: u64, - pub me_snapshot_require_http_2xx: bool, - pub me_snapshot_reject_empty_map: bool, - pub me_snapshot_min_proxy_for_lines: u32, - pub proxy_secret_stable_snapshots: u8, - pub proxy_secret_rotate_runtime: bool, - pub proxy_secret_len_max: usize, - pub telemetry_core_enabled: bool, - pub telemetry_user_enabled: bool, - pub telemetry_me_level: MeTelemetryLevel, - pub me_socks_kdf_policy: MeSocksKdfPolicy, - pub me_route_backpressure_enabled: bool, - pub me_route_fairshare_enabled: bool, - pub me_floor_mode: MeFloorMode, - pub me_adaptive_floor_idle_secs: u64, - pub me_adaptive_floor_min_writers_single_endpoint: u8, - pub me_adaptive_floor_min_writers_multi_endpoint: u8, - pub me_adaptive_floor_recover_grace_secs: u64, - pub me_adaptive_floor_writers_per_core_total: u16, - pub me_adaptive_floor_cpu_cores_override: u16, - pub me_adaptive_floor_max_extra_writers_single_per_core: u16, - pub me_adaptive_floor_max_extra_writers_multi_per_core: u16, - pub me_adaptive_floor_max_active_writers_per_core: u16, - pub me_adaptive_floor_max_warm_writers_per_core: u16, - pub me_adaptive_floor_max_active_writers_global: u32, - pub me_adaptive_floor_max_warm_writers_global: u32, - pub me_route_backpressure_base_timeout_ms: u64, - pub me_route_backpressure_high_timeout_ms: u64, - pub me_route_backpressure_high_watermark_pct: u8, - pub me_reader_route_data_wait_ms: u64, - pub me_d2c_flush_batch_max_frames: usize, - pub me_d2c_flush_batch_max_bytes: usize, - pub me_d2c_flush_batch_max_delay_us: u64, - pub me_d2c_ack_flush_immediate: bool, - pub me_quota_soft_overshoot_bytes: u64, - pub me_d2c_frame_buf_shrink_threshold_bytes: usize, - pub direct_relay_copy_buf_c2s_bytes: usize, - pub direct_relay_copy_buf_s2c_bytes: usize, - pub me_health_interval_ms_unhealthy: u64, - pub me_health_interval_ms_healthy: u64, - pub me_admission_poll_ms: u64, - pub me_warn_rate_limit_ms: u64, - pub users: std::collections::HashMap, - pub user_enabled: std::collections::HashMap, - pub user_ad_tags: std::collections::HashMap, - pub user_max_tcp_conns: std::collections::HashMap, - pub user_max_tcp_conns_global_each: usize, - pub user_expirations: std::collections::HashMap>, - pub user_data_quota: std::collections::HashMap, - pub user_rate_limits: std::collections::HashMap, - pub cidr_rate_limits: std::collections::HashMap, - pub user_max_unique_ips: std::collections::HashMap, - pub user_max_unique_ips_global_each: usize, - pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode, - pub user_max_unique_ips_window_secs: u64, -} - -impl HotFields { - pub fn from_config(cfg: &ProxyConfig) -> Self { - Self { - log_level: cfg.general.log_level.clone(), - ad_tag: cfg.general.ad_tag.clone(), - dns_overrides: cfg.network.dns_overrides.clone(), - desync_all_full: cfg.general.desync_all_full, - update_every_secs: cfg.general.effective_update_every_secs(), - me_reinit_every_secs: cfg.general.me_reinit_every_secs, - me_reinit_singleflight: cfg.general.me_reinit_singleflight, - me_reinit_coalesce_window_ms: cfg.general.me_reinit_coalesce_window_ms, - hardswap: cfg.general.hardswap, - me_pool_drain_ttl_secs: cfg.general.me_pool_drain_ttl_secs, - me_instadrain: cfg.general.me_instadrain, - me_pool_drain_threshold: cfg.general.me_pool_drain_threshold, - me_pool_min_fresh_ratio: cfg.general.me_pool_min_fresh_ratio, - me_reinit_drain_timeout_secs: cfg.general.me_reinit_drain_timeout_secs, - me_hardswap_warmup_delay_min_ms: cfg.general.me_hardswap_warmup_delay_min_ms, - me_hardswap_warmup_delay_max_ms: cfg.general.me_hardswap_warmup_delay_max_ms, - me_hardswap_warmup_extra_passes: cfg.general.me_hardswap_warmup_extra_passes, - me_hardswap_warmup_pass_backoff_base_ms: cfg - .general - .me_hardswap_warmup_pass_backoff_base_ms, - me_bind_stale_mode: cfg.general.me_bind_stale_mode, - me_bind_stale_ttl_secs: cfg.general.me_bind_stale_ttl_secs, - me_secret_atomic_snapshot: cfg.general.me_secret_atomic_snapshot, - me_deterministic_writer_sort: cfg.general.me_deterministic_writer_sort, - me_writer_pick_mode: cfg.general.me_writer_pick_mode, - me_writer_pick_sample_size: cfg.general.me_writer_pick_sample_size, - me_single_endpoint_shadow_writers: cfg.general.me_single_endpoint_shadow_writers, - me_single_endpoint_outage_mode_enabled: cfg - .general - .me_single_endpoint_outage_mode_enabled, - me_single_endpoint_outage_disable_quarantine: cfg - .general - .me_single_endpoint_outage_disable_quarantine, - me_single_endpoint_outage_backoff_min_ms: cfg - .general - .me_single_endpoint_outage_backoff_min_ms, - me_single_endpoint_outage_backoff_max_ms: cfg - .general - .me_single_endpoint_outage_backoff_max_ms, - me_single_endpoint_shadow_rotate_every_secs: cfg - .general - .me_single_endpoint_shadow_rotate_every_secs, - me_config_stable_snapshots: cfg.general.me_config_stable_snapshots, - me_config_apply_cooldown_secs: cfg.general.me_config_apply_cooldown_secs, - me_snapshot_require_http_2xx: cfg.general.me_snapshot_require_http_2xx, - me_snapshot_reject_empty_map: cfg.general.me_snapshot_reject_empty_map, - me_snapshot_min_proxy_for_lines: cfg.general.me_snapshot_min_proxy_for_lines, - proxy_secret_stable_snapshots: cfg.general.proxy_secret_stable_snapshots, - proxy_secret_rotate_runtime: cfg.general.proxy_secret_rotate_runtime, - proxy_secret_len_max: cfg.general.proxy_secret_len_max, - telemetry_core_enabled: cfg.general.telemetry.core_enabled, - telemetry_user_enabled: cfg.general.telemetry.user_enabled, - telemetry_me_level: cfg.general.telemetry.me_level, - me_socks_kdf_policy: cfg.general.me_socks_kdf_policy, - me_route_backpressure_enabled: cfg.general.me_route_backpressure_enabled, - me_route_fairshare_enabled: cfg.general.me_route_fairshare_enabled, - me_floor_mode: cfg.general.me_floor_mode, - me_adaptive_floor_idle_secs: cfg.general.me_adaptive_floor_idle_secs, - me_adaptive_floor_min_writers_single_endpoint: cfg - .general - .me_adaptive_floor_min_writers_single_endpoint, - me_adaptive_floor_min_writers_multi_endpoint: cfg - .general - .me_adaptive_floor_min_writers_multi_endpoint, - me_adaptive_floor_recover_grace_secs: cfg.general.me_adaptive_floor_recover_grace_secs, - me_adaptive_floor_writers_per_core_total: cfg - .general - .me_adaptive_floor_writers_per_core_total, - me_adaptive_floor_cpu_cores_override: cfg.general.me_adaptive_floor_cpu_cores_override, - me_adaptive_floor_max_extra_writers_single_per_core: cfg - .general - .me_adaptive_floor_max_extra_writers_single_per_core, - me_adaptive_floor_max_extra_writers_multi_per_core: cfg - .general - .me_adaptive_floor_max_extra_writers_multi_per_core, - me_adaptive_floor_max_active_writers_per_core: cfg - .general - .me_adaptive_floor_max_active_writers_per_core, - me_adaptive_floor_max_warm_writers_per_core: cfg - .general - .me_adaptive_floor_max_warm_writers_per_core, - me_adaptive_floor_max_active_writers_global: cfg - .general - .me_adaptive_floor_max_active_writers_global, - me_adaptive_floor_max_warm_writers_global: cfg - .general - .me_adaptive_floor_max_warm_writers_global, - me_route_backpressure_base_timeout_ms: cfg - .general - .me_route_backpressure_base_timeout_ms, - me_route_backpressure_high_timeout_ms: cfg - .general - .me_route_backpressure_high_timeout_ms, - me_route_backpressure_high_watermark_pct: cfg - .general - .me_route_backpressure_high_watermark_pct, - me_reader_route_data_wait_ms: cfg.general.me_reader_route_data_wait_ms, - me_d2c_flush_batch_max_frames: cfg.general.me_d2c_flush_batch_max_frames, - me_d2c_flush_batch_max_bytes: cfg.general.me_d2c_flush_batch_max_bytes, - me_d2c_flush_batch_max_delay_us: cfg.general.me_d2c_flush_batch_max_delay_us, - me_d2c_ack_flush_immediate: cfg.general.me_d2c_ack_flush_immediate, - me_quota_soft_overshoot_bytes: cfg.general.me_quota_soft_overshoot_bytes, - me_d2c_frame_buf_shrink_threshold_bytes: cfg - .general - .me_d2c_frame_buf_shrink_threshold_bytes, - direct_relay_copy_buf_c2s_bytes: cfg.general.direct_relay_copy_buf_c2s_bytes, - direct_relay_copy_buf_s2c_bytes: cfg.general.direct_relay_copy_buf_s2c_bytes, - me_health_interval_ms_unhealthy: cfg.general.me_health_interval_ms_unhealthy, - me_health_interval_ms_healthy: cfg.general.me_health_interval_ms_healthy, - me_admission_poll_ms: cfg.general.me_admission_poll_ms, - me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms, - users: cfg.access.users.clone(), - user_enabled: cfg.access.user_enabled.clone(), - user_ad_tags: cfg.access.user_ad_tags.clone(), - user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(), - user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each, - user_expirations: cfg.access.user_expirations.clone(), - user_data_quota: cfg.access.user_data_quota.clone(), - user_rate_limits: cfg.access.user_rate_limits.clone(), - cidr_rate_limits: cfg.access.cidr_rate_limits.clone(), - user_max_unique_ips: cfg.access.user_max_unique_ips.clone(), - user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each, - user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode, - user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs, - } - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn canonicalize_json(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - let mut pairs: Vec<(String, serde_json::Value)> = - std::mem::take(map).into_iter().collect(); - pairs.sort_by(|a, b| a.0.cmp(&b.0)); - for (_, item) in pairs.iter_mut() { - canonicalize_json(item); - } - for (key, item) in pairs { - map.insert(key, item); - } - } - serde_json::Value::Array(items) => { - for item in items { - canonicalize_json(item); - } - } - _ => {} - } -} - -fn config_equal(lhs: &ProxyConfig, rhs: &ProxyConfig) -> bool { - let mut left = match serde_json::to_value(lhs) { - Ok(value) => value, - Err(_) => return false, - }; - let mut right = match serde_json::to_value(rhs) { - Ok(value) => value, - Err(_) => return false, - }; - canonicalize_json(&mut left); - canonicalize_json(&mut right); - left == right -} - -fn listeners_equal( - lhs: &[crate::config::ListenerConfig], - rhs: &[crate::config::ListenerConfig], -) -> bool { - serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok() -} - -fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 { - cfg.server - .listeners - .first() - .and_then(|listener| listener.port) - .unwrap_or(cfg.server.port) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct WatchManifest { - files: BTreeSet, - dirs: BTreeSet, -} - -impl WatchManifest { - fn from_source_files(source_files: &[PathBuf]) -> Self { - let mut files = BTreeSet::new(); - let mut dirs = BTreeSet::new(); - - for path in source_files { - let normalized = normalize_watch_path(path); - files.insert(normalized.clone()); - if let Some(parent) = normalized.parent() { - dirs.insert(parent.to_path_buf()); - } - } - - Self { files, dirs } - } - - fn matches_event_paths(&self, event_paths: &[PathBuf]) -> bool { - event_paths - .iter() - .map(|path| normalize_watch_path(path)) - .any(|path| self.files.contains(&path)) - } -} - -#[derive(Debug, Default)] -struct ReloadState { - applied_snapshot_hash: Option, -} - -impl ReloadState { - fn new(applied_snapshot_hash: Option) -> Self { - Self { - applied_snapshot_hash, - } - } - - fn is_applied(&self, hash: u64) -> bool { - self.applied_snapshot_hash == Some(hash) - } - - fn mark_applied(&mut self, hash: u64) { - self.applied_snapshot_hash = Some(hash); - } -} - -fn normalize_watch_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| { - if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|_| path.to_path_buf()) - } - }) -} - -fn sync_watch_paths( - watcher: &mut W, - current: &BTreeSet, - next: &BTreeSet, - recursive_mode: RecursiveMode, - kind: &str, -) { - for path in current.difference(next) { - if let Err(e) = watcher.unwatch(path) { - warn!(path = %path.display(), error = %e, "config watcher: failed to unwatch {kind}"); - } - } - - for path in next.difference(current) { - if let Err(e) = watcher.watch(path, recursive_mode) { - warn!(path = %path.display(), error = %e, "config watcher: failed to watch {kind}"); - } - } -} - -fn apply_watch_manifest( - notify_watcher: Option<&mut W1>, - poll_watcher: Option<&mut W2>, - manifest_state: &Arc>, - next_manifest: WatchManifest, -) { - let current_manifest = manifest_state - .read() - .map(|manifest| manifest.clone()) - .unwrap_or_default(); - - if current_manifest == next_manifest { - return; - } - - if let Some(watcher) = notify_watcher { - sync_watch_paths( - watcher, - ¤t_manifest.dirs, - &next_manifest.dirs, - RecursiveMode::NonRecursive, - "config directory", - ); - } - - if let Some(watcher) = poll_watcher { - sync_watch_paths( - watcher, - ¤t_manifest.files, - &next_manifest.files, - RecursiveMode::NonRecursive, - "config file", - ); - } - - if let Ok(mut manifest) = manifest_state.write() { - *manifest = next_manifest; - } -} - -fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig { - let mut cfg = old.clone(); - - cfg.general.log_level = new.general.log_level.clone(); - cfg.general.ad_tag = new.general.ad_tag.clone(); - cfg.network.dns_overrides = new.network.dns_overrides.clone(); - cfg.general.desync_all_full = new.general.desync_all_full; - cfg.general.update_every = new.general.update_every; - cfg.general.proxy_secret_auto_reload_secs = new.general.proxy_secret_auto_reload_secs; - cfg.general.proxy_config_auto_reload_secs = new.general.proxy_config_auto_reload_secs; - cfg.general.me_reinit_every_secs = new.general.me_reinit_every_secs; - cfg.general.me_reinit_singleflight = new.general.me_reinit_singleflight; - cfg.general.me_reinit_coalesce_window_ms = new.general.me_reinit_coalesce_window_ms; - cfg.general.hardswap = new.general.hardswap; - cfg.general.me_pool_drain_ttl_secs = new.general.me_pool_drain_ttl_secs; - cfg.general.me_instadrain = new.general.me_instadrain; - cfg.general.me_pool_drain_threshold = new.general.me_pool_drain_threshold; - cfg.general.me_pool_min_fresh_ratio = new.general.me_pool_min_fresh_ratio; - cfg.general.me_reinit_drain_timeout_secs = new.general.me_reinit_drain_timeout_secs; - cfg.general.me_hardswap_warmup_delay_min_ms = new.general.me_hardswap_warmup_delay_min_ms; - cfg.general.me_hardswap_warmup_delay_max_ms = new.general.me_hardswap_warmup_delay_max_ms; - cfg.general.me_hardswap_warmup_extra_passes = new.general.me_hardswap_warmup_extra_passes; - cfg.general.me_hardswap_warmup_pass_backoff_base_ms = - new.general.me_hardswap_warmup_pass_backoff_base_ms; - cfg.general.me_bind_stale_mode = new.general.me_bind_stale_mode; - cfg.general.me_bind_stale_ttl_secs = new.general.me_bind_stale_ttl_secs; - cfg.general.me_secret_atomic_snapshot = new.general.me_secret_atomic_snapshot; - cfg.general.me_deterministic_writer_sort = new.general.me_deterministic_writer_sort; - cfg.general.me_writer_pick_mode = new.general.me_writer_pick_mode; - cfg.general.me_writer_pick_sample_size = new.general.me_writer_pick_sample_size; - cfg.general.me_single_endpoint_shadow_writers = new.general.me_single_endpoint_shadow_writers; - cfg.general.me_single_endpoint_outage_mode_enabled = - new.general.me_single_endpoint_outage_mode_enabled; - cfg.general.me_single_endpoint_outage_disable_quarantine = - new.general.me_single_endpoint_outage_disable_quarantine; - cfg.general.me_single_endpoint_outage_backoff_min_ms = - new.general.me_single_endpoint_outage_backoff_min_ms; - cfg.general.me_single_endpoint_outage_backoff_max_ms = - new.general.me_single_endpoint_outage_backoff_max_ms; - cfg.general.me_single_endpoint_shadow_rotate_every_secs = - new.general.me_single_endpoint_shadow_rotate_every_secs; - cfg.general.me_config_stable_snapshots = new.general.me_config_stable_snapshots; - cfg.general.me_config_apply_cooldown_secs = new.general.me_config_apply_cooldown_secs; - cfg.general.me_snapshot_require_http_2xx = new.general.me_snapshot_require_http_2xx; - cfg.general.me_snapshot_reject_empty_map = new.general.me_snapshot_reject_empty_map; - cfg.general.me_snapshot_min_proxy_for_lines = new.general.me_snapshot_min_proxy_for_lines; - cfg.general.proxy_secret_stable_snapshots = new.general.proxy_secret_stable_snapshots; - cfg.general.proxy_secret_rotate_runtime = new.general.proxy_secret_rotate_runtime; - cfg.general.proxy_secret_len_max = new.general.proxy_secret_len_max; - cfg.general.telemetry = new.general.telemetry.clone(); - cfg.general.me_socks_kdf_policy = new.general.me_socks_kdf_policy; - cfg.general.me_floor_mode = new.general.me_floor_mode; - cfg.general.me_adaptive_floor_idle_secs = new.general.me_adaptive_floor_idle_secs; - cfg.general.me_adaptive_floor_min_writers_single_endpoint = - new.general.me_adaptive_floor_min_writers_single_endpoint; - cfg.general.me_adaptive_floor_min_writers_multi_endpoint = - new.general.me_adaptive_floor_min_writers_multi_endpoint; - cfg.general.me_adaptive_floor_recover_grace_secs = - new.general.me_adaptive_floor_recover_grace_secs; - cfg.general.me_adaptive_floor_writers_per_core_total = - new.general.me_adaptive_floor_writers_per_core_total; - cfg.general.me_adaptive_floor_cpu_cores_override = - new.general.me_adaptive_floor_cpu_cores_override; - cfg.general - .me_adaptive_floor_max_extra_writers_single_per_core = new - .general - .me_adaptive_floor_max_extra_writers_single_per_core; - cfg.general - .me_adaptive_floor_max_extra_writers_multi_per_core = new - .general - .me_adaptive_floor_max_extra_writers_multi_per_core; - cfg.general.me_adaptive_floor_max_active_writers_per_core = - new.general.me_adaptive_floor_max_active_writers_per_core; - cfg.general.me_adaptive_floor_max_warm_writers_per_core = - new.general.me_adaptive_floor_max_warm_writers_per_core; - cfg.general.me_adaptive_floor_max_active_writers_global = - new.general.me_adaptive_floor_max_active_writers_global; - cfg.general.me_adaptive_floor_max_warm_writers_global = - new.general.me_adaptive_floor_max_warm_writers_global; - cfg.general.me_route_backpressure_base_timeout_ms = - new.general.me_route_backpressure_base_timeout_ms; - cfg.general.me_route_backpressure_high_timeout_ms = - new.general.me_route_backpressure_high_timeout_ms; - cfg.general.me_route_backpressure_high_watermark_pct = - new.general.me_route_backpressure_high_watermark_pct; - cfg.general.me_route_backpressure_enabled = new.general.me_route_backpressure_enabled; - cfg.general.me_route_fairshare_enabled = new.general.me_route_fairshare_enabled; - cfg.general.me_reader_route_data_wait_ms = new.general.me_reader_route_data_wait_ms; - cfg.general.me_d2c_flush_batch_max_frames = new.general.me_d2c_flush_batch_max_frames; - cfg.general.me_d2c_flush_batch_max_bytes = new.general.me_d2c_flush_batch_max_bytes; - cfg.general.me_d2c_flush_batch_max_delay_us = new.general.me_d2c_flush_batch_max_delay_us; - cfg.general.me_d2c_ack_flush_immediate = new.general.me_d2c_ack_flush_immediate; - cfg.general.me_quota_soft_overshoot_bytes = new.general.me_quota_soft_overshoot_bytes; - cfg.general.me_d2c_frame_buf_shrink_threshold_bytes = - new.general.me_d2c_frame_buf_shrink_threshold_bytes; - cfg.general.direct_relay_copy_buf_c2s_bytes = new.general.direct_relay_copy_buf_c2s_bytes; - cfg.general.direct_relay_copy_buf_s2c_bytes = new.general.direct_relay_copy_buf_s2c_bytes; - cfg.general.me_health_interval_ms_unhealthy = new.general.me_health_interval_ms_unhealthy; - cfg.general.me_health_interval_ms_healthy = new.general.me_health_interval_ms_healthy; - cfg.general.me_admission_poll_ms = new.general.me_admission_poll_ms; - cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms; - - cfg.access.users = new.access.users.clone(); - cfg.access.user_enabled = new.access.user_enabled.clone(); - cfg.access.user_ad_tags = new.access.user_ad_tags.clone(); - cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone(); - cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each; - cfg.access.user_expirations = new.access.user_expirations.clone(); - cfg.access.user_data_quota = new.access.user_data_quota.clone(); - cfg.access.user_rate_limits = new.access.user_rate_limits.clone(); - cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.clone(); - cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone(); - cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; - cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; - cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; - if cfg.rebuild_runtime_user_auth().is_err() { - cfg.runtime_user_auth = None; - } - - cfg -} - -/// Warn if any non-hot fields changed (require restart). -fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { - let mut warned = false; - if old.server.port != new.server.port { - warned = true; - warn!( - "config reload: server.port changed ({} → {}); restart required", - old.server.port, new.server.port - ); - } - if old.server.api.enabled != new.server.api.enabled - || old.server.api.listen != new.server.api.listen - || old.server.api.whitelist != new.server.api.whitelist - || old.server.api.gray_action != new.server.api.gray_action - || old.server.api.auth_header != new.server.api.auth_header - || old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes - || old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled - || old.server.api.minimal_runtime_cache_ttl_ms - != new.server.api.minimal_runtime_cache_ttl_ms - || old.server.api.runtime_edge_enabled != new.server.api.runtime_edge_enabled - || old.server.api.runtime_edge_cache_ttl_ms != new.server.api.runtime_edge_cache_ttl_ms - || old.server.api.runtime_edge_top_n != new.server.api.runtime_edge_top_n - || old.server.api.runtime_edge_events_capacity - != new.server.api.runtime_edge_events_capacity - || old.server.api.read_only != new.server.api.read_only - { - warned = true; - warn!("config reload: server.api changed; restart required"); - } - if old.server.proxy_protocol != new.server.proxy_protocol - || !listeners_equal(&old.server.listeners, &new.server.listeners) - || old.server.listen_backlog != new.server.listen_backlog - || old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4 - || old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6 - || old.server.listen_tcp != new.server.listen_tcp - || old.server.client_mss != new.server.client_mss - || old.server.listen_unix_sock != new.server.listen_unix_sock - || old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm - { - warned = true; - warn!("config reload: server listener settings changed; restart required"); - } - if old.censorship.tls_domain != new.censorship.tls_domain - || old.censorship.tls_domains != new.censorship.tls_domains - || old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope - || old.censorship.mask != new.censorship.mask - || old.censorship.mask_dynamic != new.censorship.mask_dynamic - || old.censorship.mask_host != new.censorship.mask_host - || old.censorship.mask_port != new.censorship.mask_port - || old.censorship.exclusive_mask != new.censorship.exclusive_mask - || old.censorship.mask_unix_sock != new.censorship.mask_unix_sock - || old.censorship.fake_cert_len != new.censorship.fake_cert_len - || old.censorship.tls_emulation != new.censorship.tls_emulation - || old.censorship.tls_front_dir != new.censorship.tls_front_dir - || old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms - || old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms - || old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets - || old.censorship.serverhello_compact != new.censorship.serverhello_compact - || old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs - || old.censorship.alpn_enforce != new.censorship.alpn_enforce - || old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol - || old.censorship.mask_shape_hardening != new.censorship.mask_shape_hardening - || old.censorship.mask_shape_bucket_floor_bytes - != new.censorship.mask_shape_bucket_floor_bytes - || old.censorship.mask_shape_bucket_cap_bytes != new.censorship.mask_shape_bucket_cap_bytes - || old.censorship.mask_shape_above_cap_blur != new.censorship.mask_shape_above_cap_blur - || old.censorship.mask_shape_above_cap_blur_max_bytes - != new.censorship.mask_shape_above_cap_blur_max_bytes - || old.censorship.mask_relay_max_bytes != new.censorship.mask_relay_max_bytes - || old.censorship.mask_relay_timeout_ms != new.censorship.mask_relay_timeout_ms - || old.censorship.mask_relay_idle_timeout_ms != new.censorship.mask_relay_idle_timeout_ms - || old.censorship.mask_classifier_prefetch_timeout_ms - != new.censorship.mask_classifier_prefetch_timeout_ms - || old.censorship.mask_timing_normalization_enabled - != new.censorship.mask_timing_normalization_enabled - || old.censorship.mask_timing_normalization_floor_ms - != new.censorship.mask_timing_normalization_floor_ms - || old.censorship.mask_timing_normalization_ceiling_ms - != new.censorship.mask_timing_normalization_ceiling_ms - { - warned = true; - warn!("config reload: censorship settings changed; restart required"); - } - if old.censorship.tls_domain != new.censorship.tls_domain { - warned = true; - warn!( - "config reload: censorship.tls_domain changed ('{}' → '{}'); restart required", - old.censorship.tls_domain, new.censorship.tls_domain - ); - } - if old.network.ipv4 != new.network.ipv4 || old.network.ipv6 != new.network.ipv6 { - warned = true; - warn!("config reload: network.ipv4/ipv6 changed; restart required"); - } - if old.network.prefer != new.network.prefer - || old.network.multipath != new.network.multipath - || old.network.stun_use != new.network.stun_use - || old.network.stun_servers != new.network.stun_servers - || old.network.stun_tcp_fallback != new.network.stun_tcp_fallback - || old.network.http_ip_detect_urls != new.network.http_ip_detect_urls - || old.network.cache_public_ip_path != new.network.cache_public_ip_path - { - warned = true; - warn!("config reload: non-hot network settings changed; restart required"); - } - if old.general.use_middle_proxy != new.general.use_middle_proxy { - warned = true; - warn!("config reload: use_middle_proxy changed; restart required"); - } - if old.general.stun_nat_probe_concurrency != new.general.stun_nat_probe_concurrency { - warned = true; - warn!("config reload: general.stun_nat_probe_concurrency changed; restart required"); - } - if old.general.middle_proxy_pool_size != new.general.middle_proxy_pool_size { - warned = true; - warn!("config reload: general.middle_proxy_pool_size changed; restart required"); - } - if old.general.me_route_no_writer_mode != new.general.me_route_no_writer_mode - || old.general.me_route_no_writer_wait_ms != new.general.me_route_no_writer_wait_ms - || old.general.me_route_hybrid_max_wait_ms != new.general.me_route_hybrid_max_wait_ms - || old.general.me_route_blocking_send_timeout_ms - != new.general.me_route_blocking_send_timeout_ms - || old.general.me_route_inline_recovery_attempts - != new.general.me_route_inline_recovery_attempts - || old.general.me_route_inline_recovery_wait_ms - != new.general.me_route_inline_recovery_wait_ms - { - warned = true; - warn!("config reload: general.me_route_no_writer_* changed; restart required"); - } - if old.general.unknown_dc_log_path != new.general.unknown_dc_log_path - || old.general.unknown_dc_file_log_enabled != new.general.unknown_dc_file_log_enabled - { - warned = true; - warn!("config reload: general.unknown_dc_* changed; restart required"); - } - if old.general.me_init_retry_attempts != new.general.me_init_retry_attempts { - warned = true; - warn!("config reload: general.me_init_retry_attempts changed; restart required"); - } - if old.general.me2dc_fallback != new.general.me2dc_fallback - || old.general.me2dc_fast != new.general.me2dc_fast - { - warned = true; - warn!("config reload: general.me2dc_fallback/me2dc_fast changed; restart required"); - } - if old.general.proxy_config_v4_cache_path != new.general.proxy_config_v4_cache_path - || old.general.proxy_config_v6_cache_path != new.general.proxy_config_v6_cache_path - { - warned = true; - warn!("config reload: general.proxy_config_*_cache_path changed; restart required"); - } - if old.general.me_keepalive_enabled != new.general.me_keepalive_enabled - || old.general.me_keepalive_interval_secs != new.general.me_keepalive_interval_secs - || old.general.me_keepalive_jitter_secs != new.general.me_keepalive_jitter_secs - || old.general.me_keepalive_payload_random != new.general.me_keepalive_payload_random - { - warned = true; - warn!("config reload: general.me_keepalive_* changed; restart required"); - } - if old.general.upstream_connect_retry_attempts != new.general.upstream_connect_retry_attempts - || old.general.upstream_connect_retry_backoff_ms - != new.general.upstream_connect_retry_backoff_ms - || old.general.tg_connect != new.general.tg_connect - || old.general.upstream_unhealthy_fail_threshold - != new.general.upstream_unhealthy_fail_threshold - || old.general.upstream_connect_failfast_hard_errors - != new.general.upstream_connect_failfast_hard_errors - || old.general.rpc_proxy_req_every != new.general.rpc_proxy_req_every - { - warned = true; - warn!("config reload: general.upstream_* changed; restart required"); - } - if non_hot_changed && !warned { - warn!("config reload: one or more non-hot fields changed; restart required"); - } -} - -/// Resolve the public host for link generation — mirrors the logic in main.rs. -/// -/// Priority: -/// 1. `[general.links] public_host` — explicit override in config -/// 2. `detected_ip_v4` — from STUN/interface probe at startup -/// 3. `detected_ip_v6` — fallback -/// 4. `"UNKNOWN"` — warn the user to set `public_host` -fn resolve_link_host( - cfg: &ProxyConfig, - detected_ip_v4: Option, - detected_ip_v6: Option, -) -> String { - if let Some(ref h) = cfg.general.links.public_host { - return h.clone(); - } - detected_ip_v4 - .or(detected_ip_v6) - .map(|ip| ip.to_string()) - .unwrap_or_else(|| { - warn!( - "config reload: could not determine public IP for proxy links. \ - Set [general.links] public_host in config." - ); - "UNKNOWN".to_string() - }) -} - -/// Print TG proxy links for a single user — mirrors print_proxy_links() in main.rs. -fn print_user_links(user: &str, secret: &str, host: &str, port: u16, cfg: &ProxyConfig) { - info!(target: "telemt::links", "--- New user: {} ---", user); - if cfg.general.modes.classic { - info!( - target: "telemt::links", - " Classic: tg://proxy?server={}&port={}&secret={}", - host, port, secret - ); - } - if cfg.general.modes.secure { - info!( - target: "telemt::links", - " DD: tg://proxy?server={}&port={}&secret=dd{}", - host, port, secret - ); - } - if cfg.general.modes.tls { - let mut domains = vec![cfg.censorship.tls_domain.clone()]; - for d in &cfg.censorship.tls_domains { - if !domains.contains(d) { - domains.push(d.clone()); - } - } - for domain in &domains { - let domain_hex = hex::encode(domain.as_bytes()); - info!( - target: "telemt::links", - " EE-TLS: tg://proxy?server={}&port={}&secret=ee{}{}", - host, port, secret, domain_hex - ); - } - } - info!(target: "telemt::links", "--------------------"); -} - -/// Log all detected changes and emit TG links for new users. -fn log_changes( - old_hot: &HotFields, - new_hot: &HotFields, - new_cfg: &ProxyConfig, - log_tx: &watch::Sender, - detected_ip_v4: Option, - detected_ip_v6: Option, -) { - if old_hot.log_level != new_hot.log_level { - info!( - "config reload: log_level: '{}' → '{}'", - old_hot.log_level, new_hot.log_level - ); - log_tx.send(new_hot.log_level.clone()).ok(); - } - - if old_hot.user_ad_tags != new_hot.user_ad_tags { - info!( - "config reload: user_ad_tags updated ({} entries)", - new_hot.user_ad_tags.len(), - ); - } - - if old_hot.ad_tag != new_hot.ad_tag { - info!("config reload: general.ad_tag updated (applied on next connection)"); - } - - if old_hot.dns_overrides != new_hot.dns_overrides { - info!( - "config reload: network.dns_overrides updated ({} entries)", - new_hot.dns_overrides.len() - ); - } - - if old_hot.desync_all_full != new_hot.desync_all_full { - info!( - "config reload: desync_all_full: {} → {}", - old_hot.desync_all_full, new_hot.desync_all_full, - ); - } - - if old_hot.update_every_secs != new_hot.update_every_secs { - info!( - "config reload: update_every(effective): {}s → {}s", - old_hot.update_every_secs, new_hot.update_every_secs, - ); - } - if old_hot.me_reinit_every_secs != new_hot.me_reinit_every_secs - || old_hot.me_reinit_singleflight != new_hot.me_reinit_singleflight - || old_hot.me_reinit_coalesce_window_ms != new_hot.me_reinit_coalesce_window_ms - { - info!( - "config reload: me_reinit: interval={}s singleflight={} coalesce={}ms", - new_hot.me_reinit_every_secs, - new_hot.me_reinit_singleflight, - new_hot.me_reinit_coalesce_window_ms - ); - } - - if old_hot.hardswap != new_hot.hardswap { - info!( - "config reload: hardswap: {} → {}", - old_hot.hardswap, new_hot.hardswap, - ); - } - - if old_hot.me_pool_drain_ttl_secs != new_hot.me_pool_drain_ttl_secs { - info!( - "config reload: me_pool_drain_ttl_secs: {}s → {}s", - old_hot.me_pool_drain_ttl_secs, new_hot.me_pool_drain_ttl_secs, - ); - } - if old_hot.me_instadrain != new_hot.me_instadrain { - info!( - "config reload: me_instadrain: {} → {}", - old_hot.me_instadrain, new_hot.me_instadrain, - ); - } - - if old_hot.me_pool_drain_threshold != new_hot.me_pool_drain_threshold { - info!( - "config reload: me_pool_drain_threshold: {} → {}", - old_hot.me_pool_drain_threshold, new_hot.me_pool_drain_threshold, - ); - } - - if (old_hot.me_pool_min_fresh_ratio - new_hot.me_pool_min_fresh_ratio).abs() > f32::EPSILON { - info!( - "config reload: me_pool_min_fresh_ratio: {:.3} → {:.3}", - old_hot.me_pool_min_fresh_ratio, new_hot.me_pool_min_fresh_ratio, - ); - } - - if old_hot.me_reinit_drain_timeout_secs != new_hot.me_reinit_drain_timeout_secs { - info!( - "config reload: me_reinit_drain_timeout_secs: {}s → {}s", - old_hot.me_reinit_drain_timeout_secs, new_hot.me_reinit_drain_timeout_secs, - ); - } - if old_hot.me_hardswap_warmup_delay_min_ms != new_hot.me_hardswap_warmup_delay_min_ms - || old_hot.me_hardswap_warmup_delay_max_ms != new_hot.me_hardswap_warmup_delay_max_ms - || old_hot.me_hardswap_warmup_extra_passes != new_hot.me_hardswap_warmup_extra_passes - || old_hot.me_hardswap_warmup_pass_backoff_base_ms - != new_hot.me_hardswap_warmup_pass_backoff_base_ms - { - info!( - "config reload: me_hardswap_warmup: min={}ms max={}ms extra_passes={} pass_backoff={}ms", - new_hot.me_hardswap_warmup_delay_min_ms, - new_hot.me_hardswap_warmup_delay_max_ms, - new_hot.me_hardswap_warmup_extra_passes, - new_hot.me_hardswap_warmup_pass_backoff_base_ms - ); - } - if old_hot.me_bind_stale_mode != new_hot.me_bind_stale_mode - || old_hot.me_bind_stale_ttl_secs != new_hot.me_bind_stale_ttl_secs - { - info!( - "config reload: me_bind_stale: mode={:?} ttl={}s", - new_hot.me_bind_stale_mode, new_hot.me_bind_stale_ttl_secs - ); - } - if old_hot.me_secret_atomic_snapshot != new_hot.me_secret_atomic_snapshot - || old_hot.me_deterministic_writer_sort != new_hot.me_deterministic_writer_sort - || old_hot.me_writer_pick_mode != new_hot.me_writer_pick_mode - || old_hot.me_writer_pick_sample_size != new_hot.me_writer_pick_sample_size - { - info!( - "config reload: me_runtime_flags: secret_atomic_snapshot={} deterministic_sort={} writer_pick_mode={:?} writer_pick_sample_size={}", - new_hot.me_secret_atomic_snapshot, - new_hot.me_deterministic_writer_sort, - new_hot.me_writer_pick_mode, - new_hot.me_writer_pick_sample_size, - ); - } - if old_hot.me_single_endpoint_shadow_writers != new_hot.me_single_endpoint_shadow_writers - || old_hot.me_single_endpoint_outage_mode_enabled - != new_hot.me_single_endpoint_outage_mode_enabled - || old_hot.me_single_endpoint_outage_disable_quarantine - != new_hot.me_single_endpoint_outage_disable_quarantine - || old_hot.me_single_endpoint_outage_backoff_min_ms - != new_hot.me_single_endpoint_outage_backoff_min_ms - || old_hot.me_single_endpoint_outage_backoff_max_ms - != new_hot.me_single_endpoint_outage_backoff_max_ms - || old_hot.me_single_endpoint_shadow_rotate_every_secs - != new_hot.me_single_endpoint_shadow_rotate_every_secs - { - info!( - "config reload: me_single_endpoint: shadow={} outage_enabled={} disable_quarantine={} backoff=[{}..{}]ms rotate={}s", - new_hot.me_single_endpoint_shadow_writers, - new_hot.me_single_endpoint_outage_mode_enabled, - new_hot.me_single_endpoint_outage_disable_quarantine, - new_hot.me_single_endpoint_outage_backoff_min_ms, - new_hot.me_single_endpoint_outage_backoff_max_ms, - new_hot.me_single_endpoint_shadow_rotate_every_secs - ); - } - if old_hot.me_config_stable_snapshots != new_hot.me_config_stable_snapshots - || old_hot.me_config_apply_cooldown_secs != new_hot.me_config_apply_cooldown_secs - || old_hot.me_snapshot_require_http_2xx != new_hot.me_snapshot_require_http_2xx - || old_hot.me_snapshot_reject_empty_map != new_hot.me_snapshot_reject_empty_map - || old_hot.me_snapshot_min_proxy_for_lines != new_hot.me_snapshot_min_proxy_for_lines - { - info!( - "config reload: me_snapshot_guard: stable={} cooldown={}s require_2xx={} reject_empty={} min_proxy_for={}", - new_hot.me_config_stable_snapshots, - new_hot.me_config_apply_cooldown_secs, - new_hot.me_snapshot_require_http_2xx, - new_hot.me_snapshot_reject_empty_map, - new_hot.me_snapshot_min_proxy_for_lines - ); - } - if old_hot.proxy_secret_stable_snapshots != new_hot.proxy_secret_stable_snapshots - || old_hot.proxy_secret_rotate_runtime != new_hot.proxy_secret_rotate_runtime - || old_hot.proxy_secret_len_max != new_hot.proxy_secret_len_max - { - info!( - "config reload: proxy_secret_runtime: stable={} rotate={} len_max={}", - new_hot.proxy_secret_stable_snapshots, - new_hot.proxy_secret_rotate_runtime, - new_hot.proxy_secret_len_max - ); - } - - if old_hot.telemetry_core_enabled != new_hot.telemetry_core_enabled - || old_hot.telemetry_user_enabled != new_hot.telemetry_user_enabled - || old_hot.telemetry_me_level != new_hot.telemetry_me_level - { - info!( - "config reload: telemetry: core_enabled={} user_enabled={} me_level={}", - new_hot.telemetry_core_enabled, - new_hot.telemetry_user_enabled, - new_hot.telemetry_me_level, - ); - } - - if old_hot.me_socks_kdf_policy != new_hot.me_socks_kdf_policy { - info!( - "config reload: me_socks_kdf_policy: {:?} → {:?}", - old_hot.me_socks_kdf_policy, new_hot.me_socks_kdf_policy, - ); - } - - if old_hot.me_floor_mode != new_hot.me_floor_mode - || old_hot.me_adaptive_floor_idle_secs != new_hot.me_adaptive_floor_idle_secs - || old_hot.me_adaptive_floor_min_writers_single_endpoint - != new_hot.me_adaptive_floor_min_writers_single_endpoint - || old_hot.me_adaptive_floor_min_writers_multi_endpoint - != new_hot.me_adaptive_floor_min_writers_multi_endpoint - || old_hot.me_adaptive_floor_recover_grace_secs - != new_hot.me_adaptive_floor_recover_grace_secs - || old_hot.me_adaptive_floor_writers_per_core_total - != new_hot.me_adaptive_floor_writers_per_core_total - || old_hot.me_adaptive_floor_cpu_cores_override - != new_hot.me_adaptive_floor_cpu_cores_override - || old_hot.me_adaptive_floor_max_extra_writers_single_per_core - != new_hot.me_adaptive_floor_max_extra_writers_single_per_core - || old_hot.me_adaptive_floor_max_extra_writers_multi_per_core - != new_hot.me_adaptive_floor_max_extra_writers_multi_per_core - || old_hot.me_adaptive_floor_max_active_writers_per_core - != new_hot.me_adaptive_floor_max_active_writers_per_core - || old_hot.me_adaptive_floor_max_warm_writers_per_core - != new_hot.me_adaptive_floor_max_warm_writers_per_core - || old_hot.me_adaptive_floor_max_active_writers_global - != new_hot.me_adaptive_floor_max_active_writers_global - || old_hot.me_adaptive_floor_max_warm_writers_global - != new_hot.me_adaptive_floor_max_warm_writers_global - { - info!( - "config reload: me_floor: mode={:?} idle={}s min_single={} min_multi={} recover_grace={}s per_core_total={} cores_override={} extra_single_per_core={} extra_multi_per_core={} max_active_per_core={} max_warm_per_core={} max_active_global={} max_warm_global={}", - new_hot.me_floor_mode, - new_hot.me_adaptive_floor_idle_secs, - new_hot.me_adaptive_floor_min_writers_single_endpoint, - new_hot.me_adaptive_floor_min_writers_multi_endpoint, - new_hot.me_adaptive_floor_recover_grace_secs, - new_hot.me_adaptive_floor_writers_per_core_total, - new_hot.me_adaptive_floor_cpu_cores_override, - new_hot.me_adaptive_floor_max_extra_writers_single_per_core, - new_hot.me_adaptive_floor_max_extra_writers_multi_per_core, - new_hot.me_adaptive_floor_max_active_writers_per_core, - new_hot.me_adaptive_floor_max_warm_writers_per_core, - new_hot.me_adaptive_floor_max_active_writers_global, - new_hot.me_adaptive_floor_max_warm_writers_global, - ); - } - - if old_hot.me_route_backpressure_base_timeout_ms - != new_hot.me_route_backpressure_base_timeout_ms - || old_hot.me_route_backpressure_high_timeout_ms - != new_hot.me_route_backpressure_high_timeout_ms - || old_hot.me_route_backpressure_high_watermark_pct - != new_hot.me_route_backpressure_high_watermark_pct - || old_hot.me_route_backpressure_enabled != new_hot.me_route_backpressure_enabled - || old_hot.me_route_fairshare_enabled != new_hot.me_route_fairshare_enabled - || old_hot.me_reader_route_data_wait_ms != new_hot.me_reader_route_data_wait_ms - || old_hot.me_health_interval_ms_unhealthy != new_hot.me_health_interval_ms_unhealthy - || old_hot.me_health_interval_ms_healthy != new_hot.me_health_interval_ms_healthy - || old_hot.me_admission_poll_ms != new_hot.me_admission_poll_ms - || old_hot.me_warn_rate_limit_ms != new_hot.me_warn_rate_limit_ms - { - info!( - "config reload: me_route_backpressure: enabled={} base={}ms high={}ms watermark={}%; me_route_fairshare_enabled={}; me_reader_route_data_wait_ms={}; me_health_interval: unhealthy={}ms healthy={}ms; me_admission_poll={}ms; me_warn_rate_limit={}ms", - new_hot.me_route_backpressure_enabled, - new_hot.me_route_backpressure_base_timeout_ms, - new_hot.me_route_backpressure_high_timeout_ms, - new_hot.me_route_backpressure_high_watermark_pct, - new_hot.me_route_fairshare_enabled, - new_hot.me_reader_route_data_wait_ms, - new_hot.me_health_interval_ms_unhealthy, - new_hot.me_health_interval_ms_healthy, - new_hot.me_admission_poll_ms, - new_hot.me_warn_rate_limit_ms, - ); - } - - if old_hot.me_d2c_flush_batch_max_frames != new_hot.me_d2c_flush_batch_max_frames - || old_hot.me_d2c_flush_batch_max_bytes != new_hot.me_d2c_flush_batch_max_bytes - || old_hot.me_d2c_flush_batch_max_delay_us != new_hot.me_d2c_flush_batch_max_delay_us - || old_hot.me_d2c_ack_flush_immediate != new_hot.me_d2c_ack_flush_immediate - || old_hot.me_quota_soft_overshoot_bytes != new_hot.me_quota_soft_overshoot_bytes - || old_hot.me_d2c_frame_buf_shrink_threshold_bytes - != new_hot.me_d2c_frame_buf_shrink_threshold_bytes - || old_hot.direct_relay_copy_buf_c2s_bytes != new_hot.direct_relay_copy_buf_c2s_bytes - || old_hot.direct_relay_copy_buf_s2c_bytes != new_hot.direct_relay_copy_buf_s2c_bytes - { - info!( - "config reload: relay_tuning: me_d2c_frames={} me_d2c_bytes={} me_d2c_delay_us={} me_ack_flush_immediate={} me_quota_soft_overshoot_bytes={} me_d2c_frame_buf_shrink_threshold_bytes={} direct_buf_c2s={} direct_buf_s2c={}", - new_hot.me_d2c_flush_batch_max_frames, - new_hot.me_d2c_flush_batch_max_bytes, - new_hot.me_d2c_flush_batch_max_delay_us, - new_hot.me_d2c_ack_flush_immediate, - new_hot.me_quota_soft_overshoot_bytes, - new_hot.me_d2c_frame_buf_shrink_threshold_bytes, - new_hot.direct_relay_copy_buf_c2s_bytes, - new_hot.direct_relay_copy_buf_s2c_bytes, - ); - } - - if old_hot.users != new_hot.users { - let mut added: Vec<&String> = new_hot - .users - .keys() - .filter(|u| !old_hot.users.contains_key(*u)) - .collect(); - added.sort(); - - let mut removed: Vec<&String> = old_hot - .users - .keys() - .filter(|u| !new_hot.users.contains_key(*u)) - .collect(); - removed.sort(); - - let mut changed: Vec<&String> = new_hot - .users - .keys() - .filter(|u| { - old_hot - .users - .get(*u) - .map(|s| s != &new_hot.users[*u]) - .unwrap_or(false) - }) - .collect(); - changed.sort(); - - if !added.is_empty() { - info!( - "config reload: users added: [{}]", - added - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ); - let host = resolve_link_host(new_cfg, detected_ip_v4, detected_ip_v6); - let port = new_cfg - .general - .links - .public_port - .unwrap_or(resolve_default_link_port(new_cfg)); - for user in &added { - if let Some(secret) = new_hot.users.get(*user) { - print_user_links(user, secret, &host, port, new_cfg); - } - } - } - if !removed.is_empty() { - info!( - "config reload: users removed: [{}]", - removed - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ); - } - if !changed.is_empty() { - info!( - "config reload: users secret changed: [{}]", - changed - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ); - } - } - - if old_hot.user_enabled != new_hot.user_enabled { - info!( - "config reload: user_enabled updated ({} disabled overrides)", - new_hot - .user_enabled - .values() - .filter(|enabled| !**enabled) - .count() - ); - } - if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns { - info!( - "config reload: user_max_tcp_conns updated ({} entries)", - new_hot.user_max_tcp_conns.len() - ); - } - if old_hot.user_max_tcp_conns_global_each != new_hot.user_max_tcp_conns_global_each { - info!( - "config reload: user_max_tcp_conns policy global_each={}", - new_hot.user_max_tcp_conns_global_each - ); - } - if old_hot.user_expirations != new_hot.user_expirations { - info!( - "config reload: user_expirations updated ({} entries)", - new_hot.user_expirations.len() - ); - } - if old_hot.user_data_quota != new_hot.user_data_quota { - info!( - "config reload: user_data_quota updated ({} entries)", - new_hot.user_data_quota.len() - ); - } - if old_hot.user_rate_limits != new_hot.user_rate_limits { - info!( - "config reload: user_rate_limits updated ({} entries)", - new_hot.user_rate_limits.len() - ); - } - if old_hot.cidr_rate_limits != new_hot.cidr_rate_limits { - info!( - "config reload: cidr_rate_limits updated ({} entries)", - new_hot.cidr_rate_limits.len() - ); - } - if old_hot.user_max_unique_ips != new_hot.user_max_unique_ips { - info!( - "config reload: user_max_unique_ips updated ({} entries)", - new_hot.user_max_unique_ips.len() - ); - } - if old_hot.user_max_unique_ips_global_each != new_hot.user_max_unique_ips_global_each - || old_hot.user_max_unique_ips_mode != new_hot.user_max_unique_ips_mode - || old_hot.user_max_unique_ips_window_secs != new_hot.user_max_unique_ips_window_secs - { - info!( - "config reload: user_max_unique_ips policy global_each={} mode={:?} window={}s", - new_hot.user_max_unique_ips_global_each, - new_hot.user_max_unique_ips_mode, - new_hot.user_max_unique_ips_window_secs - ); - } -} - -/// Load config, validate, diff against current, and broadcast if changed. -fn reload_config( - config_path: &PathBuf, - config_tx: &watch::Sender>, - log_tx: &watch::Sender, - detected_ip_v4: Option, - detected_ip_v6: Option, - reload_state: &mut ReloadState, -) -> Option { - let loaded = match ProxyConfig::load_with_metadata(config_path) { - Ok(loaded) => loaded, - Err(e) => { - error!("config reload: failed to parse {:?}: {}", config_path, e); - return None; - } - }; - let LoadedConfig { - config: new_cfg, - source_files, - source_contents: _, - rendered_hash, - } = loaded; - let next_manifest = WatchManifest::from_source_files(&source_files); - - if let Err(e) = new_cfg.validate() { - error!( - "config reload: validation failed: {}; keeping old config", - e - ); - return Some(next_manifest); - } - - if reload_state.is_applied(rendered_hash) { - return Some(next_manifest); - } - - let old_cfg = config_tx.borrow().clone(); - let applied_cfg = overlay_hot_fields(&old_cfg, &new_cfg); - let old_hot = HotFields::from_config(&old_cfg); - let applied_hot = HotFields::from_config(&applied_cfg); - let non_hot_changed = !config_equal(&applied_cfg, &new_cfg); - let hot_changed = old_hot != applied_hot; - - if non_hot_changed { - warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed); - } - - if !hot_changed { - reload_state.mark_applied(rendered_hash); - return Some(next_manifest); - } - - if old_hot.dns_overrides != applied_hot.dns_overrides - && let Err(e) = crate::network::dns_overrides::install_entries(&applied_hot.dns_overrides) - { - error!( - "config reload: invalid network.dns_overrides: {}; keeping old config", - e - ); - return Some(next_manifest); - } - - log_changes( - &old_hot, - &applied_hot, - &applied_cfg, - log_tx, - detected_ip_v4, - detected_ip_v6, - ); - config_tx.send(Arc::new(applied_cfg)).ok(); - reload_state.mark_applied(rendered_hash); - Some(next_manifest) -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -/// Spawn the hot-reload watcher task. -/// -/// Uses `notify` (inotify on Linux) to detect file changes instantly. -/// SIGHUP is also handled on Unix as an additional manual trigger. -/// -/// `detected_ip_v4` / `detected_ip_v6` are the IPs discovered during the -/// startup probe — used when generating proxy links for newly added users, -/// matching the same logic as the startup output. -/// The watcher releases its notify and signal resources when `cancellation` fires. -pub fn spawn_config_watcher( - config_path: PathBuf, - initial: Arc, - detected_ip_v4: Option, - detected_ip_v6: Option, - cancellation: tokio_util::sync::CancellationToken, -) -> (watch::Receiver>, watch::Receiver) { - let initial_level = initial.general.log_level.clone(); - let (config_tx, config_rx) = watch::channel(initial); - let (log_tx, log_rx) = watch::channel(initial_level); - - let config_path = normalize_watch_path(&config_path); - let initial_loaded = ProxyConfig::load_with_metadata(&config_path).ok(); - let initial_manifest = initial_loaded - .as_ref() - .map(|loaded| WatchManifest::from_source_files(&loaded.source_files)) - .unwrap_or_else(|| WatchManifest::from_source_files(std::slice::from_ref(&config_path))); - let initial_snapshot_hash = initial_loaded.as_ref().map(|loaded| loaded.rendered_hash); - - tokio::spawn(async move { - let (notify_tx, mut notify_rx) = mpsc::channel::<()>(4); - let manifest_state = Arc::new(StdRwLock::new(WatchManifest::default())); - let mut reload_state = ReloadState::new(initial_snapshot_hash); - - let tx_inotify = notify_tx.clone(); - let manifest_for_inotify = manifest_state.clone(); - let mut inotify_watcher = - match recommended_watcher(move |res: notify::Result| { - let Ok(event) = res else { return }; - if !matches!( - event.kind, - EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) - ) { - return; - } - let is_our_file = manifest_for_inotify - .read() - .map(|manifest| manifest.matches_event_paths(&event.paths)) - .unwrap_or(false); - if is_our_file { - let _ = tx_inotify.try_send(()); - } - }) { - Ok(watcher) => Some(watcher), - Err(e) => { - warn!("config watcher: inotify unavailable: {}", e); - None - } - }; - apply_watch_manifest( - inotify_watcher.as_mut(), - Option::<&mut notify::poll::PollWatcher>::None, - &manifest_state, - initial_manifest.clone(), - ); - if inotify_watcher.is_some() { - info!("config watcher: inotify active on {:?}", config_path); - } - - let tx_poll = notify_tx.clone(); - let manifest_for_poll = manifest_state.clone(); - let mut poll_watcher = match notify::poll::PollWatcher::new( - move |res: notify::Result| { - let Ok(event) = res else { return }; - if !matches!( - event.kind, - EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) - ) { - return; - } - let is_our_file = manifest_for_poll - .read() - .map(|manifest| manifest.matches_event_paths(&event.paths)) - .unwrap_or(false); - if is_our_file { - let _ = tx_poll.try_send(()); - } - }, - notify::Config::default() - .with_poll_interval(Duration::from_secs(3)) - .with_compare_contents(true), - ) { - Ok(watcher) => Some(watcher), - Err(e) => { - warn!("config watcher: poll watcher unavailable: {}", e); - None - } - }; - apply_watch_manifest( - Option::<&mut notify::RecommendedWatcher>::None, - poll_watcher.as_mut(), - &manifest_state, - initial_manifest.clone(), - ); - if poll_watcher.is_some() { - info!("config watcher: poll watcher active (Docker/NFS safe)"); - } - - #[cfg(unix)] - let mut sighup = { - use tokio::signal::unix::{SignalKind, signal}; - signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler") - }; - - loop { - #[cfg(unix)] - tokio::select! { - msg = notify_rx.recv() => { - if msg.is_none() { break; } - } - _ = sighup.recv() => { - info!("SIGHUP received — reloading {:?}", config_path); - } - _ = cancellation.cancelled() => break, - } - #[cfg(not(unix))] - tokio::select! { - msg = notify_rx.recv() => { - if msg.is_none() { break; } - } - _ = cancellation.cancelled() => break, - } - - // Debounce: drain extra events that arrive within a short quiet window. - tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await; - while notify_rx.try_recv().is_ok() {} - - let mut next_manifest = reload_config( - &config_path, - &config_tx, - &log_tx, - detected_ip_v4, - detected_ip_v6, - &mut reload_state, - ); - if next_manifest.is_none() { - tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await; - while notify_rx.try_recv().is_ok() {} - next_manifest = reload_config( - &config_path, - &config_tx, - &log_tx, - detected_ip_v4, - detected_ip_v6, - &mut reload_state, - ); - } - - if let Some(next_manifest) = next_manifest { - apply_watch_manifest( - inotify_watcher.as_mut(), - poll_watcher.as_mut(), - &manifest_state, - next_manifest, - ); - } - } - }); - - (config_rx, log_rx) -} - -// ── Change classification ───────────────────────────────────────────────────── - -/// Which top-level config sections changed and whether any require a restart. -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct ChangeClassification { - pub changed: Vec, - pub restart_required: bool, -} - -/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and -/// see if anything non-hot remains different. This guarantees `restart_required` -/// matches actual runtime behavior and never drifts as new fields are added. -pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification { - let applied = overlay_hot_fields(old, new); - let restart_required = !config_equal(&applied, new); - ChangeClassification { - changed: changed_sections(old, new), - restart_required, - } -} - -/// Top-level config sections whose canonical serialized form differs between -/// old and new. Uses the same serialize+canonicalize path as `config_equal`. -fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec { - let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null); - let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null); - canonicalize_json(&mut lhs); - canonicalize_json(&mut rhs); - - let mut out = Vec::new(); - if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) { - let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect(); - keys.extend(ro.keys()); - for key in keys { - if lo.get(key) != ro.get(key) { - out.push(key.clone()); - } - } - } - out -} +mod diff; +mod fields; +mod reporting; +mod watcher; + +#[allow(unused_imports)] +pub use diff::{ChangeClassification, classify_config_changes}; +pub use fields::HotFields; +pub use watcher::spawn_config_watcher; + +use diff::{config_equal, warn_non_hot_changes}; +use fields::overlay_hot_fields; +use reporting::log_changes; +#[cfg(test)] +use watcher::{ReloadState, reload_config}; #[cfg(test)] -mod tests { - use super::*; - - fn sample_config() -> ProxyConfig { - ProxyConfig::default() - } - - fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option) { - let mut config = String::from( - r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#, - ); - - if ad_tag.is_some() { - config.push_str("\n[general]\n"); - if let Some(tag) = ad_tag { - config.push_str(&format!("ad_tag = \"{tag}\"\n")); - } - } - - if let Some(port) = server_port { - config.push_str("\n[server]\n"); - config.push_str(&format!("port = {port}\n")); - } - - std::fs::write(path, config).unwrap(); - } - - fn temp_config_path(prefix: &str) -> PathBuf { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("{prefix}_{nonce}.toml")) - } - - #[test] - fn overlay_applies_hot_and_preserves_non_hot() { - let old = sample_config(); - let mut new = old.clone(); - new.general.hardswap = !old.general.hardswap; - new.server.port = old.server.port.saturating_add(1); - - let applied = overlay_hot_fields(&old, &new); - assert_eq!(applied.general.hardswap, new.general.hardswap); - assert_eq!(applied.server.port, old.server.port); - } - - #[test] - fn non_hot_only_change_does_not_change_hot_snapshot() { - let old = sample_config(); - let mut new = old.clone(); - new.server.port = old.server.port.saturating_add(1); - - let applied = overlay_hot_fields(&old, &new); - assert_eq!( - HotFields::from_config(&old), - HotFields::from_config(&applied) - ); - assert_eq!(applied.server.port, old.server.port); - } - - #[test] - fn bind_stale_mode_is_hot() { - let old = sample_config(); - let mut new = old.clone(); - new.general.me_bind_stale_mode = match old.general.me_bind_stale_mode { - MeBindStaleMode::Never => MeBindStaleMode::Ttl, - MeBindStaleMode::Ttl => MeBindStaleMode::Always, - MeBindStaleMode::Always => MeBindStaleMode::Never, - }; - - let applied = overlay_hot_fields(&old, &new); - assert_eq!( - applied.general.me_bind_stale_mode, - new.general.me_bind_stale_mode - ); - assert_ne!( - HotFields::from_config(&old), - HotFields::from_config(&applied) - ); - } - - #[test] - fn keepalive_is_not_hot() { - let old = sample_config(); - let mut new = old.clone(); - new.general.me_keepalive_interval_secs = old.general.me_keepalive_interval_secs + 5; - - let applied = overlay_hot_fields(&old, &new); - assert_eq!( - applied.general.me_keepalive_interval_secs, - old.general.me_keepalive_interval_secs - ); - assert_eq!( - HotFields::from_config(&old), - HotFields::from_config(&applied) - ); - } - - #[test] - fn mixed_hot_and_non_hot_change_applies_only_hot_subset() { - let old = sample_config(); - let mut new = old.clone(); - new.general.hardswap = !old.general.hardswap; - new.general.use_middle_proxy = !old.general.use_middle_proxy; - - let applied = overlay_hot_fields(&old, &new); - assert_eq!(applied.general.hardswap, new.general.hardswap); - assert_eq!( - applied.general.use_middle_proxy, - old.general.use_middle_proxy - ); - assert!(!config_equal(&applied, &new)); - } - - #[test] - fn listener_synlimit_fields_are_process_owned() { - let mut old = sample_config(); - old.server.listeners.push(ListenerConfig { - ip: "0.0.0.0".parse().unwrap(), - port: Some(443), - client_mss: None, - synlimit: SynLimitMode::Iptables, - synlimit_seconds: 60, - synlimit_hitcount: 48, - synlimit_burst: 1, - synlimit_ios_seconds: 1, - synlimit_ios_hitcount: 12, - synlimit_ios_burst: 24, - synlimit_hashlimit_expire_ms: 60_000, - synlimit_hashlimit_size: 32_768, - announce: None, - announce_ip: None, - proxy_protocol: None, - reuse_allow: false, - }); - let mut new = old.clone(); - new.server.port = 8443; - new.server.listeners[0].synlimit_seconds = 120; - new.server.listeners[0].synlimit_hitcount = 96; - new.server.listeners[0].synlimit_burst = 2; - new.server.listeners[0].synlimit_ios_seconds = 2; - new.server.listeners[0].synlimit_ios_hitcount = 18; - new.server.listeners[0].synlimit_ios_burst = 36; - new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000; - new.server.listeners[0].synlimit_hashlimit_size = 65_536; - - let applied = overlay_hot_fields(&old, &new); - let listener = &applied.server.listeners[0]; - assert_eq!(applied.server.port, old.server.port); - assert_eq!(listener.synlimit_seconds, old.server.listeners[0].synlimit_seconds); - assert_eq!( - listener.synlimit_hitcount, - old.server.listeners[0].synlimit_hitcount - ); - assert_eq!(listener.synlimit_burst, old.server.listeners[0].synlimit_burst); - assert_eq!( - listener.synlimit_hashlimit_size, - old.server.listeners[0].synlimit_hashlimit_size - ); - assert!(classify_config_changes(&old, &new).restart_required); - } - - #[test] - fn reload_applies_hot_change_on_first_observed_snapshot() { - let initial_tag = "11111111111111111111111111111111"; - let final_tag = "22222222222222222222222222222222"; - let path = temp_config_path("telemt_hot_reload_stable"); - - write_reload_config(&path, Some(initial_tag), None); - let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap()); - let initial_hash = ProxyConfig::load_with_metadata(&path) - .unwrap() - .rendered_hash; - let (config_tx, _config_rx) = watch::channel(initial_cfg.clone()); - let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone()); - let mut reload_state = ReloadState::new(Some(initial_hash)); - - write_reload_config(&path, Some(final_tag), None); - reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap(); - assert_eq!( - config_tx.borrow().general.ad_tag.as_deref(), - Some(final_tag) - ); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn reload_keeps_hot_apply_when_non_hot_fields_change() { - let initial_tag = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let final_tag = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let path = temp_config_path("telemt_hot_reload_mixed"); - - write_reload_config(&path, Some(initial_tag), None); - let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap()); - let initial_hash = ProxyConfig::load_with_metadata(&path) - .unwrap() - .rendered_hash; - let (config_tx, _config_rx) = watch::channel(initial_cfg.clone()); - let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone()); - let mut reload_state = ReloadState::new(Some(initial_hash)); - - write_reload_config(&path, Some(final_tag), Some(initial_cfg.server.port + 1)); - reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap(); - - let applied = config_tx.borrow().clone(); - assert_eq!(applied.general.ad_tag.as_deref(), Some(final_tag)); - assert_eq!(applied.server.port, initial_cfg.server.port); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn classify_sni_change_requires_restart() { - // censorship.* is not in overlay_hot_fields -> restart. - let old = ProxyConfig::default(); - let mut new = ProxyConfig::default(); - new.censorship.tls_domain = "front.example".to_string(); - - let class = classify_config_changes(&old, &new); - assert!(class.restart_required); - assert!(class.changed.iter().any(|c| c == "censorship")); - } - - #[test] - fn classify_dns_overrides_change_is_hot() { - // network.dns_overrides IS in overlay_hot_fields -> no restart. - let old = ProxyConfig::default(); - let mut new = ProxyConfig::default(); - new.network.dns_overrides.push("1.1.1.1".to_string()); - - let class = classify_config_changes(&old, &new); - assert!(!class.restart_required); - assert!(class.changed.iter().any(|c| c == "network")); - } - - #[test] - fn classify_timeouts_change_requires_restart() { - // timeouts.* is NOT in overlay_hot_fields -> restart. - let old = ProxyConfig::default(); - let mut new = ProxyConfig::default(); - new.timeouts.client_handshake = old.timeouts.client_handshake + 1; - - let class = classify_config_changes(&old, &new); - assert!(class.restart_required); - } - - #[test] - fn reload_recovers_after_parse_error_on_next_attempt() { - let initial_tag = "cccccccccccccccccccccccccccccccc"; - let final_tag = "dddddddddddddddddddddddddddddddd"; - let path = temp_config_path("telemt_hot_reload_parse_recovery"); - - write_reload_config(&path, Some(initial_tag), None); - let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap()); - let initial_hash = ProxyConfig::load_with_metadata(&path) - .unwrap() - .rendered_hash; - let (config_tx, _config_rx) = watch::channel(initial_cfg.clone()); - let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone()); - let mut reload_state = ReloadState::new(Some(initial_hash)); - - std::fs::write(&path, "[access.users\nuser = \"broken\"\n").unwrap(); - assert!(reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).is_none()); - assert_eq!( - config_tx.borrow().general.ad_tag.as_deref(), - Some(initial_tag) - ); - - write_reload_config(&path, Some(final_tag), None); - reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap(); - assert_eq!( - config_tx.borrow().general.ad_tag.as_deref(), - Some(final_tag) - ); - - let _ = std::fs::remove_file(path); - } -} +mod tests; diff --git a/src/config/hot_reload/diff.rs b/src/config/hot_reload/diff.rs new file mode 100644 index 0000000..f060bdd --- /dev/null +++ b/src/config/hot_reload/diff.rs @@ -0,0 +1,273 @@ +use super::*; + + +fn canonicalize_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + let mut pairs: Vec<(String, serde_json::Value)> = + std::mem::take(map).into_iter().collect(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + for (_, item) in pairs.iter_mut() { + canonicalize_json(item); + } + for (key, item) in pairs { + map.insert(key, item); + } + } + serde_json::Value::Array(items) => { + for item in items { + canonicalize_json(item); + } + } + _ => {} + } +} + +pub(super) fn config_equal(lhs: &ProxyConfig, rhs: &ProxyConfig) -> bool { + let mut left = match serde_json::to_value(lhs) { + Ok(value) => value, + Err(_) => return false, + }; + let mut right = match serde_json::to_value(rhs) { + Ok(value) => value, + Err(_) => return false, + }; + canonicalize_json(&mut left); + canonicalize_json(&mut right); + left == right +} + +fn listeners_equal( + lhs: &[crate::config::ListenerConfig], + rhs: &[crate::config::ListenerConfig], +) -> bool { + serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok() +} + + +/// Warns when the requested snapshot contains fields that require restart. +pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { + let mut warned = false; + if old.server.port != new.server.port { + warned = true; + warn!( + "config reload: server.port changed ({} → {}); restart required", + old.server.port, new.server.port + ); + } + if old.server.api.enabled != new.server.api.enabled + || old.server.api.listen != new.server.api.listen + || old.server.api.whitelist != new.server.api.whitelist + || old.server.api.gray_action != new.server.api.gray_action + || old.server.api.auth_header != new.server.api.auth_header + || old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes + || old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled + || old.server.api.minimal_runtime_cache_ttl_ms + != new.server.api.minimal_runtime_cache_ttl_ms + || old.server.api.runtime_edge_enabled != new.server.api.runtime_edge_enabled + || old.server.api.runtime_edge_cache_ttl_ms != new.server.api.runtime_edge_cache_ttl_ms + || old.server.api.runtime_edge_top_n != new.server.api.runtime_edge_top_n + || old.server.api.runtime_edge_events_capacity + != new.server.api.runtime_edge_events_capacity + || old.server.api.read_only != new.server.api.read_only + { + warned = true; + warn!("config reload: server.api changed; restart required"); + } + if old.server.proxy_protocol != new.server.proxy_protocol + || !listeners_equal(&old.server.listeners, &new.server.listeners) + || old.server.listen_backlog != new.server.listen_backlog + || old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4 + || old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6 + || old.server.listen_tcp != new.server.listen_tcp + || old.server.client_mss != new.server.client_mss + || old.server.listen_unix_sock != new.server.listen_unix_sock + || old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm + { + warned = true; + warn!("config reload: server listener settings changed; restart required"); + } + if old.censorship.tls_domain != new.censorship.tls_domain + || old.censorship.tls_domains != new.censorship.tls_domains + || old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope + || old.censorship.mask != new.censorship.mask + || old.censorship.mask_dynamic != new.censorship.mask_dynamic + || old.censorship.mask_host != new.censorship.mask_host + || old.censorship.mask_port != new.censorship.mask_port + || old.censorship.exclusive_mask != new.censorship.exclusive_mask + || old.censorship.mask_unix_sock != new.censorship.mask_unix_sock + || old.censorship.fake_cert_len != new.censorship.fake_cert_len + || old.censorship.tls_emulation != new.censorship.tls_emulation + || old.censorship.tls_front_dir != new.censorship.tls_front_dir + || old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms + || old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms + || old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets + || old.censorship.serverhello_compact != new.censorship.serverhello_compact + || old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs + || old.censorship.alpn_enforce != new.censorship.alpn_enforce + || old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol + || old.censorship.mask_shape_hardening != new.censorship.mask_shape_hardening + || old.censorship.mask_shape_bucket_floor_bytes + != new.censorship.mask_shape_bucket_floor_bytes + || old.censorship.mask_shape_bucket_cap_bytes != new.censorship.mask_shape_bucket_cap_bytes + || old.censorship.mask_shape_above_cap_blur != new.censorship.mask_shape_above_cap_blur + || old.censorship.mask_shape_above_cap_blur_max_bytes + != new.censorship.mask_shape_above_cap_blur_max_bytes + || old.censorship.mask_relay_max_bytes != new.censorship.mask_relay_max_bytes + || old.censorship.mask_relay_timeout_ms != new.censorship.mask_relay_timeout_ms + || old.censorship.mask_relay_idle_timeout_ms != new.censorship.mask_relay_idle_timeout_ms + || old.censorship.mask_classifier_prefetch_timeout_ms + != new.censorship.mask_classifier_prefetch_timeout_ms + || old.censorship.mask_timing_normalization_enabled + != new.censorship.mask_timing_normalization_enabled + || old.censorship.mask_timing_normalization_floor_ms + != new.censorship.mask_timing_normalization_floor_ms + || old.censorship.mask_timing_normalization_ceiling_ms + != new.censorship.mask_timing_normalization_ceiling_ms + { + warned = true; + warn!("config reload: censorship settings changed; restart required"); + } + if old.censorship.tls_domain != new.censorship.tls_domain { + warned = true; + warn!( + "config reload: censorship.tls_domain changed ('{}' → '{}'); restart required", + old.censorship.tls_domain, new.censorship.tls_domain + ); + } + if old.network.ipv4 != new.network.ipv4 || old.network.ipv6 != new.network.ipv6 { + warned = true; + warn!("config reload: network.ipv4/ipv6 changed; restart required"); + } + if old.network.prefer != new.network.prefer + || old.network.multipath != new.network.multipath + || old.network.stun_use != new.network.stun_use + || old.network.stun_servers != new.network.stun_servers + || old.network.stun_tcp_fallback != new.network.stun_tcp_fallback + || old.network.http_ip_detect_urls != new.network.http_ip_detect_urls + || old.network.cache_public_ip_path != new.network.cache_public_ip_path + { + warned = true; + warn!("config reload: non-hot network settings changed; restart required"); + } + if old.general.use_middle_proxy != new.general.use_middle_proxy { + warned = true; + warn!("config reload: use_middle_proxy changed; restart required"); + } + if old.general.stun_nat_probe_concurrency != new.general.stun_nat_probe_concurrency { + warned = true; + warn!("config reload: general.stun_nat_probe_concurrency changed; restart required"); + } + if old.general.middle_proxy_pool_size != new.general.middle_proxy_pool_size { + warned = true; + warn!("config reload: general.middle_proxy_pool_size changed; restart required"); + } + if old.general.me_route_no_writer_mode != new.general.me_route_no_writer_mode + || old.general.me_route_no_writer_wait_ms != new.general.me_route_no_writer_wait_ms + || old.general.me_route_hybrid_max_wait_ms != new.general.me_route_hybrid_max_wait_ms + || old.general.me_route_blocking_send_timeout_ms + != new.general.me_route_blocking_send_timeout_ms + || old.general.me_route_inline_recovery_attempts + != new.general.me_route_inline_recovery_attempts + || old.general.me_route_inline_recovery_wait_ms + != new.general.me_route_inline_recovery_wait_ms + { + warned = true; + warn!("config reload: general.me_route_no_writer_* changed; restart required"); + } + if old.general.unknown_dc_log_path != new.general.unknown_dc_log_path + || old.general.unknown_dc_file_log_enabled != new.general.unknown_dc_file_log_enabled + { + warned = true; + warn!("config reload: general.unknown_dc_* changed; restart required"); + } + if old.general.me_init_retry_attempts != new.general.me_init_retry_attempts { + warned = true; + warn!("config reload: general.me_init_retry_attempts changed; restart required"); + } + if old.general.me2dc_fallback != new.general.me2dc_fallback + || old.general.me2dc_fast != new.general.me2dc_fast + { + warned = true; + warn!("config reload: general.me2dc_fallback/me2dc_fast changed; restart required"); + } + if old.general.proxy_config_v4_cache_path != new.general.proxy_config_v4_cache_path + || old.general.proxy_config_v6_cache_path != new.general.proxy_config_v6_cache_path + { + warned = true; + warn!("config reload: general.proxy_config_*_cache_path changed; restart required"); + } + if old.general.me_keepalive_enabled != new.general.me_keepalive_enabled + || old.general.me_keepalive_interval_secs != new.general.me_keepalive_interval_secs + || old.general.me_keepalive_jitter_secs != new.general.me_keepalive_jitter_secs + || old.general.me_keepalive_payload_random != new.general.me_keepalive_payload_random + { + warned = true; + warn!("config reload: general.me_keepalive_* changed; restart required"); + } + if old.general.upstream_connect_retry_attempts != new.general.upstream_connect_retry_attempts + || old.general.upstream_connect_retry_backoff_ms + != new.general.upstream_connect_retry_backoff_ms + || old.general.tg_connect != new.general.tg_connect + || old.general.upstream_unhealthy_fail_threshold + != new.general.upstream_unhealthy_fail_threshold + || old.general.upstream_connect_failfast_hard_errors + != new.general.upstream_connect_failfast_hard_errors + || old.general.rpc_proxy_req_every != new.general.rpc_proxy_req_every + { + warned = true; + warn!("config reload: general.upstream_* changed; restart required"); + } + if non_hot_changed && !warned { + warn!("config reload: one or more non-hot fields changed; restart required"); + } +} + +/// Resolve the public host for link generation — mirrors the logic in main.rs. +/// +/// Priority: +/// 1. `[general.links] public_host` — explicit override in config +/// 2. `detected_ip_v4` — from STUN/interface probe at startup +/// 3. `detected_ip_v6` — fallback +/// 4. `"UNKNOWN"` — warn the user to set `public_host` + + +/// Which top-level config sections changed and whether any require a restart. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct ChangeClassification { + pub changed: Vec, + pub restart_required: bool, +} + +/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and +/// see if anything non-hot remains different. This guarantees `restart_required` +/// matches actual runtime behavior and never drifts as new fields are added. +pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification { + let applied = overlay_hot_fields(old, new); + let restart_required = !config_equal(&applied, new); + ChangeClassification { + changed: changed_sections(old, new), + restart_required, + } +} + +/// Top-level config sections whose canonical serialized form differs between +/// old and new. Uses the same serialize+canonicalize path as `config_equal`. +fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec { + let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null); + let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null); + canonicalize_json(&mut lhs); + canonicalize_json(&mut rhs); + + let mut out = Vec::new(); + if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) { + let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect(); + keys.extend(ro.keys()); + for key in keys { + if lo.get(key) != ro.get(key) { + out.push(key.clone()); + } + } + } + out +} diff --git a/src/config/hot_reload/fields.rs b/src/config/hot_reload/fields.rs new file mode 100644 index 0000000..f7af2f9 --- /dev/null +++ b/src/config/hot_reload/fields.rs @@ -0,0 +1,347 @@ +use super::*; + + +/// Fields that are safe to swap without restarting listeners. +#[derive(Debug, Clone, PartialEq)] +pub struct HotFields { + pub log_level: LogLevel, + pub ad_tag: Option, + pub dns_overrides: Vec, + pub desync_all_full: bool, + pub update_every_secs: u64, + pub me_reinit_every_secs: u64, + pub me_reinit_singleflight: bool, + pub me_reinit_coalesce_window_ms: u64, + pub hardswap: bool, + pub me_pool_drain_ttl_secs: u64, + pub me_instadrain: bool, + pub me_pool_drain_threshold: u64, + pub me_pool_min_fresh_ratio: f32, + pub me_reinit_drain_timeout_secs: u64, + pub me_hardswap_warmup_delay_min_ms: u64, + pub me_hardswap_warmup_delay_max_ms: u64, + pub me_hardswap_warmup_extra_passes: u8, + pub me_hardswap_warmup_pass_backoff_base_ms: u64, + pub me_bind_stale_mode: MeBindStaleMode, + pub me_bind_stale_ttl_secs: u64, + pub me_secret_atomic_snapshot: bool, + pub me_deterministic_writer_sort: bool, + pub me_writer_pick_mode: MeWriterPickMode, + pub me_writer_pick_sample_size: u8, + pub me_single_endpoint_shadow_writers: u8, + pub me_single_endpoint_outage_mode_enabled: bool, + pub me_single_endpoint_outage_disable_quarantine: bool, + pub me_single_endpoint_outage_backoff_min_ms: u64, + pub me_single_endpoint_outage_backoff_max_ms: u64, + pub me_single_endpoint_shadow_rotate_every_secs: u64, + pub me_config_stable_snapshots: u8, + pub me_config_apply_cooldown_secs: u64, + pub me_snapshot_require_http_2xx: bool, + pub me_snapshot_reject_empty_map: bool, + pub me_snapshot_min_proxy_for_lines: u32, + pub proxy_secret_stable_snapshots: u8, + pub proxy_secret_rotate_runtime: bool, + pub proxy_secret_len_max: usize, + pub telemetry_core_enabled: bool, + pub telemetry_user_enabled: bool, + pub telemetry_me_level: MeTelemetryLevel, + pub me_socks_kdf_policy: MeSocksKdfPolicy, + pub me_route_backpressure_enabled: bool, + pub me_route_fairshare_enabled: bool, + pub me_floor_mode: MeFloorMode, + pub me_adaptive_floor_idle_secs: u64, + pub me_adaptive_floor_min_writers_single_endpoint: u8, + pub me_adaptive_floor_min_writers_multi_endpoint: u8, + pub me_adaptive_floor_recover_grace_secs: u64, + pub me_adaptive_floor_writers_per_core_total: u16, + pub me_adaptive_floor_cpu_cores_override: u16, + pub me_adaptive_floor_max_extra_writers_single_per_core: u16, + pub me_adaptive_floor_max_extra_writers_multi_per_core: u16, + pub me_adaptive_floor_max_active_writers_per_core: u16, + pub me_adaptive_floor_max_warm_writers_per_core: u16, + pub me_adaptive_floor_max_active_writers_global: u32, + pub me_adaptive_floor_max_warm_writers_global: u32, + pub me_route_backpressure_base_timeout_ms: u64, + pub me_route_backpressure_high_timeout_ms: u64, + pub me_route_backpressure_high_watermark_pct: u8, + pub me_reader_route_data_wait_ms: u64, + pub me_d2c_flush_batch_max_frames: usize, + pub me_d2c_flush_batch_max_bytes: usize, + pub me_d2c_flush_batch_max_delay_us: u64, + pub me_d2c_ack_flush_immediate: bool, + pub me_quota_soft_overshoot_bytes: u64, + pub me_d2c_frame_buf_shrink_threshold_bytes: usize, + pub direct_relay_copy_buf_c2s_bytes: usize, + pub direct_relay_copy_buf_s2c_bytes: usize, + pub me_health_interval_ms_unhealthy: u64, + pub me_health_interval_ms_healthy: u64, + pub me_admission_poll_ms: u64, + pub me_warn_rate_limit_ms: u64, + pub users: std::collections::HashMap, + pub user_enabled: std::collections::HashMap, + pub user_ad_tags: std::collections::HashMap, + pub user_max_tcp_conns: std::collections::HashMap, + pub user_max_tcp_conns_global_each: usize, + pub user_expirations: std::collections::HashMap>, + pub user_data_quota: std::collections::HashMap, + pub user_rate_limits: std::collections::HashMap, + pub cidr_rate_limits: std::collections::HashMap, + pub user_max_unique_ips: std::collections::HashMap, + pub user_max_unique_ips_global_each: usize, + pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode, + pub user_max_unique_ips_window_secs: u64, +} + +impl HotFields { + pub fn from_config(cfg: &ProxyConfig) -> Self { + Self { + log_level: cfg.general.log_level.clone(), + ad_tag: cfg.general.ad_tag.clone(), + dns_overrides: cfg.network.dns_overrides.clone(), + desync_all_full: cfg.general.desync_all_full, + update_every_secs: cfg.general.effective_update_every_secs(), + me_reinit_every_secs: cfg.general.me_reinit_every_secs, + me_reinit_singleflight: cfg.general.me_reinit_singleflight, + me_reinit_coalesce_window_ms: cfg.general.me_reinit_coalesce_window_ms, + hardswap: cfg.general.hardswap, + me_pool_drain_ttl_secs: cfg.general.me_pool_drain_ttl_secs, + me_instadrain: cfg.general.me_instadrain, + me_pool_drain_threshold: cfg.general.me_pool_drain_threshold, + me_pool_min_fresh_ratio: cfg.general.me_pool_min_fresh_ratio, + me_reinit_drain_timeout_secs: cfg.general.me_reinit_drain_timeout_secs, + me_hardswap_warmup_delay_min_ms: cfg.general.me_hardswap_warmup_delay_min_ms, + me_hardswap_warmup_delay_max_ms: cfg.general.me_hardswap_warmup_delay_max_ms, + me_hardswap_warmup_extra_passes: cfg.general.me_hardswap_warmup_extra_passes, + me_hardswap_warmup_pass_backoff_base_ms: cfg + .general + .me_hardswap_warmup_pass_backoff_base_ms, + me_bind_stale_mode: cfg.general.me_bind_stale_mode, + me_bind_stale_ttl_secs: cfg.general.me_bind_stale_ttl_secs, + me_secret_atomic_snapshot: cfg.general.me_secret_atomic_snapshot, + me_deterministic_writer_sort: cfg.general.me_deterministic_writer_sort, + me_writer_pick_mode: cfg.general.me_writer_pick_mode, + me_writer_pick_sample_size: cfg.general.me_writer_pick_sample_size, + me_single_endpoint_shadow_writers: cfg.general.me_single_endpoint_shadow_writers, + me_single_endpoint_outage_mode_enabled: cfg + .general + .me_single_endpoint_outage_mode_enabled, + me_single_endpoint_outage_disable_quarantine: cfg + .general + .me_single_endpoint_outage_disable_quarantine, + me_single_endpoint_outage_backoff_min_ms: cfg + .general + .me_single_endpoint_outage_backoff_min_ms, + me_single_endpoint_outage_backoff_max_ms: cfg + .general + .me_single_endpoint_outage_backoff_max_ms, + me_single_endpoint_shadow_rotate_every_secs: cfg + .general + .me_single_endpoint_shadow_rotate_every_secs, + me_config_stable_snapshots: cfg.general.me_config_stable_snapshots, + me_config_apply_cooldown_secs: cfg.general.me_config_apply_cooldown_secs, + me_snapshot_require_http_2xx: cfg.general.me_snapshot_require_http_2xx, + me_snapshot_reject_empty_map: cfg.general.me_snapshot_reject_empty_map, + me_snapshot_min_proxy_for_lines: cfg.general.me_snapshot_min_proxy_for_lines, + proxy_secret_stable_snapshots: cfg.general.proxy_secret_stable_snapshots, + proxy_secret_rotate_runtime: cfg.general.proxy_secret_rotate_runtime, + proxy_secret_len_max: cfg.general.proxy_secret_len_max, + telemetry_core_enabled: cfg.general.telemetry.core_enabled, + telemetry_user_enabled: cfg.general.telemetry.user_enabled, + telemetry_me_level: cfg.general.telemetry.me_level, + me_socks_kdf_policy: cfg.general.me_socks_kdf_policy, + me_route_backpressure_enabled: cfg.general.me_route_backpressure_enabled, + me_route_fairshare_enabled: cfg.general.me_route_fairshare_enabled, + me_floor_mode: cfg.general.me_floor_mode, + me_adaptive_floor_idle_secs: cfg.general.me_adaptive_floor_idle_secs, + me_adaptive_floor_min_writers_single_endpoint: cfg + .general + .me_adaptive_floor_min_writers_single_endpoint, + me_adaptive_floor_min_writers_multi_endpoint: cfg + .general + .me_adaptive_floor_min_writers_multi_endpoint, + me_adaptive_floor_recover_grace_secs: cfg.general.me_adaptive_floor_recover_grace_secs, + me_adaptive_floor_writers_per_core_total: cfg + .general + .me_adaptive_floor_writers_per_core_total, + me_adaptive_floor_cpu_cores_override: cfg.general.me_adaptive_floor_cpu_cores_override, + me_adaptive_floor_max_extra_writers_single_per_core: cfg + .general + .me_adaptive_floor_max_extra_writers_single_per_core, + me_adaptive_floor_max_extra_writers_multi_per_core: cfg + .general + .me_adaptive_floor_max_extra_writers_multi_per_core, + me_adaptive_floor_max_active_writers_per_core: cfg + .general + .me_adaptive_floor_max_active_writers_per_core, + me_adaptive_floor_max_warm_writers_per_core: cfg + .general + .me_adaptive_floor_max_warm_writers_per_core, + me_adaptive_floor_max_active_writers_global: cfg + .general + .me_adaptive_floor_max_active_writers_global, + me_adaptive_floor_max_warm_writers_global: cfg + .general + .me_adaptive_floor_max_warm_writers_global, + me_route_backpressure_base_timeout_ms: cfg + .general + .me_route_backpressure_base_timeout_ms, + me_route_backpressure_high_timeout_ms: cfg + .general + .me_route_backpressure_high_timeout_ms, + me_route_backpressure_high_watermark_pct: cfg + .general + .me_route_backpressure_high_watermark_pct, + me_reader_route_data_wait_ms: cfg.general.me_reader_route_data_wait_ms, + me_d2c_flush_batch_max_frames: cfg.general.me_d2c_flush_batch_max_frames, + me_d2c_flush_batch_max_bytes: cfg.general.me_d2c_flush_batch_max_bytes, + me_d2c_flush_batch_max_delay_us: cfg.general.me_d2c_flush_batch_max_delay_us, + me_d2c_ack_flush_immediate: cfg.general.me_d2c_ack_flush_immediate, + me_quota_soft_overshoot_bytes: cfg.general.me_quota_soft_overshoot_bytes, + me_d2c_frame_buf_shrink_threshold_bytes: cfg + .general + .me_d2c_frame_buf_shrink_threshold_bytes, + direct_relay_copy_buf_c2s_bytes: cfg.general.direct_relay_copy_buf_c2s_bytes, + direct_relay_copy_buf_s2c_bytes: cfg.general.direct_relay_copy_buf_s2c_bytes, + me_health_interval_ms_unhealthy: cfg.general.me_health_interval_ms_unhealthy, + me_health_interval_ms_healthy: cfg.general.me_health_interval_ms_healthy, + me_admission_poll_ms: cfg.general.me_admission_poll_ms, + me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms, + users: cfg.access.users.clone(), + user_enabled: cfg.access.user_enabled.clone(), + user_ad_tags: cfg.access.user_ad_tags.clone(), + user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(), + user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each, + user_expirations: cfg.access.user_expirations.clone(), + user_data_quota: cfg.access.user_data_quota.clone(), + user_rate_limits: cfg.access.user_rate_limits.clone(), + cidr_rate_limits: cfg.access.cidr_rate_limits.clone(), + user_max_unique_ips: cfg.access.user_max_unique_ips.clone(), + user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each, + user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode, + user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs, + } + } +} + + +pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig { + let mut cfg = old.clone(); + + cfg.general.log_level = new.general.log_level.clone(); + cfg.general.ad_tag = new.general.ad_tag.clone(); + cfg.network.dns_overrides = new.network.dns_overrides.clone(); + cfg.general.desync_all_full = new.general.desync_all_full; + cfg.general.update_every = new.general.update_every; + cfg.general.proxy_secret_auto_reload_secs = new.general.proxy_secret_auto_reload_secs; + cfg.general.proxy_config_auto_reload_secs = new.general.proxy_config_auto_reload_secs; + cfg.general.me_reinit_every_secs = new.general.me_reinit_every_secs; + cfg.general.me_reinit_singleflight = new.general.me_reinit_singleflight; + cfg.general.me_reinit_coalesce_window_ms = new.general.me_reinit_coalesce_window_ms; + cfg.general.hardswap = new.general.hardswap; + cfg.general.me_pool_drain_ttl_secs = new.general.me_pool_drain_ttl_secs; + cfg.general.me_instadrain = new.general.me_instadrain; + cfg.general.me_pool_drain_threshold = new.general.me_pool_drain_threshold; + cfg.general.me_pool_min_fresh_ratio = new.general.me_pool_min_fresh_ratio; + cfg.general.me_reinit_drain_timeout_secs = new.general.me_reinit_drain_timeout_secs; + cfg.general.me_hardswap_warmup_delay_min_ms = new.general.me_hardswap_warmup_delay_min_ms; + cfg.general.me_hardswap_warmup_delay_max_ms = new.general.me_hardswap_warmup_delay_max_ms; + cfg.general.me_hardswap_warmup_extra_passes = new.general.me_hardswap_warmup_extra_passes; + cfg.general.me_hardswap_warmup_pass_backoff_base_ms = + new.general.me_hardswap_warmup_pass_backoff_base_ms; + cfg.general.me_bind_stale_mode = new.general.me_bind_stale_mode; + cfg.general.me_bind_stale_ttl_secs = new.general.me_bind_stale_ttl_secs; + cfg.general.me_secret_atomic_snapshot = new.general.me_secret_atomic_snapshot; + cfg.general.me_deterministic_writer_sort = new.general.me_deterministic_writer_sort; + cfg.general.me_writer_pick_mode = new.general.me_writer_pick_mode; + cfg.general.me_writer_pick_sample_size = new.general.me_writer_pick_sample_size; + cfg.general.me_single_endpoint_shadow_writers = new.general.me_single_endpoint_shadow_writers; + cfg.general.me_single_endpoint_outage_mode_enabled = + new.general.me_single_endpoint_outage_mode_enabled; + cfg.general.me_single_endpoint_outage_disable_quarantine = + new.general.me_single_endpoint_outage_disable_quarantine; + cfg.general.me_single_endpoint_outage_backoff_min_ms = + new.general.me_single_endpoint_outage_backoff_min_ms; + cfg.general.me_single_endpoint_outage_backoff_max_ms = + new.general.me_single_endpoint_outage_backoff_max_ms; + cfg.general.me_single_endpoint_shadow_rotate_every_secs = + new.general.me_single_endpoint_shadow_rotate_every_secs; + cfg.general.me_config_stable_snapshots = new.general.me_config_stable_snapshots; + cfg.general.me_config_apply_cooldown_secs = new.general.me_config_apply_cooldown_secs; + cfg.general.me_snapshot_require_http_2xx = new.general.me_snapshot_require_http_2xx; + cfg.general.me_snapshot_reject_empty_map = new.general.me_snapshot_reject_empty_map; + cfg.general.me_snapshot_min_proxy_for_lines = new.general.me_snapshot_min_proxy_for_lines; + cfg.general.proxy_secret_stable_snapshots = new.general.proxy_secret_stable_snapshots; + cfg.general.proxy_secret_rotate_runtime = new.general.proxy_secret_rotate_runtime; + cfg.general.proxy_secret_len_max = new.general.proxy_secret_len_max; + cfg.general.telemetry = new.general.telemetry.clone(); + cfg.general.me_socks_kdf_policy = new.general.me_socks_kdf_policy; + cfg.general.me_floor_mode = new.general.me_floor_mode; + cfg.general.me_adaptive_floor_idle_secs = new.general.me_adaptive_floor_idle_secs; + cfg.general.me_adaptive_floor_min_writers_single_endpoint = + new.general.me_adaptive_floor_min_writers_single_endpoint; + cfg.general.me_adaptive_floor_min_writers_multi_endpoint = + new.general.me_adaptive_floor_min_writers_multi_endpoint; + cfg.general.me_adaptive_floor_recover_grace_secs = + new.general.me_adaptive_floor_recover_grace_secs; + cfg.general.me_adaptive_floor_writers_per_core_total = + new.general.me_adaptive_floor_writers_per_core_total; + cfg.general.me_adaptive_floor_cpu_cores_override = + new.general.me_adaptive_floor_cpu_cores_override; + cfg.general + .me_adaptive_floor_max_extra_writers_single_per_core = new + .general + .me_adaptive_floor_max_extra_writers_single_per_core; + cfg.general + .me_adaptive_floor_max_extra_writers_multi_per_core = new + .general + .me_adaptive_floor_max_extra_writers_multi_per_core; + cfg.general.me_adaptive_floor_max_active_writers_per_core = + new.general.me_adaptive_floor_max_active_writers_per_core; + cfg.general.me_adaptive_floor_max_warm_writers_per_core = + new.general.me_adaptive_floor_max_warm_writers_per_core; + cfg.general.me_adaptive_floor_max_active_writers_global = + new.general.me_adaptive_floor_max_active_writers_global; + cfg.general.me_adaptive_floor_max_warm_writers_global = + new.general.me_adaptive_floor_max_warm_writers_global; + cfg.general.me_route_backpressure_base_timeout_ms = + new.general.me_route_backpressure_base_timeout_ms; + cfg.general.me_route_backpressure_high_timeout_ms = + new.general.me_route_backpressure_high_timeout_ms; + cfg.general.me_route_backpressure_high_watermark_pct = + new.general.me_route_backpressure_high_watermark_pct; + cfg.general.me_route_backpressure_enabled = new.general.me_route_backpressure_enabled; + cfg.general.me_route_fairshare_enabled = new.general.me_route_fairshare_enabled; + cfg.general.me_reader_route_data_wait_ms = new.general.me_reader_route_data_wait_ms; + cfg.general.me_d2c_flush_batch_max_frames = new.general.me_d2c_flush_batch_max_frames; + cfg.general.me_d2c_flush_batch_max_bytes = new.general.me_d2c_flush_batch_max_bytes; + cfg.general.me_d2c_flush_batch_max_delay_us = new.general.me_d2c_flush_batch_max_delay_us; + cfg.general.me_d2c_ack_flush_immediate = new.general.me_d2c_ack_flush_immediate; + cfg.general.me_quota_soft_overshoot_bytes = new.general.me_quota_soft_overshoot_bytes; + cfg.general.me_d2c_frame_buf_shrink_threshold_bytes = + new.general.me_d2c_frame_buf_shrink_threshold_bytes; + cfg.general.direct_relay_copy_buf_c2s_bytes = new.general.direct_relay_copy_buf_c2s_bytes; + cfg.general.direct_relay_copy_buf_s2c_bytes = new.general.direct_relay_copy_buf_s2c_bytes; + cfg.general.me_health_interval_ms_unhealthy = new.general.me_health_interval_ms_unhealthy; + cfg.general.me_health_interval_ms_healthy = new.general.me_health_interval_ms_healthy; + cfg.general.me_admission_poll_ms = new.general.me_admission_poll_ms; + cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms; + + cfg.access.users = new.access.users.clone(); + cfg.access.user_enabled = new.access.user_enabled.clone(); + cfg.access.user_ad_tags = new.access.user_ad_tags.clone(); + cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone(); + cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each; + cfg.access.user_expirations = new.access.user_expirations.clone(); + cfg.access.user_data_quota = new.access.user_data_quota.clone(); + cfg.access.user_rate_limits = new.access.user_rate_limits.clone(); + cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.clone(); + cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone(); + cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; + cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; + cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; + if cfg.rebuild_runtime_user_auth().is_err() { + cfg.runtime_user_auth = None; + } + + cfg +} diff --git a/src/config/hot_reload/reporting.rs b/src/config/hot_reload/reporting.rs new file mode 100644 index 0000000..ffef4e6 --- /dev/null +++ b/src/config/hot_reload/reporting.rs @@ -0,0 +1,499 @@ +use super::*; + +fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 { + cfg.server + .listeners + .first() + .and_then(|listener| listener.port) + .unwrap_or(cfg.server.port) +} + +fn resolve_link_host( + cfg: &ProxyConfig, + detected_ip_v4: Option, + detected_ip_v6: Option, +) -> String { + if let Some(ref h) = cfg.general.links.public_host { + return h.clone(); + } + detected_ip_v4 + .or(detected_ip_v6) + .map(|ip| ip.to_string()) + .unwrap_or_else(|| { + warn!( + "config reload: could not determine public IP for proxy links. \ + Set [general.links] public_host in config." + ); + "UNKNOWN".to_string() + }) +} + +/// Print TG proxy links for a single user — mirrors print_proxy_links() in main.rs. +fn print_user_links(user: &str, secret: &str, host: &str, port: u16, cfg: &ProxyConfig) { + info!(target: "telemt::links", "--- New user: {} ---", user); + if cfg.general.modes.classic { + info!( + target: "telemt::links", + " Classic: tg://proxy?server={}&port={}&secret={}", + host, port, secret + ); + } + if cfg.general.modes.secure { + info!( + target: "telemt::links", + " DD: tg://proxy?server={}&port={}&secret=dd{}", + host, port, secret + ); + } + if cfg.general.modes.tls { + let mut domains = vec![cfg.censorship.tls_domain.clone()]; + for d in &cfg.censorship.tls_domains { + if !domains.contains(d) { + domains.push(d.clone()); + } + } + for domain in &domains { + let domain_hex = hex::encode(domain.as_bytes()); + info!( + target: "telemt::links", + " EE-TLS: tg://proxy?server={}&port={}&secret=ee{}{}", + host, port, secret, domain_hex + ); + } + } + info!(target: "telemt::links", "--------------------"); +} + +/// Log all detected changes and emit TG links for new users. +pub(super) fn log_changes( + old_hot: &HotFields, + new_hot: &HotFields, + new_cfg: &ProxyConfig, + log_tx: &watch::Sender, + detected_ip_v4: Option, + detected_ip_v6: Option, +) { + if old_hot.log_level != new_hot.log_level { + info!( + "config reload: log_level: '{}' → '{}'", + old_hot.log_level, new_hot.log_level + ); + log_tx.send(new_hot.log_level.clone()).ok(); + } + + if old_hot.user_ad_tags != new_hot.user_ad_tags { + info!( + "config reload: user_ad_tags updated ({} entries)", + new_hot.user_ad_tags.len(), + ); + } + + if old_hot.ad_tag != new_hot.ad_tag { + info!("config reload: general.ad_tag updated (applied on next connection)"); + } + + if old_hot.dns_overrides != new_hot.dns_overrides { + info!( + "config reload: network.dns_overrides updated ({} entries)", + new_hot.dns_overrides.len() + ); + } + + if old_hot.desync_all_full != new_hot.desync_all_full { + info!( + "config reload: desync_all_full: {} → {}", + old_hot.desync_all_full, new_hot.desync_all_full, + ); + } + + if old_hot.update_every_secs != new_hot.update_every_secs { + info!( + "config reload: update_every(effective): {}s → {}s", + old_hot.update_every_secs, new_hot.update_every_secs, + ); + } + if old_hot.me_reinit_every_secs != new_hot.me_reinit_every_secs + || old_hot.me_reinit_singleflight != new_hot.me_reinit_singleflight + || old_hot.me_reinit_coalesce_window_ms != new_hot.me_reinit_coalesce_window_ms + { + info!( + "config reload: me_reinit: interval={}s singleflight={} coalesce={}ms", + new_hot.me_reinit_every_secs, + new_hot.me_reinit_singleflight, + new_hot.me_reinit_coalesce_window_ms + ); + } + + if old_hot.hardswap != new_hot.hardswap { + info!( + "config reload: hardswap: {} → {}", + old_hot.hardswap, new_hot.hardswap, + ); + } + + if old_hot.me_pool_drain_ttl_secs != new_hot.me_pool_drain_ttl_secs { + info!( + "config reload: me_pool_drain_ttl_secs: {}s → {}s", + old_hot.me_pool_drain_ttl_secs, new_hot.me_pool_drain_ttl_secs, + ); + } + if old_hot.me_instadrain != new_hot.me_instadrain { + info!( + "config reload: me_instadrain: {} → {}", + old_hot.me_instadrain, new_hot.me_instadrain, + ); + } + + if old_hot.me_pool_drain_threshold != new_hot.me_pool_drain_threshold { + info!( + "config reload: me_pool_drain_threshold: {} → {}", + old_hot.me_pool_drain_threshold, new_hot.me_pool_drain_threshold, + ); + } + + if (old_hot.me_pool_min_fresh_ratio - new_hot.me_pool_min_fresh_ratio).abs() > f32::EPSILON { + info!( + "config reload: me_pool_min_fresh_ratio: {:.3} → {:.3}", + old_hot.me_pool_min_fresh_ratio, new_hot.me_pool_min_fresh_ratio, + ); + } + + if old_hot.me_reinit_drain_timeout_secs != new_hot.me_reinit_drain_timeout_secs { + info!( + "config reload: me_reinit_drain_timeout_secs: {}s → {}s", + old_hot.me_reinit_drain_timeout_secs, new_hot.me_reinit_drain_timeout_secs, + ); + } + if old_hot.me_hardswap_warmup_delay_min_ms != new_hot.me_hardswap_warmup_delay_min_ms + || old_hot.me_hardswap_warmup_delay_max_ms != new_hot.me_hardswap_warmup_delay_max_ms + || old_hot.me_hardswap_warmup_extra_passes != new_hot.me_hardswap_warmup_extra_passes + || old_hot.me_hardswap_warmup_pass_backoff_base_ms + != new_hot.me_hardswap_warmup_pass_backoff_base_ms + { + info!( + "config reload: me_hardswap_warmup: min={}ms max={}ms extra_passes={} pass_backoff={}ms", + new_hot.me_hardswap_warmup_delay_min_ms, + new_hot.me_hardswap_warmup_delay_max_ms, + new_hot.me_hardswap_warmup_extra_passes, + new_hot.me_hardswap_warmup_pass_backoff_base_ms + ); + } + if old_hot.me_bind_stale_mode != new_hot.me_bind_stale_mode + || old_hot.me_bind_stale_ttl_secs != new_hot.me_bind_stale_ttl_secs + { + info!( + "config reload: me_bind_stale: mode={:?} ttl={}s", + new_hot.me_bind_stale_mode, new_hot.me_bind_stale_ttl_secs + ); + } + if old_hot.me_secret_atomic_snapshot != new_hot.me_secret_atomic_snapshot + || old_hot.me_deterministic_writer_sort != new_hot.me_deterministic_writer_sort + || old_hot.me_writer_pick_mode != new_hot.me_writer_pick_mode + || old_hot.me_writer_pick_sample_size != new_hot.me_writer_pick_sample_size + { + info!( + "config reload: me_runtime_flags: secret_atomic_snapshot={} deterministic_sort={} writer_pick_mode={:?} writer_pick_sample_size={}", + new_hot.me_secret_atomic_snapshot, + new_hot.me_deterministic_writer_sort, + new_hot.me_writer_pick_mode, + new_hot.me_writer_pick_sample_size, + ); + } + if old_hot.me_single_endpoint_shadow_writers != new_hot.me_single_endpoint_shadow_writers + || old_hot.me_single_endpoint_outage_mode_enabled + != new_hot.me_single_endpoint_outage_mode_enabled + || old_hot.me_single_endpoint_outage_disable_quarantine + != new_hot.me_single_endpoint_outage_disable_quarantine + || old_hot.me_single_endpoint_outage_backoff_min_ms + != new_hot.me_single_endpoint_outage_backoff_min_ms + || old_hot.me_single_endpoint_outage_backoff_max_ms + != new_hot.me_single_endpoint_outage_backoff_max_ms + || old_hot.me_single_endpoint_shadow_rotate_every_secs + != new_hot.me_single_endpoint_shadow_rotate_every_secs + { + info!( + "config reload: me_single_endpoint: shadow={} outage_enabled={} disable_quarantine={} backoff=[{}..{}]ms rotate={}s", + new_hot.me_single_endpoint_shadow_writers, + new_hot.me_single_endpoint_outage_mode_enabled, + new_hot.me_single_endpoint_outage_disable_quarantine, + new_hot.me_single_endpoint_outage_backoff_min_ms, + new_hot.me_single_endpoint_outage_backoff_max_ms, + new_hot.me_single_endpoint_shadow_rotate_every_secs + ); + } + if old_hot.me_config_stable_snapshots != new_hot.me_config_stable_snapshots + || old_hot.me_config_apply_cooldown_secs != new_hot.me_config_apply_cooldown_secs + || old_hot.me_snapshot_require_http_2xx != new_hot.me_snapshot_require_http_2xx + || old_hot.me_snapshot_reject_empty_map != new_hot.me_snapshot_reject_empty_map + || old_hot.me_snapshot_min_proxy_for_lines != new_hot.me_snapshot_min_proxy_for_lines + { + info!( + "config reload: me_snapshot_guard: stable={} cooldown={}s require_2xx={} reject_empty={} min_proxy_for={}", + new_hot.me_config_stable_snapshots, + new_hot.me_config_apply_cooldown_secs, + new_hot.me_snapshot_require_http_2xx, + new_hot.me_snapshot_reject_empty_map, + new_hot.me_snapshot_min_proxy_for_lines + ); + } + if old_hot.proxy_secret_stable_snapshots != new_hot.proxy_secret_stable_snapshots + || old_hot.proxy_secret_rotate_runtime != new_hot.proxy_secret_rotate_runtime + || old_hot.proxy_secret_len_max != new_hot.proxy_secret_len_max + { + info!( + "config reload: proxy_secret_runtime: stable={} rotate={} len_max={}", + new_hot.proxy_secret_stable_snapshots, + new_hot.proxy_secret_rotate_runtime, + new_hot.proxy_secret_len_max + ); + } + + if old_hot.telemetry_core_enabled != new_hot.telemetry_core_enabled + || old_hot.telemetry_user_enabled != new_hot.telemetry_user_enabled + || old_hot.telemetry_me_level != new_hot.telemetry_me_level + { + info!( + "config reload: telemetry: core_enabled={} user_enabled={} me_level={}", + new_hot.telemetry_core_enabled, + new_hot.telemetry_user_enabled, + new_hot.telemetry_me_level, + ); + } + + if old_hot.me_socks_kdf_policy != new_hot.me_socks_kdf_policy { + info!( + "config reload: me_socks_kdf_policy: {:?} → {:?}", + old_hot.me_socks_kdf_policy, new_hot.me_socks_kdf_policy, + ); + } + + if old_hot.me_floor_mode != new_hot.me_floor_mode + || old_hot.me_adaptive_floor_idle_secs != new_hot.me_adaptive_floor_idle_secs + || old_hot.me_adaptive_floor_min_writers_single_endpoint + != new_hot.me_adaptive_floor_min_writers_single_endpoint + || old_hot.me_adaptive_floor_min_writers_multi_endpoint + != new_hot.me_adaptive_floor_min_writers_multi_endpoint + || old_hot.me_adaptive_floor_recover_grace_secs + != new_hot.me_adaptive_floor_recover_grace_secs + || old_hot.me_adaptive_floor_writers_per_core_total + != new_hot.me_adaptive_floor_writers_per_core_total + || old_hot.me_adaptive_floor_cpu_cores_override + != new_hot.me_adaptive_floor_cpu_cores_override + || old_hot.me_adaptive_floor_max_extra_writers_single_per_core + != new_hot.me_adaptive_floor_max_extra_writers_single_per_core + || old_hot.me_adaptive_floor_max_extra_writers_multi_per_core + != new_hot.me_adaptive_floor_max_extra_writers_multi_per_core + || old_hot.me_adaptive_floor_max_active_writers_per_core + != new_hot.me_adaptive_floor_max_active_writers_per_core + || old_hot.me_adaptive_floor_max_warm_writers_per_core + != new_hot.me_adaptive_floor_max_warm_writers_per_core + || old_hot.me_adaptive_floor_max_active_writers_global + != new_hot.me_adaptive_floor_max_active_writers_global + || old_hot.me_adaptive_floor_max_warm_writers_global + != new_hot.me_adaptive_floor_max_warm_writers_global + { + info!( + "config reload: me_floor: mode={:?} idle={}s min_single={} min_multi={} recover_grace={}s per_core_total={} cores_override={} extra_single_per_core={} extra_multi_per_core={} max_active_per_core={} max_warm_per_core={} max_active_global={} max_warm_global={}", + new_hot.me_floor_mode, + new_hot.me_adaptive_floor_idle_secs, + new_hot.me_adaptive_floor_min_writers_single_endpoint, + new_hot.me_adaptive_floor_min_writers_multi_endpoint, + new_hot.me_adaptive_floor_recover_grace_secs, + new_hot.me_adaptive_floor_writers_per_core_total, + new_hot.me_adaptive_floor_cpu_cores_override, + new_hot.me_adaptive_floor_max_extra_writers_single_per_core, + new_hot.me_adaptive_floor_max_extra_writers_multi_per_core, + new_hot.me_adaptive_floor_max_active_writers_per_core, + new_hot.me_adaptive_floor_max_warm_writers_per_core, + new_hot.me_adaptive_floor_max_active_writers_global, + new_hot.me_adaptive_floor_max_warm_writers_global, + ); + } + + if old_hot.me_route_backpressure_base_timeout_ms + != new_hot.me_route_backpressure_base_timeout_ms + || old_hot.me_route_backpressure_high_timeout_ms + != new_hot.me_route_backpressure_high_timeout_ms + || old_hot.me_route_backpressure_high_watermark_pct + != new_hot.me_route_backpressure_high_watermark_pct + || old_hot.me_route_backpressure_enabled != new_hot.me_route_backpressure_enabled + || old_hot.me_route_fairshare_enabled != new_hot.me_route_fairshare_enabled + || old_hot.me_reader_route_data_wait_ms != new_hot.me_reader_route_data_wait_ms + || old_hot.me_health_interval_ms_unhealthy != new_hot.me_health_interval_ms_unhealthy + || old_hot.me_health_interval_ms_healthy != new_hot.me_health_interval_ms_healthy + || old_hot.me_admission_poll_ms != new_hot.me_admission_poll_ms + || old_hot.me_warn_rate_limit_ms != new_hot.me_warn_rate_limit_ms + { + info!( + "config reload: me_route_backpressure: enabled={} base={}ms high={}ms watermark={}%; me_route_fairshare_enabled={}; me_reader_route_data_wait_ms={}; me_health_interval: unhealthy={}ms healthy={}ms; me_admission_poll={}ms; me_warn_rate_limit={}ms", + new_hot.me_route_backpressure_enabled, + new_hot.me_route_backpressure_base_timeout_ms, + new_hot.me_route_backpressure_high_timeout_ms, + new_hot.me_route_backpressure_high_watermark_pct, + new_hot.me_route_fairshare_enabled, + new_hot.me_reader_route_data_wait_ms, + new_hot.me_health_interval_ms_unhealthy, + new_hot.me_health_interval_ms_healthy, + new_hot.me_admission_poll_ms, + new_hot.me_warn_rate_limit_ms, + ); + } + + if old_hot.me_d2c_flush_batch_max_frames != new_hot.me_d2c_flush_batch_max_frames + || old_hot.me_d2c_flush_batch_max_bytes != new_hot.me_d2c_flush_batch_max_bytes + || old_hot.me_d2c_flush_batch_max_delay_us != new_hot.me_d2c_flush_batch_max_delay_us + || old_hot.me_d2c_ack_flush_immediate != new_hot.me_d2c_ack_flush_immediate + || old_hot.me_quota_soft_overshoot_bytes != new_hot.me_quota_soft_overshoot_bytes + || old_hot.me_d2c_frame_buf_shrink_threshold_bytes + != new_hot.me_d2c_frame_buf_shrink_threshold_bytes + || old_hot.direct_relay_copy_buf_c2s_bytes != new_hot.direct_relay_copy_buf_c2s_bytes + || old_hot.direct_relay_copy_buf_s2c_bytes != new_hot.direct_relay_copy_buf_s2c_bytes + { + info!( + "config reload: relay_tuning: me_d2c_frames={} me_d2c_bytes={} me_d2c_delay_us={} me_ack_flush_immediate={} me_quota_soft_overshoot_bytes={} me_d2c_frame_buf_shrink_threshold_bytes={} direct_buf_c2s={} direct_buf_s2c={}", + new_hot.me_d2c_flush_batch_max_frames, + new_hot.me_d2c_flush_batch_max_bytes, + new_hot.me_d2c_flush_batch_max_delay_us, + new_hot.me_d2c_ack_flush_immediate, + new_hot.me_quota_soft_overshoot_bytes, + new_hot.me_d2c_frame_buf_shrink_threshold_bytes, + new_hot.direct_relay_copy_buf_c2s_bytes, + new_hot.direct_relay_copy_buf_s2c_bytes, + ); + } + + if old_hot.users != new_hot.users { + let mut added: Vec<&String> = new_hot + .users + .keys() + .filter(|u| !old_hot.users.contains_key(*u)) + .collect(); + added.sort(); + + let mut removed: Vec<&String> = old_hot + .users + .keys() + .filter(|u| !new_hot.users.contains_key(*u)) + .collect(); + removed.sort(); + + let mut changed: Vec<&String> = new_hot + .users + .keys() + .filter(|u| { + old_hot + .users + .get(*u) + .map(|s| s != &new_hot.users[*u]) + .unwrap_or(false) + }) + .collect(); + changed.sort(); + + if !added.is_empty() { + info!( + "config reload: users added: [{}]", + added + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + let host = resolve_link_host(new_cfg, detected_ip_v4, detected_ip_v6); + let port = new_cfg + .general + .links + .public_port + .unwrap_or(resolve_default_link_port(new_cfg)); + for user in &added { + if let Some(secret) = new_hot.users.get(*user) { + print_user_links(user, secret, &host, port, new_cfg); + } + } + } + if !removed.is_empty() { + info!( + "config reload: users removed: [{}]", + removed + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + if !changed.is_empty() { + info!( + "config reload: users secret changed: [{}]", + changed + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + } + + if old_hot.user_enabled != new_hot.user_enabled { + info!( + "config reload: user_enabled updated ({} disabled overrides)", + new_hot + .user_enabled + .values() + .filter(|enabled| !**enabled) + .count() + ); + } + if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns { + info!( + "config reload: user_max_tcp_conns updated ({} entries)", + new_hot.user_max_tcp_conns.len() + ); + } + if old_hot.user_max_tcp_conns_global_each != new_hot.user_max_tcp_conns_global_each { + info!( + "config reload: user_max_tcp_conns policy global_each={}", + new_hot.user_max_tcp_conns_global_each + ); + } + if old_hot.user_expirations != new_hot.user_expirations { + info!( + "config reload: user_expirations updated ({} entries)", + new_hot.user_expirations.len() + ); + } + if old_hot.user_data_quota != new_hot.user_data_quota { + info!( + "config reload: user_data_quota updated ({} entries)", + new_hot.user_data_quota.len() + ); + } + if old_hot.user_rate_limits != new_hot.user_rate_limits { + info!( + "config reload: user_rate_limits updated ({} entries)", + new_hot.user_rate_limits.len() + ); + } + if old_hot.cidr_rate_limits != new_hot.cidr_rate_limits { + info!( + "config reload: cidr_rate_limits updated ({} entries)", + new_hot.cidr_rate_limits.len() + ); + } + if old_hot.user_max_unique_ips != new_hot.user_max_unique_ips { + info!( + "config reload: user_max_unique_ips updated ({} entries)", + new_hot.user_max_unique_ips.len() + ); + } + if old_hot.user_max_unique_ips_global_each != new_hot.user_max_unique_ips_global_each + || old_hot.user_max_unique_ips_mode != new_hot.user_max_unique_ips_mode + || old_hot.user_max_unique_ips_window_secs != new_hot.user_max_unique_ips_window_secs + { + info!( + "config reload: user_max_unique_ips policy global_each={} mode={:?} window={}s", + new_hot.user_max_unique_ips_global_each, + new_hot.user_max_unique_ips_mode, + new_hot.user_max_unique_ips_window_secs + ); + } +} diff --git a/src/config/hot_reload/tests.rs b/src/config/hot_reload/tests.rs new file mode 100644 index 0000000..b7c9ed5 --- /dev/null +++ b/src/config/hot_reload/tests.rs @@ -0,0 +1,284 @@ + use super::*; + + fn sample_config() -> ProxyConfig { + ProxyConfig::default() + } + + fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option) { + let mut config = String::from( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + if ad_tag.is_some() { + config.push_str("\n[general]\n"); + if let Some(tag) = ad_tag { + config.push_str(&format!("ad_tag = \"{tag}\"\n")); + } + } + + if let Some(port) = server_port { + config.push_str("\n[server]\n"); + config.push_str(&format!("port = {port}\n")); + } + + std::fs::write(path, config).unwrap(); + } + + fn temp_config_path(prefix: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}_{nonce}.toml")) + } + + #[test] + fn overlay_applies_hot_and_preserves_non_hot() { + let old = sample_config(); + let mut new = old.clone(); + new.general.hardswap = !old.general.hardswap; + new.server.port = old.server.port.saturating_add(1); + + let applied = overlay_hot_fields(&old, &new); + assert_eq!(applied.general.hardswap, new.general.hardswap); + assert_eq!(applied.server.port, old.server.port); + } + + #[test] + fn non_hot_only_change_does_not_change_hot_snapshot() { + let old = sample_config(); + let mut new = old.clone(); + new.server.port = old.server.port.saturating_add(1); + + let applied = overlay_hot_fields(&old, &new); + assert_eq!( + HotFields::from_config(&old), + HotFields::from_config(&applied) + ); + assert_eq!(applied.server.port, old.server.port); + } + + #[test] + fn bind_stale_mode_is_hot() { + let old = sample_config(); + let mut new = old.clone(); + new.general.me_bind_stale_mode = match old.general.me_bind_stale_mode { + MeBindStaleMode::Never => MeBindStaleMode::Ttl, + MeBindStaleMode::Ttl => MeBindStaleMode::Always, + MeBindStaleMode::Always => MeBindStaleMode::Never, + }; + + let applied = overlay_hot_fields(&old, &new); + assert_eq!( + applied.general.me_bind_stale_mode, + new.general.me_bind_stale_mode + ); + assert_ne!( + HotFields::from_config(&old), + HotFields::from_config(&applied) + ); + } + + #[test] + fn keepalive_is_not_hot() { + let old = sample_config(); + let mut new = old.clone(); + new.general.me_keepalive_interval_secs = old.general.me_keepalive_interval_secs + 5; + + let applied = overlay_hot_fields(&old, &new); + assert_eq!( + applied.general.me_keepalive_interval_secs, + old.general.me_keepalive_interval_secs + ); + assert_eq!( + HotFields::from_config(&old), + HotFields::from_config(&applied) + ); + } + + #[test] + fn mixed_hot_and_non_hot_change_applies_only_hot_subset() { + let old = sample_config(); + let mut new = old.clone(); + new.general.hardswap = !old.general.hardswap; + new.general.use_middle_proxy = !old.general.use_middle_proxy; + + let applied = overlay_hot_fields(&old, &new); + assert_eq!(applied.general.hardswap, new.general.hardswap); + assert_eq!( + applied.general.use_middle_proxy, + old.general.use_middle_proxy + ); + assert!(!config_equal(&applied, &new)); + } + + #[test] + fn listener_synlimit_fields_are_process_owned() { + let mut old = sample_config(); + old.server.listeners.push(ListenerConfig { + ip: "0.0.0.0".parse().unwrap(), + port: Some(443), + client_mss: None, + synlimit: SynLimitMode::Iptables, + synlimit_seconds: 60, + synlimit_hitcount: 48, + synlimit_burst: 1, + synlimit_ios_seconds: 1, + synlimit_ios_hitcount: 12, + synlimit_ios_burst: 24, + synlimit_hashlimit_expire_ms: 60_000, + synlimit_hashlimit_size: 32_768, + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + }); + let mut new = old.clone(); + new.server.port = 8443; + new.server.listeners[0].synlimit_seconds = 120; + new.server.listeners[0].synlimit_hitcount = 96; + new.server.listeners[0].synlimit_burst = 2; + new.server.listeners[0].synlimit_ios_seconds = 2; + new.server.listeners[0].synlimit_ios_hitcount = 18; + new.server.listeners[0].synlimit_ios_burst = 36; + new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000; + new.server.listeners[0].synlimit_hashlimit_size = 65_536; + + let applied = overlay_hot_fields(&old, &new); + let listener = &applied.server.listeners[0]; + assert_eq!(applied.server.port, old.server.port); + assert_eq!(listener.synlimit_seconds, old.server.listeners[0].synlimit_seconds); + assert_eq!( + listener.synlimit_hitcount, + old.server.listeners[0].synlimit_hitcount + ); + assert_eq!(listener.synlimit_burst, old.server.listeners[0].synlimit_burst); + assert_eq!( + listener.synlimit_hashlimit_size, + old.server.listeners[0].synlimit_hashlimit_size + ); + assert!(classify_config_changes(&old, &new).restart_required); + } + + #[test] + fn reload_applies_hot_change_on_first_observed_snapshot() { + let initial_tag = "11111111111111111111111111111111"; + let final_tag = "22222222222222222222222222222222"; + let path = temp_config_path("telemt_hot_reload_stable"); + + write_reload_config(&path, Some(initial_tag), None); + let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap()); + let initial_hash = ProxyConfig::load_with_metadata(&path) + .unwrap() + .rendered_hash; + let (config_tx, _config_rx) = watch::channel(initial_cfg.clone()); + let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone()); + let mut reload_state = ReloadState::new(Some(initial_hash)); + + write_reload_config(&path, Some(final_tag), None); + reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap(); + assert_eq!( + config_tx.borrow().general.ad_tag.as_deref(), + Some(final_tag) + ); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn reload_keeps_hot_apply_when_non_hot_fields_change() { + let initial_tag = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let final_tag = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let path = temp_config_path("telemt_hot_reload_mixed"); + + write_reload_config(&path, Some(initial_tag), None); + let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap()); + let initial_hash = ProxyConfig::load_with_metadata(&path) + .unwrap() + .rendered_hash; + let (config_tx, _config_rx) = watch::channel(initial_cfg.clone()); + let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone()); + let mut reload_state = ReloadState::new(Some(initial_hash)); + + write_reload_config(&path, Some(final_tag), Some(initial_cfg.server.port + 1)); + reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap(); + + let applied = config_tx.borrow().clone(); + assert_eq!(applied.general.ad_tag.as_deref(), Some(final_tag)); + assert_eq!(applied.server.port, initial_cfg.server.port); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn classify_sni_change_requires_restart() { + // censorship.* is not in overlay_hot_fields -> restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.censorship.tls_domain = "front.example".to_string(); + + let class = classify_config_changes(&old, &new); + assert!(class.restart_required); + assert!(class.changed.iter().any(|c| c == "censorship")); + } + + #[test] + fn classify_dns_overrides_change_is_hot() { + // network.dns_overrides IS in overlay_hot_fields -> no restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.network.dns_overrides.push("1.1.1.1".to_string()); + + let class = classify_config_changes(&old, &new); + assert!(!class.restart_required); + assert!(class.changed.iter().any(|c| c == "network")); + } + + #[test] + fn classify_timeouts_change_requires_restart() { + // timeouts.* is NOT in overlay_hot_fields -> restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.timeouts.client_handshake = old.timeouts.client_handshake + 1; + + let class = classify_config_changes(&old, &new); + assert!(class.restart_required); + } + + #[test] + fn reload_recovers_after_parse_error_on_next_attempt() { + let initial_tag = "cccccccccccccccccccccccccccccccc"; + let final_tag = "dddddddddddddddddddddddddddddddd"; + let path = temp_config_path("telemt_hot_reload_parse_recovery"); + + write_reload_config(&path, Some(initial_tag), None); + let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap()); + let initial_hash = ProxyConfig::load_with_metadata(&path) + .unwrap() + .rendered_hash; + let (config_tx, _config_rx) = watch::channel(initial_cfg.clone()); + let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone()); + let mut reload_state = ReloadState::new(Some(initial_hash)); + + std::fs::write(&path, "[access.users\nuser = \"broken\"\n").unwrap(); + assert!(reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).is_none()); + assert_eq!( + config_tx.borrow().general.ad_tag.as_deref(), + Some(initial_tag) + ); + + write_reload_config(&path, Some(final_tag), None); + reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap(); + assert_eq!( + config_tx.borrow().general.ad_tag.as_deref(), + Some(final_tag) + ); + + let _ = std::fs::remove_file(path); + } diff --git a/src/config/hot_reload/watcher.rs b/src/config/hot_reload/watcher.rs new file mode 100644 index 0000000..8e09251 --- /dev/null +++ b/src/config/hot_reload/watcher.rs @@ -0,0 +1,372 @@ +use super::*; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct WatchManifest { + files: BTreeSet, + dirs: BTreeSet, +} + +impl WatchManifest { + fn from_source_files(source_files: &[PathBuf]) -> Self { + let mut files = BTreeSet::new(); + let mut dirs = BTreeSet::new(); + + for path in source_files { + let normalized = normalize_watch_path(path); + files.insert(normalized.clone()); + if let Some(parent) = normalized.parent() { + dirs.insert(parent.to_path_buf()); + } + } + + Self { files, dirs } + } + + fn matches_event_paths(&self, event_paths: &[PathBuf]) -> bool { + event_paths + .iter() + .map(|path| normalize_watch_path(path)) + .any(|path| self.files.contains(&path)) + } +} + +#[derive(Debug, Default)] +pub(super) struct ReloadState { + applied_snapshot_hash: Option, +} + +impl ReloadState { + pub(super) fn new(applied_snapshot_hash: Option) -> Self { + Self { + applied_snapshot_hash, + } + } + + fn is_applied(&self, hash: u64) -> bool { + self.applied_snapshot_hash == Some(hash) + } + + fn mark_applied(&mut self, hash: u64) { + self.applied_snapshot_hash = Some(hash); + } +} + +fn normalize_watch_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } + }) +} + +fn sync_watch_paths( + watcher: &mut W, + current: &BTreeSet, + next: &BTreeSet, + recursive_mode: RecursiveMode, + kind: &str, +) { + for path in current.difference(next) { + if let Err(e) = watcher.unwatch(path) { + warn!(path = %path.display(), error = %e, "config watcher: failed to unwatch {kind}"); + } + } + + for path in next.difference(current) { + if let Err(e) = watcher.watch(path, recursive_mode) { + warn!(path = %path.display(), error = %e, "config watcher: failed to watch {kind}"); + } + } +} + +fn apply_watch_manifest( + notify_watcher: Option<&mut W1>, + poll_watcher: Option<&mut W2>, + manifest_state: &Arc>, + next_manifest: WatchManifest, +) { + let current_manifest = manifest_state + .read() + .map(|manifest| manifest.clone()) + .unwrap_or_default(); + + if current_manifest == next_manifest { + return; + } + + if let Some(watcher) = notify_watcher { + sync_watch_paths( + watcher, + ¤t_manifest.dirs, + &next_manifest.dirs, + RecursiveMode::NonRecursive, + "config directory", + ); + } + + if let Some(watcher) = poll_watcher { + sync_watch_paths( + watcher, + ¤t_manifest.files, + &next_manifest.files, + RecursiveMode::NonRecursive, + "config file", + ); + } + + if let Ok(mut manifest) = manifest_state.write() { + *manifest = next_manifest; + } +} + + +/// Load config, validate, diff against current, and broadcast if changed. +pub(super) fn reload_config( + config_path: &PathBuf, + config_tx: &watch::Sender>, + log_tx: &watch::Sender, + detected_ip_v4: Option, + detected_ip_v6: Option, + reload_state: &mut ReloadState, +) -> Option { + let loaded = match ProxyConfig::load_with_metadata(config_path) { + Ok(loaded) => loaded, + Err(e) => { + error!("config reload: failed to parse {:?}: {}", config_path, e); + return None; + } + }; + let LoadedConfig { + config: new_cfg, + source_files, + source_contents: _, + rendered_hash, + } = loaded; + let next_manifest = WatchManifest::from_source_files(&source_files); + + if let Err(e) = new_cfg.validate() { + error!( + "config reload: validation failed: {}; keeping old config", + e + ); + return Some(next_manifest); + } + + if reload_state.is_applied(rendered_hash) { + return Some(next_manifest); + } + + let old_cfg = config_tx.borrow().clone(); + let applied_cfg = overlay_hot_fields(&old_cfg, &new_cfg); + let old_hot = HotFields::from_config(&old_cfg); + let applied_hot = HotFields::from_config(&applied_cfg); + let non_hot_changed = !config_equal(&applied_cfg, &new_cfg); + let hot_changed = old_hot != applied_hot; + + if non_hot_changed { + warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed); + } + + if !hot_changed { + reload_state.mark_applied(rendered_hash); + return Some(next_manifest); + } + + if old_hot.dns_overrides != applied_hot.dns_overrides + && let Err(e) = crate::network::dns_overrides::install_entries(&applied_hot.dns_overrides) + { + error!( + "config reload: invalid network.dns_overrides: {}; keeping old config", + e + ); + return Some(next_manifest); + } + + log_changes( + &old_hot, + &applied_hot, + &applied_cfg, + log_tx, + detected_ip_v4, + detected_ip_v6, + ); + config_tx.send(Arc::new(applied_cfg)).ok(); + reload_state.mark_applied(rendered_hash); + Some(next_manifest) +} + + +/// Spawn the hot-reload watcher task. +/// +/// Uses `notify` (inotify on Linux) to detect file changes instantly. +/// SIGHUP is also handled on Unix as an additional manual trigger. +/// +/// `detected_ip_v4` / `detected_ip_v6` are the IPs discovered during the +/// startup probe — used when generating proxy links for newly added users, +/// matching the same logic as the startup output. +/// The watcher releases its notify and signal resources when `cancellation` fires. +pub fn spawn_config_watcher( + config_path: PathBuf, + initial: Arc, + detected_ip_v4: Option, + detected_ip_v6: Option, + cancellation: tokio_util::sync::CancellationToken, +) -> (watch::Receiver>, watch::Receiver) { + let initial_level = initial.general.log_level.clone(); + let (config_tx, config_rx) = watch::channel(initial); + let (log_tx, log_rx) = watch::channel(initial_level); + + let config_path = normalize_watch_path(&config_path); + let initial_loaded = ProxyConfig::load_with_metadata(&config_path).ok(); + let initial_manifest = initial_loaded + .as_ref() + .map(|loaded| WatchManifest::from_source_files(&loaded.source_files)) + .unwrap_or_else(|| WatchManifest::from_source_files(std::slice::from_ref(&config_path))); + let initial_snapshot_hash = initial_loaded.as_ref().map(|loaded| loaded.rendered_hash); + + tokio::spawn(async move { + let (notify_tx, mut notify_rx) = mpsc::channel::<()>(4); + let manifest_state = Arc::new(StdRwLock::new(WatchManifest::default())); + let mut reload_state = ReloadState::new(initial_snapshot_hash); + + let tx_inotify = notify_tx.clone(); + let manifest_for_inotify = manifest_state.clone(); + let mut inotify_watcher = + match recommended_watcher(move |res: notify::Result| { + let Ok(event) = res else { return }; + if !matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + return; + } + let is_our_file = manifest_for_inotify + .read() + .map(|manifest| manifest.matches_event_paths(&event.paths)) + .unwrap_or(false); + if is_our_file { + let _ = tx_inotify.try_send(()); + } + }) { + Ok(watcher) => Some(watcher), + Err(e) => { + warn!("config watcher: inotify unavailable: {}", e); + None + } + }; + apply_watch_manifest( + inotify_watcher.as_mut(), + Option::<&mut notify::poll::PollWatcher>::None, + &manifest_state, + initial_manifest.clone(), + ); + if inotify_watcher.is_some() { + info!("config watcher: inotify active on {:?}", config_path); + } + + let tx_poll = notify_tx.clone(); + let manifest_for_poll = manifest_state.clone(); + let mut poll_watcher = match notify::poll::PollWatcher::new( + move |res: notify::Result| { + let Ok(event) = res else { return }; + if !matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + return; + } + let is_our_file = manifest_for_poll + .read() + .map(|manifest| manifest.matches_event_paths(&event.paths)) + .unwrap_or(false); + if is_our_file { + let _ = tx_poll.try_send(()); + } + }, + notify::Config::default() + .with_poll_interval(Duration::from_secs(3)) + .with_compare_contents(true), + ) { + Ok(watcher) => Some(watcher), + Err(e) => { + warn!("config watcher: poll watcher unavailable: {}", e); + None + } + }; + apply_watch_manifest( + Option::<&mut notify::RecommendedWatcher>::None, + poll_watcher.as_mut(), + &manifest_state, + initial_manifest.clone(), + ); + if poll_watcher.is_some() { + info!("config watcher: poll watcher active (Docker/NFS safe)"); + } + + #[cfg(unix)] + let mut sighup = { + use tokio::signal::unix::{SignalKind, signal}; + signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler") + }; + + loop { + #[cfg(unix)] + tokio::select! { + msg = notify_rx.recv() => { + if msg.is_none() { break; } + } + _ = sighup.recv() => { + info!("SIGHUP received — reloading {:?}", config_path); + } + _ = cancellation.cancelled() => break, + } + #[cfg(not(unix))] + tokio::select! { + msg = notify_rx.recv() => { + if msg.is_none() { break; } + } + _ = cancellation.cancelled() => break, + } + + // Debounce: drain extra events that arrive within a short quiet window. + tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await; + while notify_rx.try_recv().is_ok() {} + + let mut next_manifest = reload_config( + &config_path, + &config_tx, + &log_tx, + detected_ip_v4, + detected_ip_v6, + &mut reload_state, + ); + if next_manifest.is_none() { + tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await; + while notify_rx.try_recv().is_ok() {} + next_manifest = reload_config( + &config_path, + &config_tx, + &log_tx, + detected_ip_v4, + detected_ip_v6, + &mut reload_state, + ); + } + + if let Some(next_manifest) = next_manifest { + apply_watch_manifest( + inotify_watcher.as_mut(), + poll_watcher.as_mut(), + &manifest_state, + next_manifest, + ); + } + } + }); + + (config_rx, log_rx) +} diff --git a/src/config/load.rs b/src/config/load.rs index 71dd39e..31cc8fd 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -24,6 +24,13 @@ mod strict_keys; mod runtime_auth; // Post-deserialization validation helpers. mod validation; +mod decode; +mod effective; +mod pipeline; +mod validate_core; +mod validate_me; +mod validate_runtime; +mod validate_server; use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes}; use self::normalize::{ @@ -188,1274 +195,7 @@ impl ProxyConfig { } fn load_source_graph(graph: ConfigSourceGraph) -> Result { - let ConfigSourceGraph { - source_contents, - rendered: processed, - } = graph; - let source_files: BTreeSet = source_contents.keys().cloned().collect(); - - let parsed_toml: toml::Value = - toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?; - handle_unknown_config_keys(&parsed_toml)?; - let general_table = parsed_toml - .get("general") - .and_then(|value| value.as_table()); - let network_table = parsed_toml - .get("network") - .and_then(|value| value.as_table()); - let server_table = parsed_toml.get("server").and_then(|value| value.as_table()); - let conntrack_control_table = server_table - .and_then(|table| table.get("conntrack_control")) - .and_then(|value| value.as_table()); - let update_every_is_explicit = general_table - .map(|table| table.contains_key("update_every")) - .unwrap_or(false); - let beobachten_is_explicit = general_table - .map(|table| table.contains_key("beobachten")) - .unwrap_or(false); - let beobachten_minutes_is_explicit = general_table - .map(|table| table.contains_key("beobachten_minutes")) - .unwrap_or(false); - let beobachten_flush_secs_is_explicit = general_table - .map(|table| table.contains_key("beobachten_flush_secs")) - .unwrap_or(false); - let beobachten_file_is_explicit = general_table - .map(|table| table.contains_key("beobachten_file")) - .unwrap_or(false); - let legacy_secret_is_explicit = general_table - .map(|table| table.contains_key("proxy_secret_auto_reload_secs")) - .unwrap_or(false); - let legacy_config_is_explicit = general_table - .map(|table| table.contains_key("proxy_config_auto_reload_secs")) - .unwrap_or(false); - let legacy_top_level_beobachten = parsed_toml.get("beobachten").cloned(); - let legacy_top_level_beobachten_minutes = parsed_toml.get("beobachten_minutes").cloned(); - let legacy_top_level_beobachten_flush_secs = - parsed_toml.get("beobachten_flush_secs").cloned(); - let legacy_top_level_beobachten_file = parsed_toml.get("beobachten_file").cloned(); - let stun_servers_is_explicit = network_table - .map(|table| table.contains_key("stun_servers")) - .unwrap_or(false); - let inline_conntrack_control_is_explicit = conntrack_control_table - .map(|table| table.contains_key("inline_conntrack_control")) - .unwrap_or(false); - - let mut config: ProxyConfig = parsed_toml - .try_into() - .map_err(|e| ProxyError::Config(e.to_string()))?; - config - .server - .conntrack_control - .inline_conntrack_control_explicit = inline_conntrack_control_is_explicit; - - if !update_every_is_explicit && (legacy_secret_is_explicit || legacy_config_is_explicit) { - config.general.update_every = None; - } - - // Backward compatibility: legacy top-level beobachten* keys. - // Prefer `[general].*` when both are present. - let mut legacy_beobachten_applied = false; - if !beobachten_is_explicit && let Some(value) = legacy_top_level_beobachten.as_ref() { - let parsed = value.as_bool().ok_or_else(|| { - ProxyError::Config("beobachten (top-level) must be a boolean".to_string()) - })?; - config.general.beobachten = parsed; - legacy_beobachten_applied = true; - } - if !beobachten_minutes_is_explicit - && let Some(value) = legacy_top_level_beobachten_minutes.as_ref() - { - let raw = value.as_integer().ok_or_else(|| { - ProxyError::Config("beobachten_minutes (top-level) must be an integer".to_string()) - })?; - let parsed = u64::try_from(raw).map_err(|_| { - ProxyError::Config( - "beobachten_minutes (top-level) must be within u64 range".to_string(), - ) - })?; - config.general.beobachten_minutes = parsed; - legacy_beobachten_applied = true; - } - if !beobachten_flush_secs_is_explicit - && let Some(value) = legacy_top_level_beobachten_flush_secs.as_ref() - { - let raw = value.as_integer().ok_or_else(|| { - ProxyError::Config( - "beobachten_flush_secs (top-level) must be an integer".to_string(), - ) - })?; - let parsed = u64::try_from(raw).map_err(|_| { - ProxyError::Config( - "beobachten_flush_secs (top-level) must be within u64 range".to_string(), - ) - })?; - config.general.beobachten_flush_secs = parsed; - legacy_beobachten_applied = true; - } - if !beobachten_file_is_explicit - && let Some(value) = legacy_top_level_beobachten_file.as_ref() - { - let parsed = value.as_str().ok_or_else(|| { - ProxyError::Config("beobachten_file (top-level) must be a string".to_string()) - })?; - config.general.beobachten_file = parsed.to_string(); - legacy_beobachten_applied = true; - } - if legacy_beobachten_applied { - warn!("top-level beobachten* keys are deprecated; use general.beobachten* instead"); - } - - let legacy_nat_stun = config.general.middle_proxy_nat_stun.take(); - let legacy_nat_stun_servers = - std::mem::take(&mut config.general.middle_proxy_nat_stun_servers); - let legacy_nat_stun_used = legacy_nat_stun.is_some() || !legacy_nat_stun_servers.is_empty(); - if stun_servers_is_explicit { - let mut explicit_stun_servers = Vec::new(); - for stun in std::mem::take(&mut config.network.stun_servers) { - push_unique_nonempty(&mut explicit_stun_servers, stun); - } - config.network.stun_servers = explicit_stun_servers; - - if legacy_nat_stun_used { - warn!( - "general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are ignored because network.stun_servers is explicitly set" - ); - } - } else { - // Keep the default STUN pool unless network.stun_servers is explicitly overridden. - let mut unified_stun_servers = default_stun_servers(); - if let Some(stun) = legacy_nat_stun { - push_unique_nonempty(&mut unified_stun_servers, stun); - } - for stun in legacy_nat_stun_servers { - push_unique_nonempty(&mut unified_stun_servers, stun); - } - - config.network.stun_servers = unified_stun_servers; - - if legacy_nat_stun_used { - warn!( - "general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are deprecated; use network.stun_servers" - ); - } - } - - sanitize_ad_tag(&mut config.general.ad_tag); - - if let Some(path) = &config.general.proxy_config_v4_cache_path - && path.trim().is_empty() - { - return Err(ProxyError::Config( - "general.proxy_config_v4_cache_path cannot be empty when provided".to_string(), - )); - } - - if let Some(path) = &config.general.proxy_config_v6_cache_path - && path.trim().is_empty() - { - return Err(ProxyError::Config( - "general.proxy_config_v6_cache_path cannot be empty when provided".to_string(), - )); - } - - if let Some(update_every) = config.general.update_every { - if update_every == 0 { - return Err(ProxyError::Config( - "general.update_every must be > 0".to_string(), - )); - } - } else { - let legacy_secret = config.general.proxy_secret_auto_reload_secs; - let legacy_config = config.general.proxy_config_auto_reload_secs; - let effective = legacy_secret.min(legacy_config); - if effective == 0 { - return Err(ProxyError::Config( - "legacy proxy_*_auto_reload_secs values must be > 0 when general.update_every is not set".to_string(), - )); - } - - if legacy_secret != default_proxy_secret_reload_secs() - || legacy_config != default_proxy_config_reload_secs() - { - warn!( - proxy_secret_auto_reload_secs = legacy_secret, - proxy_config_auto_reload_secs = legacy_config, - effective_update_every_secs = effective, - "proxy_*_auto_reload_secs are deprecated; set general.update_every" - ); - } - } - - if config.general.stun_nat_probe_concurrency == 0 { - return Err(ProxyError::Config( - "general.stun_nat_probe_concurrency must be > 0".to_string(), - )); - } - - if config.general.me_init_retry_attempts > 1_000_000 { - return Err(ProxyError::Config( - "general.me_init_retry_attempts must be within [0, 1000000]".to_string(), - )); - } - - if config.general.upstream_connect_retry_attempts == 0 { - return Err(ProxyError::Config( - "general.upstream_connect_retry_attempts must be > 0".to_string(), - )); - } - - if config.general.upstream_connect_budget_ms == 0 { - return Err(ProxyError::Config( - "general.upstream_connect_budget_ms must be > 0".to_string(), - )); - } - - if config.general.tg_connect == 0 { - return Err(ProxyError::Config( - "general.tg_connect must be > 0".to_string(), - )); - } - - if config.general.upstream_unhealthy_fail_threshold == 0 { - return Err(ProxyError::Config( - "general.upstream_unhealthy_fail_threshold must be > 0".to_string(), - )); - } - - if config.general.rpc_proxy_req_every != 0 - && !(10..=300).contains(&config.general.rpc_proxy_req_every) - { - return Err(ProxyError::Config( - "general.rpc_proxy_req_every must be 0 or within [10, 300]".to_string(), - )); - } - - if config.timeouts.client_handshake == 0 { - return Err(ProxyError::Config( - "timeouts.client_handshake must be > 0".to_string(), - )); - } - - let handshake_timeout_ms = config - .timeouts - .client_handshake - .checked_mul(1000) - .ok_or_else(|| { - ProxyError::Config( - "timeouts.client_handshake is too large to validate milliseconds budget" - .to_string(), - ) - })?; - - if config.censorship.server_hello_delay_max_ms >= handshake_timeout_ms { - return Err(ProxyError::Config( - "censorship.server_hello_delay_max_ms must be < timeouts.client_handshake * 1000" - .to_string(), - )); - } - - if config.censorship.mask_shape_bucket_floor_bytes == 0 { - return Err(ProxyError::Config( - "censorship.mask_shape_bucket_floor_bytes must be > 0".to_string(), - )); - } - - if config.censorship.mask_shape_bucket_cap_bytes - < config.censorship.mask_shape_bucket_floor_bytes - { - return Err(ProxyError::Config( - "censorship.mask_shape_bucket_cap_bytes must be >= censorship.mask_shape_bucket_floor_bytes" - .to_string(), - )); - } - - if config.censorship.mask_shape_above_cap_blur && !config.censorship.mask_shape_hardening { - return Err(ProxyError::Config( - "censorship.mask_shape_above_cap_blur requires censorship.mask_shape_hardening = true" - .to_string(), - )); - } - - if config.censorship.mask_shape_hardening_aggressive_mode - && !config.censorship.mask_shape_hardening - { - return Err(ProxyError::Config( - "censorship.mask_shape_hardening_aggressive_mode requires censorship.mask_shape_hardening = true" - .to_string(), - )); - } - - if config.censorship.mask_shape_above_cap_blur - && config.censorship.mask_shape_above_cap_blur_max_bytes == 0 - { - return Err(ProxyError::Config( - "censorship.mask_shape_above_cap_blur_max_bytes must be > 0 when censorship.mask_shape_above_cap_blur is enabled" - .to_string(), - )); - } - - if config.censorship.mask_shape_above_cap_blur_max_bytes > 1_048_576 { - return Err(ProxyError::Config( - "censorship.mask_shape_above_cap_blur_max_bytes must be <= 1048576".to_string(), - )); - } - - if config.censorship.mask_relay_max_bytes > 67_108_864 { - return Err(ProxyError::Config( - "censorship.mask_relay_max_bytes must be <= 67108864".to_string(), - )); - } - - if !(5..=50).contains(&config.censorship.mask_classifier_prefetch_timeout_ms) { - return Err(ProxyError::Config( - "censorship.mask_classifier_prefetch_timeout_ms must be within [5, 50]".to_string(), - )); - } - - if config.censorship.mask_timing_normalization_ceiling_ms - < config.censorship.mask_timing_normalization_floor_ms - { - return Err(ProxyError::Config( - "censorship.mask_timing_normalization_ceiling_ms must be >= censorship.mask_timing_normalization_floor_ms" - .to_string(), - )); - } - - if config.censorship.mask_timing_normalization_enabled - && config.censorship.mask_timing_normalization_floor_ms == 0 - { - return Err(ProxyError::Config( - "censorship.mask_timing_normalization_floor_ms must be > 0 when censorship.mask_timing_normalization_enabled is true" - .to_string(), - )); - } - - if config.censorship.mask_timing_normalization_ceiling_ms > 60_000 { - return Err(ProxyError::Config( - "censorship.mask_timing_normalization_ceiling_ms must be <= 60000".to_string(), - )); - } - - if config.timeouts.relay_client_idle_soft_secs == 0 { - return Err(ProxyError::Config( - "timeouts.relay_client_idle_soft_secs must be > 0".to_string(), - )); - } - - if config.timeouts.relay_client_idle_hard_secs == 0 { - return Err(ProxyError::Config( - "timeouts.relay_client_idle_hard_secs must be > 0".to_string(), - )); - } - - if config.timeouts.relay_client_idle_hard_secs < config.timeouts.relay_client_idle_soft_secs - { - return Err(ProxyError::Config( - "timeouts.relay_client_idle_hard_secs must be >= timeouts.relay_client_idle_soft_secs" - .to_string(), - )); - } - - if config - .timeouts - .relay_idle_grace_after_downstream_activity_secs - > config.timeouts.relay_client_idle_hard_secs - { - return Err(ProxyError::Config( - "timeouts.relay_idle_grace_after_downstream_activity_secs must be <= timeouts.relay_client_idle_hard_secs" - .to_string(), - )); - } - - if config.general.me_writer_cmd_channel_capacity == 0 { - return Err(ProxyError::Config( - "general.me_writer_cmd_channel_capacity must be > 0".to_string(), - )); - } - if config.general.me_writer_cmd_channel_capacity > MAX_ME_WRITER_CMD_CHANNEL_CAPACITY { - return Err(ProxyError::Config(format!( - "general.me_writer_cmd_channel_capacity must be within [1, {MAX_ME_WRITER_CMD_CHANNEL_CAPACITY}]" - ))); - } - - if config.general.me_route_channel_capacity == 0 { - return Err(ProxyError::Config( - "general.me_route_channel_capacity must be > 0".to_string(), - )); - } - if config.general.me_route_channel_capacity > MAX_ME_ROUTE_CHANNEL_CAPACITY { - return Err(ProxyError::Config(format!( - "general.me_route_channel_capacity must be within [1, {MAX_ME_ROUTE_CHANNEL_CAPACITY}]" - ))); - } - - if config.general.me_c2me_channel_capacity == 0 { - return Err(ProxyError::Config( - "general.me_c2me_channel_capacity must be > 0".to_string(), - )); - } - if config.general.me_c2me_channel_capacity > MAX_ME_C2ME_CHANNEL_CAPACITY { - return Err(ProxyError::Config(format!( - "general.me_c2me_channel_capacity must be within [1, {MAX_ME_C2ME_CHANNEL_CAPACITY}]" - ))); - } - - if !(MIN_MAX_CLIENT_FRAME_BYTES..=MAX_MAX_CLIENT_FRAME_BYTES) - .contains(&config.general.max_client_frame) - { - return Err(ProxyError::Config(format!( - "general.max_client_frame must be within [{MIN_MAX_CLIENT_FRAME_BYTES}, {MAX_MAX_CLIENT_FRAME_BYTES}]" - ))); - } - - let min_writer_byte_budget = - minimum_me_writer_byte_budget_bytes(config.general.max_client_frame); - if config.general.me_writer_byte_budget_bytes % ME_WRITER_BYTE_PERMIT_UNIT_BYTES != 0 { - return Err(ProxyError::Config(format!( - "general.me_writer_byte_budget_bytes must be a multiple of {ME_WRITER_BYTE_PERMIT_UNIT_BYTES}" - ))); - } - if !(min_writer_byte_budget..=MAX_ME_WRITER_BYTE_BUDGET_BYTES) - .contains(&config.general.me_writer_byte_budget_bytes) - { - return Err(ProxyError::Config(format!( - "general.me_writer_byte_budget_bytes must be within [{min_writer_byte_budget}, {MAX_ME_WRITER_BYTE_BUDGET_BYTES}] for general.max_client_frame={}", - config.general.max_client_frame - ))); - } - - if config.general.me_c2me_send_timeout_ms > 60_000 { - return Err(ProxyError::Config( - "general.me_c2me_send_timeout_ms must be within [0, 60000]".to_string(), - )); - } - - if config.general.me_reader_route_data_wait_ms > 20 { - return Err(ProxyError::Config( - "general.me_reader_route_data_wait_ms must be within [0, 20]".to_string(), - )); - } - - if !(1..=512).contains(&config.general.me_d2c_flush_batch_max_frames) { - return Err(ProxyError::Config( - "general.me_d2c_flush_batch_max_frames must be within [1, 512]".to_string(), - )); - } - - if !(4096..=2 * 1024 * 1024).contains(&config.general.me_d2c_flush_batch_max_bytes) { - return Err(ProxyError::Config( - "general.me_d2c_flush_batch_max_bytes must be within [4096, 2097152]".to_string(), - )); - } - - if config.general.me_d2c_flush_batch_max_delay_us > 5000 { - return Err(ProxyError::Config( - "general.me_d2c_flush_batch_max_delay_us must be within [0, 5000]".to_string(), - )); - } - - if config.general.me_quota_soft_overshoot_bytes > 16 * 1024 * 1024 { - return Err(ProxyError::Config( - "general.me_quota_soft_overshoot_bytes must be within [0, 16777216]".to_string(), - )); - } - - if !(4096..=16 * 1024 * 1024) - .contains(&config.general.me_d2c_frame_buf_shrink_threshold_bytes) - { - return Err(ProxyError::Config( - "general.me_d2c_frame_buf_shrink_threshold_bytes must be within [4096, 16777216]" - .to_string(), - )); - } - - if !(4096..=1024 * 1024).contains(&config.general.direct_relay_copy_buf_c2s_bytes) { - return Err(ProxyError::Config( - "general.direct_relay_copy_buf_c2s_bytes must be within [4096, 1048576]" - .to_string(), - )); - } - - if !(8192..=2 * 1024 * 1024).contains(&config.general.direct_relay_copy_buf_s2c_bytes) { - return Err(ProxyError::Config( - "general.direct_relay_copy_buf_s2c_bytes must be within [8192, 2097152]" - .to_string(), - )); - } - - if config.general.direct_relay_buffer_budget_max_bytes != 0 { - if config.general.direct_relay_buffer_budget_max_bytes - % DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES - != 0 - { - return Err(ProxyError::Config(format!( - "general.direct_relay_buffer_budget_max_bytes must be 0 or a multiple of {DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES}" - ))); - } - if !(MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES..=MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES) - .contains(&config.general.direct_relay_buffer_budget_max_bytes) - { - return Err(ProxyError::Config(format!( - "general.direct_relay_buffer_budget_max_bytes must be 0 or within [{MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES}, {MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES}]" - ))); - } - } - - if config.general.me_health_interval_ms_unhealthy == 0 { - return Err(ProxyError::Config( - "general.me_health_interval_ms_unhealthy must be > 0".to_string(), - )); - } - - if config.general.me_health_interval_ms_healthy == 0 { - return Err(ProxyError::Config( - "general.me_health_interval_ms_healthy must be > 0".to_string(), - )); - } - - if config.general.me_admission_poll_ms == 0 { - return Err(ProxyError::Config( - "general.me_admission_poll_ms must be > 0".to_string(), - )); - } - - if config.general.me_warn_rate_limit_ms == 0 { - return Err(ProxyError::Config( - "general.me_warn_rate_limit_ms must be > 0".to_string(), - )); - } - - if config.general.me_pool_drain_soft_evict_grace_secs > 3600 { - return Err(ProxyError::Config( - "general.me_pool_drain_soft_evict_grace_secs must be within [0, 3600]".to_string(), - )); - } - - if config.general.me_pool_drain_soft_evict_per_writer == 0 - || config.general.me_pool_drain_soft_evict_per_writer > 16 - { - return Err(ProxyError::Config( - "general.me_pool_drain_soft_evict_per_writer must be within [1, 16]".to_string(), - )); - } - - if config.general.me_pool_drain_soft_evict_budget_per_core == 0 - || config.general.me_pool_drain_soft_evict_budget_per_core > 64 - { - return Err(ProxyError::Config( - "general.me_pool_drain_soft_evict_budget_per_core must be within [1, 64]" - .to_string(), - )); - } - - if config.general.me_pool_drain_soft_evict_cooldown_ms == 0 { - return Err(ProxyError::Config( - "general.me_pool_drain_soft_evict_cooldown_ms must be > 0".to_string(), - )); - } - - if config.access.user_max_unique_ips_window_secs == 0 { - return Err(ProxyError::Config( - "access.user_max_unique_ips_window_secs must be > 0".to_string(), - )); - } - - for (user, limit) in &config.access.user_rate_limits { - if limit.up_bps == 0 && limit.down_bps == 0 { - return Err(ProxyError::Config(format!( - "access.user_rate_limits.{user} must set at least one non-zero direction" - ))); - } - } - - for (cidr, limit) in &config.access.cidr_rate_limits { - if limit.up_bps == 0 && limit.down_bps == 0 { - return Err(ProxyError::Config(format!( - "access.cidr_rate_limits.{cidr} must set at least one non-zero direction" - ))); - } - } - let mut cidr_auto_templates = HashSet::new(); - for cidr in config.access.cidr_rate_limits.keys() { - for template in cidr.auto_templates().into_iter().flatten() { - if !cidr_auto_templates.insert(template) { - return Err(ProxyError::Config(format!( - "access.cidr_rate_limits.{cidr} duplicates normalized auto-template {template}" - ))); - } - } - } - - if config.general.me_reinit_every_secs == 0 { - return Err(ProxyError::Config( - "general.me_reinit_every_secs must be > 0".to_string(), - )); - } - - if config.general.me_single_endpoint_shadow_writers > 32 { - return Err(ProxyError::Config( - "general.me_single_endpoint_shadow_writers must be within [0, 32]".to_string(), - )); - } - - if config.general.me_adaptive_floor_min_writers_single_endpoint == 0 - || config.general.me_adaptive_floor_min_writers_single_endpoint > 32 - { - return Err(ProxyError::Config( - "general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]" - .to_string(), - )); - } - - if config.general.me_adaptive_floor_min_writers_multi_endpoint == 0 - || config.general.me_adaptive_floor_min_writers_multi_endpoint > 32 - { - return Err(ProxyError::Config( - "general.me_adaptive_floor_min_writers_multi_endpoint must be within [1, 32]" - .to_string(), - )); - } - - if config.general.me_adaptive_floor_writers_per_core_total == 0 { - return Err(ProxyError::Config( - "general.me_adaptive_floor_writers_per_core_total must be > 0".to_string(), - )); - } - - if config.general.me_adaptive_floor_max_active_writers_per_core == 0 { - return Err(ProxyError::Config( - "general.me_adaptive_floor_max_active_writers_per_core must be > 0".to_string(), - )); - } - - if config.general.me_adaptive_floor_max_warm_writers_per_core == 0 { - return Err(ProxyError::Config( - "general.me_adaptive_floor_max_warm_writers_per_core must be > 0".to_string(), - )); - } - - if config.general.me_adaptive_floor_max_active_writers_global == 0 { - return Err(ProxyError::Config( - "general.me_adaptive_floor_max_active_writers_global must be > 0".to_string(), - )); - } - - if config.general.me_adaptive_floor_max_warm_writers_global == 0 { - return Err(ProxyError::Config( - "general.me_adaptive_floor_max_warm_writers_global must be > 0".to_string(), - )); - } - - if config.general.me_single_endpoint_outage_backoff_min_ms == 0 { - return Err(ProxyError::Config( - "general.me_single_endpoint_outage_backoff_min_ms must be > 0".to_string(), - )); - } - - if config.general.me_single_endpoint_outage_backoff_max_ms == 0 { - return Err(ProxyError::Config( - "general.me_single_endpoint_outage_backoff_max_ms must be > 0".to_string(), - )); - } - - if config.general.me_single_endpoint_outage_backoff_min_ms - > config.general.me_single_endpoint_outage_backoff_max_ms - { - return Err(ProxyError::Config( - "general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms".to_string(), - )); - } - - if config.general.beobachten_minutes == 0 { - return Err(ProxyError::Config( - "general.beobachten_minutes must be > 0".to_string(), - )); - } - - if config.general.beobachten_flush_secs == 0 { - return Err(ProxyError::Config( - "general.beobachten_flush_secs must be > 0".to_string(), - )); - } - - if config.general.beobachten_file.trim().is_empty() { - return Err(ProxyError::Config( - "general.beobachten_file cannot be empty".to_string(), - )); - } - - if config.general.me_hardswap_warmup_delay_max_ms == 0 { - return Err(ProxyError::Config( - "general.me_hardswap_warmup_delay_max_ms must be > 0".to_string(), - )); - } - - if config.general.me_hardswap_warmup_delay_min_ms - > config.general.me_hardswap_warmup_delay_max_ms - { - return Err(ProxyError::Config( - "general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms".to_string(), - )); - } - - if config.general.me_hardswap_warmup_extra_passes > 10 { - return Err(ProxyError::Config( - "general.me_hardswap_warmup_extra_passes must be within [0, 10]".to_string(), - )); - } - - if config.general.me_hardswap_warmup_pass_backoff_base_ms == 0 { - return Err(ProxyError::Config( - "general.me_hardswap_warmup_pass_backoff_base_ms must be > 0".to_string(), - )); - } - - if config.general.me_config_stable_snapshots == 0 { - return Err(ProxyError::Config( - "general.me_config_stable_snapshots must be > 0".to_string(), - )); - } - - if config.general.me_snapshot_min_proxy_for_lines == 0 { - return Err(ProxyError::Config( - "general.me_snapshot_min_proxy_for_lines must be > 0".to_string(), - )); - } - - if config.general.proxy_secret_stable_snapshots == 0 { - return Err(ProxyError::Config( - "general.proxy_secret_stable_snapshots must be > 0".to_string(), - )); - } - - if config.general.me_reinit_trigger_channel == 0 { - return Err(ProxyError::Config( - "general.me_reinit_trigger_channel must be > 0".to_string(), - )); - } - - if !(32..=4096).contains(&config.general.proxy_secret_len_max) { - return Err(ProxyError::Config( - "general.proxy_secret_len_max must be within [32, 4096]".to_string(), - )); - } - - if !(0.0..=1.0).contains(&config.general.me_pool_min_fresh_ratio) { - return Err(ProxyError::Config( - "general.me_pool_min_fresh_ratio must be within [0.0, 1.0]".to_string(), - )); - } - - if config.general.me_route_backpressure_base_timeout_ms == 0 { - return Err(ProxyError::Config( - "general.me_route_backpressure_base_timeout_ms must be > 0".to_string(), - )); - } - if config.general.me_route_backpressure_base_timeout_ms > 5000 { - return Err(ProxyError::Config( - "general.me_route_backpressure_base_timeout_ms must be within [1, 5000]" - .to_string(), - )); - } - - if config.general.me_route_backpressure_high_timeout_ms - < config.general.me_route_backpressure_base_timeout_ms - { - return Err(ProxyError::Config( - "general.me_route_backpressure_high_timeout_ms must be >= general.me_route_backpressure_base_timeout_ms".to_string(), - )); - } - if config.general.me_route_backpressure_high_timeout_ms > 5000 { - return Err(ProxyError::Config( - "general.me_route_backpressure_high_timeout_ms must be within [1, 5000]" - .to_string(), - )); - } - - if !(1..=100).contains(&config.general.me_route_backpressure_high_watermark_pct) { - return Err(ProxyError::Config( - "general.me_route_backpressure_high_watermark_pct must be within [1, 100]" - .to_string(), - )); - } - - if !(10..=5000).contains(&config.general.me_route_no_writer_wait_ms) { - return Err(ProxyError::Config( - "general.me_route_no_writer_wait_ms must be within [10, 5000]".to_string(), - )); - } - - if !(50..=60_000).contains(&config.general.me_route_hybrid_max_wait_ms) { - return Err(ProxyError::Config( - "general.me_route_hybrid_max_wait_ms must be within [50, 60000]".to_string(), - )); - } - - if !(1..=5000).contains(&config.general.me_route_blocking_send_timeout_ms) { - return Err(ProxyError::Config( - "general.me_route_blocking_send_timeout_ms must be within [1, 5000]".to_string(), - )); - } - - if !(2..=4).contains(&config.general.me_writer_pick_sample_size) { - return Err(ProxyError::Config( - "general.me_writer_pick_sample_size must be within [2, 4]".to_string(), - )); - } - - if config.general.me_route_inline_recovery_attempts == 0 { - return Err(ProxyError::Config( - "general.me_route_inline_recovery_attempts must be > 0".to_string(), - )); - } - - if !(10..=30000).contains(&config.general.me_route_inline_recovery_wait_ms) { - return Err(ProxyError::Config( - "general.me_route_inline_recovery_wait_ms must be within [10, 30000]".to_string(), - )); - } - - if !(1..=MAX_API_REQUEST_BODY_LIMIT_BYTES) - .contains(&config.server.api.request_body_limit_bytes) - { - return Err(ProxyError::Config( - "server.api.request_body_limit_bytes must be within [1, 1048576]".to_string(), - )); - } - - if config.server.api.minimal_runtime_cache_ttl_ms > 60_000 { - return Err(ProxyError::Config( - "server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]".to_string(), - )); - } - - if config.server.api.runtime_edge_cache_ttl_ms > 60_000 { - return Err(ProxyError::Config( - "server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]".to_string(), - )); - } - - if !(1..=1000).contains(&config.server.api.runtime_edge_top_n) { - return Err(ProxyError::Config( - "server.api.runtime_edge_top_n must be within [1, 1000]".to_string(), - )); - } - - if !(16..=4096).contains(&config.server.api.runtime_edge_events_capacity) { - return Err(ProxyError::Config( - "server.api.runtime_edge_events_capacity must be within [16, 4096]".to_string(), - )); - } - - if config.server.api.listen.parse::().is_err() { - return Err(ProxyError::Config( - "server.api.listen must be in IP:PORT format".to_string(), - )); - } - - if config.server.proxy_protocol_header_timeout_ms == 0 { - return Err(ProxyError::Config( - "server.proxy_protocol_header_timeout_ms must be > 0".to_string(), - )); - } - - if config.server.listen_backlog == 0 || config.server.listen_backlog > i32::MAX as u32 { - return Err(ProxyError::Config(format!( - "server.listen_backlog must be within [1, {}]", - i32::MAX - ))); - } - - config - .server - .client_mss_value() - .map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?; - config - .server - .client_mss_bulk_value() - .map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {error}")))?; - for (idx, listener) in config.server.listeners.iter().enumerate() { - if listener.client_mss.is_some() { - listener - .effective_client_mss(&config.server) - .map_err(|error| { - ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}")) - })?; - } - if listener.synlimit_seconds == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_seconds must be > 0" - ))); - } - if listener.synlimit_hitcount == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_hitcount must be > 0" - ))); - } - if listener.synlimit_burst == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_burst must be > 0" - ))); - } - if listener.synlimit_ios_seconds == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_ios_seconds must be > 0" - ))); - } - if listener.synlimit_ios_hitcount == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_ios_hitcount must be > 0" - ))); - } - if listener.synlimit_ios_burst == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_ios_burst must be > 0" - ))); - } - if listener.synlimit_hashlimit_expire_ms == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_hashlimit_expire_ms must be > 0" - ))); - } - if listener.synlimit_hashlimit_size == 0 { - return Err(ProxyError::Config(format!( - "server.listeners[{idx}].synlimit_hashlimit_size must be > 0" - ))); - } - } - - if config.server.accept_permit_timeout_ms > 60_000 { - return Err(ProxyError::Config( - "server.accept_permit_timeout_ms must be within [0, 60000]".to_string(), - )); - } - - if config.server.conntrack_control.pressure_high_watermark_pct == 0 - || config.server.conntrack_control.pressure_high_watermark_pct > 100 - { - return Err(ProxyError::Config( - "server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]" - .to_string(), - )); - } - - if config.server.conntrack_control.pressure_low_watermark_pct - >= config.server.conntrack_control.pressure_high_watermark_pct - { - return Err(ProxyError::Config( - "server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct" - .to_string(), - )); - } - - if config.server.conntrack_control.delete_budget_per_sec == 0 { - return Err(ProxyError::Config( - "server.conntrack_control.delete_budget_per_sec must be > 0".to_string(), - )); - } - - if matches!(config.server.conntrack_control.mode, ConntrackMode::Hybrid) - && config - .server - .conntrack_control - .hybrid_listener_ips - .is_empty() - { - return Err(ProxyError::Config( - "server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid" - .to_string(), - )); - } - - if config.general.effective_me_pool_force_close_secs() > 0 - && config.general.effective_me_pool_force_close_secs() - < config.general.me_pool_drain_ttl_secs - { - warn!( - me_pool_drain_ttl_secs = config.general.me_pool_drain_ttl_secs, - me_reinit_drain_timeout_secs = config.general.effective_me_pool_force_close_secs(), - "force-close timeout is lower than drain TTL; bumping force-close timeout to TTL" - ); - config.general.me_reinit_drain_timeout_secs = config.general.me_pool_drain_ttl_secs; - } - - // Validate secrets. - for (user, secret) in &config.access.users { - if !secret.chars().all(|c| c.is_ascii_hexdigit()) || secret.len() != 32 { - return Err(ProxyError::InvalidSecret { - user: user.clone(), - reason: "Must be 32 hex characters".to_string(), - }); - } - } - - config.censorship.tls_domain = - normalize_domain_to_ascii(&config.censorship.tls_domain, "censorship.tls_domain")?; - - // Validate mask_unix_sock. - if let Some(ref sock_path) = config.censorship.mask_unix_sock { - if sock_path.is_empty() { - return Err(ProxyError::Config( - "mask_unix_sock cannot be empty".to_string(), - )); - } - #[cfg(unix)] - if sock_path.len() > 107 { - return Err(ProxyError::Config(format!( - "mask_unix_sock path too long: {} bytes (max 107)", - sock_path.len() - ))); - } - #[cfg(not(unix))] - return Err(ProxyError::Config( - "mask_unix_sock is only supported on Unix platforms".to_string(), - )); - - if config.censorship.mask_host.is_some() { - return Err(ProxyError::Config( - "mask_unix_sock and mask_host are mutually exclusive".to_string(), - )); - } - } - - if let Some(mask_host) = config.censorship.mask_host.as_mut() { - *mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?; - } - - for (domain, target) in &config.censorship.exclusive_mask { - if !is_valid_tls_domain_name(domain) { - return Err(ProxyError::Config(format!( - "Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name", - domain - ))); - } - if parse_exclusive_mask_target(target).is_none() { - return Err(ProxyError::Config(format!( - "Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0", - domain, target - ))); - } - } - - // Normalize optional TLS fetch scope: whitespace-only values disable scoped routing. - config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string(); - - if config.censorship.tls_fetch.profiles.is_empty() { - config.censorship.tls_fetch.profiles = TlsFetchConfig::default().profiles; - } else { - let mut seen = HashSet::new(); - config - .censorship - .tls_fetch - .profiles - .retain(|profile| seen.insert(*profile)); - } - - if config.censorship.tls_fetch.attempt_timeout_ms == 0 { - return Err(ProxyError::Config( - "censorship.tls_fetch.attempt_timeout_ms must be > 0".to_string(), - )); - } - if config.censorship.tls_fetch.total_budget_ms == 0 { - return Err(ProxyError::Config( - "censorship.tls_fetch.total_budget_ms must be > 0".to_string(), - )); - } - - // Merge primary + extra TLS domains, deduplicate (primary always first). - if !config.censorship.tls_domains.is_empty() { - let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len()); - all.push(config.censorship.tls_domain.clone()); - for d in std::mem::take(&mut config.censorship.tls_domains) { - if !d.is_empty() { - let domain = normalize_domain_to_ascii(&d, "censorship.tls_domains entry")?; - if !all.contains(&domain) { - all.push(domain); - } - } - } - // keep primary as tls_domain; store remaining back to tls_domains - if all.len() > 1 { - config.censorship.tls_domains = all[1..].to_vec(); - } - } - - let mut exclusive_mask = HashMap::with_capacity(config.censorship.exclusive_mask.len()); - let mut exclusive_mask_targets = - HashMap::with_capacity(config.censorship.exclusive_mask.len()); - for (domain, target) in std::mem::take(&mut config.censorship.exclusive_mask) { - let domain = normalize_domain_to_ascii(&domain, "censorship.exclusive_mask domain")?; - let target = - normalize_exclusive_mask_target(&target, "censorship.exclusive_mask target")?; - let Some((host, port)) = parse_exclusive_mask_target(&target) else { - return Err(ProxyError::Config(format!( - "Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0", - domain, target - ))); - }; - exclusive_mask_targets.insert( - domain.clone(), - ExclusiveMaskTarget { - host: host.to_string(), - port, - }, - ); - exclusive_mask.insert(domain, target); - } - config.censorship.exclusive_mask = exclusive_mask; - config.censorship.exclusive_mask_targets = exclusive_mask_targets; - - // Migration: prefer_ipv6 -> network.prefer. - if config.general.prefer_ipv6 { - if config.network.prefer == 4 { - config.network.prefer = 6; - } - warn!("prefer_ipv6 is deprecated, use [network].prefer = 6"); - } - - if config.general.use_middle_proxy && !config.general.me_secret_atomic_snapshot { - config.general.me_secret_atomic_snapshot = true; - warn!( - "Auto-enabled me_secret_atomic_snapshot for middle proxy mode to keep KDF key_selector/secret coherent" - ); - } - - validate_network_cfg(&mut config.network)?; - crate::network::dns_overrides::validate_entries(&config.network.dns_overrides)?; - - if config.general.use_middle_proxy && config.network.ipv6 == Some(true) { - warn!( - "IPv6 with Middle Proxy is experimental and may cause KDF address mismatch; consider disabling IPv6 or ME" - ); - } - - // Random fake_cert_len only when default is in use. - if !config.censorship.tls_emulation - && config.censorship.fake_cert_len == default_fake_cert_len() - { - config.censorship.fake_cert_len = rand::rng().random_range(1024..4096); - } - - // Resolve listen_tcp: explicit value wins, otherwise auto-detect. - // If unix socket is set → TCP only when listen_addr_ipv4 or listeners are explicitly provided. - // If no unix socket → TCP always (backward compat). - let listen_tcp = config.server.listen_tcp.unwrap_or_else(|| { - if config.server.listen_unix_sock.is_some() { - // Unix socket present: TCP only if user explicitly set addresses or listeners. - config.server.listen_addr_ipv4.is_some() || !config.server.listeners.is_empty() - } else { - true - } - }); - - // Migration: Populate listeners if empty (skip when listen_tcp = false). - if config.server.listeners.is_empty() && listen_tcp { - let ipv4_str = config - .server - .listen_addr_ipv4 - .as_deref() - .unwrap_or("0.0.0.0"); - if let Ok(ipv4) = ipv4_str.parse::() { - config.server.listeners.push(ListenerConfig { - ip: ipv4, - port: Some(config.server.port), - client_mss: None, - synlimit: SynLimitMode::default(), - synlimit_seconds: default_synlimit_seconds(), - synlimit_hitcount: default_synlimit_hitcount(), - synlimit_burst: default_synlimit_burst(), - synlimit_ios_seconds: default_synlimit_ios_seconds(), - synlimit_ios_hitcount: default_synlimit_ios_hitcount(), - synlimit_ios_burst: default_synlimit_ios_burst(), - synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(), - synlimit_hashlimit_size: default_synlimit_hashlimit_size(), - announce: None, - announce_ip: None, - proxy_protocol: None, - reuse_allow: false, - }); - } - if let Some(ipv6_str) = &config.server.listen_addr_ipv6 - && let Ok(ipv6) = ipv6_str.parse::() - { - config.server.listeners.push(ListenerConfig { - ip: ipv6, - port: Some(config.server.port), - client_mss: None, - synlimit: SynLimitMode::default(), - synlimit_seconds: default_synlimit_seconds(), - synlimit_hitcount: default_synlimit_hitcount(), - synlimit_burst: default_synlimit_burst(), - synlimit_ios_seconds: default_synlimit_ios_seconds(), - synlimit_ios_hitcount: default_synlimit_ios_hitcount(), - synlimit_ios_burst: default_synlimit_ios_burst(), - synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(), - synlimit_hashlimit_size: default_synlimit_hashlimit_size(), - announce: None, - announce_ip: None, - proxy_protocol: None, - reuse_allow: false, - }); - } - } - - // Migration: listeners[].port fallback to legacy server.port. - for listener in &mut config.server.listeners { - if listener.port.is_none() { - listener.port = Some(config.server.port); - } - } - - // Migration: announce_ip → announce for each listener. - for listener in &mut config.server.listeners { - if listener.announce.is_none() - && let Some(ip) = listener.announce_ip.take() - { - listener.announce = Some(ip.to_string()); - } - } - validate_listener_runtime_profiles(&config)?; - - // Migration: show_link (top-level) → general.links.show. - if !config.show_link.is_empty() && config.general.links.show.is_empty() { - config.general.links.show = config.show_link.clone(); - } - - // Migration: Populate upstreams if empty (Default Direct). - if config.upstreams.is_empty() { - config.upstreams.push(UpstreamConfig { - upstream_type: UpstreamType::Direct { - interface: None, - bind_addresses: None, - bindtodevice: None, - }, - weight: 1, - enabled: true, - scopes: String::new(), - selected_scope: String::new(), - ipv4: None, - ipv6: None, - prefer: None, - }); - } - normalize_upstream_family_policy(&mut config); - - // Ensure default DC203 override is present. - config - .dc_overrides - .entry("203".to_string()) - .or_insert_with(|| vec!["91.105.192.100:443".to_string()]); - - validate_logging_config(&config.logging)?; - validate_upstreams(&config)?; - config.rebuild_runtime_user_auth()?; - - Ok(LoadedConfig { - config, - source_files: source_files.into_iter().collect(), - source_contents, - rendered_hash: hash_rendered_snapshot(&processed), - }) + pipeline::load_source_graph(graph) } pub(crate) fn rebuild_runtime_user_auth(&mut self) -> Result<()> { diff --git a/src/config/load/decode.rs b/src/config/load/decode.rs new file mode 100644 index 0000000..ac3d53d --- /dev/null +++ b/src/config/load/decode.rs @@ -0,0 +1,165 @@ +use super::*; + +pub(super) type DecodedSourceGraph = ( + ProxyConfig, + BTreeSet, + BTreeMap, + String, +); + +pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result { + let ConfigSourceGraph { + source_contents, + rendered: processed, + } = graph; + let source_files: BTreeSet = source_contents.keys().cloned().collect(); + + let parsed_toml: toml::Value = + toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?; + handle_unknown_config_keys(&parsed_toml)?; + let general_table = parsed_toml + .get("general") + .and_then(|value| value.as_table()); + let network_table = parsed_toml + .get("network") + .and_then(|value| value.as_table()); + let server_table = parsed_toml.get("server").and_then(|value| value.as_table()); + let conntrack_control_table = server_table + .and_then(|table| table.get("conntrack_control")) + .and_then(|value| value.as_table()); + let update_every_is_explicit = general_table + .map(|table| table.contains_key("update_every")) + .unwrap_or(false); + let beobachten_is_explicit = general_table + .map(|table| table.contains_key("beobachten")) + .unwrap_or(false); + let beobachten_minutes_is_explicit = general_table + .map(|table| table.contains_key("beobachten_minutes")) + .unwrap_or(false); + let beobachten_flush_secs_is_explicit = general_table + .map(|table| table.contains_key("beobachten_flush_secs")) + .unwrap_or(false); + let beobachten_file_is_explicit = general_table + .map(|table| table.contains_key("beobachten_file")) + .unwrap_or(false); + let legacy_secret_is_explicit = general_table + .map(|table| table.contains_key("proxy_secret_auto_reload_secs")) + .unwrap_or(false); + let legacy_config_is_explicit = general_table + .map(|table| table.contains_key("proxy_config_auto_reload_secs")) + .unwrap_or(false); + let legacy_top_level_beobachten = parsed_toml.get("beobachten").cloned(); + let legacy_top_level_beobachten_minutes = parsed_toml.get("beobachten_minutes").cloned(); + let legacy_top_level_beobachten_flush_secs = + parsed_toml.get("beobachten_flush_secs").cloned(); + let legacy_top_level_beobachten_file = parsed_toml.get("beobachten_file").cloned(); + let stun_servers_is_explicit = network_table + .map(|table| table.contains_key("stun_servers")) + .unwrap_or(false); + let inline_conntrack_control_is_explicit = conntrack_control_table + .map(|table| table.contains_key("inline_conntrack_control")) + .unwrap_or(false); + + let mut config: ProxyConfig = parsed_toml + .try_into() + .map_err(|e| ProxyError::Config(e.to_string()))?; + config + .server + .conntrack_control + .inline_conntrack_control_explicit = inline_conntrack_control_is_explicit; + + if !update_every_is_explicit && (legacy_secret_is_explicit || legacy_config_is_explicit) { + config.general.update_every = None; + } + + // Backward compatibility: legacy top-level beobachten* keys. + // Prefer `[general].*` when both are present. + let mut legacy_beobachten_applied = false; + if !beobachten_is_explicit && let Some(value) = legacy_top_level_beobachten.as_ref() { + let parsed = value.as_bool().ok_or_else(|| { + ProxyError::Config("beobachten (top-level) must be a boolean".to_string()) + })?; + config.general.beobachten = parsed; + legacy_beobachten_applied = true; + } + if !beobachten_minutes_is_explicit + && let Some(value) = legacy_top_level_beobachten_minutes.as_ref() + { + let raw = value.as_integer().ok_or_else(|| { + ProxyError::Config("beobachten_minutes (top-level) must be an integer".to_string()) + })?; + let parsed = u64::try_from(raw).map_err(|_| { + ProxyError::Config( + "beobachten_minutes (top-level) must be within u64 range".to_string(), + ) + })?; + config.general.beobachten_minutes = parsed; + legacy_beobachten_applied = true; + } + if !beobachten_flush_secs_is_explicit + && let Some(value) = legacy_top_level_beobachten_flush_secs.as_ref() + { + let raw = value.as_integer().ok_or_else(|| { + ProxyError::Config( + "beobachten_flush_secs (top-level) must be an integer".to_string(), + ) + })?; + let parsed = u64::try_from(raw).map_err(|_| { + ProxyError::Config( + "beobachten_flush_secs (top-level) must be within u64 range".to_string(), + ) + })?; + config.general.beobachten_flush_secs = parsed; + legacy_beobachten_applied = true; + } + if !beobachten_file_is_explicit + && let Some(value) = legacy_top_level_beobachten_file.as_ref() + { + let parsed = value.as_str().ok_or_else(|| { + ProxyError::Config("beobachten_file (top-level) must be a string".to_string()) + })?; + config.general.beobachten_file = parsed.to_string(); + legacy_beobachten_applied = true; + } + if legacy_beobachten_applied { + warn!("top-level beobachten* keys are deprecated; use general.beobachten* instead"); + } + + let legacy_nat_stun = config.general.middle_proxy_nat_stun.take(); + let legacy_nat_stun_servers = + std::mem::take(&mut config.general.middle_proxy_nat_stun_servers); + let legacy_nat_stun_used = legacy_nat_stun.is_some() || !legacy_nat_stun_servers.is_empty(); + if stun_servers_is_explicit { + let mut explicit_stun_servers = Vec::new(); + for stun in std::mem::take(&mut config.network.stun_servers) { + push_unique_nonempty(&mut explicit_stun_servers, stun); + } + config.network.stun_servers = explicit_stun_servers; + + if legacy_nat_stun_used { + warn!( + "general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are ignored because network.stun_servers is explicitly set" + ); + } + } else { + // Keep the default STUN pool unless network.stun_servers is explicitly overridden. + let mut unified_stun_servers = default_stun_servers(); + if let Some(stun) = legacy_nat_stun { + push_unique_nonempty(&mut unified_stun_servers, stun); + } + for stun in legacy_nat_stun_servers { + push_unique_nonempty(&mut unified_stun_servers, stun); + } + + config.network.stun_servers = unified_stun_servers; + + if legacy_nat_stun_used { + warn!( + "general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are deprecated; use network.stun_servers" + ); + } + } + + sanitize_ad_tag(&mut config.general.ad_tag); + Ok((config, source_files, source_contents, processed)) +} diff --git a/src/config/load/effective.rs b/src/config/load/effective.rs new file mode 100644 index 0000000..313de76 --- /dev/null +++ b/src/config/load/effective.rs @@ -0,0 +1,217 @@ +use super::*; + +pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { + // Normalize optional TLS fetch scope: whitespace-only values disable scoped routing. + config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string(); + + if config.censorship.tls_fetch.profiles.is_empty() { + config.censorship.tls_fetch.profiles = TlsFetchConfig::default().profiles; + } else { + let mut seen = HashSet::new(); + config + .censorship + .tls_fetch + .profiles + .retain(|profile| seen.insert(*profile)); + } + + if config.censorship.tls_fetch.attempt_timeout_ms == 0 { + return Err(ProxyError::Config( + "censorship.tls_fetch.attempt_timeout_ms must be > 0".to_string(), + )); + } + if config.censorship.tls_fetch.total_budget_ms == 0 { + return Err(ProxyError::Config( + "censorship.tls_fetch.total_budget_ms must be > 0".to_string(), + )); + } + + // Merge primary + extra TLS domains, deduplicate (primary always first). + if !config.censorship.tls_domains.is_empty() { + let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len()); + all.push(config.censorship.tls_domain.clone()); + for d in std::mem::take(&mut config.censorship.tls_domains) { + if !d.is_empty() { + let domain = normalize_domain_to_ascii(&d, "censorship.tls_domains entry")?; + if !all.contains(&domain) { + all.push(domain); + } + } + } + // keep primary as tls_domain; store remaining back to tls_domains + if all.len() > 1 { + config.censorship.tls_domains = all[1..].to_vec(); + } + } + + let mut exclusive_mask = HashMap::with_capacity(config.censorship.exclusive_mask.len()); + let mut exclusive_mask_targets = + HashMap::with_capacity(config.censorship.exclusive_mask.len()); + for (domain, target) in std::mem::take(&mut config.censorship.exclusive_mask) { + let domain = normalize_domain_to_ascii(&domain, "censorship.exclusive_mask domain")?; + let target = + normalize_exclusive_mask_target(&target, "censorship.exclusive_mask target")?; + let Some((host, port)) = parse_exclusive_mask_target(&target) else { + return Err(ProxyError::Config(format!( + "Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0", + domain, target + ))); + }; + exclusive_mask_targets.insert( + domain.clone(), + ExclusiveMaskTarget { + host: host.to_string(), + port, + }, + ); + exclusive_mask.insert(domain, target); + } + config.censorship.exclusive_mask = exclusive_mask; + config.censorship.exclusive_mask_targets = exclusive_mask_targets; + + // Migration: prefer_ipv6 -> network.prefer. + if config.general.prefer_ipv6 { + if config.network.prefer == 4 { + config.network.prefer = 6; + } + warn!("prefer_ipv6 is deprecated, use [network].prefer = 6"); + } + + if config.general.use_middle_proxy && !config.general.me_secret_atomic_snapshot { + config.general.me_secret_atomic_snapshot = true; + warn!( + "Auto-enabled me_secret_atomic_snapshot for middle proxy mode to keep KDF key_selector/secret coherent" + ); + } + + validate_network_cfg(&mut config.network)?; + crate::network::dns_overrides::validate_entries(&config.network.dns_overrides)?; + + if config.general.use_middle_proxy && config.network.ipv6 == Some(true) { + warn!( + "IPv6 with Middle Proxy is experimental and may cause KDF address mismatch; consider disabling IPv6 or ME" + ); + } + + // Random fake_cert_len only when default is in use. + if !config.censorship.tls_emulation + && config.censorship.fake_cert_len == default_fake_cert_len() + { + config.censorship.fake_cert_len = rand::rng().random_range(1024..4096); + } + + // Resolve listen_tcp: explicit value wins, otherwise auto-detect. + // If unix socket is set → TCP only when listen_addr_ipv4 or listeners are explicitly provided. + // If no unix socket → TCP always (backward compat). + let listen_tcp = config.server.listen_tcp.unwrap_or_else(|| { + if config.server.listen_unix_sock.is_some() { + // Unix socket present: TCP only if user explicitly set addresses or listeners. + config.server.listen_addr_ipv4.is_some() || !config.server.listeners.is_empty() + } else { + true + } + }); + + // Migration: Populate listeners if empty (skip when listen_tcp = false). + if config.server.listeners.is_empty() && listen_tcp { + let ipv4_str = config + .server + .listen_addr_ipv4 + .as_deref() + .unwrap_or("0.0.0.0"); + if let Ok(ipv4) = ipv4_str.parse::() { + config.server.listeners.push(ListenerConfig { + ip: ipv4, + port: Some(config.server.port), + client_mss: None, + synlimit: SynLimitMode::default(), + synlimit_seconds: default_synlimit_seconds(), + synlimit_hitcount: default_synlimit_hitcount(), + synlimit_burst: default_synlimit_burst(), + synlimit_ios_seconds: default_synlimit_ios_seconds(), + synlimit_ios_hitcount: default_synlimit_ios_hitcount(), + synlimit_ios_burst: default_synlimit_ios_burst(), + synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(), + synlimit_hashlimit_size: default_synlimit_hashlimit_size(), + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + }); + } + if let Some(ipv6_str) = &config.server.listen_addr_ipv6 + && let Ok(ipv6) = ipv6_str.parse::() + { + config.server.listeners.push(ListenerConfig { + ip: ipv6, + port: Some(config.server.port), + client_mss: None, + synlimit: SynLimitMode::default(), + synlimit_seconds: default_synlimit_seconds(), + synlimit_hitcount: default_synlimit_hitcount(), + synlimit_burst: default_synlimit_burst(), + synlimit_ios_seconds: default_synlimit_ios_seconds(), + synlimit_ios_hitcount: default_synlimit_ios_hitcount(), + synlimit_ios_burst: default_synlimit_ios_burst(), + synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(), + synlimit_hashlimit_size: default_synlimit_hashlimit_size(), + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + }); + } + } + + // Migration: listeners[].port fallback to legacy server.port. + for listener in &mut config.server.listeners { + if listener.port.is_none() { + listener.port = Some(config.server.port); + } + } + + // Migration: announce_ip → announce for each listener. + for listener in &mut config.server.listeners { + if listener.announce.is_none() + && let Some(ip) = listener.announce_ip.take() + { + listener.announce = Some(ip.to_string()); + } + } + validate_listener_runtime_profiles(config)?; + + // Migration: show_link (top-level) → general.links.show. + if !config.show_link.is_empty() && config.general.links.show.is_empty() { + config.general.links.show = config.show_link.clone(); + } + + // Migration: Populate upstreams if empty (Default Direct). + if config.upstreams.is_empty() { + config.upstreams.push(UpstreamConfig { + upstream_type: UpstreamType::Direct { + interface: None, + bind_addresses: None, + bindtodevice: None, + }, + weight: 1, + enabled: true, + scopes: String::new(), + selected_scope: String::new(), + ipv4: None, + ipv6: None, + prefer: None, + }); + } + normalize_upstream_family_policy(config); + + // Ensure default DC203 override is present. + config + .dc_overrides + .entry("203".to_string()) + .or_insert_with(|| vec!["91.105.192.100:443".to_string()]); + + validate_logging_config(&config.logging)?; + validate_upstreams(config)?; + config.rebuild_runtime_user_auth()?; + Ok(()) +} diff --git a/src/config/load/pipeline.rs b/src/config/load/pipeline.rs new file mode 100644 index 0000000..7bdc3cb --- /dev/null +++ b/src/config/load/pipeline.rs @@ -0,0 +1,17 @@ +use super::*; + +pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result { + let (mut config, source_files, source_contents, processed) = + decode::decode_source_graph(graph)?; + validate_core::validate(&mut config)?; + validate_runtime::validate(&mut config)?; + validate_me::validate(&mut config)?; + validate_server::validate(&mut config)?; + effective::apply(&mut config)?; + Ok(LoadedConfig { + config, + source_files: source_files.into_iter().collect(), + source_contents, + rendered_hash: hash_rendered_snapshot(&processed), + }) +} diff --git a/src/config/load/validate_core.rs b/src/config/load/validate_core.rs new file mode 100644 index 0000000..bc00d34 --- /dev/null +++ b/src/config/load/validate_core.rs @@ -0,0 +1,229 @@ +use super::*; + +pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { + if let Some(path) = &config.general.proxy_config_v4_cache_path + && path.trim().is_empty() + { + return Err(ProxyError::Config( + "general.proxy_config_v4_cache_path cannot be empty when provided".to_string(), + )); + } + + if let Some(path) = &config.general.proxy_config_v6_cache_path + && path.trim().is_empty() + { + return Err(ProxyError::Config( + "general.proxy_config_v6_cache_path cannot be empty when provided".to_string(), + )); + } + + if let Some(update_every) = config.general.update_every { + if update_every == 0 { + return Err(ProxyError::Config( + "general.update_every must be > 0".to_string(), + )); + } + } else { + let legacy_secret = config.general.proxy_secret_auto_reload_secs; + let legacy_config = config.general.proxy_config_auto_reload_secs; + let effective = legacy_secret.min(legacy_config); + if effective == 0 { + return Err(ProxyError::Config( + "legacy proxy_*_auto_reload_secs values must be > 0 when general.update_every is not set".to_string(), + )); + } + + if legacy_secret != default_proxy_secret_reload_secs() + || legacy_config != default_proxy_config_reload_secs() + { + warn!( + proxy_secret_auto_reload_secs = legacy_secret, + proxy_config_auto_reload_secs = legacy_config, + effective_update_every_secs = effective, + "proxy_*_auto_reload_secs are deprecated; set general.update_every" + ); + } + } + + if config.general.stun_nat_probe_concurrency == 0 { + return Err(ProxyError::Config( + "general.stun_nat_probe_concurrency must be > 0".to_string(), + )); + } + + if config.general.me_init_retry_attempts > 1_000_000 { + return Err(ProxyError::Config( + "general.me_init_retry_attempts must be within [0, 1000000]".to_string(), + )); + } + + if config.general.upstream_connect_retry_attempts == 0 { + return Err(ProxyError::Config( + "general.upstream_connect_retry_attempts must be > 0".to_string(), + )); + } + + if config.general.upstream_connect_budget_ms == 0 { + return Err(ProxyError::Config( + "general.upstream_connect_budget_ms must be > 0".to_string(), + )); + } + + if config.general.tg_connect == 0 { + return Err(ProxyError::Config( + "general.tg_connect must be > 0".to_string(), + )); + } + + if config.general.upstream_unhealthy_fail_threshold == 0 { + return Err(ProxyError::Config( + "general.upstream_unhealthy_fail_threshold must be > 0".to_string(), + )); + } + + if config.general.rpc_proxy_req_every != 0 + && !(10..=300).contains(&config.general.rpc_proxy_req_every) + { + return Err(ProxyError::Config( + "general.rpc_proxy_req_every must be 0 or within [10, 300]".to_string(), + )); + } + + if config.timeouts.client_handshake == 0 { + return Err(ProxyError::Config( + "timeouts.client_handshake must be > 0".to_string(), + )); + } + + let handshake_timeout_ms = config + .timeouts + .client_handshake + .checked_mul(1000) + .ok_or_else(|| { + ProxyError::Config( + "timeouts.client_handshake is too large to validate milliseconds budget" + .to_string(), + ) + })?; + + if config.censorship.server_hello_delay_max_ms >= handshake_timeout_ms { + return Err(ProxyError::Config( + "censorship.server_hello_delay_max_ms must be < timeouts.client_handshake * 1000" + .to_string(), + )); + } + + if config.censorship.mask_shape_bucket_floor_bytes == 0 { + return Err(ProxyError::Config( + "censorship.mask_shape_bucket_floor_bytes must be > 0".to_string(), + )); + } + + if config.censorship.mask_shape_bucket_cap_bytes + < config.censorship.mask_shape_bucket_floor_bytes + { + return Err(ProxyError::Config( + "censorship.mask_shape_bucket_cap_bytes must be >= censorship.mask_shape_bucket_floor_bytes" + .to_string(), + )); + } + + if config.censorship.mask_shape_above_cap_blur && !config.censorship.mask_shape_hardening { + return Err(ProxyError::Config( + "censorship.mask_shape_above_cap_blur requires censorship.mask_shape_hardening = true" + .to_string(), + )); + } + + if config.censorship.mask_shape_hardening_aggressive_mode + && !config.censorship.mask_shape_hardening + { + return Err(ProxyError::Config( + "censorship.mask_shape_hardening_aggressive_mode requires censorship.mask_shape_hardening = true" + .to_string(), + )); + } + + if config.censorship.mask_shape_above_cap_blur + && config.censorship.mask_shape_above_cap_blur_max_bytes == 0 + { + return Err(ProxyError::Config( + "censorship.mask_shape_above_cap_blur_max_bytes must be > 0 when censorship.mask_shape_above_cap_blur is enabled" + .to_string(), + )); + } + + if config.censorship.mask_shape_above_cap_blur_max_bytes > 1_048_576 { + return Err(ProxyError::Config( + "censorship.mask_shape_above_cap_blur_max_bytes must be <= 1048576".to_string(), + )); + } + + if config.censorship.mask_relay_max_bytes > 67_108_864 { + return Err(ProxyError::Config( + "censorship.mask_relay_max_bytes must be <= 67108864".to_string(), + )); + } + + if !(5..=50).contains(&config.censorship.mask_classifier_prefetch_timeout_ms) { + return Err(ProxyError::Config( + "censorship.mask_classifier_prefetch_timeout_ms must be within [5, 50]".to_string(), + )); + } + + if config.censorship.mask_timing_normalization_ceiling_ms + < config.censorship.mask_timing_normalization_floor_ms + { + return Err(ProxyError::Config( + "censorship.mask_timing_normalization_ceiling_ms must be >= censorship.mask_timing_normalization_floor_ms" + .to_string(), + )); + } + + if config.censorship.mask_timing_normalization_enabled + && config.censorship.mask_timing_normalization_floor_ms == 0 + { + return Err(ProxyError::Config( + "censorship.mask_timing_normalization_floor_ms must be > 0 when censorship.mask_timing_normalization_enabled is true" + .to_string(), + )); + } + + if config.censorship.mask_timing_normalization_ceiling_ms > 60_000 { + return Err(ProxyError::Config( + "censorship.mask_timing_normalization_ceiling_ms must be <= 60000".to_string(), + )); + } + + if config.timeouts.relay_client_idle_soft_secs == 0 { + return Err(ProxyError::Config( + "timeouts.relay_client_idle_soft_secs must be > 0".to_string(), + )); + } + + if config.timeouts.relay_client_idle_hard_secs == 0 { + return Err(ProxyError::Config( + "timeouts.relay_client_idle_hard_secs must be > 0".to_string(), + )); + } + + if config.timeouts.relay_client_idle_hard_secs < config.timeouts.relay_client_idle_soft_secs + { + return Err(ProxyError::Config( + "timeouts.relay_client_idle_hard_secs must be >= timeouts.relay_client_idle_soft_secs" + .to_string(), + )); + } + + if config + .timeouts + .relay_idle_grace_after_downstream_activity_secs + > config.timeouts.relay_client_idle_hard_secs + { + return Err(ProxyError::Config( + "timeouts.relay_idle_grace_after_downstream_activity_secs must be <= timeouts.relay_client_idle_hard_secs" + .to_string(), + )); + } + Ok(()) +} diff --git a/src/config/load/validate_me.rs b/src/config/load/validate_me.rs new file mode 100644 index 0000000..2da68a4 --- /dev/null +++ b/src/config/load/validate_me.rs @@ -0,0 +1,233 @@ +use super::*; + +pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { + if config.general.me_reinit_every_secs == 0 { + return Err(ProxyError::Config( + "general.me_reinit_every_secs must be > 0".to_string(), + )); + } + + if config.general.me_single_endpoint_shadow_writers > 32 { + return Err(ProxyError::Config( + "general.me_single_endpoint_shadow_writers must be within [0, 32]".to_string(), + )); + } + + if config.general.me_adaptive_floor_min_writers_single_endpoint == 0 + || config.general.me_adaptive_floor_min_writers_single_endpoint > 32 + { + return Err(ProxyError::Config( + "general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]" + .to_string(), + )); + } + + if config.general.me_adaptive_floor_min_writers_multi_endpoint == 0 + || config.general.me_adaptive_floor_min_writers_multi_endpoint > 32 + { + return Err(ProxyError::Config( + "general.me_adaptive_floor_min_writers_multi_endpoint must be within [1, 32]" + .to_string(), + )); + } + + if config.general.me_adaptive_floor_writers_per_core_total == 0 { + return Err(ProxyError::Config( + "general.me_adaptive_floor_writers_per_core_total must be > 0".to_string(), + )); + } + + if config.general.me_adaptive_floor_max_active_writers_per_core == 0 { + return Err(ProxyError::Config( + "general.me_adaptive_floor_max_active_writers_per_core must be > 0".to_string(), + )); + } + + if config.general.me_adaptive_floor_max_warm_writers_per_core == 0 { + return Err(ProxyError::Config( + "general.me_adaptive_floor_max_warm_writers_per_core must be > 0".to_string(), + )); + } + + if config.general.me_adaptive_floor_max_active_writers_global == 0 { + return Err(ProxyError::Config( + "general.me_adaptive_floor_max_active_writers_global must be > 0".to_string(), + )); + } + + if config.general.me_adaptive_floor_max_warm_writers_global == 0 { + return Err(ProxyError::Config( + "general.me_adaptive_floor_max_warm_writers_global must be > 0".to_string(), + )); + } + + if config.general.me_single_endpoint_outage_backoff_min_ms == 0 { + return Err(ProxyError::Config( + "general.me_single_endpoint_outage_backoff_min_ms must be > 0".to_string(), + )); + } + + if config.general.me_single_endpoint_outage_backoff_max_ms == 0 { + return Err(ProxyError::Config( + "general.me_single_endpoint_outage_backoff_max_ms must be > 0".to_string(), + )); + } + + if config.general.me_single_endpoint_outage_backoff_min_ms + > config.general.me_single_endpoint_outage_backoff_max_ms + { + return Err(ProxyError::Config( + "general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms".to_string(), + )); + } + + if config.general.beobachten_minutes == 0 { + return Err(ProxyError::Config( + "general.beobachten_minutes must be > 0".to_string(), + )); + } + + if config.general.beobachten_flush_secs == 0 { + return Err(ProxyError::Config( + "general.beobachten_flush_secs must be > 0".to_string(), + )); + } + + if config.general.beobachten_file.trim().is_empty() { + return Err(ProxyError::Config( + "general.beobachten_file cannot be empty".to_string(), + )); + } + + if config.general.me_hardswap_warmup_delay_max_ms == 0 { + return Err(ProxyError::Config( + "general.me_hardswap_warmup_delay_max_ms must be > 0".to_string(), + )); + } + + if config.general.me_hardswap_warmup_delay_min_ms + > config.general.me_hardswap_warmup_delay_max_ms + { + return Err(ProxyError::Config( + "general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms".to_string(), + )); + } + + if config.general.me_hardswap_warmup_extra_passes > 10 { + return Err(ProxyError::Config( + "general.me_hardswap_warmup_extra_passes must be within [0, 10]".to_string(), + )); + } + + if config.general.me_hardswap_warmup_pass_backoff_base_ms == 0 { + return Err(ProxyError::Config( + "general.me_hardswap_warmup_pass_backoff_base_ms must be > 0".to_string(), + )); + } + + if config.general.me_config_stable_snapshots == 0 { + return Err(ProxyError::Config( + "general.me_config_stable_snapshots must be > 0".to_string(), + )); + } + + if config.general.me_snapshot_min_proxy_for_lines == 0 { + return Err(ProxyError::Config( + "general.me_snapshot_min_proxy_for_lines must be > 0".to_string(), + )); + } + + if config.general.proxy_secret_stable_snapshots == 0 { + return Err(ProxyError::Config( + "general.proxy_secret_stable_snapshots must be > 0".to_string(), + )); + } + + if config.general.me_reinit_trigger_channel == 0 { + return Err(ProxyError::Config( + "general.me_reinit_trigger_channel must be > 0".to_string(), + )); + } + + if !(32..=4096).contains(&config.general.proxy_secret_len_max) { + return Err(ProxyError::Config( + "general.proxy_secret_len_max must be within [32, 4096]".to_string(), + )); + } + + if !(0.0..=1.0).contains(&config.general.me_pool_min_fresh_ratio) { + return Err(ProxyError::Config( + "general.me_pool_min_fresh_ratio must be within [0.0, 1.0]".to_string(), + )); + } + + if config.general.me_route_backpressure_base_timeout_ms == 0 { + return Err(ProxyError::Config( + "general.me_route_backpressure_base_timeout_ms must be > 0".to_string(), + )); + } + if config.general.me_route_backpressure_base_timeout_ms > 5000 { + return Err(ProxyError::Config( + "general.me_route_backpressure_base_timeout_ms must be within [1, 5000]" + .to_string(), + )); + } + + if config.general.me_route_backpressure_high_timeout_ms + < config.general.me_route_backpressure_base_timeout_ms + { + return Err(ProxyError::Config( + "general.me_route_backpressure_high_timeout_ms must be >= general.me_route_backpressure_base_timeout_ms".to_string(), + )); + } + if config.general.me_route_backpressure_high_timeout_ms > 5000 { + return Err(ProxyError::Config( + "general.me_route_backpressure_high_timeout_ms must be within [1, 5000]" + .to_string(), + )); + } + + if !(1..=100).contains(&config.general.me_route_backpressure_high_watermark_pct) { + return Err(ProxyError::Config( + "general.me_route_backpressure_high_watermark_pct must be within [1, 100]" + .to_string(), + )); + } + + if !(10..=5000).contains(&config.general.me_route_no_writer_wait_ms) { + return Err(ProxyError::Config( + "general.me_route_no_writer_wait_ms must be within [10, 5000]".to_string(), + )); + } + + if !(50..=60_000).contains(&config.general.me_route_hybrid_max_wait_ms) { + return Err(ProxyError::Config( + "general.me_route_hybrid_max_wait_ms must be within [50, 60000]".to_string(), + )); + } + + if !(1..=5000).contains(&config.general.me_route_blocking_send_timeout_ms) { + return Err(ProxyError::Config( + "general.me_route_blocking_send_timeout_ms must be within [1, 5000]".to_string(), + )); + } + + if !(2..=4).contains(&config.general.me_writer_pick_sample_size) { + return Err(ProxyError::Config( + "general.me_writer_pick_sample_size must be within [2, 4]".to_string(), + )); + } + + if config.general.me_route_inline_recovery_attempts == 0 { + return Err(ProxyError::Config( + "general.me_route_inline_recovery_attempts must be > 0".to_string(), + )); + } + + if !(10..=30000).contains(&config.general.me_route_inline_recovery_wait_ms) { + return Err(ProxyError::Config( + "general.me_route_inline_recovery_wait_ms must be within [10, 30000]".to_string(), + )); + } + Ok(()) +} diff --git a/src/config/load/validate_runtime.rs b/src/config/load/validate_runtime.rs new file mode 100644 index 0000000..d6969e0 --- /dev/null +++ b/src/config/load/validate_runtime.rs @@ -0,0 +1,224 @@ +use super::*; + +pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { + if config.general.me_writer_cmd_channel_capacity == 0 { + return Err(ProxyError::Config( + "general.me_writer_cmd_channel_capacity must be > 0".to_string(), + )); + } + if config.general.me_writer_cmd_channel_capacity > MAX_ME_WRITER_CMD_CHANNEL_CAPACITY { + return Err(ProxyError::Config(format!( + "general.me_writer_cmd_channel_capacity must be within [1, {MAX_ME_WRITER_CMD_CHANNEL_CAPACITY}]" + ))); + } + + if config.general.me_route_channel_capacity == 0 { + return Err(ProxyError::Config( + "general.me_route_channel_capacity must be > 0".to_string(), + )); + } + if config.general.me_route_channel_capacity > MAX_ME_ROUTE_CHANNEL_CAPACITY { + return Err(ProxyError::Config(format!( + "general.me_route_channel_capacity must be within [1, {MAX_ME_ROUTE_CHANNEL_CAPACITY}]" + ))); + } + + if config.general.me_c2me_channel_capacity == 0 { + return Err(ProxyError::Config( + "general.me_c2me_channel_capacity must be > 0".to_string(), + )); + } + if config.general.me_c2me_channel_capacity > MAX_ME_C2ME_CHANNEL_CAPACITY { + return Err(ProxyError::Config(format!( + "general.me_c2me_channel_capacity must be within [1, {MAX_ME_C2ME_CHANNEL_CAPACITY}]" + ))); + } + + if !(MIN_MAX_CLIENT_FRAME_BYTES..=MAX_MAX_CLIENT_FRAME_BYTES) + .contains(&config.general.max_client_frame) + { + return Err(ProxyError::Config(format!( + "general.max_client_frame must be within [{MIN_MAX_CLIENT_FRAME_BYTES}, {MAX_MAX_CLIENT_FRAME_BYTES}]" + ))); + } + + let min_writer_byte_budget = + minimum_me_writer_byte_budget_bytes(config.general.max_client_frame); + if config.general.me_writer_byte_budget_bytes % ME_WRITER_BYTE_PERMIT_UNIT_BYTES != 0 { + return Err(ProxyError::Config(format!( + "general.me_writer_byte_budget_bytes must be a multiple of {ME_WRITER_BYTE_PERMIT_UNIT_BYTES}" + ))); + } + if !(min_writer_byte_budget..=MAX_ME_WRITER_BYTE_BUDGET_BYTES) + .contains(&config.general.me_writer_byte_budget_bytes) + { + return Err(ProxyError::Config(format!( + "general.me_writer_byte_budget_bytes must be within [{min_writer_byte_budget}, {MAX_ME_WRITER_BYTE_BUDGET_BYTES}] for general.max_client_frame={}", + config.general.max_client_frame + ))); + } + + if config.general.me_c2me_send_timeout_ms > 60_000 { + return Err(ProxyError::Config( + "general.me_c2me_send_timeout_ms must be within [0, 60000]".to_string(), + )); + } + + if config.general.me_reader_route_data_wait_ms > 20 { + return Err(ProxyError::Config( + "general.me_reader_route_data_wait_ms must be within [0, 20]".to_string(), + )); + } + + if !(1..=512).contains(&config.general.me_d2c_flush_batch_max_frames) { + return Err(ProxyError::Config( + "general.me_d2c_flush_batch_max_frames must be within [1, 512]".to_string(), + )); + } + + if !(4096..=2 * 1024 * 1024).contains(&config.general.me_d2c_flush_batch_max_bytes) { + return Err(ProxyError::Config( + "general.me_d2c_flush_batch_max_bytes must be within [4096, 2097152]".to_string(), + )); + } + + if config.general.me_d2c_flush_batch_max_delay_us > 5000 { + return Err(ProxyError::Config( + "general.me_d2c_flush_batch_max_delay_us must be within [0, 5000]".to_string(), + )); + } + + if config.general.me_quota_soft_overshoot_bytes > 16 * 1024 * 1024 { + return Err(ProxyError::Config( + "general.me_quota_soft_overshoot_bytes must be within [0, 16777216]".to_string(), + )); + } + + if !(4096..=16 * 1024 * 1024) + .contains(&config.general.me_d2c_frame_buf_shrink_threshold_bytes) + { + return Err(ProxyError::Config( + "general.me_d2c_frame_buf_shrink_threshold_bytes must be within [4096, 16777216]" + .to_string(), + )); + } + + if !(4096..=1024 * 1024).contains(&config.general.direct_relay_copy_buf_c2s_bytes) { + return Err(ProxyError::Config( + "general.direct_relay_copy_buf_c2s_bytes must be within [4096, 1048576]" + .to_string(), + )); + } + + if !(8192..=2 * 1024 * 1024).contains(&config.general.direct_relay_copy_buf_s2c_bytes) { + return Err(ProxyError::Config( + "general.direct_relay_copy_buf_s2c_bytes must be within [8192, 2097152]" + .to_string(), + )); + } + + if config.general.direct_relay_buffer_budget_max_bytes != 0 { + if config.general.direct_relay_buffer_budget_max_bytes + % DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES + != 0 + { + return Err(ProxyError::Config(format!( + "general.direct_relay_buffer_budget_max_bytes must be 0 or a multiple of {DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES}" + ))); + } + if !(MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES..=MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES) + .contains(&config.general.direct_relay_buffer_budget_max_bytes) + { + return Err(ProxyError::Config(format!( + "general.direct_relay_buffer_budget_max_bytes must be 0 or within [{MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES}, {MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES}]" + ))); + } + } + + if config.general.me_health_interval_ms_unhealthy == 0 { + return Err(ProxyError::Config( + "general.me_health_interval_ms_unhealthy must be > 0".to_string(), + )); + } + + if config.general.me_health_interval_ms_healthy == 0 { + return Err(ProxyError::Config( + "general.me_health_interval_ms_healthy must be > 0".to_string(), + )); + } + + if config.general.me_admission_poll_ms == 0 { + return Err(ProxyError::Config( + "general.me_admission_poll_ms must be > 0".to_string(), + )); + } + + if config.general.me_warn_rate_limit_ms == 0 { + return Err(ProxyError::Config( + "general.me_warn_rate_limit_ms must be > 0".to_string(), + )); + } + + if config.general.me_pool_drain_soft_evict_grace_secs > 3600 { + return Err(ProxyError::Config( + "general.me_pool_drain_soft_evict_grace_secs must be within [0, 3600]".to_string(), + )); + } + + if config.general.me_pool_drain_soft_evict_per_writer == 0 + || config.general.me_pool_drain_soft_evict_per_writer > 16 + { + return Err(ProxyError::Config( + "general.me_pool_drain_soft_evict_per_writer must be within [1, 16]".to_string(), + )); + } + + if config.general.me_pool_drain_soft_evict_budget_per_core == 0 + || config.general.me_pool_drain_soft_evict_budget_per_core > 64 + { + return Err(ProxyError::Config( + "general.me_pool_drain_soft_evict_budget_per_core must be within [1, 64]" + .to_string(), + )); + } + + if config.general.me_pool_drain_soft_evict_cooldown_ms == 0 { + return Err(ProxyError::Config( + "general.me_pool_drain_soft_evict_cooldown_ms must be > 0".to_string(), + )); + } + + if config.access.user_max_unique_ips_window_secs == 0 { + return Err(ProxyError::Config( + "access.user_max_unique_ips_window_secs must be > 0".to_string(), + )); + } + + for (user, limit) in &config.access.user_rate_limits { + if limit.up_bps == 0 && limit.down_bps == 0 { + return Err(ProxyError::Config(format!( + "access.user_rate_limits.{user} must set at least one non-zero direction" + ))); + } + } + + for (cidr, limit) in &config.access.cidr_rate_limits { + if limit.up_bps == 0 && limit.down_bps == 0 { + return Err(ProxyError::Config(format!( + "access.cidr_rate_limits.{cidr} must set at least one non-zero direction" + ))); + } + } + let mut cidr_auto_templates = HashSet::new(); + for cidr in config.access.cidr_rate_limits.keys() { + for template in cidr.auto_templates().into_iter().flatten() { + if !cidr_auto_templates.insert(template) { + return Err(ProxyError::Config(format!( + "access.cidr_rate_limits.{cidr} duplicates normalized auto-template {template}" + ))); + } + } + } + + Ok(()) +} diff --git a/src/config/load/validate_server.rs b/src/config/load/validate_server.rs new file mode 100644 index 0000000..a131435 --- /dev/null +++ b/src/config/load/validate_server.rs @@ -0,0 +1,226 @@ +use super::*; + +pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { + if !(1..=MAX_API_REQUEST_BODY_LIMIT_BYTES) + .contains(&config.server.api.request_body_limit_bytes) + { + return Err(ProxyError::Config( + "server.api.request_body_limit_bytes must be within [1, 1048576]".to_string(), + )); + } + + if config.server.api.minimal_runtime_cache_ttl_ms > 60_000 { + return Err(ProxyError::Config( + "server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]".to_string(), + )); + } + + if config.server.api.runtime_edge_cache_ttl_ms > 60_000 { + return Err(ProxyError::Config( + "server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]".to_string(), + )); + } + + if !(1..=1000).contains(&config.server.api.runtime_edge_top_n) { + return Err(ProxyError::Config( + "server.api.runtime_edge_top_n must be within [1, 1000]".to_string(), + )); + } + + if !(16..=4096).contains(&config.server.api.runtime_edge_events_capacity) { + return Err(ProxyError::Config( + "server.api.runtime_edge_events_capacity must be within [16, 4096]".to_string(), + )); + } + + if config.server.api.listen.parse::().is_err() { + return Err(ProxyError::Config( + "server.api.listen must be in IP:PORT format".to_string(), + )); + } + + if config.server.proxy_protocol_header_timeout_ms == 0 { + return Err(ProxyError::Config( + "server.proxy_protocol_header_timeout_ms must be > 0".to_string(), + )); + } + + if config.server.listen_backlog == 0 || config.server.listen_backlog > i32::MAX as u32 { + return Err(ProxyError::Config(format!( + "server.listen_backlog must be within [1, {}]", + i32::MAX + ))); + } + + config + .server + .client_mss_value() + .map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?; + config + .server + .client_mss_bulk_value() + .map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {error}")))?; + for (idx, listener) in config.server.listeners.iter().enumerate() { + if listener.client_mss.is_some() { + listener + .effective_client_mss(&config.server) + .map_err(|error| { + ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}")) + })?; + } + if listener.synlimit_seconds == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_seconds must be > 0" + ))); + } + if listener.synlimit_hitcount == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_hitcount must be > 0" + ))); + } + if listener.synlimit_burst == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_burst must be > 0" + ))); + } + if listener.synlimit_ios_seconds == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_ios_seconds must be > 0" + ))); + } + if listener.synlimit_ios_hitcount == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_ios_hitcount must be > 0" + ))); + } + if listener.synlimit_ios_burst == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_ios_burst must be > 0" + ))); + } + if listener.synlimit_hashlimit_expire_ms == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_hashlimit_expire_ms must be > 0" + ))); + } + if listener.synlimit_hashlimit_size == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_hashlimit_size must be > 0" + ))); + } + } + + if config.server.accept_permit_timeout_ms > 60_000 { + return Err(ProxyError::Config( + "server.accept_permit_timeout_ms must be within [0, 60000]".to_string(), + )); + } + + if config.server.conntrack_control.pressure_high_watermark_pct == 0 + || config.server.conntrack_control.pressure_high_watermark_pct > 100 + { + return Err(ProxyError::Config( + "server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]" + .to_string(), + )); + } + + if config.server.conntrack_control.pressure_low_watermark_pct + >= config.server.conntrack_control.pressure_high_watermark_pct + { + return Err(ProxyError::Config( + "server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct" + .to_string(), + )); + } + + if config.server.conntrack_control.delete_budget_per_sec == 0 { + return Err(ProxyError::Config( + "server.conntrack_control.delete_budget_per_sec must be > 0".to_string(), + )); + } + + if matches!(config.server.conntrack_control.mode, ConntrackMode::Hybrid) + && config + .server + .conntrack_control + .hybrid_listener_ips + .is_empty() + { + return Err(ProxyError::Config( + "server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid" + .to_string(), + )); + } + + if config.general.effective_me_pool_force_close_secs() > 0 + && config.general.effective_me_pool_force_close_secs() + < config.general.me_pool_drain_ttl_secs + { + warn!( + me_pool_drain_ttl_secs = config.general.me_pool_drain_ttl_secs, + me_reinit_drain_timeout_secs = config.general.effective_me_pool_force_close_secs(), + "force-close timeout is lower than drain TTL; bumping force-close timeout to TTL" + ); + config.general.me_reinit_drain_timeout_secs = config.general.me_pool_drain_ttl_secs; + } + + // Validate secrets. + for (user, secret) in &config.access.users { + if !secret.chars().all(|c| c.is_ascii_hexdigit()) || secret.len() != 32 { + return Err(ProxyError::InvalidSecret { + user: user.clone(), + reason: "Must be 32 hex characters".to_string(), + }); + } + } + + config.censorship.tls_domain = + normalize_domain_to_ascii(&config.censorship.tls_domain, "censorship.tls_domain")?; + + // Validate mask_unix_sock. + if let Some(ref sock_path) = config.censorship.mask_unix_sock { + if sock_path.is_empty() { + return Err(ProxyError::Config( + "mask_unix_sock cannot be empty".to_string(), + )); + } + #[cfg(unix)] + if sock_path.len() > 107 { + return Err(ProxyError::Config(format!( + "mask_unix_sock path too long: {} bytes (max 107)", + sock_path.len() + ))); + } + #[cfg(not(unix))] + return Err(ProxyError::Config( + "mask_unix_sock is only supported on Unix platforms".to_string(), + )); + + if config.censorship.mask_host.is_some() { + return Err(ProxyError::Config( + "mask_unix_sock and mask_host are mutually exclusive".to_string(), + )); + } + } + + if let Some(mask_host) = config.censorship.mask_host.as_mut() { + *mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?; + } + + for (domain, target) in &config.censorship.exclusive_mask { + if !is_valid_tls_domain_name(domain) { + return Err(ProxyError::Config(format!( + "Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name", + domain + ))); + } + if parse_exclusive_mask_target(target).is_none() { + return Err(ProxyError::Config(format!( + "Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0", + domain, target + ))); + } + } + Ok(()) +} diff --git a/src/config/tests/load_basic_tests.rs b/src/config/tests/load_basic_tests.rs index b99d71d..939e2b1 100644 --- a/src/config/tests/load_basic_tests.rs +++ b/src/config/tests/load_basic_tests.rs @@ -34,2531 +34,21 @@ fn load_config_error_from_temp_toml(toml: &str) -> String { error } -#[test] -fn synlimit_synfix_defaults_are_loaded_for_listener() { - let cfg = load_config_from_temp_toml( - r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[server.listeners]] - ip = "0.0.0.0" - port = 443 - synlimit = "iptables" - "#, - ); - - let listener = &cfg.server.listeners[0]; - assert_eq!(listener.synlimit_seconds, 60); - assert_eq!(listener.synlimit_hitcount, 48); - assert_eq!(listener.synlimit_burst, 24); - assert_eq!(listener.synlimit_ios_seconds, 1); - assert_eq!(listener.synlimit_ios_hitcount, 12); - assert_eq!(listener.synlimit_ios_burst, 24); - assert_eq!(listener.synlimit_hashlimit_expire_ms, 60_000); - assert_eq!(listener.synlimit_hashlimit_size, 32_768); -} - -#[cfg(target_os = "freebsd")] -#[test] -fn synlimit_pf_mode_is_loaded_for_listener() { - let cfg = load_config_from_temp_toml( - r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[server.listeners]] - ip = "0.0.0.0" - port = 443 - synlimit = "pf" - "#, - ); - - assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf); -} - -#[cfg(not(target_os = "freebsd"))] -#[test] -fn synlimit_pf_mode_is_rejected_off_freebsd() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[server.listeners]] - ip = "0.0.0.0" - port = 443 - synlimit = "pf" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_synlimit_pf_unsupported_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("backend pf is unsupported on this platform")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn synlimit_synfix_zero_values_are_rejected() { - for (field, expected) in [ - ( - "synlimit_ios_seconds", - "server.listeners[0].synlimit_ios_seconds must be > 0", - ), - ( - "synlimit_ios_hitcount", - "server.listeners[0].synlimit_ios_hitcount must be > 0", - ), - ( - "synlimit_ios_burst", - "server.listeners[0].synlimit_ios_burst must be > 0", - ), - ( - "synlimit_hashlimit_expire_ms", - "server.listeners[0].synlimit_hashlimit_expire_ms must be > 0", - ), - ( - "synlimit_hashlimit_size", - "server.listeners[0].synlimit_hashlimit_size must be > 0", - ), - ] { - let toml = format!( - r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[server.listeners]] - ip = "0.0.0.0" - port = 443 - synlimit = "iptables" - {field} = 0 - "# - ); - let error = load_config_error_from_temp_toml(&toml); - assert!(error.contains(expected), "{field}: {error}"); - } -} - -#[test] -fn serde_defaults_remain_unchanged_for_present_sections() { - let toml = r#" - [network] - [general] - [server] - [access] - "#; - let cfg: ProxyConfig = toml::from_str(toml).unwrap(); - - assert_eq!(cfg.logging, LoggingConfig::default()); - assert_eq!(cfg.network.ipv6, default_network_ipv6()); - assert_eq!(cfg.network.stun_use, default_true()); - assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); - assert_eq!( - cfg.general.middle_proxy_warm_standby, - default_middle_proxy_warm_standby() - ); - assert_eq!( - cfg.general.me_reconnect_max_concurrent_per_dc, - default_me_reconnect_max_concurrent_per_dc() - ); - assert_eq!( - cfg.general.me_reconnect_fast_retry_count, - default_me_reconnect_fast_retry_count() - ); - assert_eq!( - cfg.general.me_init_retry_attempts, - default_me_init_retry_attempts() - ); - assert_eq!(cfg.general.me2dc_fallback, default_me2dc_fallback()); - assert_eq!(cfg.general.me2dc_fast, default_me2dc_fast()); - assert_eq!( - cfg.general.proxy_config_v4_cache_path, - default_proxy_config_v4_cache_path() - ); - assert_eq!( - cfg.general.proxy_config_v6_cache_path, - default_proxy_config_v6_cache_path() - ); - assert_eq!( - cfg.general.me_single_endpoint_shadow_writers, - default_me_single_endpoint_shadow_writers() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_mode_enabled, - default_me_single_endpoint_outage_mode_enabled() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_disable_quarantine, - default_me_single_endpoint_outage_disable_quarantine() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_backoff_min_ms, - default_me_single_endpoint_outage_backoff_min_ms() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_backoff_max_ms, - default_me_single_endpoint_outage_backoff_max_ms() - ); - assert_eq!( - cfg.general.me_single_endpoint_shadow_rotate_every_secs, - default_me_single_endpoint_shadow_rotate_every_secs() - ); - assert_eq!(cfg.general.me_floor_mode, MeFloorMode::default()); - assert_eq!( - cfg.general.me_adaptive_floor_idle_secs, - default_me_adaptive_floor_idle_secs() - ); - assert_eq!( - cfg.general.me_adaptive_floor_min_writers_single_endpoint, - default_me_adaptive_floor_min_writers_single_endpoint() - ); - assert_eq!( - cfg.general.me_adaptive_floor_recover_grace_secs, - default_me_adaptive_floor_recover_grace_secs() - ); - assert_eq!( - cfg.general.upstream_connect_retry_attempts, - default_upstream_connect_retry_attempts() - ); - assert_eq!( - cfg.general.upstream_connect_retry_backoff_ms, - default_upstream_connect_retry_backoff_ms() - ); - assert_eq!( - cfg.general.upstream_unhealthy_fail_threshold, - default_upstream_unhealthy_fail_threshold() - ); - assert_eq!( - cfg.general.upstream_connect_failfast_hard_errors, - default_upstream_connect_failfast_hard_errors() - ); - assert_eq!( - cfg.general.rpc_proxy_req_every, - default_rpc_proxy_req_every() - ); - assert_eq!(cfg.general.beobachten_file, default_beobachten_file()); - assert_eq!(cfg.general.update_every, default_update_every()); - assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); - assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); - assert_eq!(cfg.server.client_mss_value(), Ok(None)); - assert_eq!( - cfg.server.proxy_protocol_trusted_cidrs, - default_proxy_protocol_trusted_cidrs() - ); - assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); - assert_eq!(cfg.server.api.listen, default_api_listen()); - assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); - assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop); - assert_eq!( - cfg.server.api.request_body_limit_bytes, - default_api_request_body_limit_bytes() - ); - assert_eq!( - cfg.server.api.minimal_runtime_enabled, - default_api_minimal_runtime_enabled() - ); - assert_eq!( - cfg.server.api.minimal_runtime_cache_ttl_ms, - default_api_minimal_runtime_cache_ttl_ms() - ); - assert_eq!( - cfg.server.api.runtime_edge_enabled, - default_api_runtime_edge_enabled() - ); - assert_eq!( - cfg.server.api.runtime_edge_cache_ttl_ms, - default_api_runtime_edge_cache_ttl_ms() - ); - assert_eq!( - cfg.server.api.runtime_edge_top_n, - default_api_runtime_edge_top_n() - ); - assert_eq!( - cfg.server.api.runtime_edge_events_capacity, - default_api_runtime_edge_events_capacity() - ); - assert_eq!( - cfg.server.conntrack_control.inline_conntrack_control, - default_conntrack_control_enabled() - ); - assert_eq!(cfg.server.conntrack_control.mode, ConntrackMode::default()); - assert_eq!( - cfg.server.conntrack_control.backend, - ConntrackBackend::default() - ); - assert_eq!( - cfg.server.conntrack_control.profile, - ConntrackPressureProfile::default() - ); - assert_eq!( - cfg.server.conntrack_control.pressure_high_watermark_pct, - default_conntrack_pressure_high_watermark_pct() - ); - assert_eq!( - cfg.server.conntrack_control.pressure_low_watermark_pct, - default_conntrack_pressure_low_watermark_pct() - ); - assert_eq!( - cfg.server.conntrack_control.delete_budget_per_sec, - default_conntrack_delete_budget_per_sec() - ); - assert_eq!(cfg.access.users, default_access_users()); - assert_eq!( - cfg.access.user_max_tcp_conns_global_each, - default_user_max_tcp_conns_global_each() - ); - assert_eq!( - cfg.access.user_max_unique_ips_mode, - UserMaxUniqueIpsMode::default() - ); - assert_eq!( - cfg.access.user_max_unique_ips_window_secs, - default_user_max_unique_ips_window_secs() - ); -} - -#[test] -fn logging_config_is_loaded_from_strict_config() { - let cfg = load_config_from_temp_toml( - r#" - [general] - config_strict = true - - [general.modes] - classic = false - secure = false - tls = true - - [logging] - destination = "file" - path = "/tmp/telemt.log" - rotation = "daily" - max_size_bytes = 1024 - max_files = 3 - max_age_secs = 60 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#, - ); - - assert_eq!(cfg.logging.destination, LoggingDestination::File); - assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log")); - assert_eq!(cfg.logging.rotation, LogRotation::Daily); - assert_eq!(cfg.logging.max_size_bytes, 1024); - assert_eq!(cfg.logging.max_files, 3); - assert_eq!(cfg.logging.max_age_secs, 60); -} - -#[test] -fn cidr_rate_limits_accept_auto_templates_in_strict_config() { - let cfg = load_config_from_temp_toml( - r#" - [general] - config_strict = true - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [access.cidr_rate_limits] - "*/24" = { up_bps = 1024, down_bps = 0 } - "*4/30" = { up_bps = 0, down_bps = 2048 } - "*6/64" = { up_bps = 4096, down_bps = 0 } - "#, - ); - - assert!( - cfg.access - .cidr_rate_limits - .contains_key(&CidrRateLimitKey::AutoDual(24)) - ); - assert!( - cfg.access - .cidr_rate_limits - .contains_key(&CidrRateLimitKey::AutoV4(30)) - ); - assert!( - cfg.access - .cidr_rate_limits - .contains_key(&CidrRateLimitKey::AutoV6(64)) - ); -} - -#[test] -fn cidr_rate_limits_reject_invalid_auto_template_prefix() { - let error = load_config_error_from_temp_toml( - r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [access.cidr_rate_limits] - "*4/33" = { up_bps = 1024, down_bps = 0 } - "#, - ); - - assert!(error.contains("prefix must be within 0..=32")); -} - -#[test] -fn cidr_rate_limits_reject_duplicate_normalized_auto_templates() { - let error = load_config_error_from_temp_toml( - r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [access.cidr_rate_limits] - "*/32" = { up_bps = 1024, down_bps = 0 } - "*6/128" = { up_bps = 2048, down_bps = 0 } - "#, - ); - - assert!(error.contains("duplicates normalized auto-template *6/128")); -} - -#[test] -fn file_logging_requires_path() { - let error = load_config_error_from_temp_toml( - r#" - [general.modes] - classic = false - secure = false - tls = true - - [logging] - destination = "file" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#, - ); - - assert!(error.contains("logging.path must be set")); -} - -#[test] -fn impl_defaults_are_sourced_from_default_helpers() { - let network = NetworkConfig::default(); - assert_eq!(network.ipv6, default_network_ipv6()); - assert_eq!(network.stun_use, default_true()); - assert_eq!(network.stun_tcp_fallback, default_stun_tcp_fallback()); - - let general = GeneralConfig::default(); - assert_eq!( - general.middle_proxy_warm_standby, - default_middle_proxy_warm_standby() - ); - assert_eq!( - general.me_reconnect_max_concurrent_per_dc, - default_me_reconnect_max_concurrent_per_dc() - ); - assert_eq!( - general.me_reconnect_fast_retry_count, - default_me_reconnect_fast_retry_count() - ); - assert_eq!( - general.me_init_retry_attempts, - default_me_init_retry_attempts() - ); - assert_eq!(general.me2dc_fallback, default_me2dc_fallback()); - assert_eq!(general.me2dc_fast, default_me2dc_fast()); - assert_eq!( - general.proxy_config_v4_cache_path, - default_proxy_config_v4_cache_path() - ); - assert_eq!( - general.proxy_config_v6_cache_path, - default_proxy_config_v6_cache_path() - ); - assert_eq!( - general.me_single_endpoint_shadow_writers, - default_me_single_endpoint_shadow_writers() - ); - assert_eq!( - general.me_single_endpoint_outage_mode_enabled, - default_me_single_endpoint_outage_mode_enabled() - ); - assert_eq!( - general.me_single_endpoint_outage_disable_quarantine, - default_me_single_endpoint_outage_disable_quarantine() - ); - assert_eq!( - general.me_single_endpoint_outage_backoff_min_ms, - default_me_single_endpoint_outage_backoff_min_ms() - ); - assert_eq!( - general.me_single_endpoint_outage_backoff_max_ms, - default_me_single_endpoint_outage_backoff_max_ms() - ); - assert_eq!( - general.me_single_endpoint_shadow_rotate_every_secs, - default_me_single_endpoint_shadow_rotate_every_secs() - ); - assert_eq!(general.me_floor_mode, MeFloorMode::default()); - assert_eq!( - general.me_adaptive_floor_idle_secs, - default_me_adaptive_floor_idle_secs() - ); - assert_eq!( - general.me_adaptive_floor_min_writers_single_endpoint, - default_me_adaptive_floor_min_writers_single_endpoint() - ); - assert_eq!( - general.me_adaptive_floor_recover_grace_secs, - default_me_adaptive_floor_recover_grace_secs() - ); - assert_eq!( - general.upstream_connect_retry_attempts, - default_upstream_connect_retry_attempts() - ); - assert_eq!( - general.upstream_connect_retry_backoff_ms, - default_upstream_connect_retry_backoff_ms() - ); - assert_eq!( - general.upstream_unhealthy_fail_threshold, - default_upstream_unhealthy_fail_threshold() - ); - assert_eq!( - general.upstream_connect_failfast_hard_errors, - default_upstream_connect_failfast_hard_errors() - ); - assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every()); - assert_eq!(general.beobachten_file, default_beobachten_file()); - assert_eq!(general.update_every, default_update_every()); - - let server = ServerConfig::default(); - assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6())); - assert_eq!( - server.proxy_protocol_trusted_cidrs, - default_proxy_protocol_trusted_cidrs() - ); - assert_eq!( - AntiCensorshipConfig::default().unknown_sni_action, - UnknownSniAction::Drop - ); - assert_eq!(server.api.listen, default_api_listen()); - assert_eq!(server.api.whitelist, default_api_whitelist()); - assert_eq!(server.api.gray_action, ApiGrayAction::Drop); - assert_eq!( - server.api.request_body_limit_bytes, - default_api_request_body_limit_bytes() - ); - assert_eq!( - server.api.minimal_runtime_enabled, - default_api_minimal_runtime_enabled() - ); - assert_eq!( - server.api.minimal_runtime_cache_ttl_ms, - default_api_minimal_runtime_cache_ttl_ms() - ); - assert_eq!( - server.api.runtime_edge_enabled, - default_api_runtime_edge_enabled() - ); - assert_eq!( - server.api.runtime_edge_cache_ttl_ms, - default_api_runtime_edge_cache_ttl_ms() - ); - assert_eq!( - server.api.runtime_edge_top_n, - default_api_runtime_edge_top_n() - ); - assert_eq!( - server.api.runtime_edge_events_capacity, - default_api_runtime_edge_events_capacity() - ); - assert_eq!( - server.conntrack_control.inline_conntrack_control, - default_conntrack_control_enabled() - ); - assert_eq!(server.conntrack_control.mode, ConntrackMode::default()); - assert_eq!( - server.conntrack_control.backend, - ConntrackBackend::default() - ); - assert_eq!( - server.conntrack_control.profile, - ConntrackPressureProfile::default() - ); - assert_eq!( - server.conntrack_control.pressure_high_watermark_pct, - default_conntrack_pressure_high_watermark_pct() - ); - assert_eq!( - server.conntrack_control.pressure_low_watermark_pct, - default_conntrack_pressure_low_watermark_pct() - ); - assert_eq!( - server.conntrack_control.delete_budget_per_sec, - default_conntrack_delete_budget_per_sec() - ); - - let access = AccessConfig::default(); - assert_eq!(access.users, default_access_users()); - assert_eq!( - access.user_max_tcp_conns_global_each, - default_user_max_tcp_conns_global_each() - ); -} - -#[test] -fn proxy_protocol_trusted_cidrs_missing_uses_trust_all_but_explicit_empty_stays_empty() { - let cfg_missing: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - "#, - ) - .unwrap(); - assert_eq!( - cfg_missing.server.proxy_protocol_trusted_cidrs, - default_proxy_protocol_trusted_cidrs() - ); - - let cfg_explicit_empty: ProxyConfig = toml::from_str( - r#" - [server] - proxy_protocol_trusted_cidrs = [] - - [general] - [network] - [access] - "#, - ) - .unwrap(); - assert!( - cfg_explicit_empty - .server - .proxy_protocol_trusted_cidrs - .is_empty() - ); -} - -#[test] -fn conntrack_inline_explicit_flag_is_false_when_omitted() { - let cfg = load_config_from_temp_toml( - r#" - [general] - [network] - [server] - [server.conntrack_control] - [access] - "#, - ); - assert!( - !cfg.server - .conntrack_control - .inline_conntrack_control_explicit - ); -} - -#[test] -fn conntrack_inline_explicit_flag_is_true_when_present() { - let cfg = load_config_from_temp_toml( - r#" - [general] - [network] - [server] - [server.conntrack_control] - inline_conntrack_control = true - [access] - "#, - ); - assert!( - cfg.server - .conntrack_control - .inline_conntrack_control_explicit - ); -} - -#[test] -fn unknown_sni_action_parses_and_defaults_to_drop() { - let cfg_default: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - "#, - ) - .unwrap(); - assert_eq!( - cfg_default.censorship.unknown_sni_action, - UnknownSniAction::Drop - ); - - let cfg_mask: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - unknown_sni_action = "mask" - "#, - ) - .unwrap(); - assert_eq!( - cfg_mask.censorship.unknown_sni_action, - UnknownSniAction::Mask - ); - - let cfg_accept: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - unknown_sni_action = "accept" - "#, - ) - .unwrap(); - assert_eq!( - cfg_accept.censorship.unknown_sni_action, - UnknownSniAction::Accept - ); - - let cfg_reject: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - unknown_sni_action = "reject_handshake" - "#, - ) - .unwrap(); - assert_eq!( - cfg_reject.censorship.unknown_sni_action, - UnknownSniAction::RejectHandshake - ); -} - -#[test] -fn exclusive_mask_parses_domain_target_map() { - let cfg = load_config_from_temp_toml( - r#" - [general] - [network] - [server] - [access] - [censorship] - tls_domain = "weißbiergärten.de" - tls_domains = ["bürgeramt.de"] - [censorship.exclusive_mask] - "bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443" - "ipv6.example" = "[::1]:443" - "#, - ); - - assert!(cfg.censorship.tls_domain.is_ascii()); - assert!(cfg.censorship.tls_domain.contains("xn--")); - assert_eq!(cfg.censorship.tls_domains.len(), 1); - let normalized_extra = &cfg.censorship.tls_domains[0]; - assert!(normalized_extra.is_ascii()); - assert!(normalized_extra.contains("xn--")); - - let normalized_target = cfg - .censorship - .exclusive_mask - .get(normalized_extra) - .expect("exclusive_mask key must match normalized tls_domains entry"); - assert!(normalized_target.is_ascii()); - assert!(normalized_target.contains("xn--")); - assert!(normalized_target.ends_with(":443")); - assert_eq!( - cfg.censorship.exclusive_mask.get("ipv6.example"), - Some(&"[::1]:443".to_string()) - ); -} - -#[test] -fn api_gray_action_parses_and_defaults_to_drop() { - let cfg_default: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - "#, - ) - .unwrap(); - assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop); - - let cfg_api: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [server.api] - gray_action = "api" - "#, - ) - .unwrap(); - assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api); - - let cfg_200: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [server.api] - gray_action = "200" - "#, - ) - .unwrap(); - assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200); - - let cfg_drop: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [server.api] - gray_action = "drop" - "#, - ) - .unwrap(); - assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); -} - -#[test] -fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() { - let cfg = load_config_from_temp_toml( - r#" - beobachten = false - beobachten_minutes = 7 - beobachten_flush_secs = 3 - beobachten_file = "tmp/legacy-beob.txt" - - [server] - [general] - [network] - [access] - "#, - ); - - assert!(!cfg.general.beobachten); - assert_eq!(cfg.general.beobachten_minutes, 7); - assert_eq!(cfg.general.beobachten_flush_secs, 3); - assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt"); -} - -#[test] -fn general_beobachten_keys_have_priority_over_legacy_top_level() { - let cfg = load_config_from_temp_toml( - r#" - beobachten = true - beobachten_minutes = 30 - beobachten_flush_secs = 30 - beobachten_file = "tmp/legacy-beob.txt" - - [server] - [general] - beobachten = false - beobachten_minutes = 5 - beobachten_flush_secs = 2 - beobachten_file = "tmp/general-beob.txt" - [network] - [access] - "#, - ); - - assert!(!cfg.general.beobachten); - assert_eq!(cfg.general.beobachten_minutes, 5); - assert_eq!(cfg.general.beobachten_flush_secs, 2); - assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt"); -} - -#[test] -fn dc_overrides_allow_string_and_array() { - let toml = r#" - [dc_overrides] - "201" = "149.154.175.50:443" - "202" = ["149.154.167.51:443", "149.154.175.100:443"] - "#; - let cfg: ProxyConfig = toml::from_str(toml).unwrap(); - assert_eq!(cfg.dc_overrides["201"], vec!["149.154.175.50:443"]); - assert_eq!( - cfg.dc_overrides["202"], - vec!["149.154.167.51:443", "149.154.175.100:443"] - ); -} - -#[test] -fn load_with_metadata_collects_include_files() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("telemt_load_metadata_{nonce}")); - std::fs::create_dir_all(&dir).unwrap(); - let main_path = dir.join("config.toml"); - let include_path = dir.join("included.toml"); - - std::fs::write( - &include_path, - r#" - [access.users] - user = "00000000000000000000000000000000" - "#, - ) - .unwrap(); - std::fs::write( - &main_path, - r#" - include = "included.toml" - - [censorship] - tls_domain = "example.com" - "#, - ) - .unwrap(); - - let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap(); - let main_normalized = normalize_config_path(&main_path); - let include_normalized = normalize_config_path(&include_path); - - assert!(loaded.source_files.contains(&main_normalized)); - assert!(loaded.source_files.contains(&include_normalized)); - - let _ = std::fs::remove_file(main_path); - let _ = std::fs::remove_file(include_path); - let _ = std::fs::remove_dir(dir); -} - -#[test] -fn dc_overrides_inject_dc203_default() { - let toml = r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_dc_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!( - cfg.dc_overrides - .get("203") - .map(|v| v.contains(&"91.105.192.100:443".to_string())) - .unwrap_or(false) - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn update_every_overrides_legacy_fields() { - let toml = r#" - [general] - update_every = 123 - proxy_secret_auto_reload_secs = 700 - proxy_config_auto_reload_secs = 800 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_update_every_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.effective_update_every_secs(), 123); - let _ = std::fs::remove_file(path); -} - -#[test] -fn update_every_fallback_to_legacy_min() { - let toml = r#" - [general] - proxy_secret_auto_reload_secs = 600 - proxy_config_auto_reload_secs = 120 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_update_every_legacy_min_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.update_every, None); - assert_eq!(cfg.general.effective_update_every_secs(), 120); - let _ = std::fs::remove_file(path); -} - -#[test] -fn update_every_zero_is_rejected() { - let toml = r#" - [general] - update_every = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_update_every_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.update_every must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn stun_nat_probe_concurrency_zero_is_rejected() { - let toml = r#" - [general] - stun_nat_probe_concurrency = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_stun_nat_probe_concurrency_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.stun_nat_probe_concurrency must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_reinit_every_default_is_set() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_reinit_every_default_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.me_reinit_every_secs, - default_me_reinit_every_secs() - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_reinit_every_zero_is_rejected() { - let toml = r#" - [general] - me_reinit_every_secs = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_reinit_every_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_reinit_every_secs must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_single_endpoint_outage_backoff_range_is_validated() { - let toml = r#" - [general] - me_single_endpoint_outage_backoff_min_ms = 4000 - me_single_endpoint_outage_backoff_max_ms = 3000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_single_endpoint_outage_backoff_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms" - )); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_single_endpoint_shadow_writers_too_large_is_rejected() { - let toml = r#" - [general] - me_single_endpoint_shadow_writers = 33 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_single_endpoint_shadow_writers_limit_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_single_endpoint_shadow_writers must be within [0, 32]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_adaptive_floor_min_writers_out_of_range_is_rejected() { - let toml = r#" - [general] - me_adaptive_floor_min_writers_single_endpoint = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_adaptive_floor_min_writers_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!( - err.contains( - "general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]" - ) - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_floor_mode_adaptive_is_parsed() { - let toml = r#" - [general] - me_floor_mode = "adaptive" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_floor_mode_adaptive_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_floor_mode, MeFloorMode::Adaptive); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_adaptive_floor_max_active_writers_per_core_zero_is_rejected() { - let toml = r#" - [general] - me_adaptive_floor_max_active_writers_per_core = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_adaptive_floor_max_active_per_core_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_adaptive_floor_max_active_writers_per_core must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_adaptive_floor_max_warm_writers_global_zero_is_rejected() { - let toml = r#" - [general] - me_adaptive_floor_max_warm_writers_global = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_adaptive_floor_max_warm_global_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_adaptive_floor_max_warm_writers_global must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn upstream_connect_retry_attempts_zero_is_rejected() { - let toml = r#" - [general] - upstream_connect_retry_attempts = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_upstream_connect_retry_attempts_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.upstream_connect_retry_attempts must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn upstream_unhealthy_fail_threshold_zero_is_rejected() { - let toml = r#" - [general] - upstream_unhealthy_fail_threshold = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_upstream_unhealthy_fail_threshold_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.upstream_unhealthy_fail_threshold must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tg_connect_zero_is_rejected() { - let toml = r#" - [general] - tg_connect = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tg_connect_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.tg_connect must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn rpc_proxy_req_every_out_of_range_is_rejected() { - let toml = r#" - [general] - rpc_proxy_req_every = 9 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_rpc_proxy_req_every_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.rpc_proxy_req_every must be 0 or within [10, 300]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn rpc_proxy_req_every_zero_and_valid_range_are_accepted() { - let toml_zero = r#" - [general] - rpc_proxy_req_every = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path_zero = dir.join("telemt_rpc_proxy_req_every_zero_ok_test.toml"); - std::fs::write(&path_zero, toml_zero).unwrap(); - let cfg_zero = ProxyConfig::load(&path_zero).unwrap(); - assert_eq!(cfg_zero.general.rpc_proxy_req_every, 0); - let _ = std::fs::remove_file(path_zero); - - let toml_valid = r#" - [general] - rpc_proxy_req_every = 40 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let path_valid = dir.join("telemt_rpc_proxy_req_every_valid_ok_test.toml"); - std::fs::write(&path_valid, toml_valid).unwrap(); - let cfg_valid = ProxyConfig::load(&path_valid).unwrap(); - assert_eq!(cfg_valid.general.rpc_proxy_req_every, 40); - let _ = std::fs::remove_file(path_valid); -} - -#[test] -fn me_route_backpressure_base_timeout_ms_out_of_range_is_rejected() { - let toml = r#" - [general] - me_route_backpressure_base_timeout_ms = 5001 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_backpressure_base_timeout_ms_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_route_backpressure_base_timeout_ms must be within [1, 5000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_route_backpressure_high_timeout_ms_out_of_range_is_rejected() { - let toml = r#" - [general] - me_route_backpressure_base_timeout_ms = 100 - me_route_backpressure_high_timeout_ms = 5001 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_backpressure_high_timeout_ms_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_route_backpressure_high_timeout_ms must be within [1, 5000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_route_no_writer_wait_ms_out_of_range_is_rejected() { - let toml = r#" - [general] - me_route_no_writer_wait_ms = 5 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_no_writer_wait_ms_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_route_no_writer_wait_ms must be within [10, 5000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_route_blocking_send_timeout_ms_zero_is_rejected() { - let toml = r#" - [general] - me_route_blocking_send_timeout_ms = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_route_no_writer_mode_is_parsed() { - let toml = r#" - [general] - me_route_no_writer_mode = "inline_recovery_legacy" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_no_writer_mode_parse_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.me_route_no_writer_mode, - crate::config::MeRouteNoWriterMode::InlineRecoveryLegacy - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn proxy_config_cache_paths_empty_are_rejected() { - let toml = r#" - [general] - proxy_config_v4_cache_path = " " - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_proxy_config_v4_cache_path_empty_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.proxy_config_v4_cache_path cannot be empty")); - let _ = std::fs::remove_file(path); - - let toml_v6 = r#" - [general] - proxy_config_v6_cache_path = "" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let path_v6 = dir.join("telemt_proxy_config_v6_cache_path_empty_test.toml"); - std::fs::write(&path_v6, toml_v6).unwrap(); - let err_v6 = ProxyConfig::load(&path_v6).unwrap_err().to_string(); - assert!(err_v6.contains("general.proxy_config_v6_cache_path cannot be empty")); - let _ = std::fs::remove_file(path_v6); -} - -#[test] -fn me_hardswap_warmup_defaults_are_set() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_defaults_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.me_hardswap_warmup_delay_min_ms, - default_me_hardswap_warmup_delay_min_ms() - ); - assert_eq!( - cfg.general.me_hardswap_warmup_delay_max_ms, - default_me_hardswap_warmup_delay_max_ms() - ); - assert_eq!( - cfg.general.me_hardswap_warmup_extra_passes, - default_me_hardswap_warmup_extra_passes() - ); - assert_eq!( - cfg.general.me_hardswap_warmup_pass_backoff_base_ms, - default_me_hardswap_warmup_pass_backoff_base_ms() - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_hardswap_warmup_delay_range_is_validated() { - let toml = r#" - [general] - me_hardswap_warmup_delay_min_ms = 2001 - me_hardswap_warmup_delay_max_ms = 2000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_delay_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms" - )); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_hardswap_warmup_delay_max_zero_is_rejected() { - let toml = r#" - [general] - me_hardswap_warmup_delay_max_ms = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_delay_max_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_hardswap_warmup_delay_max_ms must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_hardswap_warmup_extra_passes_out_of_range_is_rejected() { - let toml = r#" - [general] - me_hardswap_warmup_extra_passes = 11 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_extra_passes_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_hardswap_warmup_extra_passes must be within [0, 10]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_hardswap_warmup_pass_backoff_zero_is_rejected() { - let toml = r#" - [general] - me_hardswap_warmup_pass_backoff_base_ms = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_backoff_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_hardswap_warmup_pass_backoff_base_ms must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_config_stable_snapshots_zero_is_rejected() { - let toml = r#" - [general] - me_config_stable_snapshots = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_config_stable_snapshots_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_config_stable_snapshots must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn proxy_secret_stable_snapshots_zero_is_rejected() { - let toml = r#" - [general] - proxy_secret_stable_snapshots = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_proxy_secret_stable_snapshots_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.proxy_secret_stable_snapshots must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn proxy_secret_len_max_out_of_range_is_rejected() { - let toml = r#" - [general] - proxy_secret_len_max = 16 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_proxy_secret_len_max_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.proxy_secret_len_max must be within [32, 4096]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn me_pool_min_fresh_ratio_out_of_range_is_rejected() { - let toml = r#" - [general] - me_pool_min_fresh_ratio = 1.5 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_pool_min_ratio_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_pool_min_fresh_ratio must be within [0.0, 1.0]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn api_minimal_runtime_cache_ttl_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - minimal_runtime_cache_ttl_ms = 70000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_minimal_runtime_cache_ttl_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn client_mss_presets_and_listener_override_are_resolved() { - let toml = r#" - [server] - client_mss = "tspu" - - [[server.listeners]] - ip = "127.0.0.1" - port = 1443 - - [[server.listeners]] - ip = "127.0.0.2" - port = 1444 - client_mss = "2in8" - - [[server.listeners]] - ip = "127.0.0.3" - port = 1445 - client_mss = "" - - [[server.listeners]] - ip = "127.0.0.4" - port = 1446 - client_mss = "extreme-low" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_valid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); - assert_eq!( - cfg.server.listeners[0].effective_client_mss(&cfg.server), - Ok(Some(92)) - ); - assert_eq!( - cfg.server.listeners[1].effective_client_mss(&cfg.server), - Ok(Some(256)) - ); - assert_eq!( - cfg.server.listeners[2].effective_client_mss(&cfg.server), - Ok(None) - ); - assert_eq!( - cfg.server.listeners[3].effective_client_mss(&cfg.server), - Ok(Some(88)) - ); - let _ = std::fs::remove_file(path); -} - -#[cfg(target_os = "linux")] -#[test] -fn client_mss_custom_value_is_accepted() { - let toml = r#" - [server] - client_mss = "92" - client_mss_bulk = "1400" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_custom_valid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); - assert_eq!(cfg.server.client_mss_bulk_value(), Ok(Some(1400))); - let _ = std::fs::remove_file(path); -} - -#[cfg(target_os = "linux")] -#[test] -fn client_mss_bulk_requires_a_larger_bulk_profile_and_handshake_participant() { - for (name, server, expected) in [ - ( - "without_handshake", - "client_mss_bulk = \"1400\"", - "requires an effective client_mss", - ), - ( - "equal", - "client_mss = \"1400\"\nclient_mss_bulk = \"1400\"", - "must be greater than the effective handshake MSS", - ), - ( - "inverted", - "client_mss = \"1500\"\nclient_mss_bulk = \"1400\"", - "must be greater than the effective handshake MSS", - ), - ] { - let toml = format!( - "[server]\n{server}\n\n[censorship]\ntls_domain = \"example.com\"\n\n[access.users]\nuser = \"00000000000000000000000000000000\"\n" - ); - let dir = std::env::temp_dir(); - let path = dir.join(format!("telemt_client_mss_bulk_{name}_test.toml")); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains(expected), "unexpected error: {err}"); - let _ = std::fs::remove_file(path); - } -} - -#[cfg(target_os = "linux")] -#[test] -fn client_mss_bulk_allows_explicit_listener_opt_out() { - let toml = r#" - [server] - client_mss = "92" - client_mss_bulk = "1400" - - [[server.listeners]] - ip = "0.0.0.0" - port = 443 - - [[server.listeners]] - ip = "::" - port = 443 - client_mss = "" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_bulk_listener_opt_out_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert_eq!( - cfg.server.listeners[0].effective_client_mss(&cfg.server), - Ok(Some(92)) - ); - assert_eq!( - cfg.server.listeners[1].effective_client_mss(&cfg.server), - Ok(None) - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn client_mss_out_of_range_is_rejected() { - for value in ["87", "4097"] { - let toml = format!( - r#" - [server] - client_mss = "{value}" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "# - ); - let dir = std::env::temp_dir(); - let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml")); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("server.client_mss custom value must be within [88, 4096]")); - let _ = std::fs::remove_file(path); - } -} - -#[test] -fn client_mss_bulk_out_of_range_is_rejected() { - for value in ["87", "4097"] { - let toml = format!( - r#" - [server] - client_mss_bulk = "{value}" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "# - ); - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "telemt_client_mss_bulk_out_of_range_{value}_test.toml" - )); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("server.client_mss_bulk custom value must be within [88, 4096]")); - let _ = std::fs::remove_file(path); - } -} - -#[test] -fn client_mss_unquoted_number_is_rejected() { - let toml = r#" - [server] - client_mss = 256 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_unquoted_number_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("client_mss")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn listener_client_mss_invalid_preset_is_rejected() { - let toml = r#" - [[server.listeners]] - ip = "127.0.0.1" - port = 1443 - client_mss = "tiny" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_listener_client_mss_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("server.listeners[0].client_mss")); - assert!(err.contains("must be \"\", extreme-low, tspu, 2in8")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - runtime_edge_cache_ttl_ms = 70000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn api_runtime_edge_top_n_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - runtime_edge_top_n = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn api_runtime_edge_events_capacity_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - runtime_edge_events_capacity = 8 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn conntrack_pressure_high_watermark_out_of_range_is_rejected() { - let toml = r#" - [server.conntrack_control] - pressure_high_watermark_pct = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_high_watermark_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!( - err.contains( - "server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]" - ) - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn conntrack_pressure_low_watermark_must_be_below_high() { - let toml = r#" - [server.conntrack_control] - pressure_high_watermark_pct = 50 - pressure_low_watermark_pct = 50 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_low_watermark_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct" - )); - let _ = std::fs::remove_file(path); -} - -#[test] -fn conntrack_delete_budget_zero_is_rejected() { - let toml = r#" - [server.conntrack_control] - delete_budget_per_sec = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_delete_budget_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.conntrack_control.delete_budget_per_sec must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn conntrack_hybrid_mode_requires_listener_allow_list() { - let toml = r#" - [server.conntrack_control] - mode = "hybrid" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_hybrid_requires_ips_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!( - err.contains( - "server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid" - ) - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn conntrack_profile_is_loaded_from_config() { - let toml = r#" - [server.conntrack_control] - profile = "aggressive" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_profile_parse_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.server.conntrack_control.profile, - ConntrackPressureProfile::Aggressive - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn force_close_default_matches_drain_ttl() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_force_close_default_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); - assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 90); - let _ = std::fs::remove_file(path); -} - -#[test] -fn force_close_zero_uses_runtime_safety_fallback() { - let toml = r#" - [general] - me_reinit_drain_timeout_secs = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_force_close_zero_fallback_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 0); - assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 300); - let _ = std::fs::remove_file(path); -} - -#[test] -fn force_close_bumped_when_below_drain_ttl() { - let toml = r#" - [general] - me_pool_drain_ttl_secs = 90 - me_reinit_drain_timeout_secs = 30 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_force_close_bump_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_scope_default_is_empty() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_scope_default_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!(cfg.censorship.tls_fetch_scope.is_empty()); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_scope_is_trimmed_during_load() { - let toml = r#" - [censorship] - tls_domain = "example.com" - tls_fetch_scope = " me " - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_scope_trim_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.censorship.tls_fetch_scope, "me"); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_scope_whitespace_becomes_empty() { - let toml = r#" - [censorship] - tls_domain = "example.com" - tls_fetch_scope = " " - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_scope_blank_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!(cfg.censorship.tls_fetch_scope.is_empty()); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_defaults_are_applied() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_defaults_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.censorship.tls_fetch.profiles, - TlsFetchConfig::default().profiles - ); - assert!(cfg.censorship.tls_fetch.strict_route); - assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000); - assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000); - assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_profiles_are_deduplicated_preserving_order() { - let toml = r#" - [censorship] - tls_domain = "example.com" - [censorship.tls_fetch] - profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"] - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.censorship.tls_fetch.profiles, - vec![ - TlsFetchProfile::CompatTls12, - TlsFetchProfile::ModernChromeLike, - TlsFetchProfile::LegacyMinimal - ] - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_attempt_timeout_zero_is_rejected() { - let toml = r#" - [censorship] - tls_domain = "example.com" - [censorship.tls_fetch] - attempt_timeout_ms = 0 - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn tls_fetch_total_budget_zero_is_rejected() { - let toml = r#" - [censorship] - tls_domain = "example.com" - [censorship.tls_fetch] - total_budget_ms = 0 - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn invalid_ad_tag_is_disabled_during_load() { - let toml = r#" - [general] - ad_tag = "not_hex" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_invalid_ad_tag_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!(cfg.general.ad_tag.is_none()); - let _ = std::fs::remove_file(path); -} - -#[test] -fn valid_ad_tag_is_preserved_during_load() { - let toml = r#" - [general] - ad_tag = "00112233445566778899aabbccddeeff" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_valid_ad_tag_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.ad_tag.as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - let _ = std::fs::remove_file(path); -} - -#[test] -fn shadowsocks_upstream_url_loads_successfully() { - let toml = format!( - r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "{url}" - interface = "127.0.0.2" - "#, - url = TEST_SHADOWSOCKS_URL, - ); - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_valid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert!(matches!( - &cfg.upstreams[0].upstream_type, - UpstreamType::Shadowsocks { url, interface } - if url == TEST_SHADOWSOCKS_URL && interface.as_deref() == Some("127.0.0.2") - )); - - let _ = std::fs::remove_file(path); -} - -#[test] -fn shadowsocks_requires_direct_mode() { - let toml = format!( - r#" - [general] - use_middle_proxy = true - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "{url}" - "#, - url = TEST_SHADOWSOCKS_URL, - ); - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_me_reject_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("shadowsocks upstreams require general.use_middle_proxy = false")); - - let _ = std::fs::remove_file(path); -} - -#[test] -fn invalid_shadowsocks_url_is_rejected() { - let toml = r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "not-a-valid-ss-url" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_invalid_url_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("invalid shadowsocks url")); - - let _ = std::fs::remove_file(path); -} - -#[test] -fn shadowsocks_plugins_are_rejected() { - let toml = format!( - r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "{url}?plugin=obfs-local%3Bobfs%3Dhttp" - "#, - url = TEST_SHADOWSOCKS_URL, - ); - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_plugin_reject_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("shadowsocks plugins are not supported")); - - let _ = std::fs::remove_file(path); -} - -#[test] -fn invalid_user_ad_tag_reports_access_user_ad_tags_key() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - alice = "00000000000000000000000000000000" - - [access.user_ad_tags] - alice = "not_hex" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_invalid_user_ad_tag_message_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - let err = cfg.validate().unwrap_err().to_string(); - assert!(err.contains("access.user_ad_tags['alice'] must be exactly 32 hex characters")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn invalid_dns_override_is_rejected() { - let toml = r#" - [network] - dns_overrides = ["example.com:443:2001:db8::10"] - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_invalid_dns_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("must be bracketed")); - let _ = std::fs::remove_file(path); -} - -#[test] -fn valid_dns_override_is_accepted() { - let toml = r#" - [network] - dns_overrides = ["example.com:443:127.0.0.1", "example.net:443:[2001:db8::10]"] - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_valid_dns_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.network.dns_overrides.len(), 2); - let _ = std::fs::remove_file(path); -} +#[path = "load_basic_tests/api_tests.rs"] +mod api_tests; +#[path = "load_basic_tests/conntrack_tests.rs"] +mod conntrack_tests; +#[path = "load_basic_tests/defaults_access_tests.rs"] +mod defaults_access_tests; +#[path = "load_basic_tests/legacy_policy_tests.rs"] +mod legacy_policy_tests; +#[path = "load_basic_tests/me_route_tests.rs"] +mod me_route_tests; +#[path = "load_basic_tests/me_startup_tests.rs"] +mod me_startup_tests; +#[path = "load_basic_tests/synlimit_mss_tests.rs"] +mod synlimit_mss_tests; +#[path = "load_basic_tests/tls_fetch_tests.rs"] +mod tls_fetch_tests; +#[path = "load_basic_tests/upstream_tests.rs"] +mod upstream_tests; diff --git a/src/config/tests/load_basic_tests/api_tests.rs b/src/config/tests/load_basic_tests/api_tests.rs new file mode 100644 index 0000000..cb83cc1 --- /dev/null +++ b/src/config/tests/load_basic_tests/api_tests.rs @@ -0,0 +1,91 @@ +use super::*; + +#[test] +fn api_minimal_runtime_cache_ttl_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + minimal_runtime_cache_ttl_ms = 70000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_minimal_runtime_cache_ttl_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]")); + let _ = std::fs::remove_file(path); +} + + +#[test] +fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_cache_ttl_ms = 70000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn api_runtime_edge_top_n_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_top_n = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn api_runtime_edge_events_capacity_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_events_capacity = 8 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]")); + let _ = std::fs::remove_file(path); +} + diff --git a/src/config/tests/load_basic_tests/conntrack_tests.rs b/src/config/tests/load_basic_tests/conntrack_tests.rs new file mode 100644 index 0000000..f50641a --- /dev/null +++ b/src/config/tests/load_basic_tests/conntrack_tests.rs @@ -0,0 +1,176 @@ +use super::*; + +#[test] +fn conntrack_pressure_high_watermark_out_of_range_is_rejected() { + let toml = r#" + [server.conntrack_control] + pressure_high_watermark_pct = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_high_watermark_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!( + err.contains( + "server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]" + ) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_pressure_low_watermark_must_be_below_high() { + let toml = r#" + [server.conntrack_control] + pressure_high_watermark_pct = 50 + pressure_low_watermark_pct = 50 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_low_watermark_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains( + "server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_delete_budget_zero_is_rejected() { + let toml = r#" + [server.conntrack_control] + delete_budget_per_sec = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_delete_budget_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.conntrack_control.delete_budget_per_sec must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_hybrid_mode_requires_listener_allow_list() { + let toml = r#" + [server.conntrack_control] + mode = "hybrid" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_hybrid_requires_ips_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!( + err.contains( + "server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid" + ) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_profile_is_loaded_from_config() { + let toml = r#" + [server.conntrack_control] + profile = "aggressive" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_profile_parse_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.server.conntrack_control.profile, + ConntrackPressureProfile::Aggressive + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn force_close_default_matches_drain_ttl() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_force_close_default_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); + assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 90); + let _ = std::fs::remove_file(path); +} + +#[test] +fn force_close_zero_uses_runtime_safety_fallback() { + let toml = r#" + [general] + me_reinit_drain_timeout_secs = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_force_close_zero_fallback_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 0); + assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 300); + let _ = std::fs::remove_file(path); +} + +#[test] +fn force_close_bumped_when_below_drain_ttl() { + let toml = r#" + [general] + me_pool_drain_ttl_secs = 90 + me_reinit_drain_timeout_secs = 30 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_force_close_bump_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); + let _ = std::fs::remove_file(path); +} + diff --git a/src/config/tests/load_basic_tests/defaults_access_tests.rs b/src/config/tests/load_basic_tests/defaults_access_tests.rs new file mode 100644 index 0000000..a716de5 --- /dev/null +++ b/src/config/tests/load_basic_tests/defaults_access_tests.rs @@ -0,0 +1,479 @@ +use super::*; + +#[test] +fn serde_defaults_remain_unchanged_for_present_sections() { + let toml = r#" + [network] + [general] + [server] + [access] + "#; + let cfg: ProxyConfig = toml::from_str(toml).unwrap(); + + assert_eq!(cfg.logging, LoggingConfig::default()); + assert_eq!(cfg.network.ipv6, default_network_ipv6()); + assert_eq!(cfg.network.stun_use, default_true()); + assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); + assert_eq!( + cfg.general.middle_proxy_warm_standby, + default_middle_proxy_warm_standby() + ); + assert_eq!( + cfg.general.me_reconnect_max_concurrent_per_dc, + default_me_reconnect_max_concurrent_per_dc() + ); + assert_eq!( + cfg.general.me_reconnect_fast_retry_count, + default_me_reconnect_fast_retry_count() + ); + assert_eq!( + cfg.general.me_init_retry_attempts, + default_me_init_retry_attempts() + ); + assert_eq!(cfg.general.me2dc_fallback, default_me2dc_fallback()); + assert_eq!(cfg.general.me2dc_fast, default_me2dc_fast()); + assert_eq!( + cfg.general.proxy_config_v4_cache_path, + default_proxy_config_v4_cache_path() + ); + assert_eq!( + cfg.general.proxy_config_v6_cache_path, + default_proxy_config_v6_cache_path() + ); + assert_eq!( + cfg.general.me_single_endpoint_shadow_writers, + default_me_single_endpoint_shadow_writers() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_mode_enabled, + default_me_single_endpoint_outage_mode_enabled() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_disable_quarantine, + default_me_single_endpoint_outage_disable_quarantine() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_backoff_min_ms, + default_me_single_endpoint_outage_backoff_min_ms() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_backoff_max_ms, + default_me_single_endpoint_outage_backoff_max_ms() + ); + assert_eq!( + cfg.general.me_single_endpoint_shadow_rotate_every_secs, + default_me_single_endpoint_shadow_rotate_every_secs() + ); + assert_eq!(cfg.general.me_floor_mode, MeFloorMode::default()); + assert_eq!( + cfg.general.me_adaptive_floor_idle_secs, + default_me_adaptive_floor_idle_secs() + ); + assert_eq!( + cfg.general.me_adaptive_floor_min_writers_single_endpoint, + default_me_adaptive_floor_min_writers_single_endpoint() + ); + assert_eq!( + cfg.general.me_adaptive_floor_recover_grace_secs, + default_me_adaptive_floor_recover_grace_secs() + ); + assert_eq!( + cfg.general.upstream_connect_retry_attempts, + default_upstream_connect_retry_attempts() + ); + assert_eq!( + cfg.general.upstream_connect_retry_backoff_ms, + default_upstream_connect_retry_backoff_ms() + ); + assert_eq!( + cfg.general.upstream_unhealthy_fail_threshold, + default_upstream_unhealthy_fail_threshold() + ); + assert_eq!( + cfg.general.upstream_connect_failfast_hard_errors, + default_upstream_connect_failfast_hard_errors() + ); + assert_eq!( + cfg.general.rpc_proxy_req_every, + default_rpc_proxy_req_every() + ); + assert_eq!(cfg.general.beobachten_file, default_beobachten_file()); + assert_eq!(cfg.general.update_every, default_update_every()); + assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); + assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); + assert_eq!(cfg.server.client_mss_value(), Ok(None)); + assert_eq!( + cfg.server.proxy_protocol_trusted_cidrs, + default_proxy_protocol_trusted_cidrs() + ); + assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); + assert_eq!(cfg.server.api.listen, default_api_listen()); + assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); + assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop); + assert_eq!( + cfg.server.api.request_body_limit_bytes, + default_api_request_body_limit_bytes() + ); + assert_eq!( + cfg.server.api.minimal_runtime_enabled, + default_api_minimal_runtime_enabled() + ); + assert_eq!( + cfg.server.api.minimal_runtime_cache_ttl_ms, + default_api_minimal_runtime_cache_ttl_ms() + ); + assert_eq!( + cfg.server.api.runtime_edge_enabled, + default_api_runtime_edge_enabled() + ); + assert_eq!( + cfg.server.api.runtime_edge_cache_ttl_ms, + default_api_runtime_edge_cache_ttl_ms() + ); + assert_eq!( + cfg.server.api.runtime_edge_top_n, + default_api_runtime_edge_top_n() + ); + assert_eq!( + cfg.server.api.runtime_edge_events_capacity, + default_api_runtime_edge_events_capacity() + ); + assert_eq!( + cfg.server.conntrack_control.inline_conntrack_control, + default_conntrack_control_enabled() + ); + assert_eq!(cfg.server.conntrack_control.mode, ConntrackMode::default()); + assert_eq!( + cfg.server.conntrack_control.backend, + ConntrackBackend::default() + ); + assert_eq!( + cfg.server.conntrack_control.profile, + ConntrackPressureProfile::default() + ); + assert_eq!( + cfg.server.conntrack_control.pressure_high_watermark_pct, + default_conntrack_pressure_high_watermark_pct() + ); + assert_eq!( + cfg.server.conntrack_control.pressure_low_watermark_pct, + default_conntrack_pressure_low_watermark_pct() + ); + assert_eq!( + cfg.server.conntrack_control.delete_budget_per_sec, + default_conntrack_delete_budget_per_sec() + ); + assert_eq!(cfg.access.users, default_access_users()); + assert_eq!( + cfg.access.user_max_tcp_conns_global_each, + default_user_max_tcp_conns_global_each() + ); + assert_eq!( + cfg.access.user_max_unique_ips_mode, + UserMaxUniqueIpsMode::default() + ); + assert_eq!( + cfg.access.user_max_unique_ips_window_secs, + default_user_max_unique_ips_window_secs() + ); +} + +#[test] +fn logging_config_is_loaded_from_strict_config() { + let cfg = load_config_from_temp_toml( + r#" + [general] + config_strict = true + + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + path = "/tmp/telemt.log" + rotation = "daily" + max_size_bytes = 1024 + max_files = 3 + max_age_secs = 60 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert_eq!(cfg.logging.destination, LoggingDestination::File); + assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log")); + assert_eq!(cfg.logging.rotation, LogRotation::Daily); + assert_eq!(cfg.logging.max_size_bytes, 1024); + assert_eq!(cfg.logging.max_files, 3); + assert_eq!(cfg.logging.max_age_secs, 60); +} + +#[test] +fn cidr_rate_limits_accept_auto_templates_in_strict_config() { + let cfg = load_config_from_temp_toml( + r#" + [general] + config_strict = true + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [access.cidr_rate_limits] + "*/24" = { up_bps = 1024, down_bps = 0 } + "*4/30" = { up_bps = 0, down_bps = 2048 } + "*6/64" = { up_bps = 4096, down_bps = 0 } + "#, + ); + + assert!( + cfg.access + .cidr_rate_limits + .contains_key(&CidrRateLimitKey::AutoDual(24)) + ); + assert!( + cfg.access + .cidr_rate_limits + .contains_key(&CidrRateLimitKey::AutoV4(30)) + ); + assert!( + cfg.access + .cidr_rate_limits + .contains_key(&CidrRateLimitKey::AutoV6(64)) + ); +} + +#[test] +fn cidr_rate_limits_reject_invalid_auto_template_prefix() { + let error = load_config_error_from_temp_toml( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [access.cidr_rate_limits] + "*4/33" = { up_bps = 1024, down_bps = 0 } + "#, + ); + + assert!(error.contains("prefix must be within 0..=32")); +} + +#[test] +fn cidr_rate_limits_reject_duplicate_normalized_auto_templates() { + let error = load_config_error_from_temp_toml( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [access.cidr_rate_limits] + "*/32" = { up_bps = 1024, down_bps = 0 } + "*6/128" = { up_bps = 2048, down_bps = 0 } + "#, + ); + + assert!(error.contains("duplicates normalized auto-template *6/128")); +} + +#[test] +fn file_logging_requires_path() { + let error = load_config_error_from_temp_toml( + r#" + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert!(error.contains("logging.path must be set")); +} + +#[test] +fn impl_defaults_are_sourced_from_default_helpers() { + let network = NetworkConfig::default(); + assert_eq!(network.ipv6, default_network_ipv6()); + assert_eq!(network.stun_use, default_true()); + assert_eq!(network.stun_tcp_fallback, default_stun_tcp_fallback()); + + let general = GeneralConfig::default(); + assert_eq!( + general.middle_proxy_warm_standby, + default_middle_proxy_warm_standby() + ); + assert_eq!( + general.me_reconnect_max_concurrent_per_dc, + default_me_reconnect_max_concurrent_per_dc() + ); + assert_eq!( + general.me_reconnect_fast_retry_count, + default_me_reconnect_fast_retry_count() + ); + assert_eq!( + general.me_init_retry_attempts, + default_me_init_retry_attempts() + ); + assert_eq!(general.me2dc_fallback, default_me2dc_fallback()); + assert_eq!(general.me2dc_fast, default_me2dc_fast()); + assert_eq!( + general.proxy_config_v4_cache_path, + default_proxy_config_v4_cache_path() + ); + assert_eq!( + general.proxy_config_v6_cache_path, + default_proxy_config_v6_cache_path() + ); + assert_eq!( + general.me_single_endpoint_shadow_writers, + default_me_single_endpoint_shadow_writers() + ); + assert_eq!( + general.me_single_endpoint_outage_mode_enabled, + default_me_single_endpoint_outage_mode_enabled() + ); + assert_eq!( + general.me_single_endpoint_outage_disable_quarantine, + default_me_single_endpoint_outage_disable_quarantine() + ); + assert_eq!( + general.me_single_endpoint_outage_backoff_min_ms, + default_me_single_endpoint_outage_backoff_min_ms() + ); + assert_eq!( + general.me_single_endpoint_outage_backoff_max_ms, + default_me_single_endpoint_outage_backoff_max_ms() + ); + assert_eq!( + general.me_single_endpoint_shadow_rotate_every_secs, + default_me_single_endpoint_shadow_rotate_every_secs() + ); + assert_eq!(general.me_floor_mode, MeFloorMode::default()); + assert_eq!( + general.me_adaptive_floor_idle_secs, + default_me_adaptive_floor_idle_secs() + ); + assert_eq!( + general.me_adaptive_floor_min_writers_single_endpoint, + default_me_adaptive_floor_min_writers_single_endpoint() + ); + assert_eq!( + general.me_adaptive_floor_recover_grace_secs, + default_me_adaptive_floor_recover_grace_secs() + ); + assert_eq!( + general.upstream_connect_retry_attempts, + default_upstream_connect_retry_attempts() + ); + assert_eq!( + general.upstream_connect_retry_backoff_ms, + default_upstream_connect_retry_backoff_ms() + ); + assert_eq!( + general.upstream_unhealthy_fail_threshold, + default_upstream_unhealthy_fail_threshold() + ); + assert_eq!( + general.upstream_connect_failfast_hard_errors, + default_upstream_connect_failfast_hard_errors() + ); + assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every()); + assert_eq!(general.beobachten_file, default_beobachten_file()); + assert_eq!(general.update_every, default_update_every()); + + let server = ServerConfig::default(); + assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6())); + assert_eq!( + server.proxy_protocol_trusted_cidrs, + default_proxy_protocol_trusted_cidrs() + ); + assert_eq!( + AntiCensorshipConfig::default().unknown_sni_action, + UnknownSniAction::Drop + ); + assert_eq!(server.api.listen, default_api_listen()); + assert_eq!(server.api.whitelist, default_api_whitelist()); + assert_eq!(server.api.gray_action, ApiGrayAction::Drop); + assert_eq!( + server.api.request_body_limit_bytes, + default_api_request_body_limit_bytes() + ); + assert_eq!( + server.api.minimal_runtime_enabled, + default_api_minimal_runtime_enabled() + ); + assert_eq!( + server.api.minimal_runtime_cache_ttl_ms, + default_api_minimal_runtime_cache_ttl_ms() + ); + assert_eq!( + server.api.runtime_edge_enabled, + default_api_runtime_edge_enabled() + ); + assert_eq!( + server.api.runtime_edge_cache_ttl_ms, + default_api_runtime_edge_cache_ttl_ms() + ); + assert_eq!( + server.api.runtime_edge_top_n, + default_api_runtime_edge_top_n() + ); + assert_eq!( + server.api.runtime_edge_events_capacity, + default_api_runtime_edge_events_capacity() + ); + assert_eq!( + server.conntrack_control.inline_conntrack_control, + default_conntrack_control_enabled() + ); + assert_eq!(server.conntrack_control.mode, ConntrackMode::default()); + assert_eq!( + server.conntrack_control.backend, + ConntrackBackend::default() + ); + assert_eq!( + server.conntrack_control.profile, + ConntrackPressureProfile::default() + ); + assert_eq!( + server.conntrack_control.pressure_high_watermark_pct, + default_conntrack_pressure_high_watermark_pct() + ); + assert_eq!( + server.conntrack_control.pressure_low_watermark_pct, + default_conntrack_pressure_low_watermark_pct() + ); + assert_eq!( + server.conntrack_control.delete_budget_per_sec, + default_conntrack_delete_budget_per_sec() + ); + + let access = AccessConfig::default(); + assert_eq!(access.users, default_access_users()); + assert_eq!( + access.user_max_tcp_conns_global_each, + default_user_max_tcp_conns_global_each() + ); +} + diff --git a/src/config/tests/load_basic_tests/legacy_policy_tests.rs b/src/config/tests/load_basic_tests/legacy_policy_tests.rs new file mode 100644 index 0000000..8a3dff1 --- /dev/null +++ b/src/config/tests/load_basic_tests/legacy_policy_tests.rs @@ -0,0 +1,425 @@ +use super::*; + +#[test] +fn proxy_protocol_trusted_cidrs_missing_uses_trust_all_but_explicit_empty_stays_empty() { + let cfg_missing: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert_eq!( + cfg_missing.server.proxy_protocol_trusted_cidrs, + default_proxy_protocol_trusted_cidrs() + ); + + let cfg_explicit_empty: ProxyConfig = toml::from_str( + r#" + [server] + proxy_protocol_trusted_cidrs = [] + + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert!( + cfg_explicit_empty + .server + .proxy_protocol_trusted_cidrs + .is_empty() + ); +} + +#[test] +fn conntrack_inline_explicit_flag_is_false_when_omitted() { + let cfg = load_config_from_temp_toml( + r#" + [general] + [network] + [server] + [server.conntrack_control] + [access] + "#, + ); + assert!( + !cfg.server + .conntrack_control + .inline_conntrack_control_explicit + ); +} + +#[test] +fn conntrack_inline_explicit_flag_is_true_when_present() { + let cfg = load_config_from_temp_toml( + r#" + [general] + [network] + [server] + [server.conntrack_control] + inline_conntrack_control = true + [access] + "#, + ); + assert!( + cfg.server + .conntrack_control + .inline_conntrack_control_explicit + ); +} + +#[test] +fn unknown_sni_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + "#, + ) + .unwrap(); + assert_eq!( + cfg_default.censorship.unknown_sni_action, + UnknownSniAction::Drop + ); + + let cfg_mask: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "mask" + "#, + ) + .unwrap(); + assert_eq!( + cfg_mask.censorship.unknown_sni_action, + UnknownSniAction::Mask + ); + + let cfg_accept: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "accept" + "#, + ) + .unwrap(); + assert_eq!( + cfg_accept.censorship.unknown_sni_action, + UnknownSniAction::Accept + ); + + let cfg_reject: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "reject_handshake" + "#, + ) + .unwrap(); + assert_eq!( + cfg_reject.censorship.unknown_sni_action, + UnknownSniAction::RejectHandshake + ); +} + +#[test] +fn exclusive_mask_parses_domain_target_map() { + let cfg = load_config_from_temp_toml( + r#" + [general] + [network] + [server] + [access] + [censorship] + tls_domain = "weißbiergärten.de" + tls_domains = ["bürgeramt.de"] + [censorship.exclusive_mask] + "bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443" + "ipv6.example" = "[::1]:443" + "#, + ); + + assert!(cfg.censorship.tls_domain.is_ascii()); + assert!(cfg.censorship.tls_domain.contains("xn--")); + assert_eq!(cfg.censorship.tls_domains.len(), 1); + let normalized_extra = &cfg.censorship.tls_domains[0]; + assert!(normalized_extra.is_ascii()); + assert!(normalized_extra.contains("xn--")); + + let normalized_target = cfg + .censorship + .exclusive_mask + .get(normalized_extra) + .expect("exclusive_mask key must match normalized tls_domains entry"); + assert!(normalized_target.is_ascii()); + assert!(normalized_target.contains("xn--")); + assert!(normalized_target.ends_with(":443")); + assert_eq!( + cfg.censorship.exclusive_mask.get("ipv6.example"), + Some(&"[::1]:443".to_string()) + ); +} + +#[test] +fn api_gray_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop); + + let cfg_api: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "api" + "#, + ) + .unwrap(); + assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api); + + let cfg_200: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "200" + "#, + ) + .unwrap(); + assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200); + + let cfg_drop: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "drop" + "#, + ) + .unwrap(); + assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); +} + +#[test] +fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() { + let cfg = load_config_from_temp_toml( + r#" + beobachten = false + beobachten_minutes = 7 + beobachten_flush_secs = 3 + beobachten_file = "tmp/legacy-beob.txt" + + [server] + [general] + [network] + [access] + "#, + ); + + assert!(!cfg.general.beobachten); + assert_eq!(cfg.general.beobachten_minutes, 7); + assert_eq!(cfg.general.beobachten_flush_secs, 3); + assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt"); +} + +#[test] +fn general_beobachten_keys_have_priority_over_legacy_top_level() { + let cfg = load_config_from_temp_toml( + r#" + beobachten = true + beobachten_minutes = 30 + beobachten_flush_secs = 30 + beobachten_file = "tmp/legacy-beob.txt" + + [server] + [general] + beobachten = false + beobachten_minutes = 5 + beobachten_flush_secs = 2 + beobachten_file = "tmp/general-beob.txt" + [network] + [access] + "#, + ); + + assert!(!cfg.general.beobachten); + assert_eq!(cfg.general.beobachten_minutes, 5); + assert_eq!(cfg.general.beobachten_flush_secs, 2); + assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt"); +} + +#[test] +fn dc_overrides_allow_string_and_array() { + let toml = r#" + [dc_overrides] + "201" = "149.154.175.50:443" + "202" = ["149.154.167.51:443", "149.154.175.100:443"] + "#; + let cfg: ProxyConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.dc_overrides["201"], vec!["149.154.175.50:443"]); + assert_eq!( + cfg.dc_overrides["202"], + vec!["149.154.167.51:443", "149.154.175.100:443"] + ); +} + +#[test] +fn load_with_metadata_collects_include_files() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("telemt_load_metadata_{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let main_path = dir.join("config.toml"); + let include_path = dir.join("included.toml"); + + std::fs::write( + &include_path, + r#" + [access.users] + user = "00000000000000000000000000000000" + "#, + ) + .unwrap(); + std::fs::write( + &main_path, + r#" + include = "included.toml" + + [censorship] + tls_domain = "example.com" + "#, + ) + .unwrap(); + + let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap(); + let main_normalized = normalize_config_path(&main_path); + let include_normalized = normalize_config_path(&include_path); + + assert!(loaded.source_files.contains(&main_normalized)); + assert!(loaded.source_files.contains(&include_normalized)); + + let _ = std::fs::remove_file(main_path); + let _ = std::fs::remove_file(include_path); + let _ = std::fs::remove_dir(dir); +} + +#[test] +fn dc_overrides_inject_dc203_default() { + let toml = r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_dc_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!( + cfg.dc_overrides + .get("203") + .map(|v| v.contains(&"91.105.192.100:443".to_string())) + .unwrap_or(false) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn update_every_overrides_legacy_fields() { + let toml = r#" + [general] + update_every = 123 + proxy_secret_auto_reload_secs = 700 + proxy_config_auto_reload_secs = 800 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_update_every_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.effective_update_every_secs(), 123); + let _ = std::fs::remove_file(path); +} + +#[test] +fn update_every_fallback_to_legacy_min() { + let toml = r#" + [general] + proxy_secret_auto_reload_secs = 600 + proxy_config_auto_reload_secs = 120 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_update_every_legacy_min_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.update_every, None); + assert_eq!(cfg.general.effective_update_every_secs(), 120); + let _ = std::fs::remove_file(path); +} + +#[test] +fn update_every_zero_is_rejected() { + let toml = r#" + [general] + update_every = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_update_every_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.update_every must be > 0")); + let _ = std::fs::remove_file(path); +} + diff --git a/src/config/tests/load_basic_tests/me_route_tests.rs b/src/config/tests/load_basic_tests/me_route_tests.rs new file mode 100644 index 0000000..e628b73 --- /dev/null +++ b/src/config/tests/load_basic_tests/me_route_tests.rs @@ -0,0 +1,337 @@ +use super::*; + +#[test] +fn me_route_backpressure_base_timeout_ms_out_of_range_is_rejected() { + let toml = r#" + [general] + me_route_backpressure_base_timeout_ms = 5001 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_backpressure_base_timeout_ms_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_backpressure_base_timeout_ms must be within [1, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_backpressure_high_timeout_ms_out_of_range_is_rejected() { + let toml = r#" + [general] + me_route_backpressure_base_timeout_ms = 100 + me_route_backpressure_high_timeout_ms = 5001 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_backpressure_high_timeout_ms_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_backpressure_high_timeout_ms must be within [1, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_no_writer_wait_ms_out_of_range_is_rejected() { + let toml = r#" + [general] + me_route_no_writer_wait_ms = 5 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_no_writer_wait_ms_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_no_writer_wait_ms must be within [10, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_blocking_send_timeout_ms_zero_is_rejected() { + let toml = r#" + [general] + me_route_blocking_send_timeout_ms = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_no_writer_mode_is_parsed() { + let toml = r#" + [general] + me_route_no_writer_mode = "inline_recovery_legacy" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_no_writer_mode_parse_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.me_route_no_writer_mode, + crate::config::MeRouteNoWriterMode::InlineRecoveryLegacy + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn proxy_config_cache_paths_empty_are_rejected() { + let toml = r#" + [general] + proxy_config_v4_cache_path = " " + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_proxy_config_v4_cache_path_empty_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.proxy_config_v4_cache_path cannot be empty")); + let _ = std::fs::remove_file(path); + + let toml_v6 = r#" + [general] + proxy_config_v6_cache_path = "" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let path_v6 = dir.join("telemt_proxy_config_v6_cache_path_empty_test.toml"); + std::fs::write(&path_v6, toml_v6).unwrap(); + let err_v6 = ProxyConfig::load(&path_v6).unwrap_err().to_string(); + assert!(err_v6.contains("general.proxy_config_v6_cache_path cannot be empty")); + let _ = std::fs::remove_file(path_v6); +} + +#[test] +fn me_hardswap_warmup_defaults_are_set() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_defaults_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.me_hardswap_warmup_delay_min_ms, + default_me_hardswap_warmup_delay_min_ms() + ); + assert_eq!( + cfg.general.me_hardswap_warmup_delay_max_ms, + default_me_hardswap_warmup_delay_max_ms() + ); + assert_eq!( + cfg.general.me_hardswap_warmup_extra_passes, + default_me_hardswap_warmup_extra_passes() + ); + assert_eq!( + cfg.general.me_hardswap_warmup_pass_backoff_base_ms, + default_me_hardswap_warmup_pass_backoff_base_ms() + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_delay_range_is_validated() { + let toml = r#" + [general] + me_hardswap_warmup_delay_min_ms = 2001 + me_hardswap_warmup_delay_max_ms = 2000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_delay_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains( + "general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_delay_max_zero_is_rejected() { + let toml = r#" + [general] + me_hardswap_warmup_delay_max_ms = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_delay_max_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_hardswap_warmup_delay_max_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_extra_passes_out_of_range_is_rejected() { + let toml = r#" + [general] + me_hardswap_warmup_extra_passes = 11 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_extra_passes_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_hardswap_warmup_extra_passes must be within [0, 10]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_pass_backoff_zero_is_rejected() { + let toml = r#" + [general] + me_hardswap_warmup_pass_backoff_base_ms = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_backoff_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_hardswap_warmup_pass_backoff_base_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_config_stable_snapshots_zero_is_rejected() { + let toml = r#" + [general] + me_config_stable_snapshots = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_config_stable_snapshots_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_config_stable_snapshots must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn proxy_secret_stable_snapshots_zero_is_rejected() { + let toml = r#" + [general] + proxy_secret_stable_snapshots = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_proxy_secret_stable_snapshots_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.proxy_secret_stable_snapshots must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn proxy_secret_len_max_out_of_range_is_rejected() { + let toml = r#" + [general] + proxy_secret_len_max = 16 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_proxy_secret_len_max_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.proxy_secret_len_max must be within [32, 4096]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_pool_min_fresh_ratio_out_of_range_is_rejected() { + let toml = r#" + [general] + me_pool_min_fresh_ratio = 1.5 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_pool_min_ratio_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_pool_min_fresh_ratio must be within [0.0, 1.0]")); + let _ = std::fs::remove_file(path); +} + diff --git a/src/config/tests/load_basic_tests/me_startup_tests.rs b/src/config/tests/load_basic_tests/me_startup_tests.rs new file mode 100644 index 0000000..a60aec3 --- /dev/null +++ b/src/config/tests/load_basic_tests/me_startup_tests.rs @@ -0,0 +1,305 @@ +use super::*; + +#[test] +fn stun_nat_probe_concurrency_zero_is_rejected() { + let toml = r#" + [general] + stun_nat_probe_concurrency = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_stun_nat_probe_concurrency_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.stun_nat_probe_concurrency must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_reinit_every_default_is_set() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_reinit_every_default_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.me_reinit_every_secs, + default_me_reinit_every_secs() + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_reinit_every_zero_is_rejected() { + let toml = r#" + [general] + me_reinit_every_secs = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_reinit_every_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_reinit_every_secs must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_single_endpoint_outage_backoff_range_is_validated() { + let toml = r#" + [general] + me_single_endpoint_outage_backoff_min_ms = 4000 + me_single_endpoint_outage_backoff_max_ms = 3000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_single_endpoint_outage_backoff_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains( + "general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_single_endpoint_shadow_writers_too_large_is_rejected() { + let toml = r#" + [general] + me_single_endpoint_shadow_writers = 33 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_single_endpoint_shadow_writers_limit_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_single_endpoint_shadow_writers must be within [0, 32]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_adaptive_floor_min_writers_out_of_range_is_rejected() { + let toml = r#" + [general] + me_adaptive_floor_min_writers_single_endpoint = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_adaptive_floor_min_writers_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!( + err.contains( + "general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]" + ) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_floor_mode_adaptive_is_parsed() { + let toml = r#" + [general] + me_floor_mode = "adaptive" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_floor_mode_adaptive_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_floor_mode, MeFloorMode::Adaptive); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_adaptive_floor_max_active_writers_per_core_zero_is_rejected() { + let toml = r#" + [general] + me_adaptive_floor_max_active_writers_per_core = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_adaptive_floor_max_active_per_core_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_adaptive_floor_max_active_writers_per_core must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_adaptive_floor_max_warm_writers_global_zero_is_rejected() { + let toml = r#" + [general] + me_adaptive_floor_max_warm_writers_global = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_adaptive_floor_max_warm_global_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_adaptive_floor_max_warm_writers_global must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn upstream_connect_retry_attempts_zero_is_rejected() { + let toml = r#" + [general] + upstream_connect_retry_attempts = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_upstream_connect_retry_attempts_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.upstream_connect_retry_attempts must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn upstream_unhealthy_fail_threshold_zero_is_rejected() { + let toml = r#" + [general] + upstream_unhealthy_fail_threshold = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_upstream_unhealthy_fail_threshold_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.upstream_unhealthy_fail_threshold must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tg_connect_zero_is_rejected() { + let toml = r#" + [general] + tg_connect = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tg_connect_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.tg_connect must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn rpc_proxy_req_every_out_of_range_is_rejected() { + let toml = r#" + [general] + rpc_proxy_req_every = 9 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_rpc_proxy_req_every_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.rpc_proxy_req_every must be 0 or within [10, 300]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn rpc_proxy_req_every_zero_and_valid_range_are_accepted() { + let toml_zero = r#" + [general] + rpc_proxy_req_every = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path_zero = dir.join("telemt_rpc_proxy_req_every_zero_ok_test.toml"); + std::fs::write(&path_zero, toml_zero).unwrap(); + let cfg_zero = ProxyConfig::load(&path_zero).unwrap(); + assert_eq!(cfg_zero.general.rpc_proxy_req_every, 0); + let _ = std::fs::remove_file(path_zero); + + let toml_valid = r#" + [general] + rpc_proxy_req_every = 40 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let path_valid = dir.join("telemt_rpc_proxy_req_every_valid_ok_test.toml"); + std::fs::write(&path_valid, toml_valid).unwrap(); + let cfg_valid = ProxyConfig::load(&path_valid).unwrap(); + assert_eq!(cfg_valid.general.rpc_proxy_req_every, 40); + let _ = std::fs::remove_file(path_valid); +} + diff --git a/src/config/tests/load_basic_tests/synlimit_mss_tests.rs b/src/config/tests/load_basic_tests/synlimit_mss_tests.rs new file mode 100644 index 0000000..bfbcf4e --- /dev/null +++ b/src/config/tests/load_basic_tests/synlimit_mss_tests.rs @@ -0,0 +1,369 @@ +use super::*; + +#[test] +fn synlimit_synfix_defaults_are_loaded_for_listener() { + let cfg = load_config_from_temp_toml( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + "#, + ); + + let listener = &cfg.server.listeners[0]; + assert_eq!(listener.synlimit_seconds, 60); + assert_eq!(listener.synlimit_hitcount, 48); + assert_eq!(listener.synlimit_burst, 24); + assert_eq!(listener.synlimit_ios_seconds, 1); + assert_eq!(listener.synlimit_ios_hitcount, 12); + assert_eq!(listener.synlimit_ios_burst, 24); + assert_eq!(listener.synlimit_hashlimit_expire_ms, 60_000); + assert_eq!(listener.synlimit_hashlimit_size, 32_768); +} + +#[cfg(target_os = "freebsd")] +#[test] +fn synlimit_pf_mode_is_loaded_for_listener() { + let cfg = load_config_from_temp_toml( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "pf" + "#, + ); + + assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf); +} + +#[cfg(not(target_os = "freebsd"))] +#[test] +fn synlimit_pf_mode_is_rejected_off_freebsd() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "pf" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_synlimit_pf_unsupported_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("backend pf is unsupported on this platform")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn synlimit_synfix_zero_values_are_rejected() { + for (field, expected) in [ + ( + "synlimit_ios_seconds", + "server.listeners[0].synlimit_ios_seconds must be > 0", + ), + ( + "synlimit_ios_hitcount", + "server.listeners[0].synlimit_ios_hitcount must be > 0", + ), + ( + "synlimit_ios_burst", + "server.listeners[0].synlimit_ios_burst must be > 0", + ), + ( + "synlimit_hashlimit_expire_ms", + "server.listeners[0].synlimit_hashlimit_expire_ms must be > 0", + ), + ( + "synlimit_hashlimit_size", + "server.listeners[0].synlimit_hashlimit_size must be > 0", + ), + ] { + let toml = format!( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + {field} = 0 + "# + ); + let error = load_config_error_from_temp_toml(&toml); + assert!(error.contains(expected), "{field}: {error}"); + } +} + + +#[test] +fn client_mss_presets_and_listener_override_are_resolved() { + let toml = r#" + [server] + client_mss = "tspu" + + [[server.listeners]] + ip = "127.0.0.1" + port = 1443 + + [[server.listeners]] + ip = "127.0.0.2" + port = 1444 + client_mss = "2in8" + + [[server.listeners]] + ip = "127.0.0.3" + port = 1445 + client_mss = "" + + [[server.listeners]] + ip = "127.0.0.4" + port = 1446 + client_mss = "extreme-low" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); + assert_eq!( + cfg.server.listeners[0].effective_client_mss(&cfg.server), + Ok(Some(92)) + ); + assert_eq!( + cfg.server.listeners[1].effective_client_mss(&cfg.server), + Ok(Some(256)) + ); + assert_eq!( + cfg.server.listeners[2].effective_client_mss(&cfg.server), + Ok(None) + ); + assert_eq!( + cfg.server.listeners[3].effective_client_mss(&cfg.server), + Ok(Some(88)) + ); + let _ = std::fs::remove_file(path); +} + +#[cfg(target_os = "linux")] +#[test] +fn client_mss_custom_value_is_accepted() { + let toml = r#" + [server] + client_mss = "92" + client_mss_bulk = "1400" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_custom_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); + assert_eq!(cfg.server.client_mss_bulk_value(), Ok(Some(1400))); + let _ = std::fs::remove_file(path); +} + +#[cfg(target_os = "linux")] +#[test] +fn client_mss_bulk_requires_a_larger_bulk_profile_and_handshake_participant() { + for (name, server, expected) in [ + ( + "without_handshake", + "client_mss_bulk = \"1400\"", + "requires an effective client_mss", + ), + ( + "equal", + "client_mss = \"1400\"\nclient_mss_bulk = \"1400\"", + "must be greater than the effective handshake MSS", + ), + ( + "inverted", + "client_mss = \"1500\"\nclient_mss_bulk = \"1400\"", + "must be greater than the effective handshake MSS", + ), + ] { + let toml = format!( + "[server]\n{server}\n\n[censorship]\ntls_domain = \"example.com\"\n\n[access.users]\nuser = \"00000000000000000000000000000000\"\n" + ); + let dir = std::env::temp_dir(); + let path = dir.join(format!("telemt_client_mss_bulk_{name}_test.toml")); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains(expected), "unexpected error: {err}"); + let _ = std::fs::remove_file(path); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn client_mss_bulk_allows_explicit_listener_opt_out() { + let toml = r#" + [server] + client_mss = "92" + client_mss_bulk = "1400" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + + [[server.listeners]] + ip = "::" + port = 443 + client_mss = "" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_bulk_listener_opt_out_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!( + cfg.server.listeners[0].effective_client_mss(&cfg.server), + Ok(Some(92)) + ); + assert_eq!( + cfg.server.listeners[1].effective_client_mss(&cfg.server), + Ok(None) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn client_mss_out_of_range_is_rejected() { + for value in ["87", "4097"] { + let toml = format!( + r#" + [server] + client_mss = "{value}" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "# + ); + let dir = std::env::temp_dir(); + let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml")); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.client_mss custom value must be within [88, 4096]")); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn client_mss_bulk_out_of_range_is_rejected() { + for value in ["87", "4097"] { + let toml = format!( + r#" + [server] + client_mss_bulk = "{value}" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "# + ); + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "telemt_client_mss_bulk_out_of_range_{value}_test.toml" + )); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.client_mss_bulk custom value must be within [88, 4096]")); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn client_mss_unquoted_number_is_rejected() { + let toml = r#" + [server] + client_mss = 256 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_unquoted_number_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("client_mss")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn listener_client_mss_invalid_preset_is_rejected() { + let toml = r#" + [[server.listeners]] + ip = "127.0.0.1" + port = 1443 + client_mss = "tiny" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_listener_client_mss_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.listeners[0].client_mss")); + assert!(err.contains("must be \"\", extreme-low, tspu, 2in8")); + let _ = std::fs::remove_file(path); +} + diff --git a/src/config/tests/load_basic_tests/tls_fetch_tests.rs b/src/config/tests/load_basic_tests/tls_fetch_tests.rs new file mode 100644 index 0000000..5b8338d --- /dev/null +++ b/src/config/tests/load_basic_tests/tls_fetch_tests.rs @@ -0,0 +1,186 @@ +use super::*; + +#[test] +fn tls_fetch_scope_default_is_empty() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_scope_default_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!(cfg.censorship.tls_fetch_scope.is_empty()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_scope_is_trimmed_during_load() { + let toml = r#" + [censorship] + tls_domain = "example.com" + tls_fetch_scope = " me " + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_scope_trim_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.censorship.tls_fetch_scope, "me"); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_scope_whitespace_becomes_empty() { + let toml = r#" + [censorship] + tls_domain = "example.com" + tls_fetch_scope = " " + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_scope_blank_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!(cfg.censorship.tls_fetch_scope.is_empty()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_defaults_are_applied() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_defaults_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.censorship.tls_fetch.profiles, + TlsFetchConfig::default().profiles + ); + assert!(cfg.censorship.tls_fetch.strict_route); + assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000); + assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000); + assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_profiles_are_deduplicated_preserving_order() { + let toml = r#" + [censorship] + tls_domain = "example.com" + [censorship.tls_fetch] + profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"] + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.censorship.tls_fetch.profiles, + vec![ + TlsFetchProfile::CompatTls12, + TlsFetchProfile::ModernChromeLike, + TlsFetchProfile::LegacyMinimal + ] + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_attempt_timeout_zero_is_rejected() { + let toml = r#" + [censorship] + tls_domain = "example.com" + [censorship.tls_fetch] + attempt_timeout_ms = 0 + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_total_budget_zero_is_rejected() { + let toml = r#" + [censorship] + tls_domain = "example.com" + [censorship.tls_fetch] + total_budget_ms = 0 + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_ad_tag_is_disabled_during_load() { + let toml = r#" + [general] + ad_tag = "not_hex" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_invalid_ad_tag_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!(cfg.general.ad_tag.is_none()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn valid_ad_tag_is_preserved_during_load() { + let toml = r#" + [general] + ad_tag = "00112233445566778899aabbccddeeff" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_valid_ad_tag_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.ad_tag.as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + let _ = std::fs::remove_file(path); +} + diff --git a/src/config/tests/load_basic_tests/upstream_tests.rs b/src/config/tests/load_basic_tests/upstream_tests.rs new file mode 100644 index 0000000..65c33a0 --- /dev/null +++ b/src/config/tests/load_basic_tests/upstream_tests.rs @@ -0,0 +1,180 @@ +use super::*; + +#[test] +fn shadowsocks_upstream_url_loads_successfully() { + let toml = format!( + r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "{url}" + interface = "127.0.0.2" + "#, + url = TEST_SHADOWSOCKS_URL, + ); + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert!(matches!( + &cfg.upstreams[0].upstream_type, + UpstreamType::Shadowsocks { url, interface } + if url == TEST_SHADOWSOCKS_URL && interface.as_deref() == Some("127.0.0.2") + )); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn shadowsocks_requires_direct_mode() { + let toml = format!( + r#" + [general] + use_middle_proxy = true + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "{url}" + "#, + url = TEST_SHADOWSOCKS_URL, + ); + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_me_reject_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("shadowsocks upstreams require general.use_middle_proxy = false")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_shadowsocks_url_is_rejected() { + let toml = r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "not-a-valid-ss-url" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_invalid_url_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("invalid shadowsocks url")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn shadowsocks_plugins_are_rejected() { + let toml = format!( + r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "{url}?plugin=obfs-local%3Bobfs%3Dhttp" + "#, + url = TEST_SHADOWSOCKS_URL, + ); + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_plugin_reject_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("shadowsocks plugins are not supported")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_user_ad_tag_reports_access_user_ad_tags_key() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + alice = "00000000000000000000000000000000" + + [access.user_ad_tags] + alice = "not_hex" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_invalid_user_ad_tag_message_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("access.user_ad_tags['alice'] must be exactly 32 hex characters")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_dns_override_is_rejected() { + let toml = r#" + [network] + dns_overrides = ["example.com:443:2001:db8::10"] + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_invalid_dns_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("must be bracketed")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn valid_dns_override_is_accepted() { + let toml = r#" + [network] + dns_overrides = ["example.com:443:127.0.0.1", "example.net:443:[2001:db8::10]"] + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_valid_dns_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.network.dns_overrides.len(), 2); + let _ = std::fs::remove_file(path); +} diff --git a/src/config/types.rs b/src/config/types.rs index 8594853..756e342 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,3 +1,8 @@ +//! Configuration data model split by serialized responsibility. +//! +//! Each private submodule owns one stable group of existing TOML fields while +//! this facade preserves the public crate configuration surface. + use chrono::{DateTime, Utc}; use ipnetwork::IpNetwork; use serde::{Deserialize, Serialize}; @@ -8,2626 +13,30 @@ use std::path::PathBuf; use super::defaults::*; -// ============= Log Level ============= +mod access; +mod api; +mod censorship; +mod general; +mod general_impl; +mod links; +mod logging; +mod network; +mod policies; +mod server; -/// Logging verbosity level. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum LogLevel { - /// All messages including trace (trace + debug + info + warn + error). - Debug, - /// Detailed operational logs (debug + info + warn + error). - Verbose, - /// Standard operational logs (info + warn + error). - #[default] - Normal, - /// Minimal output: only warnings and errors (warn + error). - /// Proxy links may still be emitted through their dedicated target. - Silent, -} +pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps}; +#[allow(unused_imports)] +pub(crate) use access::{CidrAutoTemplate, CidrAutoTemplateFamily}; +pub use api::{ApiConfig, ApiGrayAction}; +pub use censorship::{AntiCensorshipConfig, ExclusiveMaskTarget, TlsFetchConfig, TlsFetchProfile, UnknownSniAction}; +pub use general::GeneralConfig; +pub use links::{LinksConfig, ShowLink}; +pub use logging::{LogLevel, LoggingConfig, LoggingDestination, LogRotation}; +pub use network::{NetworkConfig, ProxyModes, UpstreamConfig, UpstreamType}; +pub use policies::{MeBindStaleMode, MeFloorMode, MeRouteNoWriterMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode, RstOnCloseMode, TelemetryConfig, UserMaxUniqueIpsMode}; +#[allow(unused_imports)] +pub use server::{CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU, ConntrackBackend, ConntrackControlConfig, ConntrackMode, ConntrackPressureProfile, ListenerConfig, ServerConfig, SynLimitMode, TimeoutsConfig}; fn default_quota_state_path() -> PathBuf { PathBuf::from("telemt.limit.json") } - -impl LogLevel { - /// Convert to tracing EnvFilter directive string. - pub fn to_filter_str(&self) -> &'static str { - match self { - LogLevel::Debug => "trace", - LogLevel::Verbose => "debug", - LogLevel::Normal => "info", - LogLevel::Silent => "warn", - } - } - - /// Parse from a loose string (CLI argument). - pub fn from_str_loose(s: &str) -> Self { - match s.to_lowercase().as_str() { - "debug" | "trace" => LogLevel::Debug, - "verbose" => LogLevel::Verbose, - "normal" | "info" => LogLevel::Normal, - "silent" | "quiet" | "error" | "warn" => LogLevel::Silent, - _ => LogLevel::Normal, - } - } -} - -impl std::fmt::Display for LogLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LogLevel::Debug => write!(f, "debug"), - LogLevel::Verbose => write!(f, "verbose"), - LogLevel::Normal => write!(f, "normal"), - LogLevel::Silent => write!(f, "silent"), - } - } -} - -/// Logging output destination. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum LoggingDestination { - /// Write logs to stderr. - #[default] - Stderr, - /// Write logs to syslog on Unix platforms. - Syslog, - /// Write logs to a file. - File, -} - -/// Time-based log rotation interval for file logging. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum LogRotation { - /// Do not rotate logs by time. - #[default] - Never, - /// Rotate once per minute. - Minutely, - /// Rotate once per hour. - Hourly, - /// Rotate once per day. - Daily, - /// Rotate once per week. - Weekly, -} - -impl LogRotation { - /// Parse a CLI rotation value. - pub fn from_cli_arg(value: &str) -> Option { - match value.to_ascii_lowercase().as_str() { - "never" | "none" | "off" => Some(Self::Never), - "minutely" | "minute" => Some(Self::Minutely), - "hourly" | "hour" => Some(Self::Hourly), - "daily" | "day" => Some(Self::Daily), - "weekly" | "week" => Some(Self::Weekly), - _ => None, - } - } -} - -/// File logging and retention settings. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LoggingConfig { - /// Effective logging destination. - #[serde(default)] - pub destination: LoggingDestination, - /// File path used when `destination = "file"`. - #[serde(default)] - pub path: Option, - /// Time rotation interval for file logs. - #[serde(default)] - pub rotation: LogRotation, - /// Maximum active log file size before rotating. `0` disables size rotation. - #[serde(default)] - pub max_size_bytes: u64, - /// Maximum number of matching log files to keep. `0` disables count retention. - #[serde(default)] - pub max_files: usize, - /// Maximum age for rotated log files in seconds. `0` disables age retention. - #[serde(default)] - pub max_age_secs: u64, -} - -impl Default for LoggingConfig { - fn default() -> Self { - Self { - destination: LoggingDestination::Stderr, - path: None, - rotation: LogRotation::Never, - max_size_bytes: 0, - max_files: 0, - max_age_secs: 0, - } - } -} - -/// Middle-End telemetry verbosity level. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum MeTelemetryLevel { - #[default] - Normal, - Silent, - Debug, -} - -impl MeTelemetryLevel { - pub fn as_u8(self) -> u8 { - match self { - MeTelemetryLevel::Silent => 0, - MeTelemetryLevel::Normal => 1, - MeTelemetryLevel::Debug => 2, - } - } - - pub fn from_u8(raw: u8) -> Self { - match raw { - 0 => MeTelemetryLevel::Silent, - 2 => MeTelemetryLevel::Debug, - _ => MeTelemetryLevel::Normal, - } - } - - pub fn allows_normal(self) -> bool { - !matches!(self, MeTelemetryLevel::Silent) - } - - pub fn allows_debug(self) -> bool { - matches!(self, MeTelemetryLevel::Debug) - } -} - -impl std::fmt::Display for MeTelemetryLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MeTelemetryLevel::Silent => write!(f, "silent"), - MeTelemetryLevel::Normal => write!(f, "normal"), - MeTelemetryLevel::Debug => write!(f, "debug"), - } - } -} - -/// Middle-End SOCKS KDF fallback policy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum MeSocksKdfPolicy { - #[default] - Strict, - Compat, -} - -impl MeSocksKdfPolicy { - pub fn as_u8(self) -> u8 { - match self { - MeSocksKdfPolicy::Strict => 0, - MeSocksKdfPolicy::Compat => 1, - } - } - - pub fn from_u8(raw: u8) -> Self { - match raw { - 1 => MeSocksKdfPolicy::Compat, - _ => MeSocksKdfPolicy::Strict, - } - } -} - -/// Stale ME writer bind policy during drain window. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum MeBindStaleMode { - #[default] - Never, - Ttl, - Always, -} - -impl MeBindStaleMode { - pub fn as_u8(self) -> u8 { - match self { - MeBindStaleMode::Never => 0, - MeBindStaleMode::Ttl => 1, - MeBindStaleMode::Always => 2, - } - } - - pub fn from_u8(raw: u8) -> Self { - match raw { - 0 => MeBindStaleMode::Never, - 2 => MeBindStaleMode::Always, - _ => MeBindStaleMode::Ttl, - } - } -} - -/// RST-on-close mode for accepted client sockets. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum RstOnCloseMode { - /// Normal FIN on all closes (default, no behaviour change). - #[default] - Off, - /// SO_LINGER(0) on accept; cleared after successful auth. - /// Pre-handshake failures (scanners, DPI, timeouts) send RST; - /// authenticated relay sessions close gracefully with FIN. - Errors, - /// SO_LINGER(0) on accept, never cleared — all closes send RST. - Always, -} - -/// Middle-End writer floor policy mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum MeFloorMode { - Static, - #[default] - Adaptive, -} - -impl MeFloorMode { - pub fn as_u8(self) -> u8 { - match self { - MeFloorMode::Static => 0, - MeFloorMode::Adaptive => 1, - } - } - - pub fn from_u8(raw: u8) -> Self { - match raw { - 1 => MeFloorMode::Adaptive, - _ => MeFloorMode::Static, - } - } -} - -/// Middle-End route behavior when no writer is immediately available. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum MeRouteNoWriterMode { - AsyncRecoveryFailfast, - InlineRecoveryLegacy, - #[default] - HybridAsyncPersistent, -} - -impl MeRouteNoWriterMode { - pub fn as_u8(self) -> u8 { - match self { - MeRouteNoWriterMode::AsyncRecoveryFailfast => 0, - MeRouteNoWriterMode::InlineRecoveryLegacy => 1, - MeRouteNoWriterMode::HybridAsyncPersistent => 2, - } - } - - pub fn from_u8(raw: u8) -> Self { - match raw { - 0 => MeRouteNoWriterMode::AsyncRecoveryFailfast, - 1 => MeRouteNoWriterMode::InlineRecoveryLegacy, - 2 => MeRouteNoWriterMode::HybridAsyncPersistent, - _ => MeRouteNoWriterMode::HybridAsyncPersistent, - } - } -} - -/// Middle-End writer selection mode for new client bindings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum MeWriterPickMode { - SortedRr, - #[default] - P2c, -} - -impl MeWriterPickMode { - pub fn as_u8(self) -> u8 { - match self { - MeWriterPickMode::SortedRr => 0, - MeWriterPickMode::P2c => 1, - } - } - - pub fn from_u8(raw: u8) -> Self { - match raw { - 0 => MeWriterPickMode::SortedRr, - 1 => MeWriterPickMode::P2c, - _ => MeWriterPickMode::P2c, - } - } -} - -/// Per-user unique source IP limit mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum UserMaxUniqueIpsMode { - /// Count only currently active source IPs. - #[default] - ActiveWindow, - /// Count source IPs seen within the recent time window. - TimeWindow, - /// Enforce both active and recent-window limits at the same time. - Combined, -} - -/// Telemetry controls for hot-path counters and ME diagnostics. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TelemetryConfig { - #[serde(default = "default_true")] - pub core_enabled: bool, - #[serde(default = "default_true")] - pub user_enabled: bool, - #[serde(default)] - pub me_level: MeTelemetryLevel, -} - -impl Default for TelemetryConfig { - fn default() -> Self { - Self { - core_enabled: default_true(), - user_enabled: default_true(), - me_level: MeTelemetryLevel::Normal, - } - } -} - -// ============= Sub-Configs ============= - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyModes { - #[serde(default)] - pub classic: bool, - #[serde(default)] - pub secure: bool, - #[serde(default = "default_true")] - pub tls: bool, -} - -impl Default for ProxyModes { - fn default() -> Self { - Self { - classic: false, - secure: false, - tls: default_true(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NetworkConfig { - #[serde(default = "default_true")] - pub ipv4: bool, - - /// None = auto-detect IPv6 availability. - #[serde(default = "default_network_ipv6")] - pub ipv6: Option, - - /// 4 or 6. - #[serde(default = "default_prefer_4")] - pub prefer: u8, - - #[serde(default)] - pub multipath: bool, - - /// Global switch for STUN probing. - /// When false, STUN is fully disabled and only non-STUN detection remains. - #[serde(default = "default_true")] - pub stun_use: bool, - - /// STUN servers list for public IP discovery. - #[serde(default = "default_stun_servers")] - pub stun_servers: Vec, - - /// Enable TCP STUN fallback when UDP is blocked. - #[serde(default = "default_stun_tcp_fallback")] - pub stun_tcp_fallback: bool, - - /// HTTP-based public IP detection endpoints (fallback after STUN). - #[serde(default = "default_http_ip_detect_urls")] - pub http_ip_detect_urls: Vec, - - /// Cache file path for detected public IP. - #[serde(default = "default_cache_public_ip_path")] - pub cache_public_ip_path: String, - - /// Runtime DNS overrides in `host:port:ip` format. - /// IPv6 IP values must be bracketed: `[2001:db8::1]`. - #[serde(default)] - pub dns_overrides: Vec, -} - -impl Default for NetworkConfig { - fn default() -> Self { - Self { - ipv4: default_true(), - ipv6: default_network_ipv6(), - prefer: default_prefer_4(), - multipath: false, - stun_use: default_true(), - stun_servers: default_stun_servers(), - stun_tcp_fallback: default_stun_tcp_fallback(), - http_ip_detect_urls: default_http_ip_detect_urls(), - cache_public_ip_path: default_cache_public_ip_path(), - dns_overrides: Vec::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeneralConfig { - #[serde(default)] - pub data_path: Option, - - /// JSON state file for runtime per-user quota consumption. - #[serde(default = "default_quota_state_path")] - pub quota_state_path: PathBuf, - - /// Reject unknown TOML config keys during load. - /// Startup fails fast; hot-reload rejects the new snapshot and keeps the current config. - #[serde(default)] - pub config_strict: bool, - - #[serde(default)] - pub modes: ProxyModes, - - #[serde(default)] - pub prefer_ipv6: bool, - - #[serde(default = "default_true")] - pub fast_mode: bool, - - #[serde(default = "default_true")] - pub use_middle_proxy: bool, - - /// Path to proxy-secret binary file (auto-downloaded if absent). - /// Infrastructure secret from https://core.telegram.org/getProxySecret. - #[serde(default = "default_proxy_secret_path")] - pub proxy_secret_path: Option, - - /// Optional custom URL for infrastructure secret (https://core.telegram.org/getProxySecret if absent). - #[serde(default)] - pub proxy_secret_url: Option, - - /// Optional path to cache raw getProxyConfig (IPv4) snapshot for startup fallback. - #[serde(default = "default_proxy_config_v4_cache_path")] - pub proxy_config_v4_cache_path: Option, - - /// Optional custom URL for getProxyConfig (https://core.telegram.org/getProxyConfig if absent). - #[serde(default)] - pub proxy_config_v4_url: Option, - - /// Optional path to cache raw getProxyConfigV6 snapshot for startup fallback. - #[serde(default = "default_proxy_config_v6_cache_path")] - pub proxy_config_v6_cache_path: Option, - - /// Optional custom URL for getProxyConfigV6 (https://core.telegram.org/getProxyConfigV6 if absent). - #[serde(default)] - pub proxy_config_v6_url: Option, - - /// Global ad_tag (32 hex chars from @MTProxybot). Fallback when user has no per-user tag in access.user_ad_tags. - #[serde(default)] - pub ad_tag: Option, - - /// Public IP override for middle-proxy NAT environments. - /// When set, this IP is used in ME key derivation and local address translation. - #[serde(default)] - pub middle_proxy_nat_ip: Option, - - /// Enable STUN-based NAT probing to discover public IP:port for ME KDF. - #[serde(default = "default_true")] - pub middle_proxy_nat_probe: bool, - - /// Deprecated legacy single STUN server for NAT probing. - /// Use `network.stun_servers` instead. - #[serde(default = "default_middle_proxy_nat_stun")] - pub middle_proxy_nat_stun: Option, - - /// Deprecated legacy STUN list for NAT probing fallback. - /// Use `network.stun_servers` instead. - #[serde(default = "default_middle_proxy_nat_stun_servers")] - pub middle_proxy_nat_stun_servers: Vec, - - /// Maximum number of concurrent STUN probes during NAT detection. - #[serde(default = "default_stun_nat_probe_concurrency")] - pub stun_nat_probe_concurrency: usize, - - /// Desired size of active Middle-Proxy writer pool. - #[serde(default = "default_pool_size")] - pub middle_proxy_pool_size: usize, - - /// Number of warm standby ME connections kept pre-initialized. - #[serde(default = "default_middle_proxy_warm_standby")] - pub middle_proxy_warm_standby: usize, - - /// Startup retries for Middle-End pool initialization before ME→Direct fallback. - /// 0 means unlimited retries. - #[serde(default = "default_me_init_retry_attempts")] - pub me_init_retry_attempts: u32, - - /// Allow fallback from Middle-End mode to direct DC when ME startup cannot be initialized. - #[serde(default = "default_me2dc_fallback")] - pub me2dc_fallback: bool, - - /// Fast ME->Direct fallback mode for new sessions. - /// Active only when both `use_middle_proxy=true` and `me2dc_fallback=true`. - #[serde(default = "default_me2dc_fast")] - pub me2dc_fast: bool, - - /// Enable ME keepalive padding frames. - #[serde(default = "default_true")] - pub me_keepalive_enabled: bool, - - /// Keepalive interval in seconds. - #[serde(default = "default_keepalive_interval")] - pub me_keepalive_interval_secs: u64, - - /// Keepalive jitter in seconds. - #[serde(default = "default_keepalive_jitter")] - pub me_keepalive_jitter_secs: u64, - - /// Keepalive payload randomized (4 bytes); otherwise zeros. - #[serde(default = "default_true")] - pub me_keepalive_payload_random: bool, - - /// Interval in seconds for service RPC_PROXY_REQ activity signals to ME. - /// 0 disables service activity signals. - #[serde(default = "default_rpc_proxy_req_every")] - pub rpc_proxy_req_every: u64, - - /// Capacity of per-ME writer command channel. - #[serde(default = "default_me_writer_cmd_channel_capacity")] - pub me_writer_cmd_channel_capacity: usize, - - /// Resident-memory budget in bytes for each ME writer data queue. - #[serde(default = "default_me_writer_byte_budget_bytes")] - pub me_writer_byte_budget_bytes: usize, - - /// Capacity of per-connection ME response route channel. - #[serde(default = "default_me_route_channel_capacity")] - pub me_route_channel_capacity: usize, - - /// Capacity of per-client command queue from client reader to ME sender task. - #[serde(default = "default_me_c2me_channel_capacity")] - pub me_c2me_channel_capacity: usize, - - /// Maximum wait in milliseconds for enqueueing C2ME commands when the queue is full. - /// `0` keeps legacy unbounded wait behavior. - #[serde(default = "default_me_c2me_send_timeout_ms")] - pub me_c2me_send_timeout_ms: u64, - - /// Bounded wait in milliseconds for routing ME DATA to per-connection queue. - /// `0` keeps non-blocking routing; values >0 enable bounded wait for compatibility. - #[serde(default = "default_me_reader_route_data_wait_ms")] - pub me_reader_route_data_wait_ms: u64, - - /// Maximum number of ME->Client responses coalesced before flush. - #[serde(default = "default_me_d2c_flush_batch_max_frames")] - pub me_d2c_flush_batch_max_frames: usize, - - /// Maximum total payload bytes coalesced before flush. - #[serde(default = "default_me_d2c_flush_batch_max_bytes")] - pub me_d2c_flush_batch_max_bytes: usize, - - /// Maximum wait in microseconds to coalesce additional ME->Client responses. - /// `0` disables timed coalescing. - #[serde(default = "default_me_d2c_flush_batch_max_delay_us")] - pub me_d2c_flush_batch_max_delay_us: u64, - - /// Flush client writer immediately after quick-ack write. - #[serde(default = "default_me_d2c_ack_flush_immediate")] - pub me_d2c_ack_flush_immediate: bool, - - /// Additional bytes above strict per-user quota allowed in hot-path soft mode. - #[serde(default = "default_me_quota_soft_overshoot_bytes")] - pub me_quota_soft_overshoot_bytes: u64, - - /// Shrink threshold for reusable ME->Client frame assembly buffer. - #[serde(default = "default_me_d2c_frame_buf_shrink_threshold_bytes")] - pub me_d2c_frame_buf_shrink_threshold_bytes: usize, - - /// Copy buffer ceiling for client->DC direction in direct relay. - /// - /// This is also the upper bound for one amortized upload rate-limit burst: - /// upload debt is settled before the next relay read instead of blocking - /// inside the completed read path. - #[serde(default = "default_direct_relay_copy_buf_c2s_bytes")] - pub direct_relay_copy_buf_c2s_bytes: usize, - - /// Copy buffer ceiling for DC->client direction in direct relay. - /// - /// This bounds one direct download rate-limit grant because writes are - /// clipped to the currently available shaper budget. - #[serde(default = "default_direct_relay_copy_buf_s2c_bytes")] - pub direct_relay_copy_buf_s2c_bytes: usize, - - /// Process-wide hard ceiling for Direct relay copy buffers. - /// `0` derives the ceiling from host and cgroup memory limits. - #[serde(default = "default_direct_relay_buffer_budget_max_bytes")] - pub direct_relay_buffer_budget_max_bytes: usize, - - /// Max pending ciphertext buffer per client writer (bytes). - /// Controls FakeTLS backpressure vs throughput. - #[serde(default = "default_crypto_pending_buffer")] - pub crypto_pending_buffer: usize, - - /// Maximum allowed client MTProto frame size (bytes). - #[serde(default = "default_max_client_frame")] - pub max_client_frame: usize, - - /// Emit full crypto-desync forensic logs for every event. - /// When false, full forensic details are emitted once per key window. - #[serde(default = "default_desync_all_full")] - pub desync_all_full: bool, - - /// Enable per-IP forensic observation buckets for scanners and handshake failures. - #[serde(default = "default_true")] - pub beobachten: bool, - - /// Observation retention window in minutes for per-IP forensic buckets. - #[serde(default = "default_beobachten_minutes")] - pub beobachten_minutes: u64, - - /// Snapshot flush interval in seconds for beob output file. - #[serde(default = "default_beobachten_flush_secs")] - pub beobachten_flush_secs: u64, - - /// Snapshot file path for beob output. - #[serde(default = "default_beobachten_file")] - pub beobachten_file: String, - - /// Enable C-like hard-swap for ME pool generations. - /// When true, Telemt prewarms a new generation and switches once full coverage is reached. - #[serde(default = "default_hardswap")] - pub hardswap: bool, - - /// Enable staggered warmup of extra ME writers. - #[serde(default = "default_true")] - pub me_warmup_stagger_enabled: bool, - - /// Base delay between warmup connections in ms. - #[serde(default = "default_warmup_step_delay_ms")] - pub me_warmup_step_delay_ms: u64, - - /// Jitter for warmup delay in ms. - #[serde(default = "default_warmup_step_jitter_ms")] - pub me_warmup_step_jitter_ms: u64, - - /// Max concurrent reconnect attempts per DC. - #[serde(default = "default_me_reconnect_max_concurrent_per_dc")] - pub me_reconnect_max_concurrent_per_dc: u32, - - /// Base backoff in ms for reconnect. - #[serde(default = "default_reconnect_backoff_base_ms")] - pub me_reconnect_backoff_base_ms: u64, - - /// Cap backoff in ms for reconnect. - #[serde(default = "default_reconnect_backoff_cap_ms")] - pub me_reconnect_backoff_cap_ms: u64, - - /// Fast retry attempts before backoff. - #[serde(default = "default_me_reconnect_fast_retry_count")] - pub me_reconnect_fast_retry_count: u32, - - /// Number of additional reserve writers for DC groups with exactly one endpoint. - #[serde(default = "default_me_single_endpoint_shadow_writers")] - pub me_single_endpoint_shadow_writers: u8, - - /// Enable aggressive outage recovery mode for single-endpoint DC groups. - #[serde(default = "default_me_single_endpoint_outage_mode_enabled")] - pub me_single_endpoint_outage_mode_enabled: bool, - - /// Ignore endpoint quarantine while in single-endpoint outage mode. - #[serde(default = "default_me_single_endpoint_outage_disable_quarantine")] - pub me_single_endpoint_outage_disable_quarantine: bool, - - /// Minimum reconnect backoff in ms for single-endpoint outage mode. - #[serde(default = "default_me_single_endpoint_outage_backoff_min_ms")] - pub me_single_endpoint_outage_backoff_min_ms: u64, - - /// Maximum reconnect backoff in ms for single-endpoint outage mode. - #[serde(default = "default_me_single_endpoint_outage_backoff_max_ms")] - pub me_single_endpoint_outage_backoff_max_ms: u64, - - /// Periodic shadow writer rotation interval in seconds for single-endpoint DC groups. - /// Set to 0 to disable periodic shadow rotation. - #[serde(default = "default_me_single_endpoint_shadow_rotate_every_secs")] - pub me_single_endpoint_shadow_rotate_every_secs: u64, - - /// Floor policy mode for ME writer targets. - #[serde(default)] - pub me_floor_mode: MeFloorMode, - - /// Idle time in seconds before adaptive floor can reduce single-endpoint writer target. - #[serde(default = "default_me_adaptive_floor_idle_secs")] - pub me_adaptive_floor_idle_secs: u64, - - /// Minimum writer target for single-endpoint DC groups in adaptive floor mode. - #[serde(default = "default_me_adaptive_floor_min_writers_single_endpoint")] - pub me_adaptive_floor_min_writers_single_endpoint: u8, - - /// Minimum writer target for multi-endpoint DC groups in adaptive floor mode. - #[serde(default = "default_me_adaptive_floor_min_writers_multi_endpoint")] - pub me_adaptive_floor_min_writers_multi_endpoint: u8, - - /// Grace period in seconds to hold static floor after activity in adaptive mode. - #[serde(default = "default_me_adaptive_floor_recover_grace_secs")] - pub me_adaptive_floor_recover_grace_secs: u64, - - /// Global ME writer budget per logical CPU core in adaptive mode. - #[serde(default = "default_me_adaptive_floor_writers_per_core_total")] - pub me_adaptive_floor_writers_per_core_total: u16, - - /// Override logical CPU core count for adaptive floor calculations. - /// Set to 0 to use runtime auto-detection. - #[serde(default = "default_me_adaptive_floor_cpu_cores_override")] - pub me_adaptive_floor_cpu_cores_override: u16, - - /// Per-core max extra writers above base required floor for single-endpoint DC groups. - #[serde(default = "default_me_adaptive_floor_max_extra_writers_single_per_core")] - pub me_adaptive_floor_max_extra_writers_single_per_core: u16, - - /// Per-core max extra writers above base required floor for multi-endpoint DC groups. - #[serde(default = "default_me_adaptive_floor_max_extra_writers_multi_per_core")] - pub me_adaptive_floor_max_extra_writers_multi_per_core: u16, - - /// Hard cap for active ME writers per logical CPU core. - #[serde(default = "default_me_adaptive_floor_max_active_writers_per_core")] - pub me_adaptive_floor_max_active_writers_per_core: u16, - - /// Hard cap for warm ME writers per logical CPU core. - #[serde(default = "default_me_adaptive_floor_max_warm_writers_per_core")] - pub me_adaptive_floor_max_warm_writers_per_core: u16, - - /// Hard global cap for active ME writers. - #[serde(default = "default_me_adaptive_floor_max_active_writers_global")] - pub me_adaptive_floor_max_active_writers_global: u32, - - /// Hard global cap for warm ME writers. - #[serde(default = "default_me_adaptive_floor_max_warm_writers_global")] - pub me_adaptive_floor_max_warm_writers_global: u32, - - /// Connect attempts for the selected upstream before returning error/fallback. - #[serde(default = "default_upstream_connect_retry_attempts")] - pub upstream_connect_retry_attempts: u32, - - /// Delay in milliseconds between upstream connect attempts. - #[serde(default = "default_upstream_connect_retry_backoff_ms")] - pub upstream_connect_retry_backoff_ms: u64, - - /// Total wall-clock budget in milliseconds for one upstream connect request across retries. - #[serde(default = "default_upstream_connect_budget_ms")] - pub upstream_connect_budget_ms: u64, - - /// Per-attempt TCP connect timeout to Telegram DC (seconds). - #[serde(default = "default_connect_timeout")] - pub tg_connect: u64, - - /// Consecutive failed requests before upstream is marked unhealthy. - #[serde(default = "default_upstream_unhealthy_fail_threshold")] - pub upstream_unhealthy_fail_threshold: u32, - - /// Skip additional retries for hard non-transient upstream connect errors. - #[serde(default = "default_upstream_connect_failfast_hard_errors")] - pub upstream_connect_failfast_hard_errors: bool, - - /// Ignore STUN/interface IP mismatch (keep using Middle Proxy even if NAT detected). - #[serde(default)] - pub stun_iface_mismatch_ignore: bool, - - /// Log unknown (non-standard) DC requests to a file (default: unknown-dc.txt). Set to null to disable. - #[serde(default = "default_unknown_dc_log_path")] - pub unknown_dc_log_path: Option, - - /// Enable unknown-DC file logging. - #[serde(default = "default_unknown_dc_file_log_enabled")] - pub unknown_dc_file_log_enabled: bool, - - #[serde(default)] - pub log_level: LogLevel, - - /// Disable colored output in logs (useful for files/systemd). - #[serde(default)] - pub disable_colors: bool, - - /// Runtime telemetry controls for counters/metrics in hot paths. - #[serde(default)] - pub telemetry: TelemetryConfig, - - /// SOCKS-bound KDF policy for Middle-End handshake. - #[serde(default)] - pub me_socks_kdf_policy: MeSocksKdfPolicy, - - /// Enable route-level ME backpressure controls in reader fairness path. - #[serde(default = "default_me_route_backpressure_enabled")] - pub me_route_backpressure_enabled: bool, - - /// Enable worker-local fairshare scheduler for ME reader routing. - #[serde(default = "default_me_route_fairshare_enabled")] - pub me_route_fairshare_enabled: bool, - - /// Base backpressure timeout in milliseconds for ME route channel send. - #[serde(default = "default_me_route_backpressure_base_timeout_ms")] - pub me_route_backpressure_base_timeout_ms: u64, - - /// High backpressure timeout in milliseconds when queue occupancy is above watermark. - #[serde(default = "default_me_route_backpressure_high_timeout_ms")] - pub me_route_backpressure_high_timeout_ms: u64, - - /// Queue occupancy percent threshold for high backpressure timeout. - #[serde(default = "default_me_route_backpressure_high_watermark_pct")] - pub me_route_backpressure_high_watermark_pct: u8, - - /// Health monitor interval in milliseconds while writer coverage is degraded. - #[serde(default = "default_me_health_interval_ms_unhealthy")] - pub me_health_interval_ms_unhealthy: u64, - - /// Health monitor interval in milliseconds while writer coverage is stable. - #[serde(default = "default_me_health_interval_ms_healthy")] - pub me_health_interval_ms_healthy: u64, - - /// Poll interval in milliseconds for conditional-admission state checks. - #[serde(default = "default_me_admission_poll_ms")] - pub me_admission_poll_ms: u64, - - /// Cooldown for repetitive ME warning logs in milliseconds. - #[serde(default = "default_me_warn_rate_limit_ms")] - pub me_warn_rate_limit_ms: u64, - - /// ME route behavior when no writer is immediately available. - #[serde(default)] - pub me_route_no_writer_mode: MeRouteNoWriterMode, - - /// Maximum wait time in milliseconds for async-recovery failfast mode. - #[serde(default = "default_me_route_no_writer_wait_ms")] - pub me_route_no_writer_wait_ms: u64, - - /// Maximum cumulative wait in milliseconds for hybrid no-writer mode before failfast. - #[serde(default = "default_me_route_hybrid_max_wait_ms")] - pub me_route_hybrid_max_wait_ms: u64, - - /// Maximum wait in milliseconds for blocking ME writer channel send fallback. - /// Must be within [1, 5000]. - #[serde(default = "default_me_route_blocking_send_timeout_ms")] - pub me_route_blocking_send_timeout_ms: u64, - - /// Number of inline recovery attempts in legacy mode. - #[serde(default = "default_me_route_inline_recovery_attempts")] - pub me_route_inline_recovery_attempts: u32, - - /// Maximum wait time in milliseconds for inline recovery in legacy mode. - #[serde(default = "default_me_route_inline_recovery_wait_ms")] - pub me_route_inline_recovery_wait_ms: u64, - - /// [general.links] — proxy link generation overrides. - #[serde(default)] - pub links: LinksConfig, - - /// Minimum TLS record size when fast_mode coalescing is enabled (0 = disabled). - #[serde(default = "default_fast_mode_min_tls_record")] - pub fast_mode_min_tls_record: usize, - - /// Unified ME updater interval in seconds for getProxyConfig/getProxyConfigV6/getProxySecret. - /// When omitted, effective value falls back to legacy proxy_*_auto_reload_secs fields. - #[serde(default = "default_update_every")] - pub update_every: Option, - - /// Periodic ME pool reinitialization interval in seconds. - #[serde(default = "default_me_reinit_every_secs")] - pub me_reinit_every_secs: u64, - - /// Minimum delay in ms between hardswap warmup connect attempts. - #[serde(default = "default_me_hardswap_warmup_delay_min_ms")] - pub me_hardswap_warmup_delay_min_ms: u64, - - /// Maximum delay in ms between hardswap warmup connect attempts. - #[serde(default = "default_me_hardswap_warmup_delay_max_ms")] - pub me_hardswap_warmup_delay_max_ms: u64, - - /// Additional warmup passes in the same hardswap cycle after the base pass. - #[serde(default = "default_me_hardswap_warmup_extra_passes")] - pub me_hardswap_warmup_extra_passes: u8, - - /// Base backoff in ms between hardswap warmup passes when floor is still incomplete. - #[serde(default = "default_me_hardswap_warmup_pass_backoff_base_ms")] - pub me_hardswap_warmup_pass_backoff_base_ms: u64, - - /// Number of identical getProxyConfig snapshots required before applying ME map updates. - #[serde(default = "default_me_config_stable_snapshots")] - pub me_config_stable_snapshots: u8, - - /// Cooldown in seconds between applied ME map updates. - #[serde(default = "default_me_config_apply_cooldown_secs")] - pub me_config_apply_cooldown_secs: u64, - - /// Ensure getProxyConfig snapshots are applied only for 2xx HTTP responses. - #[serde(default = "default_me_snapshot_require_http_2xx")] - pub me_snapshot_require_http_2xx: bool, - - /// Reject empty getProxyConfig snapshots instead of marking them applied. - #[serde(default = "default_me_snapshot_reject_empty_map")] - pub me_snapshot_reject_empty_map: bool, - - /// Minimum parsed `proxy_for` rows required to accept a snapshot. - #[serde(default = "default_me_snapshot_min_proxy_for_lines")] - pub me_snapshot_min_proxy_for_lines: u32, - - /// Number of identical getProxySecret snapshots required before runtime secret rotation. - #[serde(default = "default_proxy_secret_stable_snapshots")] - pub proxy_secret_stable_snapshots: u8, - - /// Enable runtime proxy-secret rotation from getProxySecret. - #[serde(default = "default_proxy_secret_rotate_runtime")] - pub proxy_secret_rotate_runtime: bool, - - /// Keep key-selector and secret bytes from one snapshot during ME handshake. - #[serde(default = "default_me_secret_atomic_snapshot")] - pub me_secret_atomic_snapshot: bool, - - /// Maximum allowed proxy-secret length in bytes for startup and runtime refresh. - #[serde(default = "default_proxy_secret_len_max")] - pub proxy_secret_len_max: usize, - - /// Drain-TTL in seconds for stale ME writers after endpoint map changes. - /// During TTL, stale writers may be used only as fallback for new bindings. - #[serde(default = "default_me_pool_drain_ttl_secs")] - pub me_pool_drain_ttl_secs: u64, - - /// Force-remove any draining writer on the next cleanup tick, regardless of age/deadline. - #[serde(default = "default_me_instadrain")] - pub me_instadrain: bool, - - /// Maximum allowed number of draining ME writers before oldest ones are force-closed in batches. - /// Set to 0 to disable threshold-based draining cleanup and keep timeout-only behavior. - #[serde(default = "default_me_pool_drain_threshold")] - pub me_pool_drain_threshold: u64, - - /// Enable staged client eviction for draining ME writers that remain non-empty past TTL. - #[serde(default = "default_me_pool_drain_soft_evict_enabled")] - pub me_pool_drain_soft_evict_enabled: bool, - - /// Extra grace in seconds after drain TTL before soft-eviction stage starts. - #[serde(default = "default_me_pool_drain_soft_evict_grace_secs")] - pub me_pool_drain_soft_evict_grace_secs: u64, - - /// Maximum number of client sessions to evict from one draining writer per health tick. - #[serde(default = "default_me_pool_drain_soft_evict_per_writer")] - pub me_pool_drain_soft_evict_per_writer: u8, - - /// Soft-eviction budget per CPU core for one health tick. - #[serde(default = "default_me_pool_drain_soft_evict_budget_per_core")] - pub me_pool_drain_soft_evict_budget_per_core: u16, - - /// Cooldown for repetitive soft-eviction on the same writer in milliseconds. - #[serde(default = "default_me_pool_drain_soft_evict_cooldown_ms")] - pub me_pool_drain_soft_evict_cooldown_ms: u64, - - /// Policy for new binds on stale draining writers. - #[serde(default)] - pub me_bind_stale_mode: MeBindStaleMode, - - /// TTL for stale bind allowance when `me_bind_stale_mode = \"ttl\"`. - #[serde(default = "default_me_bind_stale_ttl_secs")] - pub me_bind_stale_ttl_secs: u64, - - /// Minimum desired-DC coverage ratio required before draining stale writers. - /// Range: 0.0..=1.0. - #[serde(default = "default_me_pool_min_fresh_ratio")] - pub me_pool_min_fresh_ratio: f32, - - /// Drain timeout in seconds for stale ME writers after endpoint map changes. - /// Set to 0 to use the runtime safety fallback timeout. - #[serde(default = "default_me_reinit_drain_timeout_secs")] - pub me_reinit_drain_timeout_secs: u64, - - /// Deprecated legacy setting; kept for backward compatibility fallback. - /// Use `update_every` instead. - #[serde(default = "default_proxy_secret_reload_secs")] - pub proxy_secret_auto_reload_secs: u64, - - /// Deprecated legacy setting; kept for backward compatibility fallback. - /// Use `update_every` instead. - #[serde(default = "default_proxy_config_reload_secs")] - pub proxy_config_auto_reload_secs: u64, - - /// Serialize ME reinit cycles across all trigger sources. - #[serde(default = "default_me_reinit_singleflight")] - pub me_reinit_singleflight: bool, - - /// Trigger queue capacity for reinit scheduler. - #[serde(default = "default_me_reinit_trigger_channel")] - pub me_reinit_trigger_channel: usize, - - /// Trigger coalescing window before starting a reinit cycle. - #[serde(default = "default_me_reinit_coalesce_window_ms")] - pub me_reinit_coalesce_window_ms: u64, - - /// Deterministic candidate sort for ME writer binding path. - #[serde(default = "default_me_deterministic_writer_sort")] - pub me_deterministic_writer_sort: bool, - - /// Writer selection mode for ME route bind path. - #[serde(default)] - pub me_writer_pick_mode: MeWriterPickMode, - - /// Number of candidates sampled by writer picker in `p2c` mode. - #[serde(default = "default_me_writer_pick_sample_size")] - pub me_writer_pick_sample_size: u8, - - /// Enable NTP drift check at startup. - #[serde(default = "default_ntp_check")] - pub ntp_check: bool, - - /// NTP servers for drift check. - #[serde(default = "default_ntp_servers")] - pub ntp_servers: Vec, - - /// Enable auto-degradation from ME to Direct-DC. - #[serde(default = "default_true")] - pub auto_degradation_enabled: bool, - - /// Minimum unavailable ME DC groups before degrading. - #[serde(default = "default_degradation_min_unavailable_dc_groups")] - pub degradation_min_unavailable_dc_groups: u8, - - /// RST-on-close mode for accepted client sockets. - /// `off` — normal FIN on all closes (default). - /// `errors` — SO_LINGER(0) on accept, cleared after successful auth; - /// pre-handshake failures send RST, relayed sessions close gracefully. - /// `always` — SO_LINGER(0) on accept, never cleared; all closes send RST. - #[serde(default)] - pub rst_on_close: RstOnCloseMode, -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - data_path: None, - quota_state_path: default_quota_state_path(), - config_strict: false, - modes: ProxyModes::default(), - prefer_ipv6: false, - fast_mode: default_true(), - use_middle_proxy: default_true(), - ad_tag: None, - proxy_secret_path: default_proxy_secret_path(), - proxy_secret_url: None, - proxy_config_v4_cache_path: default_proxy_config_v4_cache_path(), - proxy_config_v4_url: None, - proxy_config_v6_cache_path: default_proxy_config_v6_cache_path(), - proxy_config_v6_url: None, - middle_proxy_nat_ip: None, - middle_proxy_nat_probe: default_true(), - middle_proxy_nat_stun: default_middle_proxy_nat_stun(), - middle_proxy_nat_stun_servers: default_middle_proxy_nat_stun_servers(), - stun_nat_probe_concurrency: default_stun_nat_probe_concurrency(), - middle_proxy_pool_size: default_pool_size(), - middle_proxy_warm_standby: default_middle_proxy_warm_standby(), - me_init_retry_attempts: default_me_init_retry_attempts(), - me2dc_fallback: default_me2dc_fallback(), - me2dc_fast: default_me2dc_fast(), - me_keepalive_enabled: default_true(), - me_keepalive_interval_secs: default_keepalive_interval(), - me_keepalive_jitter_secs: default_keepalive_jitter(), - me_keepalive_payload_random: default_true(), - rpc_proxy_req_every: default_rpc_proxy_req_every(), - me_writer_cmd_channel_capacity: default_me_writer_cmd_channel_capacity(), - me_writer_byte_budget_bytes: default_me_writer_byte_budget_bytes(), - me_route_channel_capacity: default_me_route_channel_capacity(), - me_c2me_channel_capacity: default_me_c2me_channel_capacity(), - me_c2me_send_timeout_ms: default_me_c2me_send_timeout_ms(), - me_reader_route_data_wait_ms: default_me_reader_route_data_wait_ms(), - me_d2c_flush_batch_max_frames: default_me_d2c_flush_batch_max_frames(), - me_d2c_flush_batch_max_bytes: default_me_d2c_flush_batch_max_bytes(), - me_d2c_flush_batch_max_delay_us: default_me_d2c_flush_batch_max_delay_us(), - me_d2c_ack_flush_immediate: default_me_d2c_ack_flush_immediate(), - me_quota_soft_overshoot_bytes: default_me_quota_soft_overshoot_bytes(), - me_d2c_frame_buf_shrink_threshold_bytes: - default_me_d2c_frame_buf_shrink_threshold_bytes(), - direct_relay_copy_buf_c2s_bytes: default_direct_relay_copy_buf_c2s_bytes(), - direct_relay_copy_buf_s2c_bytes: default_direct_relay_copy_buf_s2c_bytes(), - direct_relay_buffer_budget_max_bytes: default_direct_relay_buffer_budget_max_bytes(), - me_warmup_stagger_enabled: default_true(), - me_warmup_step_delay_ms: default_warmup_step_delay_ms(), - me_warmup_step_jitter_ms: default_warmup_step_jitter_ms(), - me_reconnect_max_concurrent_per_dc: default_me_reconnect_max_concurrent_per_dc(), - me_reconnect_backoff_base_ms: default_reconnect_backoff_base_ms(), - me_reconnect_backoff_cap_ms: default_reconnect_backoff_cap_ms(), - me_reconnect_fast_retry_count: default_me_reconnect_fast_retry_count(), - me_single_endpoint_shadow_writers: default_me_single_endpoint_shadow_writers(), - me_single_endpoint_outage_mode_enabled: default_me_single_endpoint_outage_mode_enabled( - ), - me_single_endpoint_outage_disable_quarantine: - default_me_single_endpoint_outage_disable_quarantine(), - me_single_endpoint_outage_backoff_min_ms: - default_me_single_endpoint_outage_backoff_min_ms(), - me_single_endpoint_outage_backoff_max_ms: - default_me_single_endpoint_outage_backoff_max_ms(), - me_single_endpoint_shadow_rotate_every_secs: - default_me_single_endpoint_shadow_rotate_every_secs(), - me_floor_mode: MeFloorMode::default(), - me_adaptive_floor_idle_secs: default_me_adaptive_floor_idle_secs(), - me_adaptive_floor_min_writers_single_endpoint: - default_me_adaptive_floor_min_writers_single_endpoint(), - me_adaptive_floor_min_writers_multi_endpoint: - default_me_adaptive_floor_min_writers_multi_endpoint(), - me_adaptive_floor_recover_grace_secs: default_me_adaptive_floor_recover_grace_secs(), - me_adaptive_floor_writers_per_core_total: - default_me_adaptive_floor_writers_per_core_total(), - me_adaptive_floor_cpu_cores_override: default_me_adaptive_floor_cpu_cores_override(), - me_adaptive_floor_max_extra_writers_single_per_core: - default_me_adaptive_floor_max_extra_writers_single_per_core(), - me_adaptive_floor_max_extra_writers_multi_per_core: - default_me_adaptive_floor_max_extra_writers_multi_per_core(), - me_adaptive_floor_max_active_writers_per_core: - default_me_adaptive_floor_max_active_writers_per_core(), - me_adaptive_floor_max_warm_writers_per_core: - default_me_adaptive_floor_max_warm_writers_per_core(), - me_adaptive_floor_max_active_writers_global: - default_me_adaptive_floor_max_active_writers_global(), - me_adaptive_floor_max_warm_writers_global: - default_me_adaptive_floor_max_warm_writers_global(), - upstream_connect_retry_attempts: default_upstream_connect_retry_attempts(), - upstream_connect_retry_backoff_ms: default_upstream_connect_retry_backoff_ms(), - upstream_connect_budget_ms: default_upstream_connect_budget_ms(), - tg_connect: default_connect_timeout(), - upstream_unhealthy_fail_threshold: default_upstream_unhealthy_fail_threshold(), - upstream_connect_failfast_hard_errors: default_upstream_connect_failfast_hard_errors(), - stun_iface_mismatch_ignore: false, - unknown_dc_log_path: default_unknown_dc_log_path(), - unknown_dc_file_log_enabled: default_unknown_dc_file_log_enabled(), - log_level: LogLevel::Normal, - disable_colors: false, - telemetry: TelemetryConfig::default(), - me_socks_kdf_policy: MeSocksKdfPolicy::Strict, - me_route_backpressure_enabled: default_me_route_backpressure_enabled(), - me_route_fairshare_enabled: default_me_route_fairshare_enabled(), - me_route_backpressure_base_timeout_ms: default_me_route_backpressure_base_timeout_ms(), - me_route_backpressure_high_timeout_ms: default_me_route_backpressure_high_timeout_ms(), - me_route_backpressure_high_watermark_pct: - default_me_route_backpressure_high_watermark_pct(), - me_health_interval_ms_unhealthy: default_me_health_interval_ms_unhealthy(), - me_health_interval_ms_healthy: default_me_health_interval_ms_healthy(), - me_admission_poll_ms: default_me_admission_poll_ms(), - me_warn_rate_limit_ms: default_me_warn_rate_limit_ms(), - me_route_no_writer_mode: MeRouteNoWriterMode::default(), - me_route_no_writer_wait_ms: default_me_route_no_writer_wait_ms(), - me_route_hybrid_max_wait_ms: default_me_route_hybrid_max_wait_ms(), - me_route_blocking_send_timeout_ms: default_me_route_blocking_send_timeout_ms(), - me_route_inline_recovery_attempts: default_me_route_inline_recovery_attempts(), - me_route_inline_recovery_wait_ms: default_me_route_inline_recovery_wait_ms(), - links: LinksConfig::default(), - crypto_pending_buffer: default_crypto_pending_buffer(), - max_client_frame: default_max_client_frame(), - desync_all_full: default_desync_all_full(), - beobachten: default_true(), - beobachten_minutes: default_beobachten_minutes(), - beobachten_flush_secs: default_beobachten_flush_secs(), - beobachten_file: default_beobachten_file(), - hardswap: default_hardswap(), - fast_mode_min_tls_record: default_fast_mode_min_tls_record(), - update_every: default_update_every(), - me_reinit_every_secs: default_me_reinit_every_secs(), - me_hardswap_warmup_delay_min_ms: default_me_hardswap_warmup_delay_min_ms(), - me_hardswap_warmup_delay_max_ms: default_me_hardswap_warmup_delay_max_ms(), - me_hardswap_warmup_extra_passes: default_me_hardswap_warmup_extra_passes(), - me_hardswap_warmup_pass_backoff_base_ms: - default_me_hardswap_warmup_pass_backoff_base_ms(), - me_config_stable_snapshots: default_me_config_stable_snapshots(), - me_config_apply_cooldown_secs: default_me_config_apply_cooldown_secs(), - me_snapshot_require_http_2xx: default_me_snapshot_require_http_2xx(), - me_snapshot_reject_empty_map: default_me_snapshot_reject_empty_map(), - me_snapshot_min_proxy_for_lines: default_me_snapshot_min_proxy_for_lines(), - proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(), - proxy_secret_rotate_runtime: default_proxy_secret_rotate_runtime(), - me_secret_atomic_snapshot: default_me_secret_atomic_snapshot(), - proxy_secret_len_max: default_proxy_secret_len_max(), - me_pool_drain_ttl_secs: default_me_pool_drain_ttl_secs(), - me_instadrain: default_me_instadrain(), - me_pool_drain_threshold: default_me_pool_drain_threshold(), - me_pool_drain_soft_evict_enabled: default_me_pool_drain_soft_evict_enabled(), - me_pool_drain_soft_evict_grace_secs: default_me_pool_drain_soft_evict_grace_secs(), - me_pool_drain_soft_evict_per_writer: default_me_pool_drain_soft_evict_per_writer(), - me_pool_drain_soft_evict_budget_per_core: - default_me_pool_drain_soft_evict_budget_per_core(), - me_pool_drain_soft_evict_cooldown_ms: default_me_pool_drain_soft_evict_cooldown_ms(), - me_bind_stale_mode: MeBindStaleMode::default(), - me_bind_stale_ttl_secs: default_me_bind_stale_ttl_secs(), - me_pool_min_fresh_ratio: default_me_pool_min_fresh_ratio(), - me_reinit_drain_timeout_secs: default_me_reinit_drain_timeout_secs(), - proxy_secret_auto_reload_secs: default_proxy_secret_reload_secs(), - proxy_config_auto_reload_secs: default_proxy_config_reload_secs(), - me_reinit_singleflight: default_me_reinit_singleflight(), - me_reinit_trigger_channel: default_me_reinit_trigger_channel(), - me_reinit_coalesce_window_ms: default_me_reinit_coalesce_window_ms(), - me_deterministic_writer_sort: default_me_deterministic_writer_sort(), - me_writer_pick_mode: MeWriterPickMode::default(), - me_writer_pick_sample_size: default_me_writer_pick_sample_size(), - ntp_check: default_ntp_check(), - ntp_servers: default_ntp_servers(), - auto_degradation_enabled: default_true(), - degradation_min_unavailable_dc_groups: default_degradation_min_unavailable_dc_groups(), - rst_on_close: RstOnCloseMode::default(), - } - } -} - -impl GeneralConfig { - /// Resolve the active updater interval for ME infrastructure refresh tasks. - /// `update_every` has priority, otherwise legacy proxy_*_auto_reload_secs are used. - pub fn effective_update_every_secs(&self) -> u64 { - self.update_every.unwrap_or_else(|| { - self.proxy_secret_auto_reload_secs - .min(self.proxy_config_auto_reload_secs) - }) - } - - /// Resolve periodic zero-downtime reinit interval for ME writers. - pub fn effective_me_reinit_every_secs(&self) -> u64 { - self.me_reinit_every_secs - } - - /// Resolve force-close timeout for stale writers. - /// `me_reinit_drain_timeout_secs` remains backward-compatible alias. - /// A configured `0` uses the runtime safety fallback (300s). - pub fn effective_me_pool_force_close_secs(&self) -> u64 { - if self.me_reinit_drain_timeout_secs == 0 { - 300 - } else { - self.me_reinit_drain_timeout_secs - } - } -} - -/// `[general.links]` — proxy link generation settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LinksConfig { - /// List of usernames whose tg:// links to display at startup. - /// `"*"` = all users, `["alice", "bob"]` = specific users. - #[serde(default = "default_links_show")] - pub show: ShowLink, - - /// Public hostname/IP for tg:// link generation (overrides detected IP). - #[serde(default)] - pub public_host: Option, - - /// Public port for tg:// link generation. - /// Overrides listener ports and legacy `server.port`. - #[serde(default)] - pub public_port: Option, -} - -impl Default for LinksConfig { - fn default() -> Self { - Self { - show: default_links_show(), - public_host: None, - public_port: None, - } - } -} - -/// API settings for control-plane endpoints. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ApiConfig { - /// Enable or disable REST API. - #[serde(default = "default_true")] - pub enabled: bool, - - /// Listen address for API in `IP:PORT` format. - #[serde(default = "default_api_listen")] - pub listen: String, - - /// CIDR whitelist allowed to access API. - #[serde(default = "default_api_whitelist")] - pub whitelist: Vec, - - /// Behavior for requests from source IPs outside `whitelist`. - /// - `api`: return structured API forbidden response. - /// - `200`: return `200 OK` with an empty body. - /// - `drop`: close the connection without HTTP response. - #[serde(default)] - pub gray_action: ApiGrayAction, - - /// Optional static value for `Authorization` header validation. - /// Empty string disables header auth. - #[serde(default)] - pub auth_header: String, - - /// Maximum accepted HTTP request body size in bytes. - #[serde(default = "default_api_request_body_limit_bytes")] - pub request_body_limit_bytes: usize, - - /// Enable runtime snapshots that require read-lock aggregation on API request path. - #[serde(default = "default_api_minimal_runtime_enabled")] - pub minimal_runtime_enabled: bool, - - /// Cache TTL for minimal runtime snapshots in milliseconds (0 disables caching). - #[serde(default = "default_api_minimal_runtime_cache_ttl_ms")] - pub minimal_runtime_cache_ttl_ms: u64, - - /// Enables runtime edge endpoints with optional cached aggregation. - #[serde(default = "default_api_runtime_edge_enabled")] - pub runtime_edge_enabled: bool, - - /// Cache TTL for runtime edge aggregation payloads in milliseconds. - #[serde(default = "default_api_runtime_edge_cache_ttl_ms")] - pub runtime_edge_cache_ttl_ms: u64, - - /// Top-N limit for edge connection leaderboard payloads. - #[serde(default = "default_api_runtime_edge_top_n")] - pub runtime_edge_top_n: usize, - - /// Ring-buffer capacity for runtime edge control-plane events. - #[serde(default = "default_api_runtime_edge_events_capacity")] - pub runtime_edge_events_capacity: usize, - - /// Read-only mode: mutating endpoints are rejected. - #[serde(default)] - pub read_only: bool, -} - -impl Default for ApiConfig { - fn default() -> Self { - Self { - enabled: default_true(), - listen: default_api_listen(), - whitelist: default_api_whitelist(), - gray_action: ApiGrayAction::default(), - auth_header: String::new(), - request_body_limit_bytes: default_api_request_body_limit_bytes(), - minimal_runtime_enabled: default_api_minimal_runtime_enabled(), - minimal_runtime_cache_ttl_ms: default_api_minimal_runtime_cache_ttl_ms(), - runtime_edge_enabled: default_api_runtime_edge_enabled(), - runtime_edge_cache_ttl_ms: default_api_runtime_edge_cache_ttl_ms(), - runtime_edge_top_n: default_api_runtime_edge_top_n(), - runtime_edge_events_capacity: default_api_runtime_edge_events_capacity(), - read_only: false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ApiGrayAction { - /// Preserve current API behavior for denied source IPs. - Api, - /// Mimic a plain web endpoint by returning `200 OK` with an empty body. - #[serde(rename = "200")] - Ok200, - /// Drop connection without HTTP response for denied source IPs. - #[default] - Drop, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ConntrackMode { - #[default] - Tracked, - Notrack, - Hybrid, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ConntrackBackend { - #[default] - Auto, - Nftables, - Iptables, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ConntrackPressureProfile { - Conservative, - #[default] - Balanced, - Aggressive, -} - -impl ConntrackPressureProfile { - pub fn client_first_byte_idle_cap_secs(self) -> u64 { - match self { - Self::Conservative => 30, - Self::Balanced => 20, - Self::Aggressive => 10, - } - } - - pub fn direct_activity_timeout_secs(self) -> u64 { - match self { - Self::Conservative => 180, - Self::Balanced => 120, - Self::Aggressive => 60, - } - } - - pub fn middle_soft_idle_cap_secs(self) -> u64 { - match self { - Self::Conservative => 60, - Self::Balanced => 30, - Self::Aggressive => 20, - } - } - - pub fn middle_hard_idle_cap_secs(self) -> u64 { - match self { - Self::Conservative => 180, - Self::Balanced => 90, - Self::Aggressive => 60, - } - } -} - -/// Per-listener SYN limiter mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SynLimitMode { - /// Disable SYN limiting for this listener. - #[default] - Off, - /// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match. - Iptables, - /// Use nftables two-tier SYN-fix rules with per-source token-bucket meters. - Nftables, - /// Use FreeBSD PF source tracking with connection-rate state limits. - Pf, -} - -impl Serialize for SynLimitMode { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - match self { - Self::Off => serializer.serialize_bool(false), - Self::Iptables => serializer.serialize_str("iptables"), - Self::Nftables => serializer.serialize_str("nftables"), - Self::Pf => serializer.serialize_str("pf"), - } - } -} - -impl<'de> Deserialize<'de> for SynLimitMode { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - struct SynLimitModeVisitor; - - impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor { - type Value = SynLimitMode; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("false, iptables, nftables, or pf") - } - - fn visit_bool(self, value: bool) -> std::result::Result - where - E: serde::de::Error, - { - if value { - Err(E::custom( - "synlimit=true is ambiguous; use \"iptables\", \"nftables\", or \"pf\"", - )) - } else { - Ok(SynLimitMode::Off) - } - } - - fn visit_str(self, value: &str) -> std::result::Result - where - E: serde::de::Error, - { - match value.trim().to_ascii_lowercase().as_str() { - "false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off), - "iptables" => Ok(SynLimitMode::Iptables), - "nftables" => Ok(SynLimitMode::Nftables), - "pf" => Ok(SynLimitMode::Pf), - _ => Err(E::custom( - "synlimit must be false, \"iptables\", \"nftables\", or \"pf\"", - )), - } - } - } - - deserializer.deserialize_any(SynLimitModeVisitor) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConntrackControlConfig { - /// Enables runtime conntrack-control worker for pressure mitigation. - #[serde(default = "default_conntrack_control_enabled")] - pub inline_conntrack_control: bool, - - /// Tracks whether inline_conntrack_control was explicitly set in config. - #[serde(skip)] - pub inline_conntrack_control_explicit: bool, - - /// Conntrack mode for listener ingress traffic. - #[serde(default)] - pub mode: ConntrackMode, - - /// Netfilter backend used to reconcile notrack rules. - #[serde(default)] - pub backend: ConntrackBackend, - - /// Pressure profile for timeout caps under resource saturation. - #[serde(default)] - pub profile: ConntrackPressureProfile, - - /// Listener IP allow-list for hybrid mode. - /// Ignored in tracked/notrack mode. - #[serde(default)] - pub hybrid_listener_ips: Vec, - - /// Pressure high watermark as percentage. - #[serde(default = "default_conntrack_pressure_high_watermark_pct")] - pub pressure_high_watermark_pct: u8, - - /// Pressure low watermark as percentage. - #[serde(default = "default_conntrack_pressure_low_watermark_pct")] - pub pressure_low_watermark_pct: u8, - - /// Maximum conntrack delete operations per second. - #[serde(default = "default_conntrack_delete_budget_per_sec")] - pub delete_budget_per_sec: u64, -} - -impl Default for ConntrackControlConfig { - fn default() -> Self { - Self { - inline_conntrack_control: default_conntrack_control_enabled(), - inline_conntrack_control_explicit: false, - mode: ConntrackMode::default(), - backend: ConntrackBackend::default(), - profile: ConntrackPressureProfile::default(), - hybrid_listener_ips: Vec::new(), - pressure_high_watermark_pct: default_conntrack_pressure_high_watermark_pct(), - pressure_low_watermark_pct: default_conntrack_pressure_low_watermark_pct(), - delete_budget_per_sec: default_conntrack_delete_budget_per_sec(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - /// Legacy listener port used for backward compatibility. - /// For new configs prefer `[[server.listeners]].port`. - #[serde(default = "default_port")] - pub port: u16, - - #[serde(default = "default_listen_addr_ipv4")] - pub listen_addr_ipv4: Option, - - #[serde(default = "default_listen_addr_ipv6_opt")] - pub listen_addr_ipv6: Option, - - #[serde(default)] - pub listen_unix_sock: Option, - - /// Unix socket file permissions (octal, e.g. "0666" or "0777"). - /// Applied via chmod after bind. Default: no change (inherits umask). - #[serde(default)] - pub listen_unix_sock_perm: Option, - - /// Enable TCP listening. Default: true when no unix socket, false when - /// listen_unix_sock is set. Set explicitly to override auto-detection. - #[serde(default)] - pub listen_tcp: Option, - - /// Client-facing TCP MSS preset or custom value for all TCP listeners. - /// Empty string or omitted value keeps the kernel default. - #[serde(default)] - pub client_mss: Option, - - /// Experimental Linux-only bulk MSS used with best-effort userspace - /// chunking of the authenticated FakeTLS response. TCP offloads, loss, and - /// retransmission may coalesce write boundaries. Empty or omitted keeps - /// `client_mss` connection-wide. Uses the same preset/integer grammar as - /// `client_mss`. - #[serde(default)] - pub client_mss_bulk: Option, - - /// Accept HAProxy PROXY protocol headers on incoming connections. - /// When enabled, real client IPs are extracted from PROXY v1/v2 headers. - #[serde(default)] - pub proxy_protocol: bool, - - /// Timeout in milliseconds for reading and parsing PROXY protocol headers. - #[serde(default = "default_proxy_protocol_header_timeout_ms")] - pub proxy_protocol_header_timeout_ms: u64, - - /// Trusted source CIDRs allowed to send incoming PROXY protocol headers. - /// - /// If this field is omitted in config, it defaults to trust-all CIDRs - /// (`0.0.0.0/0` and `::/0`). If it is explicitly set to an empty list, - /// all PROXY protocol headers are rejected. - #[serde(default = "default_proxy_protocol_trusted_cidrs")] - pub proxy_protocol_trusted_cidrs: Vec, - - /// Port for the Prometheus-compatible metrics endpoint. - /// Enables metrics when set; binds on all interfaces (dual-stack) by default. - #[serde(default)] - pub metrics_port: Option, - - /// Listen address for metrics in `IP:PORT` format (e.g. `"127.0.0.1:9090"`). - /// When set, takes precedence over `metrics_port` and binds on the specified address only. - #[serde(default)] - pub metrics_listen: Option, - - /// CIDR whitelist for the metrics endpoint. - #[serde(default = "default_metrics_whitelist")] - pub metrics_whitelist: Vec, - - #[serde(default, alias = "admin_api")] - pub api: ApiConfig, - - #[serde(default)] - pub listeners: Vec, - - /// TCP `listen(2)` backlog for client-facing sockets (also used for the metrics HTTP listener). - /// The effective queue is capped by the kernel (for example `somaxconn` on Linux). - #[serde(default = "default_listen_backlog")] - pub listen_backlog: u32, - - /// Maximum number of concurrent client connections. - /// 0 means unlimited. - #[serde(default = "default_server_max_connections")] - pub max_connections: u32, - - /// Maximum wait in milliseconds while acquiring a connection slot permit. - /// `0` keeps legacy unbounded wait behavior. - #[serde(default = "default_accept_permit_timeout_ms")] - pub accept_permit_timeout_ms: u64, - - /// Runtime conntrack control and pressure policy. - #[serde(default)] - pub conntrack_control: ConntrackControlConfig, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - port: default_port(), - listen_addr_ipv4: default_listen_addr_ipv4(), - listen_addr_ipv6: default_listen_addr_ipv6_opt(), - listen_unix_sock: None, - listen_unix_sock_perm: None, - listen_tcp: None, - client_mss: None, - client_mss_bulk: None, - proxy_protocol: false, - proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(), - proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(), - metrics_port: None, - metrics_listen: None, - metrics_whitelist: default_metrics_whitelist(), - api: ApiConfig::default(), - listeners: Vec::new(), - listen_backlog: default_listen_backlog(), - max_connections: default_server_max_connections(), - accept_permit_timeout_ms: default_accept_permit_timeout_ms(), - conntrack_control: ConntrackControlConfig::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TimeoutsConfig { - /// Maximum idle wait in seconds for the first client byte before handshake parsing starts. - /// `0` disables the separate idle phase and keeps legacy timeout behavior. - #[serde(default = "default_client_first_byte_idle_secs")] - pub client_first_byte_idle_secs: u64, - - /// Maximum active handshake duration in seconds after the first client byte is received. - #[serde(default = "default_handshake_timeout")] - pub client_handshake: u64, - - /// Enables soft/hard relay client idle policy for middle-relay sessions. - #[serde(default = "default_relay_idle_policy_v2_enabled")] - pub relay_idle_policy_v2_enabled: bool, - - /// Soft idle threshold for middle-relay client uplink activity in seconds. - /// Hitting this threshold marks the session as idle-candidate, but does not close it. - #[serde(default = "default_relay_client_idle_soft_secs")] - pub relay_client_idle_soft_secs: u64, - - /// Hard idle threshold for middle-relay client uplink activity in seconds. - /// Hitting this threshold closes the session. - #[serde(default = "default_relay_client_idle_hard_secs")] - pub relay_client_idle_hard_secs: u64, - - /// Additional grace in seconds added to hard idle window after recent downstream activity. - #[serde(default = "default_relay_idle_grace_after_downstream_activity_secs")] - pub relay_idle_grace_after_downstream_activity_secs: u64, - - #[serde(default = "default_keepalive")] - pub client_keepalive: u64, - - #[serde(default = "default_ack_timeout")] - pub client_ack: u64, - - /// Number of quick ME reconnect attempts for single-address DC. - #[serde(default = "default_me_one_retry")] - pub me_one_retry: u8, - - /// Timeout per quick attempt in milliseconds for single-address DC. - #[serde(default = "default_me_one_timeout")] - pub me_one_timeout_ms: u64, -} - -impl Default for TimeoutsConfig { - fn default() -> Self { - Self { - client_first_byte_idle_secs: default_client_first_byte_idle_secs(), - client_handshake: default_handshake_timeout(), - relay_idle_policy_v2_enabled: default_relay_idle_policy_v2_enabled(), - relay_client_idle_soft_secs: default_relay_client_idle_soft_secs(), - relay_client_idle_hard_secs: default_relay_client_idle_hard_secs(), - relay_idle_grace_after_downstream_activity_secs: - default_relay_idle_grace_after_downstream_activity_secs(), - client_keepalive: default_keepalive(), - client_ack: default_ack_timeout(), - me_one_retry: default_me_one_retry(), - me_one_timeout_ms: default_me_one_timeout(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum UnknownSniAction { - #[default] - Drop, - Mask, - Accept, - /// Reject the TLS handshake by sending a fatal `unrecognized_name` alert - /// (RFC 6066, AlertDescription = 112) before closing the connection. - /// Mimics nginx `ssl_reject_handshake on;` behavior on the default vhost — - /// the wire response indistinguishable from a stock modern web server - /// that simply does not host the requested name. - #[serde(rename = "reject_handshake")] - RejectHandshake, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TlsFetchProfile { - ModernChromeLike, - ModernFirefoxLike, - CompatTls12, - LegacyMinimal, -} - -impl TlsFetchProfile { - pub fn as_str(self) -> &'static str { - match self { - TlsFetchProfile::ModernChromeLike => "modern_chrome_like", - TlsFetchProfile::ModernFirefoxLike => "modern_firefox_like", - TlsFetchProfile::CompatTls12 => "compat_tls12", - TlsFetchProfile::LegacyMinimal => "legacy_minimal", - } - } -} - -fn default_tls_fetch_profiles() -> Vec { - vec![ - TlsFetchProfile::ModernChromeLike, - TlsFetchProfile::ModernFirefoxLike, - TlsFetchProfile::CompatTls12, - TlsFetchProfile::LegacyMinimal, - ] -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TlsFetchConfig { - /// Ordered list of ClientHello profiles used for adaptive fallback. - #[serde(default = "default_tls_fetch_profiles")] - pub profiles: Vec, - - /// When true and upstream route is configured, TLS fetch fails closed on - /// upstream connect errors and does not fallback to direct TCP. - #[serde(default = "default_tls_fetch_strict_route")] - pub strict_route: bool, - - /// Timeout per one profile attempt in milliseconds. - #[serde(default = "default_tls_fetch_attempt_timeout_ms")] - pub attempt_timeout_ms: u64, - - /// Total wall-clock budget in milliseconds across all profile attempts. - #[serde(default = "default_tls_fetch_total_budget_ms")] - pub total_budget_ms: u64, - - /// Adds GREASE-style values into selected ClientHello extensions. - #[serde(default)] - pub grease_enabled: bool, - - /// Produces deterministic ClientHello randomness for debugging/tests. - #[serde(default)] - pub deterministic: bool, - - /// TTL for winner-profile cache entries in seconds. - /// Set to 0 to disable profile cache. - #[serde(default = "default_tls_fetch_profile_cache_ttl_secs")] - pub profile_cache_ttl_secs: u64, -} - -impl Default for TlsFetchConfig { - fn default() -> Self { - Self { - profiles: default_tls_fetch_profiles(), - strict_route: default_tls_fetch_strict_route(), - attempt_timeout_ms: default_tls_fetch_attempt_timeout_ms(), - total_budget_ms: default_tls_fetch_total_budget_ms(), - grease_enabled: false, - deterministic: false, - profile_cache_ttl_secs: default_tls_fetch_profile_cache_ttl_secs(), - } - } -} - -#[derive(Debug, Clone)] -pub struct ExclusiveMaskTarget { - /// Target host after IDNA/IP normalization. - pub host: String, - /// TCP port for the selected target. - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AntiCensorshipConfig { - #[serde(default = "default_tls_domain")] - pub tls_domain: String, - - /// Additional TLS domains for generating multiple proxy links. - #[serde(default)] - pub tls_domains: Vec, - - /// Policy for TLS ClientHello with unknown (non-configured) SNI. - #[serde(default)] - pub unknown_sni_action: UnknownSniAction, - - /// Upstream scope used for TLS front metadata fetches. - /// Empty value keeps default upstream routing behavior. - #[serde(default = "default_tls_fetch_scope")] - pub tls_fetch_scope: String, - - /// Fetch strategy for TLS front metadata bootstrap and periodic refresh. - #[serde(default)] - pub tls_fetch: TlsFetchConfig, - - #[serde(default = "default_true")] - pub mask: bool, - - /// Use the ClientHello SNI as the mask TCP target for configured TLS domains. - #[serde(default = "default_true")] - pub mask_dynamic: bool, - - #[serde(default)] - pub mask_host: Option, - - #[serde(default = "default_mask_port")] - pub mask_port: u16, - - /// Per-SNI TCP mask targets. Keys are SNI domains, values are `host:port`. - #[serde(default)] - pub exclusive_mask: HashMap, - - /// Parsed runtime cache for per-SNI TCP mask targets. - #[serde(skip)] - pub exclusive_mask_targets: HashMap, - - #[serde(default)] - pub mask_unix_sock: Option, - - #[serde(default = "default_fake_cert_len")] - pub fake_cert_len: usize, - - /// Enable TLS certificate emulation using cached real certificates. - #[serde(default = "default_true")] - pub tls_emulation: bool, - - /// Directory to store TLS front cache (on disk). - #[serde(default = "default_tls_front_dir")] - pub tls_front_dir: String, - - /// Minimum server_hello delay in milliseconds (anti-fingerprint). - #[serde(default = "default_server_hello_delay_min_ms")] - pub server_hello_delay_min_ms: u64, - - /// Maximum server_hello delay in milliseconds. - #[serde(default = "default_server_hello_delay_max_ms")] - pub server_hello_delay_max_ms: u64, - - /// Number of NewSessionTicket messages to emit post-handshake. - #[serde(default = "default_tls_new_session_tickets")] - pub tls_new_session_tickets: u8, - - /// Enable compact ServerHello payload mode. - /// When false, FakeTLS always uses full ServerHello payload behavior. - /// When true, compact certificate payload mode can be used by TTL policy. - #[serde(default = "default_serverhello_compact")] - pub serverhello_compact: bool, - - /// TTL in seconds for sending full certificate payload per client IP. - /// First client connection per (SNI domain, client IP) gets full cert payload. - /// Subsequent handshakes within TTL use compact cert metadata payload. - /// Applied only when `serverhello_compact` is enabled. - #[serde(default = "default_tls_full_cert_ttl_secs")] - pub tls_full_cert_ttl_secs: u64, - - /// Enforce ALPN echo of client preference. - #[serde(default = "default_alpn_enforce")] - pub alpn_enforce: bool, - - /// Send PROXY protocol header when connecting to mask_host. - /// 0 = disabled, 1 = v1 (text), 2 = v2 (binary). - /// Allows the backend to see the real client IP. - #[serde(default)] - pub mask_proxy_protocol: u8, - - /// Enable shape-channel hardening on mask backend path by padding - /// client->mask stream tail to configured buckets on stream end. - #[serde(default = "default_mask_shape_hardening")] - pub mask_shape_hardening: bool, - - /// Opt-in aggressive shape hardening mode. - /// When enabled, masking may shape some backend-silent timeout paths and - /// enforces strictly positive above-cap blur when blur is enabled. - #[serde(default = "default_mask_shape_hardening_aggressive_mode")] - pub mask_shape_hardening_aggressive_mode: bool, - - /// Minimum bucket size for mask shape hardening padding. - #[serde(default = "default_mask_shape_bucket_floor_bytes")] - pub mask_shape_bucket_floor_bytes: usize, - - /// Maximum bucket size for mask shape hardening padding. - #[serde(default = "default_mask_shape_bucket_cap_bytes")] - pub mask_shape_bucket_cap_bytes: usize, - - /// Add bounded random tail bytes even when total bytes already exceed - /// mask_shape_bucket_cap_bytes. - #[serde(default = "default_mask_shape_above_cap_blur")] - pub mask_shape_above_cap_blur: bool, - - /// Maximum random bytes appended above cap when above-cap blur is enabled. - #[serde(default = "default_mask_shape_above_cap_blur_max_bytes")] - pub mask_shape_above_cap_blur_max_bytes: usize, - - /// Maximum bytes relayed per direction on unauthenticated masking fallback paths. - /// Set to 0 to disable byte cap (unlimited within relay/idle timeouts). - #[serde(default = "default_mask_relay_max_bytes")] - pub mask_relay_max_bytes: usize, - - /// Wall-clock cap for the full masking relay on non-MTProto fallback paths. - /// Raise when the mask target is a long-lived service (e.g. WebSocket). - /// Default: 60 000 ms (60 s). - #[serde(default = "default_mask_relay_timeout_ms")] - pub mask_relay_timeout_ms: u64, - - /// Per-read idle timeout on masking relay and drain paths. - /// Limits resource consumption by slow-loris attacks and port scanners. - /// A read call stalling beyond this is treated as an abandoned connection. - /// Default: 5 000 ms (5 s). - #[serde(default = "default_mask_relay_idle_timeout_ms")] - pub mask_relay_idle_timeout_ms: u64, - - /// Prefetch timeout (ms) for extending fragmented masking classifier window. - #[serde(default = "default_mask_classifier_prefetch_timeout_ms")] - pub mask_classifier_prefetch_timeout_ms: u64, - - /// Enable outcome-time normalization envelope for masking fallback. - #[serde(default = "default_mask_timing_normalization_enabled")] - pub mask_timing_normalization_enabled: bool, - - /// Lower bound (ms) for masking outcome timing envelope. - #[serde(default = "default_mask_timing_normalization_floor_ms")] - pub mask_timing_normalization_floor_ms: u64, - - /// Upper bound (ms) for masking outcome timing envelope. - #[serde(default = "default_mask_timing_normalization_ceiling_ms")] - pub mask_timing_normalization_ceiling_ms: u64, -} - -impl Default for AntiCensorshipConfig { - fn default() -> Self { - Self { - tls_domain: default_tls_domain(), - tls_domains: Vec::new(), - unknown_sni_action: UnknownSniAction::Drop, - tls_fetch_scope: default_tls_fetch_scope(), - tls_fetch: TlsFetchConfig::default(), - mask: default_true(), - mask_dynamic: default_true(), - mask_host: None, - mask_port: default_mask_port(), - exclusive_mask: HashMap::new(), - exclusive_mask_targets: HashMap::new(), - mask_unix_sock: None, - fake_cert_len: default_fake_cert_len(), - tls_emulation: true, - tls_front_dir: default_tls_front_dir(), - server_hello_delay_min_ms: default_server_hello_delay_min_ms(), - server_hello_delay_max_ms: default_server_hello_delay_max_ms(), - tls_new_session_tickets: default_tls_new_session_tickets(), - serverhello_compact: default_serverhello_compact(), - tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(), - alpn_enforce: default_alpn_enforce(), - mask_proxy_protocol: 0, - mask_shape_hardening: default_mask_shape_hardening(), - mask_shape_hardening_aggressive_mode: default_mask_shape_hardening_aggressive_mode(), - mask_shape_bucket_floor_bytes: default_mask_shape_bucket_floor_bytes(), - mask_shape_bucket_cap_bytes: default_mask_shape_bucket_cap_bytes(), - mask_shape_above_cap_blur: default_mask_shape_above_cap_blur(), - mask_shape_above_cap_blur_max_bytes: default_mask_shape_above_cap_blur_max_bytes(), - mask_relay_max_bytes: default_mask_relay_max_bytes(), - mask_relay_timeout_ms: default_mask_relay_timeout_ms(), - mask_relay_idle_timeout_ms: default_mask_relay_idle_timeout_ms(), - mask_classifier_prefetch_timeout_ms: default_mask_classifier_prefetch_timeout_ms(), - mask_timing_normalization_enabled: default_mask_timing_normalization_enabled(), - mask_timing_normalization_floor_ms: default_mask_timing_normalization_floor_ms(), - mask_timing_normalization_ceiling_ms: default_mask_timing_normalization_ceiling_ms(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AccessConfig { - #[serde(default = "default_access_users")] - pub users: HashMap, - - #[serde(default)] - pub user_enabled: HashMap, - - /// Per-user ad_tag (32 hex chars from @MTProxybot). - #[serde(default)] - pub user_ad_tags: HashMap, - - #[serde(default)] - pub user_max_tcp_conns: HashMap, - - /// Global per-user TCP connection limit applied when a user has no - /// positive individual override. - /// `0` disables the inherited limit. - #[serde(default = "default_user_max_tcp_conns_global_each")] - pub user_max_tcp_conns_global_each: usize, - - #[serde(default)] - pub user_expirations: HashMap>, - - #[serde(default)] - pub user_data_quota: HashMap, - - /// Per-user transport rate limits in bits-per-second. - /// - /// Each entry supports independent upload (`up_bps`) and download - /// (`down_bps`) ceilings. A value of `0` in one direction means - /// "unlimited" for that direction. Limits are amortized: a relay quantum - /// may pass as a bounded burst, and the limiter applies the resulting wait - /// before later traffic in the same direction proceeds. - #[serde(default)] - pub user_rate_limits: HashMap, - - /// Per-CIDR aggregate transport rate limits in bits-per-second. - /// - /// Explicit CIDR keys use longest-prefix-wins semantics. Auto-template - /// keys (`*4/N`, `*6/N`, `*/N`) lazily create per-source-subnet buckets - /// after explicit CIDR matching misses. A value of `0` in one direction - /// means "unlimited" for that direction. Limits are amortized with the - /// same bounded-burst contract as per-user rate limits. - #[serde(default)] - pub cidr_rate_limits: HashMap, - - /// Per-username client source IP/CIDR deny list. Checked after successful - /// authentication; matching IPs get the same rejection path as invalid auth - /// (handshake fails closed for that connection). - #[serde(default)] - pub user_source_deny: HashMap>, - - #[serde(default)] - pub user_max_unique_ips: HashMap, - - /// Global per-user unique IP limit applied when a user has no individual override. - /// `0` disables the inherited limit. - #[serde(default = "default_user_max_unique_ips_global_each")] - pub user_max_unique_ips_global_each: usize, - - #[serde(default)] - pub user_max_unique_ips_mode: UserMaxUniqueIpsMode, - - #[serde(default = "default_user_max_unique_ips_window_secs")] - pub user_max_unique_ips_window_secs: u64, - - #[serde(default = "default_replay_check_len")] - pub replay_check_len: usize, - - #[serde(default = "default_replay_window_secs")] - pub replay_window_secs: u64, - - #[serde(default)] - pub ignore_time_skew: bool, -} - -impl Default for AccessConfig { - fn default() -> Self { - Self { - users: default_access_users(), - user_enabled: HashMap::new(), - user_ad_tags: HashMap::new(), - user_max_tcp_conns: HashMap::new(), - user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(), - user_expirations: HashMap::new(), - user_data_quota: HashMap::new(), - user_rate_limits: HashMap::new(), - cidr_rate_limits: HashMap::new(), - user_source_deny: HashMap::new(), - user_max_unique_ips: HashMap::new(), - user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(), - user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(), - user_max_unique_ips_window_secs: default_user_max_unique_ips_window_secs(), - replay_check_len: default_replay_check_len(), - replay_window_secs: default_replay_window_secs(), - ignore_time_skew: false, - } - } -} - -impl AccessConfig { - pub fn is_user_enabled(&self, username: &str) -> bool { - self.user_enabled.get(username).copied().unwrap_or(true) - } - - /// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`. - pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool { - self.user_source_deny - .get(username) - .is_some_and(|nets| nets.iter().any(|n| n.contains(ip))) - } -} - -/// Key used by `access.cidr_rate_limits`. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum CidrRateLimitKey { - /// Explicit source CIDR rule. - Network(IpNetwork), - /// IPv4 auto-template that creates one bucket for each matching `/N`. - AutoV4(u8), - /// IPv6 auto-template that creates one bucket for each matching `/N`. - AutoV6(u8), - /// Dual-stack auto-template; IPv4 uses `/N`, IPv6 uses `/(N * 4)`. - AutoDual(u8), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum CidrAutoTemplateFamily { - V4, - V6, -} - -impl CidrAutoTemplateFamily { - pub(crate) fn marker(self) -> &'static str { - match self { - Self::V4 => "*4", - Self::V6 => "*6", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct CidrAutoTemplate { - pub(crate) family: CidrAutoTemplateFamily, - pub(crate) prefix_len: u8, -} - -impl fmt::Display for CidrAutoTemplate { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}/{}", self.family.marker(), self.prefix_len) - } -} - -impl CidrRateLimitKey { - pub(crate) fn auto_templates(&self) -> [Option; 2] { - match *self { - Self::Network(_) => [None, None], - Self::AutoV4(prefix_len) => [ - Some(CidrAutoTemplate { - family: CidrAutoTemplateFamily::V4, - prefix_len, - }), - None, - ], - Self::AutoV6(prefix_len) => [ - Some(CidrAutoTemplate { - family: CidrAutoTemplateFamily::V6, - prefix_len, - }), - None, - ], - Self::AutoDual(prefix_len) => [ - Some(CidrAutoTemplate { - family: CidrAutoTemplateFamily::V4, - prefix_len, - }), - Some(CidrAutoTemplate { - family: CidrAutoTemplateFamily::V6, - prefix_len: prefix_len.saturating_mul(4), - }), - ], - } - } -} - -impl fmt::Display for CidrRateLimitKey { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Network(cidr) => write!(formatter, "{cidr}"), - Self::AutoV4(prefix_len) => write!(formatter, "*4/{prefix_len}"), - Self::AutoV6(prefix_len) => write!(formatter, "*6/{prefix_len}"), - Self::AutoDual(prefix_len) => write!(formatter, "*/{prefix_len}"), - } - } -} - -impl Serialize for CidrRateLimitKey { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for CidrRateLimitKey { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - parse_cidr_rate_limit_key(&value).map_err(serde::de::Error::custom) - } -} - -fn parse_cidr_rate_limit_key(value: &str) -> std::result::Result { - if let Some(prefix) = value.strip_prefix("*4/") { - return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoV4); - } - if let Some(prefix) = value.strip_prefix("*6/") { - return parse_cidr_auto_prefix(value, prefix, 128).map(CidrRateLimitKey::AutoV6); - } - if let Some(prefix) = value.strip_prefix("*/") { - return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoDual); - } - if value.starts_with('*') { - return Err(format!( - "invalid CIDR rate limit key {value:?}; expected CIDR, *4/N, *6/N, or */N" - )); - } - value - .parse::() - .map(CidrRateLimitKey::Network) - .map_err(|error| { - format!( - "invalid CIDR rate limit key {value:?}: {error}; expected CIDR, *4/N, *6/N, or */N" - ) - }) -} - -fn parse_cidr_auto_prefix( - key: &str, - prefix: &str, - max_prefix: u8, -) -> std::result::Result { - let prefix = prefix.parse::().map_err(|_| { - format!("invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}") - })?; - if prefix > max_prefix { - return Err(format!( - "invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}" - )); - } - Ok(prefix) -} - -/// Transport rate limit in bits-per-second. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct RateLimitBps { - /// Upload direction limit in bits-per-second; `0` means unlimited. - #[serde(default)] - pub up_bps: u64, - /// Download direction limit in bits-per-second; `0` means unlimited. - #[serde(default)] - pub down_bps: u64, -} - -// ============= Aux Structures ============= - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum UpstreamType { - Direct { - #[serde(default)] - interface: Option, - #[serde(default)] - bind_addresses: Option>, - /// Linux-only hard interface pinning via `SO_BINDTODEVICE`. - /// Optional alias: `force_bind`. - #[serde(default, alias = "force_bind")] - bindtodevice: Option, - }, - Socks4 { - address: String, - #[serde(default)] - interface: Option, - #[serde(default)] - user_id: Option, - }, - Socks5 { - address: String, - #[serde(default)] - interface: Option, - #[serde(default)] - username: Option, - #[serde(default)] - password: Option, - }, - Shadowsocks { - url: String, - #[serde(default)] - interface: Option, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpstreamConfig { - #[serde(flatten)] - pub upstream_type: UpstreamType, - #[serde(default = "default_weight")] - pub weight: u16, - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default)] - pub scopes: String, - #[serde(skip)] - pub selected_scope: String, - /// Allow IPv4 DC targets for this upstream. - /// `None` means auto-detect from runtime connectivity state. - #[serde(default)] - pub ipv4: Option, - /// Allow IPv6 DC targets for this upstream. - /// `None` means auto-detect from runtime connectivity state. - #[serde(default)] - pub ipv6: Option, - /// Per-upstream IP family preference for Telegram DC targets. - /// `None` inherits the effective global `[network].prefer` decision. - #[serde(default)] - pub prefer: Option, -} - -impl UpstreamConfig { - pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool { - match self.prefer { - Some(6) => true, - Some(4) => false, - _ => default_prefer_ipv6, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListenerConfig { - pub ip: IpAddr, - /// Per-listener TCP port. If omitted, falls back to legacy `server.port`. - #[serde(default)] - pub port: Option, - /// Per-listener client-facing TCP MSS preset or custom value. - /// Empty string disables MSS shaping for this listener. - #[serde(default)] - pub client_mss: Option, - /// Per-listener SYN limiter mode. - #[serde(default)] - pub synlimit: SynLimitMode, - /// Generic SYN-fix token-bucket rate interval. - #[serde(default = "default_synlimit_seconds")] - pub synlimit_seconds: u32, - /// Generic SYN-fix token-bucket rate amount. - #[serde(default = "default_synlimit_hitcount")] - pub synlimit_hitcount: u32, - /// Generic SYN-fix token-bucket burst size. - #[serde(default = "default_synlimit_burst")] - pub synlimit_burst: u32, - /// iOS-like SYN-fix token-bucket rate interval. - #[serde(default = "default_synlimit_ios_seconds")] - pub synlimit_ios_seconds: u32, - /// iOS-like SYN-fix token-bucket rate amount. - #[serde(default = "default_synlimit_ios_hitcount")] - pub synlimit_ios_hitcount: u32, - /// iOS-like SYN-fix token-bucket burst size. - #[serde(default = "default_synlimit_ios_burst")] - pub synlimit_ios_burst: u32, - /// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules. - #[serde(default = "default_synlimit_hashlimit_expire_ms")] - pub synlimit_hashlimit_expire_ms: u32, - /// Hashlimit table size for iptables/ip6tables rules. - #[serde(default = "default_synlimit_hashlimit_size")] - pub synlimit_hashlimit_size: u32, - /// IP address or hostname to announce in proxy links. - /// Takes precedence over `announce_ip` if both are set. - #[serde(default)] - pub announce: Option, - /// Deprecated: Use `announce` instead. IP address to announce in proxy links. - /// Migrated to `announce` automatically if `announce` is not set. - #[serde(default)] - pub announce_ip: Option, - /// Per-listener PROXY protocol override. When set, overrides global server.proxy_protocol. - #[serde(default)] - pub proxy_protocol: Option, - /// Allow multiple telemt instances to listen on the same IP:port (SO_REUSEPORT). - /// Default is false for safety. - #[serde(default)] - pub reuse_allow: bool, -} - -/// Client-facing TCP MSS preset for extreme-low fragmentation profiles. -pub const CLIENT_MSS_EXTREME_LOW: u16 = 88; -/// Client-facing TCP MSS preset matching TSPU-oriented deployments. -pub const CLIENT_MSS_TSPU: u16 = 92; -/// Client-facing TCP MSS preset for 2-in-8 segment shaping. -pub const CLIENT_MSS_2IN8: u16 = 256; -/// Minimum accepted custom client-facing TCP MSS value. -pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW; -/// Maximum accepted custom client-facing TCP MSS value. -pub const CLIENT_MSS_MAX: u16 = 4096; - -impl ServerConfig { - /// Resolves the global client-facing TCP MSS setting. - pub fn client_mss_value(&self) -> std::result::Result, String> { - parse_client_mss(self.client_mss.as_deref()) - } - - /// Resolves the bulk-transfer client MSS, if configured. - pub fn client_mss_bulk_value(&self) -> std::result::Result, String> { - parse_client_mss(self.client_mss_bulk.as_deref()) - } -} - -impl ListenerConfig { - /// Resolves the listener MSS override, falling back to the global server value. - pub fn effective_client_mss( - &self, - server: &ServerConfig, - ) -> std::result::Result, String> { - match self.client_mss.as_deref() { - Some(value) => parse_client_mss(Some(value)), - None => server.client_mss_value(), - } - } -} - -fn parse_client_mss(raw: Option<&str>) -> std::result::Result, String> { - let Some(raw) = raw else { - return Ok(None); - }; - let value = raw.trim(); - if value.is_empty() { - return Ok(None); - } - - match value.to_ascii_lowercase().as_str() { - "extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)), - "tspu" => return Ok(Some(CLIENT_MSS_TSPU)), - "2in8" => return Ok(Some(CLIENT_MSS_2IN8)), - _ => {} - } - - let parsed = value - .parse::() - .map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?; - if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) { - return Err(format!( - "custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]" - )); - } - Ok(Some(parsed)) -} - -// ============= ShowLink ============= - -/// Controls which users' proxy links are displayed at startup. -/// -/// In TOML, this can be: -/// - `show_link = "*"` — show links for all users -/// - `show_link = ["a", "b"]` — show links for specific users -/// - omitted — default depends on the owning config field -#[derive(Debug, Clone, Default)] -pub enum ShowLink { - /// Don't show any links (default when omitted). - #[default] - None, - /// Show links for all configured users. - All, - /// Show links for specific users. - Specific(Vec), -} - -fn default_links_show() -> ShowLink { - ShowLink::All -} - -impl ShowLink { - /// Returns true if no links should be shown. - pub fn is_empty(&self) -> bool { - matches!(self, ShowLink::None) || matches!(self, ShowLink::Specific(v) if v.is_empty()) - } - - /// Resolve the list of user names to display, given all configured users. - pub fn resolve_users<'a>(&'a self, all_users: &'a HashMap) -> Vec<&'a String> { - match self { - ShowLink::None => vec![], - ShowLink::All => { - let mut names: Vec<&String> = all_users.keys().collect(); - names.sort(); - names - } - ShowLink::Specific(names) => names.iter().collect(), - } - } -} - -impl Serialize for ShowLink { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result { - match self { - ShowLink::None => Vec::::new().serialize(serializer), - ShowLink::All => serializer.serialize_str("*"), - ShowLink::Specific(v) => v.serialize(serializer), - } - } -} - -impl<'de> Deserialize<'de> for ShowLink { - fn deserialize>( - deserializer: D, - ) -> std::result::Result { - use serde::de; - - struct ShowLinkVisitor; - - impl<'de> de::Visitor<'de> for ShowLinkVisitor { - type Value = ShowLink; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str(r#""*" or an array of user names"#) - } - - fn visit_str(self, v: &str) -> std::result::Result { - if v == "*" { - Ok(ShowLink::All) - } else { - Err(de::Error::invalid_value(de::Unexpected::Str(v), &r#""*""#)) - } - } - - fn visit_seq>( - self, - mut seq: A, - ) -> std::result::Result { - let mut names = Vec::new(); - while let Some(name) = seq.next_element::()? { - names.push(name); - } - if names.is_empty() { - Ok(ShowLink::None) - } else { - Ok(ShowLink::Specific(names)) - } - } - } - - deserializer.deserialize_any(ShowLinkVisitor) - } -} diff --git a/src/config/types/access.rs b/src/config/types/access.rs new file mode 100644 index 0000000..3f68d60 --- /dev/null +++ b/src/config/types/access.rs @@ -0,0 +1,269 @@ +use super::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AccessConfig { + #[serde(default = "default_access_users")] + pub users: HashMap, + + #[serde(default)] + pub user_enabled: HashMap, + + /// Per-user ad_tag (32 hex chars from @MTProxybot). + #[serde(default)] + pub user_ad_tags: HashMap, + + #[serde(default)] + pub user_max_tcp_conns: HashMap, + + /// Global per-user TCP connection limit applied when a user has no + /// positive individual override. + /// `0` disables the inherited limit. + #[serde(default = "default_user_max_tcp_conns_global_each")] + pub user_max_tcp_conns_global_each: usize, + + #[serde(default)] + pub user_expirations: HashMap>, + + #[serde(default)] + pub user_data_quota: HashMap, + + /// Per-user transport rate limits in bits-per-second. + /// + /// Each entry supports independent upload (`up_bps`) and download + /// (`down_bps`) ceilings. A value of `0` in one direction means + /// "unlimited" for that direction. Limits are amortized: a relay quantum + /// may pass as a bounded burst, and the limiter applies the resulting wait + /// before later traffic in the same direction proceeds. + #[serde(default)] + pub user_rate_limits: HashMap, + + /// Per-CIDR aggregate transport rate limits in bits-per-second. + /// + /// Explicit CIDR keys use longest-prefix-wins semantics. Auto-template + /// keys (`*4/N`, `*6/N`, `*/N`) lazily create per-source-subnet buckets + /// after explicit CIDR matching misses. A value of `0` in one direction + /// means "unlimited" for that direction. Limits are amortized with the + /// same bounded-burst contract as per-user rate limits. + #[serde(default)] + pub cidr_rate_limits: HashMap, + + /// Per-username client source IP/CIDR deny list. Checked after successful + /// authentication; matching IPs get the same rejection path as invalid auth + /// (handshake fails closed for that connection). + #[serde(default)] + pub user_source_deny: HashMap>, + + #[serde(default)] + pub user_max_unique_ips: HashMap, + + /// Global per-user unique IP limit applied when a user has no individual override. + /// `0` disables the inherited limit. + #[serde(default = "default_user_max_unique_ips_global_each")] + pub user_max_unique_ips_global_each: usize, + + #[serde(default)] + pub user_max_unique_ips_mode: UserMaxUniqueIpsMode, + + #[serde(default = "default_user_max_unique_ips_window_secs")] + pub user_max_unique_ips_window_secs: u64, + + #[serde(default = "default_replay_check_len")] + pub replay_check_len: usize, + + #[serde(default = "default_replay_window_secs")] + pub replay_window_secs: u64, + + #[serde(default)] + pub ignore_time_skew: bool, +} + +impl Default for AccessConfig { + fn default() -> Self { + Self { + users: default_access_users(), + user_enabled: HashMap::new(), + user_ad_tags: HashMap::new(), + user_max_tcp_conns: HashMap::new(), + user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(), + user_expirations: HashMap::new(), + user_data_quota: HashMap::new(), + user_rate_limits: HashMap::new(), + cidr_rate_limits: HashMap::new(), + user_source_deny: HashMap::new(), + user_max_unique_ips: HashMap::new(), + user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(), + user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(), + user_max_unique_ips_window_secs: default_user_max_unique_ips_window_secs(), + replay_check_len: default_replay_check_len(), + replay_window_secs: default_replay_window_secs(), + ignore_time_skew: false, + } + } +} + +impl AccessConfig { + pub fn is_user_enabled(&self, username: &str) -> bool { + self.user_enabled.get(username).copied().unwrap_or(true) + } + + /// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`. + pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool { + self.user_source_deny + .get(username) + .is_some_and(|nets| nets.iter().any(|n| n.contains(ip))) + } +} + +/// Key used by `access.cidr_rate_limits`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CidrRateLimitKey { + /// Explicit source CIDR rule. + Network(IpNetwork), + /// IPv4 auto-template that creates one bucket for each matching `/N`. + AutoV4(u8), + /// IPv6 auto-template that creates one bucket for each matching `/N`. + AutoV6(u8), + /// Dual-stack auto-template; IPv4 uses `/N`, IPv6 uses `/(N * 4)`. + AutoDual(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum CidrAutoTemplateFamily { + V4, + V6, +} + +impl CidrAutoTemplateFamily { + pub(crate) fn marker(self) -> &'static str { + match self { + Self::V4 => "*4", + Self::V6 => "*6", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct CidrAutoTemplate { + pub(crate) family: CidrAutoTemplateFamily, + pub(crate) prefix_len: u8, +} + +impl fmt::Display for CidrAutoTemplate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}/{}", self.family.marker(), self.prefix_len) + } +} + +impl CidrRateLimitKey { + pub(crate) fn auto_templates(&self) -> [Option; 2] { + match *self { + Self::Network(_) => [None, None], + Self::AutoV4(prefix_len) => [ + Some(CidrAutoTemplate { + family: CidrAutoTemplateFamily::V4, + prefix_len, + }), + None, + ], + Self::AutoV6(prefix_len) => [ + Some(CidrAutoTemplate { + family: CidrAutoTemplateFamily::V6, + prefix_len, + }), + None, + ], + Self::AutoDual(prefix_len) => [ + Some(CidrAutoTemplate { + family: CidrAutoTemplateFamily::V4, + prefix_len, + }), + Some(CidrAutoTemplate { + family: CidrAutoTemplateFamily::V6, + prefix_len: prefix_len.saturating_mul(4), + }), + ], + } + } +} + +impl fmt::Display for CidrRateLimitKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(cidr) => write!(formatter, "{cidr}"), + Self::AutoV4(prefix_len) => write!(formatter, "*4/{prefix_len}"), + Self::AutoV6(prefix_len) => write!(formatter, "*6/{prefix_len}"), + Self::AutoDual(prefix_len) => write!(formatter, "*/{prefix_len}"), + } + } +} + +impl Serialize for CidrRateLimitKey { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for CidrRateLimitKey { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + parse_cidr_rate_limit_key(&value).map_err(serde::de::Error::custom) + } +} + +fn parse_cidr_rate_limit_key(value: &str) -> std::result::Result { + if let Some(prefix) = value.strip_prefix("*4/") { + return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoV4); + } + if let Some(prefix) = value.strip_prefix("*6/") { + return parse_cidr_auto_prefix(value, prefix, 128).map(CidrRateLimitKey::AutoV6); + } + if let Some(prefix) = value.strip_prefix("*/") { + return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoDual); + } + if value.starts_with('*') { + return Err(format!( + "invalid CIDR rate limit key {value:?}; expected CIDR, *4/N, *6/N, or */N" + )); + } + value + .parse::() + .map(CidrRateLimitKey::Network) + .map_err(|error| { + format!( + "invalid CIDR rate limit key {value:?}: {error}; expected CIDR, *4/N, *6/N, or */N" + ) + }) +} + +fn parse_cidr_auto_prefix( + key: &str, + prefix: &str, + max_prefix: u8, +) -> std::result::Result { + let prefix = prefix.parse::().map_err(|_| { + format!("invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}") + })?; + if prefix > max_prefix { + return Err(format!( + "invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}" + )); + } + Ok(prefix) +} + +/// Transport rate limit in bits-per-second. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimitBps { + /// Upload direction limit in bits-per-second; `0` means unlimited. + #[serde(default)] + pub up_bps: u64, + /// Download direction limit in bits-per-second; `0` means unlimited. + #[serde(default)] + pub down_bps: u64, +} diff --git a/src/config/types/api.rs b/src/config/types/api.rs new file mode 100644 index 0000000..ed1825a --- /dev/null +++ b/src/config/types/api.rs @@ -0,0 +1,94 @@ +use super::*; + +/// Control-plane API settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiConfig { + /// Enable or disable REST API. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Listen address for API in `IP:PORT` format. + #[serde(default = "default_api_listen")] + pub listen: String, + + /// CIDR whitelist allowed to access API. + #[serde(default = "default_api_whitelist")] + pub whitelist: Vec, + + /// Behavior for requests from source IPs outside `whitelist`. + /// - `api`: return structured API forbidden response. + /// - `200`: return `200 OK` with an empty body. + /// - `drop`: close the connection without HTTP response. + #[serde(default)] + pub gray_action: ApiGrayAction, + + /// Optional static value for `Authorization` header validation. + /// Empty string disables header auth. + #[serde(default)] + pub auth_header: String, + + /// Maximum accepted HTTP request body size in bytes. + #[serde(default = "default_api_request_body_limit_bytes")] + pub request_body_limit_bytes: usize, + + /// Enable runtime snapshots that require read-lock aggregation on API request path. + #[serde(default = "default_api_minimal_runtime_enabled")] + pub minimal_runtime_enabled: bool, + + /// Cache TTL for minimal runtime snapshots in milliseconds (0 disables caching). + #[serde(default = "default_api_minimal_runtime_cache_ttl_ms")] + pub minimal_runtime_cache_ttl_ms: u64, + + /// Enables runtime edge endpoints with optional cached aggregation. + #[serde(default = "default_api_runtime_edge_enabled")] + pub runtime_edge_enabled: bool, + + /// Cache TTL for runtime edge aggregation payloads in milliseconds. + #[serde(default = "default_api_runtime_edge_cache_ttl_ms")] + pub runtime_edge_cache_ttl_ms: u64, + + /// Top-N limit for edge connection leaderboard payloads. + #[serde(default = "default_api_runtime_edge_top_n")] + pub runtime_edge_top_n: usize, + + /// Ring-buffer capacity for runtime edge control-plane events. + #[serde(default = "default_api_runtime_edge_events_capacity")] + pub runtime_edge_events_capacity: usize, + + /// Read-only mode: mutating endpoints are rejected. + #[serde(default)] + pub read_only: bool, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + enabled: default_true(), + listen: default_api_listen(), + whitelist: default_api_whitelist(), + gray_action: ApiGrayAction::default(), + auth_header: String::new(), + request_body_limit_bytes: default_api_request_body_limit_bytes(), + minimal_runtime_enabled: default_api_minimal_runtime_enabled(), + minimal_runtime_cache_ttl_ms: default_api_minimal_runtime_cache_ttl_ms(), + runtime_edge_enabled: default_api_runtime_edge_enabled(), + runtime_edge_cache_ttl_ms: default_api_runtime_edge_cache_ttl_ms(), + runtime_edge_top_n: default_api_runtime_edge_top_n(), + runtime_edge_events_capacity: default_api_runtime_edge_events_capacity(), + read_only: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ApiGrayAction { + /// Preserve current API behavior for denied source IPs. + Api, + /// Mimic a plain web endpoint by returning `200 OK` with an empty body. + #[serde(rename = "200")] + Ok200, + /// Drop connection without HTTP response for denied source IPs. + #[default] + Drop, +} diff --git a/src/config/types/censorship.rs b/src/config/types/censorship.rs new file mode 100644 index 0000000..b076268 --- /dev/null +++ b/src/config/types/censorship.rs @@ -0,0 +1,298 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UnknownSniAction { + #[default] + Drop, + Mask, + Accept, + /// Reject the TLS handshake by sending a fatal `unrecognized_name` alert + /// (RFC 6066, AlertDescription = 112) before closing the connection. + /// Mimics nginx `ssl_reject_handshake on;` behavior on the default vhost — + /// the wire response indistinguishable from a stock modern web server + /// that simply does not host the requested name. + #[serde(rename = "reject_handshake")] + RejectHandshake, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TlsFetchProfile { + ModernChromeLike, + ModernFirefoxLike, + CompatTls12, + LegacyMinimal, +} + +impl TlsFetchProfile { + pub fn as_str(self) -> &'static str { + match self { + TlsFetchProfile::ModernChromeLike => "modern_chrome_like", + TlsFetchProfile::ModernFirefoxLike => "modern_firefox_like", + TlsFetchProfile::CompatTls12 => "compat_tls12", + TlsFetchProfile::LegacyMinimal => "legacy_minimal", + } + } +} + +fn default_tls_fetch_profiles() -> Vec { + vec![ + TlsFetchProfile::ModernChromeLike, + TlsFetchProfile::ModernFirefoxLike, + TlsFetchProfile::CompatTls12, + TlsFetchProfile::LegacyMinimal, + ] +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsFetchConfig { + /// Ordered list of ClientHello profiles used for adaptive fallback. + #[serde(default = "default_tls_fetch_profiles")] + pub profiles: Vec, + + /// When true and upstream route is configured, TLS fetch fails closed on + /// upstream connect errors and does not fallback to direct TCP. + #[serde(default = "default_tls_fetch_strict_route")] + pub strict_route: bool, + + /// Timeout per one profile attempt in milliseconds. + #[serde(default = "default_tls_fetch_attempt_timeout_ms")] + pub attempt_timeout_ms: u64, + + /// Total wall-clock budget in milliseconds across all profile attempts. + #[serde(default = "default_tls_fetch_total_budget_ms")] + pub total_budget_ms: u64, + + /// Adds GREASE-style values into selected ClientHello extensions. + #[serde(default)] + pub grease_enabled: bool, + + /// Produces deterministic ClientHello randomness for debugging/tests. + #[serde(default)] + pub deterministic: bool, + + /// TTL for winner-profile cache entries in seconds. + /// Set to 0 to disable profile cache. + #[serde(default = "default_tls_fetch_profile_cache_ttl_secs")] + pub profile_cache_ttl_secs: u64, +} + +impl Default for TlsFetchConfig { + fn default() -> Self { + Self { + profiles: default_tls_fetch_profiles(), + strict_route: default_tls_fetch_strict_route(), + attempt_timeout_ms: default_tls_fetch_attempt_timeout_ms(), + total_budget_ms: default_tls_fetch_total_budget_ms(), + grease_enabled: false, + deterministic: false, + profile_cache_ttl_secs: default_tls_fetch_profile_cache_ttl_secs(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ExclusiveMaskTarget { + /// Target host after IDNA/IP normalization. + pub host: String, + /// TCP port for the selected target. + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AntiCensorshipConfig { + #[serde(default = "default_tls_domain")] + pub tls_domain: String, + + /// Additional TLS domains for generating multiple proxy links. + #[serde(default)] + pub tls_domains: Vec, + + /// Policy for TLS ClientHello with unknown (non-configured) SNI. + #[serde(default)] + pub unknown_sni_action: UnknownSniAction, + + /// Upstream scope used for TLS front metadata fetches. + /// Empty value keeps default upstream routing behavior. + #[serde(default = "default_tls_fetch_scope")] + pub tls_fetch_scope: String, + + /// Fetch strategy for TLS front metadata bootstrap and periodic refresh. + #[serde(default)] + pub tls_fetch: TlsFetchConfig, + + #[serde(default = "default_true")] + pub mask: bool, + + /// Use the ClientHello SNI as the mask TCP target for configured TLS domains. + #[serde(default = "default_true")] + pub mask_dynamic: bool, + + #[serde(default)] + pub mask_host: Option, + + #[serde(default = "default_mask_port")] + pub mask_port: u16, + + /// Per-SNI TCP mask targets. Keys are SNI domains, values are `host:port`. + #[serde(default)] + pub exclusive_mask: HashMap, + + /// Parsed runtime cache for per-SNI TCP mask targets. + #[serde(skip)] + pub exclusive_mask_targets: HashMap, + + #[serde(default)] + pub mask_unix_sock: Option, + + #[serde(default = "default_fake_cert_len")] + pub fake_cert_len: usize, + + /// Enable TLS certificate emulation using cached real certificates. + #[serde(default = "default_true")] + pub tls_emulation: bool, + + /// Directory to store TLS front cache (on disk). + #[serde(default = "default_tls_front_dir")] + pub tls_front_dir: String, + + /// Minimum server_hello delay in milliseconds (anti-fingerprint). + #[serde(default = "default_server_hello_delay_min_ms")] + pub server_hello_delay_min_ms: u64, + + /// Maximum server_hello delay in milliseconds. + #[serde(default = "default_server_hello_delay_max_ms")] + pub server_hello_delay_max_ms: u64, + + /// Number of NewSessionTicket messages to emit post-handshake. + #[serde(default = "default_tls_new_session_tickets")] + pub tls_new_session_tickets: u8, + + /// Enable compact ServerHello payload mode. + /// When false, FakeTLS always uses full ServerHello payload behavior. + /// When true, compact certificate payload mode can be used by TTL policy. + #[serde(default = "default_serverhello_compact")] + pub serverhello_compact: bool, + + /// TTL in seconds for sending full certificate payload per client IP. + /// First client connection per (SNI domain, client IP) gets full cert payload. + /// Subsequent handshakes within TTL use compact cert metadata payload. + /// Applied only when `serverhello_compact` is enabled. + #[serde(default = "default_tls_full_cert_ttl_secs")] + pub tls_full_cert_ttl_secs: u64, + + /// Enforce ALPN echo of client preference. + #[serde(default = "default_alpn_enforce")] + pub alpn_enforce: bool, + + /// Send PROXY protocol header when connecting to mask_host. + /// 0 = disabled, 1 = v1 (text), 2 = v2 (binary). + /// Allows the backend to see the real client IP. + #[serde(default)] + pub mask_proxy_protocol: u8, + + /// Enable shape-channel hardening on mask backend path by padding + /// client->mask stream tail to configured buckets on stream end. + #[serde(default = "default_mask_shape_hardening")] + pub mask_shape_hardening: bool, + + /// Opt-in aggressive shape hardening mode. + /// When enabled, masking may shape some backend-silent timeout paths and + /// enforces strictly positive above-cap blur when blur is enabled. + #[serde(default = "default_mask_shape_hardening_aggressive_mode")] + pub mask_shape_hardening_aggressive_mode: bool, + + /// Minimum bucket size for mask shape hardening padding. + #[serde(default = "default_mask_shape_bucket_floor_bytes")] + pub mask_shape_bucket_floor_bytes: usize, + + /// Maximum bucket size for mask shape hardening padding. + #[serde(default = "default_mask_shape_bucket_cap_bytes")] + pub mask_shape_bucket_cap_bytes: usize, + + /// Add bounded random tail bytes even when total bytes already exceed + /// mask_shape_bucket_cap_bytes. + #[serde(default = "default_mask_shape_above_cap_blur")] + pub mask_shape_above_cap_blur: bool, + + /// Maximum random bytes appended above cap when above-cap blur is enabled. + #[serde(default = "default_mask_shape_above_cap_blur_max_bytes")] + pub mask_shape_above_cap_blur_max_bytes: usize, + + /// Maximum bytes relayed per direction on unauthenticated masking fallback paths. + /// Set to 0 to disable byte cap (unlimited within relay/idle timeouts). + #[serde(default = "default_mask_relay_max_bytes")] + pub mask_relay_max_bytes: usize, + + /// Wall-clock cap for the full masking relay on non-MTProto fallback paths. + /// Raise when the mask target is a long-lived service (e.g. WebSocket). + /// Default: 60 000 ms (60 s). + #[serde(default = "default_mask_relay_timeout_ms")] + pub mask_relay_timeout_ms: u64, + + /// Per-read idle timeout on masking relay and drain paths. + /// Limits resource consumption by slow-loris attacks and port scanners. + /// A read call stalling beyond this is treated as an abandoned connection. + /// Default: 5 000 ms (5 s). + #[serde(default = "default_mask_relay_idle_timeout_ms")] + pub mask_relay_idle_timeout_ms: u64, + + /// Prefetch timeout (ms) for extending fragmented masking classifier window. + #[serde(default = "default_mask_classifier_prefetch_timeout_ms")] + pub mask_classifier_prefetch_timeout_ms: u64, + + /// Enable outcome-time normalization envelope for masking fallback. + #[serde(default = "default_mask_timing_normalization_enabled")] + pub mask_timing_normalization_enabled: bool, + + /// Lower bound (ms) for masking outcome timing envelope. + #[serde(default = "default_mask_timing_normalization_floor_ms")] + pub mask_timing_normalization_floor_ms: u64, + + /// Upper bound (ms) for masking outcome timing envelope. + #[serde(default = "default_mask_timing_normalization_ceiling_ms")] + pub mask_timing_normalization_ceiling_ms: u64, +} + +impl Default for AntiCensorshipConfig { + fn default() -> Self { + Self { + tls_domain: default_tls_domain(), + tls_domains: Vec::new(), + unknown_sni_action: UnknownSniAction::Drop, + tls_fetch_scope: default_tls_fetch_scope(), + tls_fetch: TlsFetchConfig::default(), + mask: default_true(), + mask_dynamic: default_true(), + mask_host: None, + mask_port: default_mask_port(), + exclusive_mask: HashMap::new(), + exclusive_mask_targets: HashMap::new(), + mask_unix_sock: None, + fake_cert_len: default_fake_cert_len(), + tls_emulation: true, + tls_front_dir: default_tls_front_dir(), + server_hello_delay_min_ms: default_server_hello_delay_min_ms(), + server_hello_delay_max_ms: default_server_hello_delay_max_ms(), + tls_new_session_tickets: default_tls_new_session_tickets(), + serverhello_compact: default_serverhello_compact(), + tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(), + alpn_enforce: default_alpn_enforce(), + mask_proxy_protocol: 0, + mask_shape_hardening: default_mask_shape_hardening(), + mask_shape_hardening_aggressive_mode: default_mask_shape_hardening_aggressive_mode(), + mask_shape_bucket_floor_bytes: default_mask_shape_bucket_floor_bytes(), + mask_shape_bucket_cap_bytes: default_mask_shape_bucket_cap_bytes(), + mask_shape_above_cap_blur: default_mask_shape_above_cap_blur(), + mask_shape_above_cap_blur_max_bytes: default_mask_shape_above_cap_blur_max_bytes(), + mask_relay_max_bytes: default_mask_relay_max_bytes(), + mask_relay_timeout_ms: default_mask_relay_timeout_ms(), + mask_relay_idle_timeout_ms: default_mask_relay_idle_timeout_ms(), + mask_classifier_prefetch_timeout_ms: default_mask_classifier_prefetch_timeout_ms(), + mask_timing_normalization_enabled: default_mask_timing_normalization_enabled(), + mask_timing_normalization_floor_ms: default_mask_timing_normalization_floor_ms(), + mask_timing_normalization_ceiling_ms: default_mask_timing_normalization_ceiling_ms(), + } + } +} diff --git a/src/config/types/general.rs b/src/config/types/general.rs new file mode 100644 index 0000000..2218132 --- /dev/null +++ b/src/config/types/general.rs @@ -0,0 +1,478 @@ +use super::*; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneralConfig { + #[serde(default)] + pub data_path: Option, + /// JSON state file for runtime per-user quota consumption. + #[serde(default = "default_quota_state_path")] + pub quota_state_path: PathBuf, + /// Reject unknown TOML config keys during load. + /// Startup fails fast; hot-reload rejects the new snapshot and keeps the current config. + #[serde(default)] + pub config_strict: bool, + #[serde(default)] + pub modes: ProxyModes, + #[serde(default)] + pub prefer_ipv6: bool, + #[serde(default = "default_true")] + pub fast_mode: bool, + #[serde(default = "default_true")] + pub use_middle_proxy: bool, + /// Path to proxy-secret binary file (auto-downloaded if absent). + /// Infrastructure secret from https://core.telegram.org/getProxySecret. + #[serde(default = "default_proxy_secret_path")] + pub proxy_secret_path: Option, + /// Optional custom URL for infrastructure secret (https://core.telegram.org/getProxySecret if absent). + #[serde(default)] + pub proxy_secret_url: Option, + /// Optional path to cache raw getProxyConfig (IPv4) snapshot for startup fallback. + #[serde(default = "default_proxy_config_v4_cache_path")] + pub proxy_config_v4_cache_path: Option, + /// Optional custom URL for getProxyConfig (https://core.telegram.org/getProxyConfig if absent). + #[serde(default)] + pub proxy_config_v4_url: Option, + /// Optional path to cache raw getProxyConfigV6 snapshot for startup fallback. + #[serde(default = "default_proxy_config_v6_cache_path")] + pub proxy_config_v6_cache_path: Option, + /// Optional custom URL for getProxyConfigV6 (https://core.telegram.org/getProxyConfigV6 if absent). + #[serde(default)] + pub proxy_config_v6_url: Option, + /// Global ad_tag (32 hex chars from @MTProxybot). Fallback when user has no per-user tag in access.user_ad_tags. + #[serde(default)] + pub ad_tag: Option, + /// Public IP override for middle-proxy NAT environments. + /// When set, this IP is used in ME key derivation and local address translation. + #[serde(default)] + pub middle_proxy_nat_ip: Option, + /// Enable STUN-based NAT probing to discover public IP:port for ME KDF. + #[serde(default = "default_true")] + pub middle_proxy_nat_probe: bool, + /// Deprecated legacy single STUN server for NAT probing. + /// Use `network.stun_servers` instead. + #[serde(default = "default_middle_proxy_nat_stun")] + pub middle_proxy_nat_stun: Option, + /// Deprecated legacy STUN list for NAT probing fallback. + /// Use `network.stun_servers` instead. + #[serde(default = "default_middle_proxy_nat_stun_servers")] + pub middle_proxy_nat_stun_servers: Vec, + /// Maximum number of concurrent STUN probes during NAT detection. + #[serde(default = "default_stun_nat_probe_concurrency")] + pub stun_nat_probe_concurrency: usize, + /// Desired size of active Middle-Proxy writer pool. + #[serde(default = "default_pool_size")] + pub middle_proxy_pool_size: usize, + /// Number of warm standby ME connections kept pre-initialized. + #[serde(default = "default_middle_proxy_warm_standby")] + pub middle_proxy_warm_standby: usize, + /// Startup retries for Middle-End pool initialization before ME→Direct fallback. + /// 0 means unlimited retries. + #[serde(default = "default_me_init_retry_attempts")] + pub me_init_retry_attempts: u32, + /// Allow fallback from Middle-End mode to direct DC when ME startup cannot be initialized. + #[serde(default = "default_me2dc_fallback")] + pub me2dc_fallback: bool, + /// Fast ME->Direct fallback mode for new sessions. + /// Active only when both `use_middle_proxy=true` and `me2dc_fallback=true`. + #[serde(default = "default_me2dc_fast")] + pub me2dc_fast: bool, + /// Enable ME keepalive padding frames. + #[serde(default = "default_true")] + pub me_keepalive_enabled: bool, + /// Keepalive interval in seconds. + #[serde(default = "default_keepalive_interval")] + pub me_keepalive_interval_secs: u64, + /// Keepalive jitter in seconds. + #[serde(default = "default_keepalive_jitter")] + pub me_keepalive_jitter_secs: u64, + /// Keepalive payload randomized (4 bytes); otherwise zeros. + #[serde(default = "default_true")] + pub me_keepalive_payload_random: bool, + /// Interval in seconds for service RPC_PROXY_REQ activity signals to ME. + /// 0 disables service activity signals. + #[serde(default = "default_rpc_proxy_req_every")] + pub rpc_proxy_req_every: u64, + /// Capacity of per-ME writer command channel. + #[serde(default = "default_me_writer_cmd_channel_capacity")] + pub me_writer_cmd_channel_capacity: usize, + /// Resident-memory budget in bytes for each ME writer data queue. + #[serde(default = "default_me_writer_byte_budget_bytes")] + pub me_writer_byte_budget_bytes: usize, + /// Capacity of per-connection ME response route channel. + #[serde(default = "default_me_route_channel_capacity")] + pub me_route_channel_capacity: usize, + /// Capacity of per-client command queue from client reader to ME sender task. + #[serde(default = "default_me_c2me_channel_capacity")] + pub me_c2me_channel_capacity: usize, + /// Maximum wait in milliseconds for enqueueing C2ME commands when the queue is full. + /// `0` keeps legacy unbounded wait behavior. + #[serde(default = "default_me_c2me_send_timeout_ms")] + pub me_c2me_send_timeout_ms: u64, + /// Bounded wait in milliseconds for routing ME DATA to per-connection queue. + /// `0` keeps non-blocking routing; values >0 enable bounded wait for compatibility. + #[serde(default = "default_me_reader_route_data_wait_ms")] + pub me_reader_route_data_wait_ms: u64, + /// Maximum number of ME->Client responses coalesced before flush. + #[serde(default = "default_me_d2c_flush_batch_max_frames")] + pub me_d2c_flush_batch_max_frames: usize, + /// Maximum total payload bytes coalesced before flush. + #[serde(default = "default_me_d2c_flush_batch_max_bytes")] + pub me_d2c_flush_batch_max_bytes: usize, + /// Maximum wait in microseconds to coalesce additional ME->Client responses. + /// `0` disables timed coalescing. + #[serde(default = "default_me_d2c_flush_batch_max_delay_us")] + pub me_d2c_flush_batch_max_delay_us: u64, + /// Flush client writer immediately after quick-ack write. + #[serde(default = "default_me_d2c_ack_flush_immediate")] + pub me_d2c_ack_flush_immediate: bool, + /// Additional bytes above strict per-user quota allowed in hot-path soft mode. + #[serde(default = "default_me_quota_soft_overshoot_bytes")] + pub me_quota_soft_overshoot_bytes: u64, + /// Shrink threshold for reusable ME->Client frame assembly buffer. + #[serde(default = "default_me_d2c_frame_buf_shrink_threshold_bytes")] + pub me_d2c_frame_buf_shrink_threshold_bytes: usize, + /// Copy buffer ceiling for client->DC direction in direct relay. + /// + /// This is also the upper bound for one amortized upload rate-limit burst: + /// upload debt is settled before the next relay read instead of blocking + /// inside the completed read path. + #[serde(default = "default_direct_relay_copy_buf_c2s_bytes")] + pub direct_relay_copy_buf_c2s_bytes: usize, + /// Copy buffer ceiling for DC->client direction in direct relay. + /// + /// This bounds one direct download rate-limit grant because writes are + /// clipped to the currently available shaper budget. + #[serde(default = "default_direct_relay_copy_buf_s2c_bytes")] + pub direct_relay_copy_buf_s2c_bytes: usize, + /// Process-wide hard ceiling for Direct relay copy buffers. + /// `0` derives the ceiling from host and cgroup memory limits. + #[serde(default = "default_direct_relay_buffer_budget_max_bytes")] + pub direct_relay_buffer_budget_max_bytes: usize, + /// Max pending ciphertext buffer per client writer (bytes). + /// Controls FakeTLS backpressure vs throughput. + #[serde(default = "default_crypto_pending_buffer")] + pub crypto_pending_buffer: usize, + /// Maximum allowed client MTProto frame size (bytes). + #[serde(default = "default_max_client_frame")] + pub max_client_frame: usize, + /// Emit full crypto-desync forensic logs for every event. + /// When false, full forensic details are emitted once per key window. + #[serde(default = "default_desync_all_full")] + pub desync_all_full: bool, + /// Enable per-IP forensic observation buckets for scanners and handshake failures. + #[serde(default = "default_true")] + pub beobachten: bool, + /// Observation retention window in minutes for per-IP forensic buckets. + #[serde(default = "default_beobachten_minutes")] + pub beobachten_minutes: u64, + /// Snapshot flush interval in seconds for beob output file. + #[serde(default = "default_beobachten_flush_secs")] + pub beobachten_flush_secs: u64, + /// Snapshot file path for beob output. + #[serde(default = "default_beobachten_file")] + pub beobachten_file: String, + /// Enable C-like hard-swap for ME pool generations. + /// When true, Telemt prewarms a new generation and switches once full coverage is reached. + #[serde(default = "default_hardswap")] + pub hardswap: bool, + /// Enable staggered warmup of extra ME writers. + #[serde(default = "default_true")] + pub me_warmup_stagger_enabled: bool, + /// Base delay between warmup connections in ms. + #[serde(default = "default_warmup_step_delay_ms")] + pub me_warmup_step_delay_ms: u64, + /// Jitter for warmup delay in ms. + #[serde(default = "default_warmup_step_jitter_ms")] + pub me_warmup_step_jitter_ms: u64, + /// Max concurrent reconnect attempts per DC. + #[serde(default = "default_me_reconnect_max_concurrent_per_dc")] + pub me_reconnect_max_concurrent_per_dc: u32, + /// Base backoff in ms for reconnect. + #[serde(default = "default_reconnect_backoff_base_ms")] + pub me_reconnect_backoff_base_ms: u64, + /// Cap backoff in ms for reconnect. + #[serde(default = "default_reconnect_backoff_cap_ms")] + pub me_reconnect_backoff_cap_ms: u64, + /// Fast retry attempts before backoff. + #[serde(default = "default_me_reconnect_fast_retry_count")] + pub me_reconnect_fast_retry_count: u32, + /// Number of additional reserve writers for DC groups with exactly one endpoint. + #[serde(default = "default_me_single_endpoint_shadow_writers")] + pub me_single_endpoint_shadow_writers: u8, + /// Enable aggressive outage recovery mode for single-endpoint DC groups. + #[serde(default = "default_me_single_endpoint_outage_mode_enabled")] + pub me_single_endpoint_outage_mode_enabled: bool, + /// Ignore endpoint quarantine while in single-endpoint outage mode. + #[serde(default = "default_me_single_endpoint_outage_disable_quarantine")] + pub me_single_endpoint_outage_disable_quarantine: bool, + /// Minimum reconnect backoff in ms for single-endpoint outage mode. + #[serde(default = "default_me_single_endpoint_outage_backoff_min_ms")] + pub me_single_endpoint_outage_backoff_min_ms: u64, + /// Maximum reconnect backoff in ms for single-endpoint outage mode. + #[serde(default = "default_me_single_endpoint_outage_backoff_max_ms")] + pub me_single_endpoint_outage_backoff_max_ms: u64, + /// Periodic shadow writer rotation interval in seconds for single-endpoint DC groups. + /// Set to 0 to disable periodic shadow rotation. + #[serde(default = "default_me_single_endpoint_shadow_rotate_every_secs")] + pub me_single_endpoint_shadow_rotate_every_secs: u64, + /// Floor policy mode for ME writer targets. + #[serde(default)] + pub me_floor_mode: MeFloorMode, + /// Idle time in seconds before adaptive floor can reduce single-endpoint writer target. + #[serde(default = "default_me_adaptive_floor_idle_secs")] + pub me_adaptive_floor_idle_secs: u64, + /// Minimum writer target for single-endpoint DC groups in adaptive floor mode. + #[serde(default = "default_me_adaptive_floor_min_writers_single_endpoint")] + pub me_adaptive_floor_min_writers_single_endpoint: u8, + /// Minimum writer target for multi-endpoint DC groups in adaptive floor mode. + #[serde(default = "default_me_adaptive_floor_min_writers_multi_endpoint")] + pub me_adaptive_floor_min_writers_multi_endpoint: u8, + /// Grace period in seconds to hold static floor after activity in adaptive mode. + #[serde(default = "default_me_adaptive_floor_recover_grace_secs")] + pub me_adaptive_floor_recover_grace_secs: u64, + /// Global ME writer budget per logical CPU core in adaptive mode. + #[serde(default = "default_me_adaptive_floor_writers_per_core_total")] + pub me_adaptive_floor_writers_per_core_total: u16, + /// Override logical CPU core count for adaptive floor calculations. + /// Set to 0 to use runtime auto-detection. + #[serde(default = "default_me_adaptive_floor_cpu_cores_override")] + pub me_adaptive_floor_cpu_cores_override: u16, + /// Per-core max extra writers above base required floor for single-endpoint DC groups. + #[serde(default = "default_me_adaptive_floor_max_extra_writers_single_per_core")] + pub me_adaptive_floor_max_extra_writers_single_per_core: u16, + /// Per-core max extra writers above base required floor for multi-endpoint DC groups. + #[serde(default = "default_me_adaptive_floor_max_extra_writers_multi_per_core")] + pub me_adaptive_floor_max_extra_writers_multi_per_core: u16, + /// Hard cap for active ME writers per logical CPU core. + #[serde(default = "default_me_adaptive_floor_max_active_writers_per_core")] + pub me_adaptive_floor_max_active_writers_per_core: u16, + /// Hard cap for warm ME writers per logical CPU core. + #[serde(default = "default_me_adaptive_floor_max_warm_writers_per_core")] + pub me_adaptive_floor_max_warm_writers_per_core: u16, + /// Hard global cap for active ME writers. + #[serde(default = "default_me_adaptive_floor_max_active_writers_global")] + pub me_adaptive_floor_max_active_writers_global: u32, + /// Hard global cap for warm ME writers. + #[serde(default = "default_me_adaptive_floor_max_warm_writers_global")] + pub me_adaptive_floor_max_warm_writers_global: u32, + /// Connect attempts for the selected upstream before returning error/fallback. + #[serde(default = "default_upstream_connect_retry_attempts")] + pub upstream_connect_retry_attempts: u32, + /// Delay in milliseconds between upstream connect attempts. + #[serde(default = "default_upstream_connect_retry_backoff_ms")] + pub upstream_connect_retry_backoff_ms: u64, + /// Total wall-clock budget in milliseconds for one upstream connect request across retries. + #[serde(default = "default_upstream_connect_budget_ms")] + pub upstream_connect_budget_ms: u64, + /// Per-attempt TCP connect timeout to Telegram DC (seconds). + #[serde(default = "default_connect_timeout")] + pub tg_connect: u64, + /// Consecutive failed requests before upstream is marked unhealthy. + #[serde(default = "default_upstream_unhealthy_fail_threshold")] + pub upstream_unhealthy_fail_threshold: u32, + /// Skip additional retries for hard non-transient upstream connect errors. + #[serde(default = "default_upstream_connect_failfast_hard_errors")] + pub upstream_connect_failfast_hard_errors: bool, + /// Ignore STUN/interface IP mismatch (keep using Middle Proxy even if NAT detected). + #[serde(default)] + pub stun_iface_mismatch_ignore: bool, + /// Log unknown (non-standard) DC requests to a file (default: unknown-dc.txt). Set to null to disable. + #[serde(default = "default_unknown_dc_log_path")] + pub unknown_dc_log_path: Option, + /// Enable unknown-DC file logging. + #[serde(default = "default_unknown_dc_file_log_enabled")] + pub unknown_dc_file_log_enabled: bool, + #[serde(default)] + pub log_level: LogLevel, + /// Disable colored output in logs (useful for files/systemd). + #[serde(default)] + pub disable_colors: bool, + /// Runtime telemetry controls for counters/metrics in hot paths. + #[serde(default)] + pub telemetry: TelemetryConfig, + /// SOCKS-bound KDF policy for Middle-End handshake. + #[serde(default)] + pub me_socks_kdf_policy: MeSocksKdfPolicy, + /// Enable route-level ME backpressure controls in reader fairness path. + #[serde(default = "default_me_route_backpressure_enabled")] + pub me_route_backpressure_enabled: bool, + /// Enable worker-local fairshare scheduler for ME reader routing. + #[serde(default = "default_me_route_fairshare_enabled")] + pub me_route_fairshare_enabled: bool, + /// Base backpressure timeout in milliseconds for ME route channel send. + #[serde(default = "default_me_route_backpressure_base_timeout_ms")] + pub me_route_backpressure_base_timeout_ms: u64, + /// High backpressure timeout in milliseconds when queue occupancy is above watermark. + #[serde(default = "default_me_route_backpressure_high_timeout_ms")] + pub me_route_backpressure_high_timeout_ms: u64, + /// Queue occupancy percent threshold for high backpressure timeout. + #[serde(default = "default_me_route_backpressure_high_watermark_pct")] + pub me_route_backpressure_high_watermark_pct: u8, + /// Health monitor interval in milliseconds while writer coverage is degraded. + #[serde(default = "default_me_health_interval_ms_unhealthy")] + pub me_health_interval_ms_unhealthy: u64, + /// Health monitor interval in milliseconds while writer coverage is stable. + #[serde(default = "default_me_health_interval_ms_healthy")] + pub me_health_interval_ms_healthy: u64, + /// Poll interval in milliseconds for conditional-admission state checks. + #[serde(default = "default_me_admission_poll_ms")] + pub me_admission_poll_ms: u64, + /// Cooldown for repetitive ME warning logs in milliseconds. + #[serde(default = "default_me_warn_rate_limit_ms")] + pub me_warn_rate_limit_ms: u64, + /// ME route behavior when no writer is immediately available. + #[serde(default)] + pub me_route_no_writer_mode: MeRouteNoWriterMode, + /// Maximum wait time in milliseconds for async-recovery failfast mode. + #[serde(default = "default_me_route_no_writer_wait_ms")] + pub me_route_no_writer_wait_ms: u64, + /// Maximum cumulative wait in milliseconds for hybrid no-writer mode before failfast. + #[serde(default = "default_me_route_hybrid_max_wait_ms")] + pub me_route_hybrid_max_wait_ms: u64, + /// Maximum wait in milliseconds for blocking ME writer channel send fallback. + /// Must be within [1, 5000]. + #[serde(default = "default_me_route_blocking_send_timeout_ms")] + pub me_route_blocking_send_timeout_ms: u64, + /// Number of inline recovery attempts in legacy mode. + #[serde(default = "default_me_route_inline_recovery_attempts")] + pub me_route_inline_recovery_attempts: u32, + /// Maximum wait time in milliseconds for inline recovery in legacy mode. + #[serde(default = "default_me_route_inline_recovery_wait_ms")] + pub me_route_inline_recovery_wait_ms: u64, + /// [general.links] — proxy link generation overrides. + #[serde(default)] + pub links: LinksConfig, + /// Minimum TLS record size when fast_mode coalescing is enabled (0 = disabled). + #[serde(default = "default_fast_mode_min_tls_record")] + pub fast_mode_min_tls_record: usize, + /// Unified ME updater interval in seconds for getProxyConfig/getProxyConfigV6/getProxySecret. + /// When omitted, effective value falls back to legacy proxy_*_auto_reload_secs fields. + #[serde(default = "default_update_every")] + pub update_every: Option, + /// Periodic ME pool reinitialization interval in seconds. + #[serde(default = "default_me_reinit_every_secs")] + pub me_reinit_every_secs: u64, + /// Minimum delay in ms between hardswap warmup connect attempts. + #[serde(default = "default_me_hardswap_warmup_delay_min_ms")] + pub me_hardswap_warmup_delay_min_ms: u64, + /// Maximum delay in ms between hardswap warmup connect attempts. + #[serde(default = "default_me_hardswap_warmup_delay_max_ms")] + pub me_hardswap_warmup_delay_max_ms: u64, + /// Additional warmup passes in the same hardswap cycle after the base pass. + #[serde(default = "default_me_hardswap_warmup_extra_passes")] + pub me_hardswap_warmup_extra_passes: u8, + /// Base backoff in ms between hardswap warmup passes when floor is still incomplete. + #[serde(default = "default_me_hardswap_warmup_pass_backoff_base_ms")] + pub me_hardswap_warmup_pass_backoff_base_ms: u64, + /// Number of identical getProxyConfig snapshots required before applying ME map updates. + #[serde(default = "default_me_config_stable_snapshots")] + pub me_config_stable_snapshots: u8, + /// Cooldown in seconds between applied ME map updates. + #[serde(default = "default_me_config_apply_cooldown_secs")] + pub me_config_apply_cooldown_secs: u64, + /// Ensure getProxyConfig snapshots are applied only for 2xx HTTP responses. + #[serde(default = "default_me_snapshot_require_http_2xx")] + pub me_snapshot_require_http_2xx: bool, + /// Reject empty getProxyConfig snapshots instead of marking them applied. + #[serde(default = "default_me_snapshot_reject_empty_map")] + pub me_snapshot_reject_empty_map: bool, + /// Minimum parsed `proxy_for` rows required to accept a snapshot. + #[serde(default = "default_me_snapshot_min_proxy_for_lines")] + pub me_snapshot_min_proxy_for_lines: u32, + /// Number of identical getProxySecret snapshots required before runtime secret rotation. + #[serde(default = "default_proxy_secret_stable_snapshots")] + pub proxy_secret_stable_snapshots: u8, + /// Enable runtime proxy-secret rotation from getProxySecret. + #[serde(default = "default_proxy_secret_rotate_runtime")] + pub proxy_secret_rotate_runtime: bool, + /// Keep key-selector and secret bytes from one snapshot during ME handshake. + #[serde(default = "default_me_secret_atomic_snapshot")] + pub me_secret_atomic_snapshot: bool, + /// Maximum allowed proxy-secret length in bytes for startup and runtime refresh. + #[serde(default = "default_proxy_secret_len_max")] + pub proxy_secret_len_max: usize, + /// Drain-TTL in seconds for stale ME writers after endpoint map changes. + /// During TTL, stale writers may be used only as fallback for new bindings. + #[serde(default = "default_me_pool_drain_ttl_secs")] + pub me_pool_drain_ttl_secs: u64, + /// Force-remove any draining writer on the next cleanup tick, regardless of age/deadline. + #[serde(default = "default_me_instadrain")] + pub me_instadrain: bool, + /// Maximum allowed number of draining ME writers before oldest ones are force-closed in batches. + /// Set to 0 to disable threshold-based draining cleanup and keep timeout-only behavior. + #[serde(default = "default_me_pool_drain_threshold")] + pub me_pool_drain_threshold: u64, + /// Enable staged client eviction for draining ME writers that remain non-empty past TTL. + #[serde(default = "default_me_pool_drain_soft_evict_enabled")] + pub me_pool_drain_soft_evict_enabled: bool, + /// Extra grace in seconds after drain TTL before soft-eviction stage starts. + #[serde(default = "default_me_pool_drain_soft_evict_grace_secs")] + pub me_pool_drain_soft_evict_grace_secs: u64, + /// Maximum number of client sessions to evict from one draining writer per health tick. + #[serde(default = "default_me_pool_drain_soft_evict_per_writer")] + pub me_pool_drain_soft_evict_per_writer: u8, + /// Soft-eviction budget per CPU core for one health tick. + #[serde(default = "default_me_pool_drain_soft_evict_budget_per_core")] + pub me_pool_drain_soft_evict_budget_per_core: u16, + /// Cooldown for repetitive soft-eviction on the same writer in milliseconds. + #[serde(default = "default_me_pool_drain_soft_evict_cooldown_ms")] + pub me_pool_drain_soft_evict_cooldown_ms: u64, + /// Policy for new binds on stale draining writers. + #[serde(default)] + pub me_bind_stale_mode: MeBindStaleMode, + /// TTL for stale bind allowance when `me_bind_stale_mode = \"ttl\"`. + #[serde(default = "default_me_bind_stale_ttl_secs")] + pub me_bind_stale_ttl_secs: u64, + /// Minimum desired-DC coverage ratio required before draining stale writers. + /// Range: 0.0..=1.0. + #[serde(default = "default_me_pool_min_fresh_ratio")] + pub me_pool_min_fresh_ratio: f32, + /// Drain timeout in seconds for stale ME writers after endpoint map changes. + /// Set to 0 to use the runtime safety fallback timeout. + #[serde(default = "default_me_reinit_drain_timeout_secs")] + pub me_reinit_drain_timeout_secs: u64, + /// Deprecated legacy setting; kept for backward compatibility fallback. + /// Use `update_every` instead. + #[serde(default = "default_proxy_secret_reload_secs")] + pub proxy_secret_auto_reload_secs: u64, + /// Deprecated legacy setting; kept for backward compatibility fallback. + /// Use `update_every` instead. + #[serde(default = "default_proxy_config_reload_secs")] + pub proxy_config_auto_reload_secs: u64, + /// Serialize ME reinit cycles across all trigger sources. + #[serde(default = "default_me_reinit_singleflight")] + pub me_reinit_singleflight: bool, + /// Trigger queue capacity for reinit scheduler. + #[serde(default = "default_me_reinit_trigger_channel")] + pub me_reinit_trigger_channel: usize, + /// Trigger coalescing window before starting a reinit cycle. + #[serde(default = "default_me_reinit_coalesce_window_ms")] + pub me_reinit_coalesce_window_ms: u64, + /// Deterministic candidate sort for ME writer binding path. + #[serde(default = "default_me_deterministic_writer_sort")] + pub me_deterministic_writer_sort: bool, + /// Writer selection mode for ME route bind path. + #[serde(default)] + pub me_writer_pick_mode: MeWriterPickMode, + /// Number of candidates sampled by writer picker in `p2c` mode. + #[serde(default = "default_me_writer_pick_sample_size")] + pub me_writer_pick_sample_size: u8, + /// Enable NTP drift check at startup. + #[serde(default = "default_ntp_check")] + pub ntp_check: bool, + /// NTP servers for drift check. + #[serde(default = "default_ntp_servers")] + pub ntp_servers: Vec, + /// Enable auto-degradation from ME to Direct-DC. + #[serde(default = "default_true")] + pub auto_degradation_enabled: bool, + /// Minimum unavailable ME DC groups before degrading. + #[serde(default = "default_degradation_min_unavailable_dc_groups")] + pub degradation_min_unavailable_dc_groups: u8, + /// RST-on-close mode for accepted client sockets. + /// `off` — normal FIN on all closes (default). + /// `errors` — SO_LINGER(0) on accept, cleared after successful auth; + /// pre-handshake failures send RST, relayed sessions close gracefully. + /// `always` — SO_LINGER(0) on accept, never cleared; all closes send RST. + #[serde(default)] + pub rst_on_close: RstOnCloseMode, +} diff --git a/src/config/types/general_impl.rs b/src/config/types/general_impl.rs new file mode 100644 index 0000000..a0afb36 --- /dev/null +++ b/src/config/types/general_impl.rs @@ -0,0 +1,201 @@ +use super::*; + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + data_path: None, + quota_state_path: default_quota_state_path(), + config_strict: false, + modes: ProxyModes::default(), + prefer_ipv6: false, + fast_mode: default_true(), + use_middle_proxy: default_true(), + ad_tag: None, + proxy_secret_path: default_proxy_secret_path(), + proxy_secret_url: None, + proxy_config_v4_cache_path: default_proxy_config_v4_cache_path(), + proxy_config_v4_url: None, + proxy_config_v6_cache_path: default_proxy_config_v6_cache_path(), + proxy_config_v6_url: None, + middle_proxy_nat_ip: None, + middle_proxy_nat_probe: default_true(), + middle_proxy_nat_stun: default_middle_proxy_nat_stun(), + middle_proxy_nat_stun_servers: default_middle_proxy_nat_stun_servers(), + stun_nat_probe_concurrency: default_stun_nat_probe_concurrency(), + middle_proxy_pool_size: default_pool_size(), + middle_proxy_warm_standby: default_middle_proxy_warm_standby(), + me_init_retry_attempts: default_me_init_retry_attempts(), + me2dc_fallback: default_me2dc_fallback(), + me2dc_fast: default_me2dc_fast(), + me_keepalive_enabled: default_true(), + me_keepalive_interval_secs: default_keepalive_interval(), + me_keepalive_jitter_secs: default_keepalive_jitter(), + me_keepalive_payload_random: default_true(), + rpc_proxy_req_every: default_rpc_proxy_req_every(), + me_writer_cmd_channel_capacity: default_me_writer_cmd_channel_capacity(), + me_writer_byte_budget_bytes: default_me_writer_byte_budget_bytes(), + me_route_channel_capacity: default_me_route_channel_capacity(), + me_c2me_channel_capacity: default_me_c2me_channel_capacity(), + me_c2me_send_timeout_ms: default_me_c2me_send_timeout_ms(), + me_reader_route_data_wait_ms: default_me_reader_route_data_wait_ms(), + me_d2c_flush_batch_max_frames: default_me_d2c_flush_batch_max_frames(), + me_d2c_flush_batch_max_bytes: default_me_d2c_flush_batch_max_bytes(), + me_d2c_flush_batch_max_delay_us: default_me_d2c_flush_batch_max_delay_us(), + me_d2c_ack_flush_immediate: default_me_d2c_ack_flush_immediate(), + me_quota_soft_overshoot_bytes: default_me_quota_soft_overshoot_bytes(), + me_d2c_frame_buf_shrink_threshold_bytes: + default_me_d2c_frame_buf_shrink_threshold_bytes(), + direct_relay_copy_buf_c2s_bytes: default_direct_relay_copy_buf_c2s_bytes(), + direct_relay_copy_buf_s2c_bytes: default_direct_relay_copy_buf_s2c_bytes(), + direct_relay_buffer_budget_max_bytes: default_direct_relay_buffer_budget_max_bytes(), + me_warmup_stagger_enabled: default_true(), + me_warmup_step_delay_ms: default_warmup_step_delay_ms(), + me_warmup_step_jitter_ms: default_warmup_step_jitter_ms(), + me_reconnect_max_concurrent_per_dc: default_me_reconnect_max_concurrent_per_dc(), + me_reconnect_backoff_base_ms: default_reconnect_backoff_base_ms(), + me_reconnect_backoff_cap_ms: default_reconnect_backoff_cap_ms(), + me_reconnect_fast_retry_count: default_me_reconnect_fast_retry_count(), + me_single_endpoint_shadow_writers: default_me_single_endpoint_shadow_writers(), + me_single_endpoint_outage_mode_enabled: default_me_single_endpoint_outage_mode_enabled( + ), + me_single_endpoint_outage_disable_quarantine: + default_me_single_endpoint_outage_disable_quarantine(), + me_single_endpoint_outage_backoff_min_ms: + default_me_single_endpoint_outage_backoff_min_ms(), + me_single_endpoint_outage_backoff_max_ms: + default_me_single_endpoint_outage_backoff_max_ms(), + me_single_endpoint_shadow_rotate_every_secs: + default_me_single_endpoint_shadow_rotate_every_secs(), + me_floor_mode: MeFloorMode::default(), + me_adaptive_floor_idle_secs: default_me_adaptive_floor_idle_secs(), + me_adaptive_floor_min_writers_single_endpoint: + default_me_adaptive_floor_min_writers_single_endpoint(), + me_adaptive_floor_min_writers_multi_endpoint: + default_me_adaptive_floor_min_writers_multi_endpoint(), + me_adaptive_floor_recover_grace_secs: default_me_adaptive_floor_recover_grace_secs(), + me_adaptive_floor_writers_per_core_total: + default_me_adaptive_floor_writers_per_core_total(), + me_adaptive_floor_cpu_cores_override: default_me_adaptive_floor_cpu_cores_override(), + me_adaptive_floor_max_extra_writers_single_per_core: + default_me_adaptive_floor_max_extra_writers_single_per_core(), + me_adaptive_floor_max_extra_writers_multi_per_core: + default_me_adaptive_floor_max_extra_writers_multi_per_core(), + me_adaptive_floor_max_active_writers_per_core: + default_me_adaptive_floor_max_active_writers_per_core(), + me_adaptive_floor_max_warm_writers_per_core: + default_me_adaptive_floor_max_warm_writers_per_core(), + me_adaptive_floor_max_active_writers_global: + default_me_adaptive_floor_max_active_writers_global(), + me_adaptive_floor_max_warm_writers_global: + default_me_adaptive_floor_max_warm_writers_global(), + upstream_connect_retry_attempts: default_upstream_connect_retry_attempts(), + upstream_connect_retry_backoff_ms: default_upstream_connect_retry_backoff_ms(), + upstream_connect_budget_ms: default_upstream_connect_budget_ms(), + tg_connect: default_connect_timeout(), + upstream_unhealthy_fail_threshold: default_upstream_unhealthy_fail_threshold(), + upstream_connect_failfast_hard_errors: default_upstream_connect_failfast_hard_errors(), + stun_iface_mismatch_ignore: false, + unknown_dc_log_path: default_unknown_dc_log_path(), + unknown_dc_file_log_enabled: default_unknown_dc_file_log_enabled(), + log_level: LogLevel::Normal, + disable_colors: false, + telemetry: TelemetryConfig::default(), + me_socks_kdf_policy: MeSocksKdfPolicy::Strict, + me_route_backpressure_enabled: default_me_route_backpressure_enabled(), + me_route_fairshare_enabled: default_me_route_fairshare_enabled(), + me_route_backpressure_base_timeout_ms: default_me_route_backpressure_base_timeout_ms(), + me_route_backpressure_high_timeout_ms: default_me_route_backpressure_high_timeout_ms(), + me_route_backpressure_high_watermark_pct: + default_me_route_backpressure_high_watermark_pct(), + me_health_interval_ms_unhealthy: default_me_health_interval_ms_unhealthy(), + me_health_interval_ms_healthy: default_me_health_interval_ms_healthy(), + me_admission_poll_ms: default_me_admission_poll_ms(), + me_warn_rate_limit_ms: default_me_warn_rate_limit_ms(), + me_route_no_writer_mode: MeRouteNoWriterMode::default(), + me_route_no_writer_wait_ms: default_me_route_no_writer_wait_ms(), + me_route_hybrid_max_wait_ms: default_me_route_hybrid_max_wait_ms(), + me_route_blocking_send_timeout_ms: default_me_route_blocking_send_timeout_ms(), + me_route_inline_recovery_attempts: default_me_route_inline_recovery_attempts(), + me_route_inline_recovery_wait_ms: default_me_route_inline_recovery_wait_ms(), + links: LinksConfig::default(), + crypto_pending_buffer: default_crypto_pending_buffer(), + max_client_frame: default_max_client_frame(), + desync_all_full: default_desync_all_full(), + beobachten: default_true(), + beobachten_minutes: default_beobachten_minutes(), + beobachten_flush_secs: default_beobachten_flush_secs(), + beobachten_file: default_beobachten_file(), + hardswap: default_hardswap(), + fast_mode_min_tls_record: default_fast_mode_min_tls_record(), + update_every: default_update_every(), + me_reinit_every_secs: default_me_reinit_every_secs(), + me_hardswap_warmup_delay_min_ms: default_me_hardswap_warmup_delay_min_ms(), + me_hardswap_warmup_delay_max_ms: default_me_hardswap_warmup_delay_max_ms(), + me_hardswap_warmup_extra_passes: default_me_hardswap_warmup_extra_passes(), + me_hardswap_warmup_pass_backoff_base_ms: + default_me_hardswap_warmup_pass_backoff_base_ms(), + me_config_stable_snapshots: default_me_config_stable_snapshots(), + me_config_apply_cooldown_secs: default_me_config_apply_cooldown_secs(), + me_snapshot_require_http_2xx: default_me_snapshot_require_http_2xx(), + me_snapshot_reject_empty_map: default_me_snapshot_reject_empty_map(), + me_snapshot_min_proxy_for_lines: default_me_snapshot_min_proxy_for_lines(), + proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(), + proxy_secret_rotate_runtime: default_proxy_secret_rotate_runtime(), + me_secret_atomic_snapshot: default_me_secret_atomic_snapshot(), + proxy_secret_len_max: default_proxy_secret_len_max(), + me_pool_drain_ttl_secs: default_me_pool_drain_ttl_secs(), + me_instadrain: default_me_instadrain(), + me_pool_drain_threshold: default_me_pool_drain_threshold(), + me_pool_drain_soft_evict_enabled: default_me_pool_drain_soft_evict_enabled(), + me_pool_drain_soft_evict_grace_secs: default_me_pool_drain_soft_evict_grace_secs(), + me_pool_drain_soft_evict_per_writer: default_me_pool_drain_soft_evict_per_writer(), + me_pool_drain_soft_evict_budget_per_core: + default_me_pool_drain_soft_evict_budget_per_core(), + me_pool_drain_soft_evict_cooldown_ms: default_me_pool_drain_soft_evict_cooldown_ms(), + me_bind_stale_mode: MeBindStaleMode::default(), + me_bind_stale_ttl_secs: default_me_bind_stale_ttl_secs(), + me_pool_min_fresh_ratio: default_me_pool_min_fresh_ratio(), + me_reinit_drain_timeout_secs: default_me_reinit_drain_timeout_secs(), + proxy_secret_auto_reload_secs: default_proxy_secret_reload_secs(), + proxy_config_auto_reload_secs: default_proxy_config_reload_secs(), + me_reinit_singleflight: default_me_reinit_singleflight(), + me_reinit_trigger_channel: default_me_reinit_trigger_channel(), + me_reinit_coalesce_window_ms: default_me_reinit_coalesce_window_ms(), + me_deterministic_writer_sort: default_me_deterministic_writer_sort(), + me_writer_pick_mode: MeWriterPickMode::default(), + me_writer_pick_sample_size: default_me_writer_pick_sample_size(), + ntp_check: default_ntp_check(), + ntp_servers: default_ntp_servers(), + auto_degradation_enabled: default_true(), + degradation_min_unavailable_dc_groups: default_degradation_min_unavailable_dc_groups(), + rst_on_close: RstOnCloseMode::default(), + } + } +} + +impl GeneralConfig { + /// Resolve the active updater interval for ME infrastructure refresh tasks. + /// `update_every` has priority, otherwise legacy proxy_*_auto_reload_secs are used. + pub fn effective_update_every_secs(&self) -> u64 { + self.update_every.unwrap_or_else(|| { + self.proxy_secret_auto_reload_secs + .min(self.proxy_config_auto_reload_secs) + }) + } + + /// Resolve periodic zero-downtime reinit interval for ME writers. + pub fn effective_me_reinit_every_secs(&self) -> u64 { + self.me_reinit_every_secs + } + + /// Resolve force-close timeout for stale writers. + /// `me_reinit_drain_timeout_secs` remains backward-compatible alias. + /// A configured `0` uses the runtime safety fallback (300s). + pub fn effective_me_pool_force_close_secs(&self) -> u64 { + if self.me_reinit_drain_timeout_secs == 0 { + 300 + } else { + self.me_reinit_drain_timeout_secs + } + } +} diff --git a/src/config/types/links.rs b/src/config/types/links.rs new file mode 100644 index 0000000..e0095d0 --- /dev/null +++ b/src/config/types/links.rs @@ -0,0 +1,125 @@ +use super::*; + +/// Proxy link generation settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinksConfig { + /// List of usernames whose tg:// links to display at startup. + /// `"*"` = all users, `["alice", "bob"]` = specific users. + #[serde(default = "default_links_show")] + pub show: ShowLink, + + /// Public hostname/IP for tg:// link generation (overrides detected IP). + #[serde(default)] + pub public_host: Option, + + /// Public port for tg:// link generation. + /// Overrides listener ports and legacy `server.port`. + #[serde(default)] + pub public_port: Option, +} + +impl Default for LinksConfig { + fn default() -> Self { + Self { + show: default_links_show(), + public_host: None, + public_port: None, + } + } +} + + +/// In TOML, this can be: +/// - `show_link = "*"` — show links for all users +/// - `show_link = ["a", "b"]` — show links for specific users +/// - omitted — default depends on the owning config field +#[derive(Debug, Clone, Default)] +pub enum ShowLink { + /// Don't show any links (default when omitted). + #[default] + None, + /// Show links for all configured users. + All, + /// Show links for specific users. + Specific(Vec), +} + +fn default_links_show() -> ShowLink { + ShowLink::All +} + +impl ShowLink { + /// Returns true if no links should be shown. + pub fn is_empty(&self) -> bool { + matches!(self, ShowLink::None) || matches!(self, ShowLink::Specific(v) if v.is_empty()) + } + + /// Resolve the list of user names to display, given all configured users. + pub fn resolve_users<'a>(&'a self, all_users: &'a HashMap) -> Vec<&'a String> { + match self { + ShowLink::None => vec![], + ShowLink::All => { + let mut names: Vec<&String> = all_users.keys().collect(); + names.sort(); + names + } + ShowLink::Specific(names) => names.iter().collect(), + } + } +} + +impl Serialize for ShowLink { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + match self { + ShowLink::None => Vec::::new().serialize(serializer), + ShowLink::All => serializer.serialize_str("*"), + ShowLink::Specific(v) => v.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for ShowLink { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + use serde::de; + + struct ShowLinkVisitor; + + impl<'de> de::Visitor<'de> for ShowLinkVisitor { + type Value = ShowLink; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str(r#""*" or an array of user names"#) + } + + fn visit_str(self, v: &str) -> std::result::Result { + if v == "*" { + Ok(ShowLink::All) + } else { + Err(de::Error::invalid_value(de::Unexpected::Str(v), &r#""*""#)) + } + } + + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + let mut names = Vec::new(); + while let Some(name) = seq.next_element::()? { + names.push(name); + } + if names.is_empty() { + Ok(ShowLink::None) + } else { + Ok(ShowLink::Specific(names)) + } + } + } + + deserializer.deserialize_any(ShowLinkVisitor) + } +} diff --git a/src/config/types/logging.rs b/src/config/types/logging.rs new file mode 100644 index 0000000..7f9e725 --- /dev/null +++ b/src/config/types/logging.rs @@ -0,0 +1,131 @@ +use super::*; + +/// Logging verbosity level. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + /// All messages including trace (trace + debug + info + warn + error). + Debug, + /// Detailed operational logs (debug + info + warn + error). + Verbose, + /// Standard operational logs (info + warn + error). + #[default] + Normal, + /// Minimal output: only warnings and errors (warn + error). + /// Proxy links may still be emitted through their dedicated target. + Silent, +} + +impl LogLevel { + /// Convert to tracing EnvFilter directive string. + pub fn to_filter_str(&self) -> &'static str { + match self { + LogLevel::Debug => "trace", + LogLevel::Verbose => "debug", + LogLevel::Normal => "info", + LogLevel::Silent => "warn", + } + } + + /// Parse from a loose string (CLI argument). + pub fn from_str_loose(s: &str) -> Self { + match s.to_lowercase().as_str() { + "debug" | "trace" => LogLevel::Debug, + "verbose" => LogLevel::Verbose, + "normal" | "info" => LogLevel::Normal, + "silent" | "quiet" | "error" | "warn" => LogLevel::Silent, + _ => LogLevel::Normal, + } + } +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Debug => write!(f, "debug"), + LogLevel::Verbose => write!(f, "verbose"), + LogLevel::Normal => write!(f, "normal"), + LogLevel::Silent => write!(f, "silent"), + } + } +} + +/// Logging output destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LoggingDestination { + /// Write logs to stderr. + #[default] + Stderr, + /// Write logs to syslog on Unix platforms. + Syslog, + /// Write logs to a file. + File, +} + +/// Time-based log rotation interval for file logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LogRotation { + /// Do not rotate logs by time. + #[default] + Never, + /// Rotate once per minute. + Minutely, + /// Rotate once per hour. + Hourly, + /// Rotate once per day. + Daily, + /// Rotate once per week. + Weekly, +} + +impl LogRotation { + /// Parse a CLI rotation value. + pub fn from_cli_arg(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "never" | "none" | "off" => Some(Self::Never), + "minutely" | "minute" => Some(Self::Minutely), + "hourly" | "hour" => Some(Self::Hourly), + "daily" | "day" => Some(Self::Daily), + "weekly" | "week" => Some(Self::Weekly), + _ => None, + } + } +} + +/// File logging and retention settings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoggingConfig { + /// Effective logging destination. + #[serde(default)] + pub destination: LoggingDestination, + /// File path used when `destination = "file"`. + #[serde(default)] + pub path: Option, + /// Time rotation interval for file logs. + #[serde(default)] + pub rotation: LogRotation, + /// Maximum active log file size before rotating. `0` disables size rotation. + #[serde(default)] + pub max_size_bytes: u64, + /// Maximum number of matching log files to keep. `0` disables count retention. + #[serde(default)] + pub max_files: usize, + /// Maximum age for rotated log files in seconds. `0` disables age retention. + #[serde(default)] + pub max_age_secs: u64, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + destination: LoggingDestination::Stderr, + path: None, + rotation: LogRotation::Never, + max_size_bytes: 0, + max_files: 0, + max_age_secs: 0, + } + } +} diff --git a/src/config/types/network.rs b/src/config/types/network.rs new file mode 100644 index 0000000..d832029 --- /dev/null +++ b/src/config/types/network.rs @@ -0,0 +1,153 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyModes { + #[serde(default)] + pub classic: bool, + #[serde(default)] + pub secure: bool, + #[serde(default = "default_true")] + pub tls: bool, +} + +impl Default for ProxyModes { + fn default() -> Self { + Self { + classic: false, + secure: false, + tls: default_true(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + #[serde(default = "default_true")] + pub ipv4: bool, + + /// None = auto-detect IPv6 availability. + #[serde(default = "default_network_ipv6")] + pub ipv6: Option, + + /// 4 or 6. + #[serde(default = "default_prefer_4")] + pub prefer: u8, + + #[serde(default)] + pub multipath: bool, + + /// Global switch for STUN probing. + /// When false, STUN is fully disabled and only non-STUN detection remains. + #[serde(default = "default_true")] + pub stun_use: bool, + + /// STUN servers list for public IP discovery. + #[serde(default = "default_stun_servers")] + pub stun_servers: Vec, + + /// Enable TCP STUN fallback when UDP is blocked. + #[serde(default = "default_stun_tcp_fallback")] + pub stun_tcp_fallback: bool, + + /// HTTP-based public IP detection endpoints (fallback after STUN). + #[serde(default = "default_http_ip_detect_urls")] + pub http_ip_detect_urls: Vec, + + /// Cache file path for detected public IP. + #[serde(default = "default_cache_public_ip_path")] + pub cache_public_ip_path: String, + + /// Runtime DNS overrides in `host:port:ip` format. + /// IPv6 IP values must be bracketed: `[2001:db8::1]`. + #[serde(default)] + pub dns_overrides: Vec, +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + ipv4: default_true(), + ipv6: default_network_ipv6(), + prefer: default_prefer_4(), + multipath: false, + stun_use: default_true(), + stun_servers: default_stun_servers(), + stun_tcp_fallback: default_stun_tcp_fallback(), + http_ip_detect_urls: default_http_ip_detect_urls(), + cache_public_ip_path: default_cache_public_ip_path(), + dns_overrides: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum UpstreamType { + Direct { + #[serde(default)] + interface: Option, + #[serde(default)] + bind_addresses: Option>, + /// Linux-only hard interface pinning via `SO_BINDTODEVICE`. + /// Optional alias: `force_bind`. + #[serde(default, alias = "force_bind")] + bindtodevice: Option, + }, + Socks4 { + address: String, + #[serde(default)] + interface: Option, + #[serde(default)] + user_id: Option, + }, + Socks5 { + address: String, + #[serde(default)] + interface: Option, + #[serde(default)] + username: Option, + #[serde(default)] + password: Option, + }, + Shadowsocks { + url: String, + #[serde(default)] + interface: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpstreamConfig { + #[serde(flatten)] + pub upstream_type: UpstreamType, + #[serde(default = "default_weight")] + pub weight: u16, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub scopes: String, + #[serde(skip)] + pub selected_scope: String, + /// Allow IPv4 DC targets for this upstream. + /// `None` means auto-detect from runtime connectivity state. + #[serde(default)] + pub ipv4: Option, + /// Allow IPv6 DC targets for this upstream. + /// `None` means auto-detect from runtime connectivity state. + #[serde(default)] + pub ipv6: Option, + /// Per-upstream IP family preference for Telegram DC targets. + /// `None` inherits the effective global `[network].prefer` decision. + #[serde(default)] + pub prefer: Option, +} + +impl UpstreamConfig { + pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool { + match self.prefer { + Some(6) => true, + Some(4) => false, + _ => default_prefer_ipv6, + } + } +} diff --git a/src/config/types/policies.rs b/src/config/types/policies.rs new file mode 100644 index 0000000..6dad3b0 --- /dev/null +++ b/src/config/types/policies.rs @@ -0,0 +1,230 @@ +use super::*; + +/// Middle-End telemetry verbosity level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MeTelemetryLevel { + #[default] + Normal, + Silent, + Debug, +} + +impl MeTelemetryLevel { + pub fn as_u8(self) -> u8 { + match self { + MeTelemetryLevel::Silent => 0, + MeTelemetryLevel::Normal => 1, + MeTelemetryLevel::Debug => 2, + } + } + + pub fn from_u8(raw: u8) -> Self { + match raw { + 0 => MeTelemetryLevel::Silent, + 2 => MeTelemetryLevel::Debug, + _ => MeTelemetryLevel::Normal, + } + } + + pub fn allows_normal(self) -> bool { + !matches!(self, MeTelemetryLevel::Silent) + } + + pub fn allows_debug(self) -> bool { + matches!(self, MeTelemetryLevel::Debug) + } +} + +impl std::fmt::Display for MeTelemetryLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MeTelemetryLevel::Silent => write!(f, "silent"), + MeTelemetryLevel::Normal => write!(f, "normal"), + MeTelemetryLevel::Debug => write!(f, "debug"), + } + } +} + +/// Middle-End SOCKS KDF fallback policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MeSocksKdfPolicy { + #[default] + Strict, + Compat, +} + +impl MeSocksKdfPolicy { + pub fn as_u8(self) -> u8 { + match self { + MeSocksKdfPolicy::Strict => 0, + MeSocksKdfPolicy::Compat => 1, + } + } + + pub fn from_u8(raw: u8) -> Self { + match raw { + 1 => MeSocksKdfPolicy::Compat, + _ => MeSocksKdfPolicy::Strict, + } + } +} + +/// Stale ME writer bind policy during drain window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MeBindStaleMode { + #[default] + Never, + Ttl, + Always, +} + +impl MeBindStaleMode { + pub fn as_u8(self) -> u8 { + match self { + MeBindStaleMode::Never => 0, + MeBindStaleMode::Ttl => 1, + MeBindStaleMode::Always => 2, + } + } + + pub fn from_u8(raw: u8) -> Self { + match raw { + 0 => MeBindStaleMode::Never, + 2 => MeBindStaleMode::Always, + _ => MeBindStaleMode::Ttl, + } + } +} + +/// RST-on-close mode for accepted client sockets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RstOnCloseMode { + /// Normal FIN on all closes (default, no behaviour change). + #[default] + Off, + /// SO_LINGER(0) on accept; cleared after successful auth. + /// Pre-handshake failures (scanners, DPI, timeouts) send RST; + /// authenticated relay sessions close gracefully with FIN. + Errors, + /// SO_LINGER(0) on accept, never cleared — all closes send RST. + Always, +} + +/// Middle-End writer floor policy mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MeFloorMode { + Static, + #[default] + Adaptive, +} + +impl MeFloorMode { + pub fn as_u8(self) -> u8 { + match self { + MeFloorMode::Static => 0, + MeFloorMode::Adaptive => 1, + } + } + + pub fn from_u8(raw: u8) -> Self { + match raw { + 1 => MeFloorMode::Adaptive, + _ => MeFloorMode::Static, + } + } +} + +/// Middle-End route behavior when no writer is immediately available. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MeRouteNoWriterMode { + AsyncRecoveryFailfast, + InlineRecoveryLegacy, + #[default] + HybridAsyncPersistent, +} + +impl MeRouteNoWriterMode { + pub fn as_u8(self) -> u8 { + match self { + MeRouteNoWriterMode::AsyncRecoveryFailfast => 0, + MeRouteNoWriterMode::InlineRecoveryLegacy => 1, + MeRouteNoWriterMode::HybridAsyncPersistent => 2, + } + } + + pub fn from_u8(raw: u8) -> Self { + match raw { + 0 => MeRouteNoWriterMode::AsyncRecoveryFailfast, + 1 => MeRouteNoWriterMode::InlineRecoveryLegacy, + 2 => MeRouteNoWriterMode::HybridAsyncPersistent, + _ => MeRouteNoWriterMode::HybridAsyncPersistent, + } + } +} + +/// Middle-End writer selection mode for new client bindings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MeWriterPickMode { + SortedRr, + #[default] + P2c, +} + +impl MeWriterPickMode { + pub fn as_u8(self) -> u8 { + match self { + MeWriterPickMode::SortedRr => 0, + MeWriterPickMode::P2c => 1, + } + } + + pub fn from_u8(raw: u8) -> Self { + match raw { + 0 => MeWriterPickMode::SortedRr, + 1 => MeWriterPickMode::P2c, + _ => MeWriterPickMode::P2c, + } + } +} + +/// Per-user unique source IP limit mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum UserMaxUniqueIpsMode { + /// Count only currently active source IPs. + #[default] + ActiveWindow, + /// Count source IPs seen within the recent time window. + TimeWindow, + /// Enforce both active and recent-window limits at the same time. + Combined, +} + +/// Telemetry controls for hot-path counters and ME diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TelemetryConfig { + #[serde(default = "default_true")] + pub core_enabled: bool, + #[serde(default = "default_true")] + pub user_enabled: bool, + #[serde(default)] + pub me_level: MeTelemetryLevel, +} + +impl Default for TelemetryConfig { + fn default() -> Self { + Self { + core_enabled: default_true(), + user_enabled: default_true(), + me_level: MeTelemetryLevel::Normal, + } + } +} + diff --git a/src/config/types/server.rs b/src/config/types/server.rs new file mode 100644 index 0000000..a1153a5 --- /dev/null +++ b/src/config/types/server.rs @@ -0,0 +1,495 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ConntrackMode { + #[default] + Tracked, + Notrack, + Hybrid, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ConntrackBackend { + #[default] + Auto, + Nftables, + Iptables, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ConntrackPressureProfile { + Conservative, + #[default] + Balanced, + Aggressive, +} + +impl ConntrackPressureProfile { + pub fn client_first_byte_idle_cap_secs(self) -> u64 { + match self { + Self::Conservative => 30, + Self::Balanced => 20, + Self::Aggressive => 10, + } + } + + pub fn direct_activity_timeout_secs(self) -> u64 { + match self { + Self::Conservative => 180, + Self::Balanced => 120, + Self::Aggressive => 60, + } + } + + pub fn middle_soft_idle_cap_secs(self) -> u64 { + match self { + Self::Conservative => 60, + Self::Balanced => 30, + Self::Aggressive => 20, + } + } + + pub fn middle_hard_idle_cap_secs(self) -> u64 { + match self { + Self::Conservative => 180, + Self::Balanced => 90, + Self::Aggressive => 60, + } + } +} + +/// Per-listener SYN limiter mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SynLimitMode { + /// Disable SYN limiting for this listener. + #[default] + Off, + /// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match. + Iptables, + /// Use nftables two-tier SYN-fix rules with per-source token-bucket meters. + Nftables, + /// Use FreeBSD PF source tracking with connection-rate state limits. + Pf, +} + +impl Serialize for SynLimitMode { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + match self { + Self::Off => serializer.serialize_bool(false), + Self::Iptables => serializer.serialize_str("iptables"), + Self::Nftables => serializer.serialize_str("nftables"), + Self::Pf => serializer.serialize_str("pf"), + } + } +} + +impl<'de> Deserialize<'de> for SynLimitMode { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct SynLimitModeVisitor; + + impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor { + type Value = SynLimitMode; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("false, iptables, nftables, or pf") + } + + fn visit_bool(self, value: bool) -> std::result::Result + where + E: serde::de::Error, + { + if value { + Err(E::custom( + "synlimit=true is ambiguous; use \"iptables\", \"nftables\", or \"pf\"", + )) + } else { + Ok(SynLimitMode::Off) + } + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value.trim().to_ascii_lowercase().as_str() { + "false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off), + "iptables" => Ok(SynLimitMode::Iptables), + "nftables" => Ok(SynLimitMode::Nftables), + "pf" => Ok(SynLimitMode::Pf), + _ => Err(E::custom( + "synlimit must be false, \"iptables\", \"nftables\", or \"pf\"", + )), + } + } + } + + deserializer.deserialize_any(SynLimitModeVisitor) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConntrackControlConfig { + /// Enables runtime conntrack-control worker for pressure mitigation. + #[serde(default = "default_conntrack_control_enabled")] + pub inline_conntrack_control: bool, + + /// Tracks whether inline_conntrack_control was explicitly set in config. + #[serde(skip)] + pub inline_conntrack_control_explicit: bool, + + /// Conntrack mode for listener ingress traffic. + #[serde(default)] + pub mode: ConntrackMode, + + /// Netfilter backend used to reconcile notrack rules. + #[serde(default)] + pub backend: ConntrackBackend, + + /// Pressure profile for timeout caps under resource saturation. + #[serde(default)] + pub profile: ConntrackPressureProfile, + + /// Listener IP allow-list for hybrid mode. + /// Ignored in tracked/notrack mode. + #[serde(default)] + pub hybrid_listener_ips: Vec, + + /// Pressure high watermark as percentage. + #[serde(default = "default_conntrack_pressure_high_watermark_pct")] + pub pressure_high_watermark_pct: u8, + + /// Pressure low watermark as percentage. + #[serde(default = "default_conntrack_pressure_low_watermark_pct")] + pub pressure_low_watermark_pct: u8, + + /// Maximum conntrack delete operations per second. + #[serde(default = "default_conntrack_delete_budget_per_sec")] + pub delete_budget_per_sec: u64, +} + +impl Default for ConntrackControlConfig { + fn default() -> Self { + Self { + inline_conntrack_control: default_conntrack_control_enabled(), + inline_conntrack_control_explicit: false, + mode: ConntrackMode::default(), + backend: ConntrackBackend::default(), + profile: ConntrackPressureProfile::default(), + hybrid_listener_ips: Vec::new(), + pressure_high_watermark_pct: default_conntrack_pressure_high_watermark_pct(), + pressure_low_watermark_pct: default_conntrack_pressure_low_watermark_pct(), + delete_budget_per_sec: default_conntrack_delete_budget_per_sec(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + /// Legacy listener port used for backward compatibility. + /// For new configs prefer `[[server.listeners]].port`. + #[serde(default = "default_port")] + pub port: u16, + + #[serde(default = "default_listen_addr_ipv4")] + pub listen_addr_ipv4: Option, + + #[serde(default = "default_listen_addr_ipv6_opt")] + pub listen_addr_ipv6: Option, + + #[serde(default)] + pub listen_unix_sock: Option, + + /// Unix socket file permissions (octal, e.g. "0666" or "0777"). + /// Applied via chmod after bind. Default: no change (inherits umask). + #[serde(default)] + pub listen_unix_sock_perm: Option, + + /// Enable TCP listening. Default: true when no unix socket, false when + /// listen_unix_sock is set. Set explicitly to override auto-detection. + #[serde(default)] + pub listen_tcp: Option, + + /// Client-facing TCP MSS preset or custom value for all TCP listeners. + /// Empty string or omitted value keeps the kernel default. + #[serde(default)] + pub client_mss: Option, + + /// Experimental Linux-only bulk MSS used with best-effort userspace + /// chunking of the authenticated FakeTLS response. TCP offloads, loss, and + /// retransmission may coalesce write boundaries. Empty or omitted keeps + /// `client_mss` connection-wide. Uses the same preset/integer grammar as + /// `client_mss`. + #[serde(default)] + pub client_mss_bulk: Option, + + /// Accept HAProxy PROXY protocol headers on incoming connections. + /// When enabled, real client IPs are extracted from PROXY v1/v2 headers. + #[serde(default)] + pub proxy_protocol: bool, + + /// Timeout in milliseconds for reading and parsing PROXY protocol headers. + #[serde(default = "default_proxy_protocol_header_timeout_ms")] + pub proxy_protocol_header_timeout_ms: u64, + + /// Trusted source CIDRs allowed to send incoming PROXY protocol headers. + /// + /// If this field is omitted in config, it defaults to trust-all CIDRs + /// (`0.0.0.0/0` and `::/0`). If it is explicitly set to an empty list, + /// all PROXY protocol headers are rejected. + #[serde(default = "default_proxy_protocol_trusted_cidrs")] + pub proxy_protocol_trusted_cidrs: Vec, + + /// Port for the Prometheus-compatible metrics endpoint. + /// Enables metrics when set; binds on all interfaces (dual-stack) by default. + #[serde(default)] + pub metrics_port: Option, + + /// Listen address for metrics in `IP:PORT` format (e.g. `"127.0.0.1:9090"`). + /// When set, takes precedence over `metrics_port` and binds on the specified address only. + #[serde(default)] + pub metrics_listen: Option, + + /// CIDR whitelist for the metrics endpoint. + #[serde(default = "default_metrics_whitelist")] + pub metrics_whitelist: Vec, + + #[serde(default, alias = "admin_api")] + pub api: ApiConfig, + + #[serde(default)] + pub listeners: Vec, + + /// TCP `listen(2)` backlog for client-facing sockets (also used for the metrics HTTP listener). + /// The effective queue is capped by the kernel (for example `somaxconn` on Linux). + #[serde(default = "default_listen_backlog")] + pub listen_backlog: u32, + + /// Maximum number of concurrent client connections. + /// 0 means unlimited. + #[serde(default = "default_server_max_connections")] + pub max_connections: u32, + + /// Maximum wait in milliseconds while acquiring a connection slot permit. + /// `0` keeps legacy unbounded wait behavior. + #[serde(default = "default_accept_permit_timeout_ms")] + pub accept_permit_timeout_ms: u64, + + /// Runtime conntrack control and pressure policy. + #[serde(default)] + pub conntrack_control: ConntrackControlConfig, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + port: default_port(), + listen_addr_ipv4: default_listen_addr_ipv4(), + listen_addr_ipv6: default_listen_addr_ipv6_opt(), + listen_unix_sock: None, + listen_unix_sock_perm: None, + listen_tcp: None, + client_mss: None, + client_mss_bulk: None, + proxy_protocol: false, + proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(), + proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(), + metrics_port: None, + metrics_listen: None, + metrics_whitelist: default_metrics_whitelist(), + api: ApiConfig::default(), + listeners: Vec::new(), + listen_backlog: default_listen_backlog(), + max_connections: default_server_max_connections(), + accept_permit_timeout_ms: default_accept_permit_timeout_ms(), + conntrack_control: ConntrackControlConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimeoutsConfig { + /// Maximum idle wait in seconds for the first client byte before handshake parsing starts. + /// `0` disables the separate idle phase and keeps legacy timeout behavior. + #[serde(default = "default_client_first_byte_idle_secs")] + pub client_first_byte_idle_secs: u64, + + /// Maximum active handshake duration in seconds after the first client byte is received. + #[serde(default = "default_handshake_timeout")] + pub client_handshake: u64, + + /// Enables soft/hard relay client idle policy for middle-relay sessions. + #[serde(default = "default_relay_idle_policy_v2_enabled")] + pub relay_idle_policy_v2_enabled: bool, + + /// Soft idle threshold for middle-relay client uplink activity in seconds. + /// Hitting this threshold marks the session as idle-candidate, but does not close it. + #[serde(default = "default_relay_client_idle_soft_secs")] + pub relay_client_idle_soft_secs: u64, + + /// Hard idle threshold for middle-relay client uplink activity in seconds. + /// Hitting this threshold closes the session. + #[serde(default = "default_relay_client_idle_hard_secs")] + pub relay_client_idle_hard_secs: u64, + + /// Additional grace in seconds added to hard idle window after recent downstream activity. + #[serde(default = "default_relay_idle_grace_after_downstream_activity_secs")] + pub relay_idle_grace_after_downstream_activity_secs: u64, + + #[serde(default = "default_keepalive")] + pub client_keepalive: u64, + + #[serde(default = "default_ack_timeout")] + pub client_ack: u64, + + /// Number of quick ME reconnect attempts for single-address DC. + #[serde(default = "default_me_one_retry")] + pub me_one_retry: u8, + + /// Timeout per quick attempt in milliseconds for single-address DC. + #[serde(default = "default_me_one_timeout")] + pub me_one_timeout_ms: u64, +} + +impl Default for TimeoutsConfig { + fn default() -> Self { + Self { + client_first_byte_idle_secs: default_client_first_byte_idle_secs(), + client_handshake: default_handshake_timeout(), + relay_idle_policy_v2_enabled: default_relay_idle_policy_v2_enabled(), + relay_client_idle_soft_secs: default_relay_client_idle_soft_secs(), + relay_client_idle_hard_secs: default_relay_client_idle_hard_secs(), + relay_idle_grace_after_downstream_activity_secs: + default_relay_idle_grace_after_downstream_activity_secs(), + client_keepalive: default_keepalive(), + client_ack: default_ack_timeout(), + me_one_retry: default_me_one_retry(), + me_one_timeout_ms: default_me_one_timeout(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListenerConfig { + pub ip: IpAddr, + /// Per-listener TCP port. If omitted, falls back to legacy `server.port`. + #[serde(default)] + pub port: Option, + /// Per-listener client-facing TCP MSS preset or custom value. + /// Empty string disables MSS shaping for this listener. + #[serde(default)] + pub client_mss: Option, + /// Per-listener SYN limiter mode. + #[serde(default)] + pub synlimit: SynLimitMode, + /// Generic SYN-fix token-bucket rate interval. + #[serde(default = "default_synlimit_seconds")] + pub synlimit_seconds: u32, + /// Generic SYN-fix token-bucket rate amount. + #[serde(default = "default_synlimit_hitcount")] + pub synlimit_hitcount: u32, + /// Generic SYN-fix token-bucket burst size. + #[serde(default = "default_synlimit_burst")] + pub synlimit_burst: u32, + /// iOS-like SYN-fix token-bucket rate interval. + #[serde(default = "default_synlimit_ios_seconds")] + pub synlimit_ios_seconds: u32, + /// iOS-like SYN-fix token-bucket rate amount. + #[serde(default = "default_synlimit_ios_hitcount")] + pub synlimit_ios_hitcount: u32, + /// iOS-like SYN-fix token-bucket burst size. + #[serde(default = "default_synlimit_ios_burst")] + pub synlimit_ios_burst: u32, + /// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules. + #[serde(default = "default_synlimit_hashlimit_expire_ms")] + pub synlimit_hashlimit_expire_ms: u32, + /// Hashlimit table size for iptables/ip6tables rules. + #[serde(default = "default_synlimit_hashlimit_size")] + pub synlimit_hashlimit_size: u32, + /// IP address or hostname to announce in proxy links. + /// Takes precedence over `announce_ip` if both are set. + #[serde(default)] + pub announce: Option, + /// Deprecated: Use `announce` instead. IP address to announce in proxy links. + /// Migrated to `announce` automatically if `announce` is not set. + #[serde(default)] + pub announce_ip: Option, + /// Per-listener PROXY protocol override. When set, overrides global server.proxy_protocol. + #[serde(default)] + pub proxy_protocol: Option, + /// Allow multiple telemt instances to listen on the same IP:port (SO_REUSEPORT). + /// Default is false for safety. + #[serde(default)] + pub reuse_allow: bool, +} + +/// Client-facing TCP MSS preset for extreme-low fragmentation profiles. +pub const CLIENT_MSS_EXTREME_LOW: u16 = 88; +/// Client-facing TCP MSS preset matching TSPU-oriented deployments. +pub const CLIENT_MSS_TSPU: u16 = 92; +/// Client-facing TCP MSS preset for 2-in-8 segment shaping. +pub const CLIENT_MSS_2IN8: u16 = 256; +/// Minimum accepted custom client-facing TCP MSS value. +pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW; +/// Maximum accepted custom client-facing TCP MSS value. +pub const CLIENT_MSS_MAX: u16 = 4096; + +impl ServerConfig { + /// Resolves the global client-facing TCP MSS setting. + pub fn client_mss_value(&self) -> std::result::Result, String> { + parse_client_mss(self.client_mss.as_deref()) + } + + /// Resolves the bulk-transfer client MSS, if configured. + pub fn client_mss_bulk_value(&self) -> std::result::Result, String> { + parse_client_mss(self.client_mss_bulk.as_deref()) + } +} + +impl ListenerConfig { + /// Resolves the listener MSS override, falling back to the global server value. + pub fn effective_client_mss( + &self, + server: &ServerConfig, + ) -> std::result::Result, String> { + match self.client_mss.as_deref() { + Some(value) => parse_client_mss(Some(value)), + None => server.client_mss_value(), + } + } +} + +fn parse_client_mss(raw: Option<&str>) -> std::result::Result, String> { + let Some(raw) = raw else { + return Ok(None); + }; + let value = raw.trim(); + if value.is_empty() { + return Ok(None); + } + + match value.to_ascii_lowercase().as_str() { + "extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)), + "tspu" => return Ok(Some(CLIENT_MSS_TSPU)), + "2in8" => return Ok(Some(CLIENT_MSS_2IN8)), + _ => {} + } + + let parsed = value + .parse::() + .map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?; + if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) { + return Err(format!( + "custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]" + )); + } + Ok(Some(parsed)) +} diff --git a/src/maestro/bootstrap.rs b/src/maestro/bootstrap.rs new file mode 100644 index 0000000..d4a2a80 --- /dev/null +++ b/src/maestro/bootstrap.rs @@ -0,0 +1,384 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use tracing::{info, warn}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload}; + +use crate::config::{LogLevel, ProxyConfig}; +use crate::startup::{ + COMPONENT_CONFIG_LOAD, COMPONENT_TRACING_INIT, StartupTracker, +}; + +use super::helpers::{ + parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path, + set_maestro_colors_enabled, +}; +use super::runtime_tasks; +use super::validate_synlimit_privilege_drop; + +pub(super) struct BootstrapState { + pub(super) process_started_at: Instant, + pub(super) process_started_at_epoch_secs: u64, + pub(super) startup_tracker: Arc, + pub(super) config: ProxyConfig, + pub(super) config_path: PathBuf, + pub(super) has_rust_log: bool, + pub(super) effective_log_level: LogLevel, + pub(super) runtime_log_filter: runtime_tasks::RuntimeLogFilter, + pub(super) logging_guard: Option, +} + +pub(super) async fn bootstrap( + privilege_drop_requested: bool, +) -> std::result::Result> { + let process_started_at = Instant::now(); + let process_started_at_epoch_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let startup_tracker = Arc::new(StartupTracker::new(process_started_at_epoch_secs)); + startup_tracker + .start_component( + COMPONENT_CONFIG_LOAD, + Some("load and validate config".to_string()), + ) + .await; + let cli_args = parse_cli(); + let config_path_cli = cli_args.config_path; + let config_path_explicit = cli_args.config_path_explicit; + let data_path = cli_args.data_path; + let cli_silent = cli_args.silent; + let cli_log_level = cli_args.log_level; + let log_cli_options = cli_args.log_cli_options; + let startup_cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(e) => { + eprintln!("[telemt] Can't read current_dir: {}", e); + 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, + Err(e) => { + if config_path.exists() { + eprintln!("[telemt] Error: {}", e); + std::process::exit(1); + } else { + let default = ProxyConfig::default(); + + let serialized = + match toml::to_string_pretty(&default).or_else(|_| toml::to_string(&default)) { + Ok(value) => Some(value), + Err(serialize_error) => { + eprintln!( + "[telemt] Warning: failed to serialize default config: {}", + serialize_error + ); + None + } + }; + + if config_path_explicit { + if let Some(serialized) = serialized.as_ref() { + if let Err(write_error) = std::fs::write(&config_path, serialized) { + eprintln!( + "[telemt] Error: failed to create explicit config at {}: {}", + config_path.display(), + write_error + ); + std::process::exit(1); + } + eprintln!( + "[telemt] Created default config at {}", + config_path.display() + ); + } else { + eprintln!( + "[telemt] Warning: running with in-memory default config without writing to disk" + ); + } + } else { + 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(&runtime_base_dir) { + Ok(()) => match std::fs::write(&runtime_config_path, serialized) { + Ok(()) => { + config_path = runtime_config_path; + eprintln!( + "[telemt] Created default config at {}", + config_path.display() + ); + persisted = true; + } + Err(write_error) => { + eprintln!( + "[telemt] Warning: failed to write default config at {}: {}", + runtime_config_path.display(), + write_error + ); + } + }, + Err(create_error) => { + eprintln!( + "[telemt] Warning: failed to create {}: {}", + runtime_base_dir.display(), + create_error + ); + } + } + + if !persisted { + match std::fs::write(&fallback_config_path, serialized) { + Ok(()) => { + config_path = fallback_config_path; + eprintln!( + "[telemt] Created default config at {}", + config_path.display() + ); + persisted = true; + } + Err(write_error) => { + eprintln!( + "[telemt] Warning: failed to write default config at {}: {}", + fallback_config_path.display(), + write_error + ); + } + } + } + } + + if !persisted { + eprintln!( + "[telemt] Warning: running with in-memory default config without writing to disk" + ); + } + } + default + } + } + }; + + if let Err(e) = config.validate() { + eprintln!("[telemt] Invalid config: {}", e); + std::process::exit(1); + } + validate_synlimit_privilege_drop(&config, privilege_drop_requested)?; + + if let Some(p) = data_path { + config.general.data_path = Some(p); + } + + if let Some(ref data_path) = config.general.data_path { + if !data_path.is_absolute() { + eprintln!( + "[telemt] data_path must be absolute: {}", + data_path.display() + ); + std::process::exit(1); + } + + if data_path.exists() { + if !data_path.is_dir() { + eprintln!( + "[telemt] data_path exists but is not a directory: {}", + data_path.display() + ); + std::process::exit(1); + } + } else if let Err(e) = std::fs::create_dir_all(data_path) { + eprintln!( + "[telemt] Can't create data_path {}: {}", + data_path.display(), + e + ); + std::process::exit(1); + } + + if let Err(e) = std::env::set_current_dir(data_path) { + eprintln!( + "[telemt] Can't use data_path {}: {}", + data_path.display(), + e + ); + std::process::exit(1); + } + } + + if let Err(e) = crate::network::dns_overrides::install_entries(&config.network.dns_overrides) { + eprintln!("[telemt] Invalid network.dns_overrides: {}", e); + std::process::exit(1); + } + set_maestro_colors_enabled(!config.general.disable_colors); + startup_tracker + .complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string())) + .await; + + let has_rust_log = std::env::var("RUST_LOG").is_ok(); + let effective_log_level = if cli_silent { + LogLevel::Silent + } else if let Some(ref s) = cli_log_level { + LogLevel::from_str_loose(s) + } else { + config.general.log_level.clone() + }; + + let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level); + let log_destination = + match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) { + Ok(destination) => destination, + Err(error) => { + eprintln!("[telemt] {error}"); + std::process::exit(1); + } + }; + let (filter_layer, filter_handle) = + tracing_reload::Layer::new(EnvFilter::new(initial_filter_spec.clone())); + startup_tracker + .start_component( + COMPONENT_TRACING_INIT, + Some("initialize tracing subscriber".to_string()), + ) + .await; + + let logging_guard: Option; + match log_destination { + crate::logging::LogDestination::Stderr => { + let fmt_layer = if config.general.disable_colors { + fmt::Layer::default().with_ansi(false) + } else { + fmt::Layer::default().with_ansi(true) + }; + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + logging_guard = None; + } + #[cfg(unix)] + crate::logging::LogDestination::Syslog => { + let logging_opts = crate::logging::LoggingOptions { + destination: log_destination, + disable_colors: true, + }; + let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec); + logging_guard = Some(guard); + } + crate::logging::LogDestination::File { .. } => { + let logging_opts = crate::logging::LoggingOptions { + destination: log_destination, + disable_colors: true, + }; + let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec); + logging_guard = Some(guard); + } + } + let runtime_log_filter = runtime_tasks::RuntimeLogFilter::new(filter_handle); + + startup_tracker + .complete_component( + COMPONENT_TRACING_INIT, + Some("tracing initialized".to_string()), + ) + .await; + + print_maestro_line(format!("Telemt MTProxy v{}", env!("CARGO_PKG_VERSION"))); + info!("Log level: {}", effective_log_level); + if config.general.disable_colors { + info!("Colors: disabled"); + } + info!( + "Modes: classic={} secure={} tls={}", + config.general.modes.classic, config.general.modes.secure, config.general.modes.tls + ); + if config.general.modes.classic { + warn!("Classic mode is vulnerable to DPI detection; enable only for legacy clients"); + } + info!("TLS domain: {}", config.censorship.tls_domain); + if let Some(ref sock) = config.censorship.mask_unix_sock { + info!("Mask: {} -> unix:{}", config.censorship.mask, sock); + if !std::path::Path::new(sock).exists() { + warn!( + "Unix socket '{}' does not exist yet. Masking will fail until it appears.", + sock + ); + } + } else { + info!( + "Mask: {} -> {}:{}", + config.censorship.mask, + config + .censorship + .mask_host + .as_deref() + .unwrap_or(&config.censorship.tls_domain), + config.censorship.mask_port + ); + } + + if config.censorship.tls_domain == "www.google.com" { + warn!("Using default tls_domain. Consider setting a custom domain."); + } + + Ok(BootstrapState { + process_started_at, + process_started_at_epoch_secs, + startup_tracker, + config, + config_path, + has_rust_log, + effective_log_level, + runtime_log_filter, + logging_guard, + }) +} diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index 3540ce5..9d065a7 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -1,64 +1,22 @@ -use std::error::Error; -use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; -use std::time::Duration; - -use arc_swap::ArcSwap; -use tokio::net::TcpListener; -#[cfg(unix)] -use tokio::net::UnixListener; -use tracing::{debug, error, info, warn}; - -use crate::config::{ProxyConfig, RstOnCloseMode}; -use crate::proxy::ClientHandler; -use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker}; -use crate::transport::socket::set_linger_zero; -use crate::transport::{ListenOptions, create_listener, find_listener_processes}; - -use super::generation::RuntimeGeneration; -use super::helpers::{ - expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, - print_proxy_links, -}; +//! Client listener planning, binding, lifecycle control, and accept loops. +//! +//! Submodules keep process-owned socket state separate from generation-owned +//! runtime state: +//! - `plan` derives deterministic bind intent from validated configuration. +//! - `bind` prepares and activates sockets without partial startup binding. +//! - `accept` runs cancellation-aware TCP accept loops. +//! - `control` coordinates reversible listener transitions and shutdown. +mod accept; +mod bind; +mod control; +mod plan; #[cfg(unix)] mod unix; -#[cfg(unix)] -/// Runs the Unix socket accept loop against the active runtime generation. -pub(crate) use unix::spawn_unix_accept_loop; -/// Owns the sockets bound during process startup. -pub(crate) struct BoundListeners { - /// TCP listeners and their immutable bind-time settings. - pub(crate) listeners: Vec, - #[cfg(unix)] - /// The optional Unix listener transferred to its accept loop after startup. - pub(crate) unix_listener: Option, -} - -/// A TCP listener and the connection settings fixed when it was bound. -pub(crate) struct BoundTcpListener { - listener: TcpListener, - proxy_protocol: bool, - tls_response_fragment_size: Option, -} - -fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, config: &ProxyConfig) -> u16 { - listener.port.unwrap_or(config.server.port) -} - -fn default_link_port(config: &ProxyConfig) -> u16 { - config - .server - .listeners - .first() - .and_then(|listener| listener.port) - .unwrap_or(config.server.port) -} - -fn mss_segment_multiplier(client_mss: u16) -> u16 { - 1460u16.div_ceil(client_mss) -} +pub(crate) use bind::bind_listeners; +pub(crate) use control::{ListenerManager, PreparedListenerTransition}; +pub(crate) use plan::listener_rebind_supported; #[cfg(any(target_os = "linux", test))] fn tcp_mss_runtime_profile( @@ -72,441 +30,6 @@ fn tcp_mss_runtime_profile( } } -#[allow(clippy::too_many_arguments)] -/// Binds configured TCP and Unix listeners without starting accept loops. -pub(crate) async fn bind_listeners( - config: &Arc, - decision_ipv4_dc: bool, - decision_ipv6_dc: bool, - detected_ip_v4: Option, - detected_ip_v6: Option, - startup_tracker: &Arc, -) -> Result> { - startup_tracker - .start_component( - COMPONENT_LISTENERS_BIND, - Some("bind TCP/Unix listeners".to_string()), - ) - .await; - let mut listeners = Vec::new(); - let bulk_client_mss = match config.server.client_mss_bulk_value() { - Ok(value) => value, - Err(error) => { - warn!( - error = %error, - "Invalid bulk client MSS after config validation; disabling bulk MSS" - ); - None - } - }; - - for listener_conf in &config.server.listeners { - let listener_port = listener_port_or_legacy(listener_conf, config); - let addr = SocketAddr::new(listener_conf.ip, listener_port); - if addr.is_ipv4() && !decision_ipv4_dc { - warn!(%addr, "Skipping IPv4 listener: IPv4 disabled by [network]"); - continue; - } - if addr.is_ipv6() && !decision_ipv6_dc { - warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]"); - continue; - } - let configured_client_mss = match listener_conf.effective_client_mss(&config.server) { - Ok(value) => value, - Err(error) => { - warn!( - %addr, - error = %error, - "Invalid listener client MSS after config validation; using kernel default" - ); - None - } - }; - #[cfg(target_os = "linux")] - let (client_mss, tls_response_fragment_size) = - tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss); - #[cfg(not(target_os = "linux"))] - let (client_mss, tls_response_fragment_size) = (configured_client_mss, None); - let options = ListenOptions { - reuse_port: listener_conf.reuse_allow, - ipv6_only: listener_conf.ip.is_ipv6(), - backlog: config.server.listen_backlog, - client_mss, - ..Default::default() - }; - - match create_listener(addr, &options) { - Ok(socket) => { - let listener = TcpListener::from_std(socket.into())?; - info!("Listening on {}", addr); - if let Some(client_mss) = client_mss { - info!( - %addr, - client_mss, - segment_multiplier = mss_segment_multiplier(client_mss), - "Client-facing TCP MSS configured" - ); - } - if let Some(fragment_size) = tls_response_fragment_size { - info!( - %addr, - fragment_size, - bulk_mss = client_mss, - "Initial FakeTLS response best-effort chunking configured" - ); - } - let listener_proxy_protocol = listener_conf - .proxy_protocol - .unwrap_or(config.server.proxy_protocol); - - let public_host = if let Some(ref announce) = listener_conf.announce { - announce.clone() - } else if listener_conf.ip.is_unspecified() { - if listener_conf.ip.is_ipv4() { - detected_ip_v4 - .map(|ip| ip.to_string()) - .unwrap_or_else(|| listener_conf.ip.to_string()) - } else { - detected_ip_v6 - .map(|ip| ip.to_string()) - .unwrap_or_else(|| listener_conf.ip.to_string()) - } - } else { - listener_conf.ip.to_string() - }; - - if config.general.links.public_host.is_none() - && !config.general.links.show.is_empty() - { - let link_port = config.general.links.public_port.unwrap_or(listener_port); - print_proxy_links(&public_host, link_port, config); - } - - listeners.push(BoundTcpListener { - listener, - proxy_protocol: listener_proxy_protocol, - tls_response_fragment_size, - }); - } - Err(e) => { - if e.kind() == std::io::ErrorKind::AddrInUse { - let owners = find_listener_processes(addr); - if owners.is_empty() { - error!( - %addr, - "Failed to bind: address already in use (owner process unresolved)" - ); - } else { - for owner in owners { - error!( - %addr, - pid = owner.pid, - process = %owner.process, - "Failed to bind: address already in use" - ); - } - } - - if !listener_conf.reuse_allow { - error!( - %addr, - "reuse_allow=false; set [[server.listeners]].reuse_allow=true to allow multi-instance listening" - ); - } - } else { - error!("Failed to bind to {}: {}", addr, e); - } - } - } - } - - if !config.general.links.show.is_empty() - && (config.general.links.public_host.is_some() || listeners.is_empty()) - { - let (host, port) = if let Some(ref h) = config.general.links.public_host { - ( - h.clone(), - config - .general - .links - .public_port - .unwrap_or(default_link_port(config)), - ) - } else { - let ip = detected_ip_v4.or(detected_ip_v6).map(|ip| ip.to_string()); - if ip.is_none() { - warn!( - "show_link is configured but public IP could not be detected. Set public_host in config." - ); - } - ( - ip.unwrap_or_else(|| "UNKNOWN".to_string()), - config - .general - .links - .public_port - .unwrap_or(default_link_port(config)), - ) - }; - - print_proxy_links(&host, port, config); - } - - #[cfg(unix)] - let mut unix_listener_out = None; - #[cfg(unix)] - if let Some(ref unix_path) = config.server.listen_unix_sock { - let _ = tokio::fs::remove_file(unix_path).await; - - let unix_listener = UnixListener::bind(unix_path)?; - - if let Some(ref perm_str) = config.server.listen_unix_sock_perm { - match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) { - Ok(mode) => { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(mode); - if let Err(e) = std::fs::set_permissions(unix_path, perms) { - error!( - "Failed to set unix socket permissions to {}: {}", - perm_str, e - ); - } else { - info!("Listening on unix:{} (mode {})", unix_path, perm_str); - } - } - Err(e) => { - warn!( - "Invalid listen_unix_sock_perm '{}': {}. Ignoring.", - perm_str, e - ); - info!("Listening on unix:{}", unix_path); - } - } - } else { - info!("Listening on unix:{}", unix_path); - } - - unix_listener_out = Some(unix_listener); - } - - #[cfg(unix)] - let has_unix_listener = unix_listener_out.is_some(); - #[cfg(not(unix))] - let has_unix_listener = false; - - startup_tracker - .complete_component( - COMPONENT_LISTENERS_BIND, - Some(format!( - "listeners configured tcp={} unix={}", - listeners.len(), - has_unix_listener - )), - ) - .await; - - Ok(BoundListeners { - listeners, - #[cfg(unix)] - unix_listener: unix_listener_out, - }) -} - -/// Starts one TCP accept loop per bound listener. -pub(crate) fn spawn_tcp_accept_loops( - listeners: Vec, - active_runtime: Arc>, -) { - for bound_listener in listeners { - let listener = bound_listener.listener; - let listener_proxy_protocol = bound_listener.proxy_protocol; - let tls_response_fragment_size = bound_listener.tls_response_fragment_size; - let active_runtime = active_runtime.clone(); - - tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, peer_addr)) => { - let runtime = active_runtime.load_full(); - let config = runtime.config(); - let rst_mode = config.general.rst_on_close; - #[cfg(unix)] - let raw_fd = { - use std::os::unix::io::AsRawFd; - stream.as_raw_fd() - }; - if matches!(rst_mode, RstOnCloseMode::Errors | RstOnCloseMode::Always) { - let _ = set_linger_zero(&stream); - } - if !*runtime.admission_rx.borrow() { - debug!(peer = %peer_addr, "Admission gate closed, dropping connection"); - drop(stream); - continue; - } - let accept_permit_timeout_ms = config.server.accept_permit_timeout_ms; - let permit = if accept_permit_timeout_ms == 0 { - match runtime.max_connections.clone().acquire_owned().await { - Ok(permit) => permit, - Err(_) => { - error!("Connection limiter is closed"); - break; - } - } - } else { - match tokio::time::timeout( - Duration::from_millis(accept_permit_timeout_ms), - runtime.max_connections.clone().acquire_owned(), - ) - .await - { - Ok(Ok(permit)) => permit, - Ok(Err(_)) => { - error!("Connection limiter is closed"); - break; - } - Err(_) => { - runtime.stats.increment_accept_permit_timeout_total(); - debug!( - peer = %peer_addr, - timeout_ms = accept_permit_timeout_ms, - "Dropping accepted connection: permit wait timeout" - ); - drop(stream); - continue; - } - } - }; - let stats = runtime.stats.clone(); - let upstream_manager = runtime.upstream_manager.clone(); - let replay_checker = runtime.replay_checker.clone(); - let buffer_pool = runtime.buffer_pool.clone(); - let rng = runtime.rng.clone(); - let me_pool = runtime.me_pool.clone(); - let me_pool_runtime = runtime.me_pool_runtime.clone(); - let route_runtime = runtime.route_runtime.clone(); - let tls_cache = runtime.tls_cache.clone(); - let ip_tracker = runtime.ip_tracker.clone(); - let beobachten = runtime.beobachten.clone(); - let shared = runtime.proxy_shared.clone(); - let proxy_protocol_enabled = listener_proxy_protocol; - let real_peer_report = Arc::new(std::sync::Mutex::new(None)); - let real_peer_report_for_handler = real_peer_report.clone(); - - let _ = runtime.spawn_session(async move { - let _permit = permit; - if let Err(e) = ClientHandler::new_with_shared( - stream, - peer_addr, - config, - stats, - upstream_manager, - replay_checker, - buffer_pool, - rng, - me_pool, - Some(me_pool_runtime), - route_runtime, - tls_cache, - ip_tracker, - beobachten, - shared, - proxy_protocol_enabled, - real_peer_report_for_handler, - #[cfg(unix)] - raw_fd, - rst_mode, - tls_response_fragment_size, - ) - .run() - .await - { - let real_peer = match real_peer_report.lock() { - Ok(guard) => *guard, - Err(_) => None, - }; - let peer_close_reason = peer_close_description(&e); - let handshake_close_reason = - expected_handshake_close_description(&e); - - let me_closed = - matches!(&e, crate::error::ProxyError::MiddleConnectionLost); - let route_switched = - matches!(&e, crate::error::ProxyError::RouteSwitched); - - match (peer_close_reason, me_closed) { - (Some(reason), _) => { - if let Some(real_peer) = real_peer { - debug!( - peer = %peer_addr, - real_peer = %real_peer, - error = %e, - close_reason = reason, - "Connection closed by peer" - ); - } else { - debug!( - peer = %peer_addr, - error = %e, - close_reason = reason, - "Connection closed by peer" - ); - } - } - (_, true) => { - if let Some(real_peer) = real_peer { - warn!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed: Middle-End dropped session"); - } else { - warn!(peer = %peer_addr, error = %e, "Connection closed: Middle-End dropped session"); - } - } - _ if route_switched => { - if let Some(real_peer) = real_peer { - info!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed by controlled route cutover"); - } else { - info!(peer = %peer_addr, error = %e, "Connection closed by controlled route cutover"); - } - } - _ if is_expected_handshake_eof(&e) => { - let reason = handshake_close_reason - .unwrap_or("Peer closed during initial handshake"); - if let Some(real_peer) = real_peer { - info!( - peer = %peer_addr, - real_peer = %real_peer, - error = %e, - close_reason = reason, - "Connection closed during initial handshake" - ); - } else { - info!( - peer = %peer_addr, - error = %e, - close_reason = reason, - "Connection closed during initial handshake" - ); - } - } - _ => { - if let Some(real_peer) = real_peer { - warn!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed with error"); - } else { - warn!(peer = %peer_addr, error = %e, "Connection closed with error"); - } - } - } - } - }); - } - Err(e) => { - error!("Accept error: {}", e); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); - } -} - #[cfg(test)] mod tests { use super::tcp_mss_runtime_profile; diff --git a/src/maestro/listeners/accept.rs b/src/maestro/listeners/accept.rs new file mode 100644 index 0000000..e1cb6e7 --- /dev/null +++ b/src/maestro/listeners/accept.rs @@ -0,0 +1,269 @@ +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::OwnedSemaphorePermit; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +use crate::config::RstOnCloseMode; +use crate::proxy::ClientHandler; +use crate::transport::socket::set_linger_zero; + +use super::bind::BoundTcpListener; +use super::plan::ListenerBindSpec; +use crate::maestro::generation::RuntimeGeneration; +use crate::maestro::helpers::{ + expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, +}; + +pub(super) struct ListenerSlot { + pub(super) spec: ListenerBindSpec, + listener: Arc, + cancellation: CancellationToken, + task: Option>, +} + +enum PermitWait { + Acquired(OwnedSemaphorePermit), + TimedOut, + Closed, + Cancelled, +} + +async fn wait_for_permit( + runtime: &Arc, + cancellation: &CancellationToken, +) -> PermitWait { + let timeout_ms = runtime.config().server.accept_permit_timeout_ms; + let acquire = runtime.max_connections.clone().acquire_owned(); + if timeout_ms == 0 { + return tokio::select! { + biased; + _ = cancellation.cancelled() => PermitWait::Cancelled, + permit = acquire => match permit { + Ok(permit) => PermitWait::Acquired(permit), + Err(_) => PermitWait::Closed, + }, + }; + } + tokio::select! { + biased; + _ = cancellation.cancelled() => PermitWait::Cancelled, + result = tokio::time::timeout(Duration::from_millis(timeout_ms), acquire) => { + match result { + Ok(Ok(permit)) => PermitWait::Acquired(permit), + Ok(Err(_)) => PermitWait::Closed, + Err(_) => PermitWait::TimedOut, + } + } + } +} + +fn spawn_client_session( + stream: TcpStream, + peer_addr: std::net::SocketAddr, + runtime: Arc, + permit: OwnedSemaphorePermit, + spec: &ListenerBindSpec, +) { + let config = runtime.config(); + let rst_mode = config.general.rst_on_close; + #[cfg(unix)] + let raw_fd = { + use std::os::unix::io::AsRawFd; + stream.as_raw_fd() + }; + if matches!(rst_mode, RstOnCloseMode::Errors | RstOnCloseMode::Always) { + let _ = set_linger_zero(&stream); + } + let stats = runtime.stats.clone(); + let upstream_manager = runtime.upstream_manager.clone(); + let replay_checker = runtime.replay_checker.clone(); + let buffer_pool = runtime.buffer_pool.clone(); + let rng = runtime.rng.clone(); + let me_pool = runtime.me_pool.clone(); + let me_pool_runtime = runtime.me_pool_runtime.clone(); + let route_runtime = runtime.route_runtime.clone(); + let tls_cache = runtime.tls_cache.clone(); + let ip_tracker = runtime.ip_tracker.clone(); + let beobachten = runtime.beobachten.clone(); + let shared = runtime.proxy_shared.clone(); + let proxy_protocol_enabled = spec.proxy_protocol; + let tls_response_fragment_size = spec.tls_response_fragment_size; + let real_peer_report = Arc::new(std::sync::Mutex::new(None)); + let real_peer_report_for_handler = real_peer_report.clone(); + + let _ = runtime.spawn_session(async move { + let _permit = permit; + if let Err(error_value) = ClientHandler::new_with_shared( + stream, + peer_addr, + config, + stats, + upstream_manager, + replay_checker, + buffer_pool, + rng, + me_pool, + Some(me_pool_runtime), + route_runtime, + tls_cache, + ip_tracker, + beobachten, + shared, + proxy_protocol_enabled, + real_peer_report_for_handler, + #[cfg(unix)] + raw_fd, + rst_mode, + tls_response_fragment_size, + ) + .run() + .await + { + let real_peer = real_peer_report.lock().ok().and_then(|guard| *guard); + let peer_close_reason = peer_close_description(&error_value); + let handshake_close_reason = expected_handshake_close_description(&error_value); + let me_closed = matches!( + &error_value, + crate::error::ProxyError::MiddleConnectionLost + ); + let route_switched = + matches!(&error_value, crate::error::ProxyError::RouteSwitched); + + match (peer_close_reason, me_closed) { + (Some(reason), _) => { + if let Some(real_peer) = real_peer { + debug!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, close_reason = reason, "Connection closed by peer"); + } else { + debug!(peer = %peer_addr, error = %error_value, close_reason = reason, "Connection closed by peer"); + } + } + (_, true) => { + if let Some(real_peer) = real_peer { + warn!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, "Connection closed: Middle-End dropped session"); + } else { + warn!(peer = %peer_addr, error = %error_value, "Connection closed: Middle-End dropped session"); + } + } + _ if route_switched => { + if let Some(real_peer) = real_peer { + info!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, "Connection closed by controlled route cutover"); + } else { + info!(peer = %peer_addr, error = %error_value, "Connection closed by controlled route cutover"); + } + } + _ if is_expected_handshake_eof(&error_value) => { + let reason = handshake_close_reason + .unwrap_or("Peer closed during initial handshake"); + if let Some(real_peer) = real_peer { + info!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, close_reason = reason, "Connection closed during initial handshake"); + } else { + info!(peer = %peer_addr, error = %error_value, close_reason = reason, "Connection closed during initial handshake"); + } + } + _ => { + if let Some(real_peer) = real_peer { + warn!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, "Connection closed with error"); + } else { + warn!(peer = %peer_addr, error = %error_value, "Connection closed with error"); + } + } + } + } + }); +} + +async fn run_accept_loop( + listener: Arc, + spec: ListenerBindSpec, + active_runtime: Arc>, + cancellation: CancellationToken, +) { + loop { + let accepted = tokio::select! { + biased; + _ = cancellation.cancelled() => return, + accepted = listener.accept() => accepted, + }; + match accepted { + Ok((stream, peer_addr)) => { + let runtime = active_runtime.load_full(); + if !*runtime.admission_rx.borrow() { + debug!(peer = %peer_addr, "Admission gate closed, dropping connection"); + drop(stream); + continue; + } + match wait_for_permit(&runtime, &cancellation).await { + PermitWait::Acquired(permit) => { + spawn_client_session(stream, peer_addr, runtime, permit, &spec); + } + PermitWait::TimedOut => { + runtime.stats.increment_accept_permit_timeout_total(); + debug!( + peer = %peer_addr, + timeout_ms = runtime.config().server.accept_permit_timeout_ms, + "Dropping accepted connection: permit wait timeout" + ); + } + PermitWait::Closed => { + error!(addr = %spec.addr, "Connection limiter is closed"); + return; + } + PermitWait::Cancelled => return, + } + } + Err(error_value) => { + error!(addr = %spec.addr, error = %error_value, "TCP accept error"); + tokio::select! { + biased; + _ = cancellation.cancelled() => return, + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + } + } + } + } +} + +impl ListenerSlot { + pub(super) fn start( + bound: BoundTcpListener, + active_runtime: Arc>, + ) -> Self { + let cancellation = CancellationToken::new(); + let task = tokio::spawn(run_accept_loop( + bound.listener.clone(), + bound.spec.clone(), + active_runtime, + cancellation.clone(), + )); + Self { + spec: bound.spec, + listener: bound.listener, + cancellation, + task: Some(task), + } + } + + pub(super) async fn stop(&mut self) -> Result<(), String> { + self.cancellation.cancel(); + if let Some(task) = self.task.take() { + task.await + .map_err(|error_value| format!("listener {} task failed: {error_value}", self.spec.addr))?; + } + Ok(()) + } + + pub(super) fn restart(&mut self, active_runtime: Arc>) { + self.cancellation = CancellationToken::new(); + self.task = Some(tokio::spawn(run_accept_loop( + self.listener.clone(), + self.spec.clone(), + active_runtime, + self.cancellation.clone(), + ))); + } +} diff --git a/src/maestro/listeners/bind.rs b/src/maestro/listeners/bind.rs new file mode 100644 index 0000000..ff999dd --- /dev/null +++ b/src/maestro/listeners/bind.rs @@ -0,0 +1,260 @@ +use std::error::Error; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use socket2::Socket; +use tokio::net::TcpListener; +#[cfg(unix)] +use tokio::net::UnixListener; +use tracing::{error, info, warn}; + +use crate::config::ProxyConfig; +use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker}; +use crate::transport::socket::{activate_listener_socket, bind_listener_socket}; +use crate::transport::find_listener_processes; + +use super::plan::{ListenerBindSpec, listener_bind_plan}; +use crate::maestro::helpers::print_proxy_links; + +/// Owns sockets bound before process accept loops start. +pub(crate) struct BoundListeners { + pub(super) listeners: Vec, + #[cfg(unix)] + pub(super) unix_listener: Option, +} + +impl BoundListeners { + pub(crate) fn is_empty(&self) -> bool { + let tcp_empty = self.listeners.is_empty(); + #[cfg(unix)] + { + tcp_empty && self.unix_listener.is_none() + } + #[cfg(not(unix))] + { + tcp_empty + } + } +} + +/// Active socket and immutable connection policy for one endpoint. +pub(super) struct BoundTcpListener { + pub(super) listener: Arc, + pub(super) spec: ListenerBindSpec, +} + +/// Socket bound for a candidate transition but not yet listening. +pub(super) struct PreparedTcpListener { + socket: Socket, + spec: ListenerBindSpec, +} + +fn mss_segment_multiplier(client_mss: u16) -> u16 { + 1460u16.div_ceil(client_mss) +} + +fn default_link_port(config: &ProxyConfig) -> u16 { + config + .server + .listeners + .first() + .and_then(|listener| listener.port) + .unwrap_or(config.server.port) +} + +fn log_bind_error(addr: SocketAddr, reuse_allow: bool, error_value: &std::io::Error) { + if error_value.kind() == std::io::ErrorKind::AddrInUse { + let owners = find_listener_processes(addr); + if owners.is_empty() { + error!(%addr, "Failed to bind: address already in use (owner process unresolved)"); + } else { + for owner in owners { + error!( + %addr, + pid = owner.pid, + process = %owner.process, + "Failed to bind: address already in use" + ); + } + } + if !reuse_allow { + error!( + %addr, + "reuse_allow=false; set [[server.listeners]].reuse_allow=true to allow multi-instance listening" + ); + } + } else { + error!(%addr, error = %error_value, "Failed to bind listener"); + } +} + +pub(super) fn prepare_listener( + spec: ListenerBindSpec, +) -> std::io::Result { + match bind_listener_socket(spec.addr, &spec.options) { + Ok(socket) => Ok(PreparedTcpListener { socket, spec }), + Err(error_value) => { + log_bind_error(spec.addr, spec.options.reuse_port, &error_value); + Err(error_value) + } + } +} + +impl PreparedTcpListener { + pub(super) fn activate(self) -> std::io::Result { + activate_listener_socket(&self.socket, self.spec.options.backlog)?; + let listener = TcpListener::from_std(self.socket.into())?; + Ok(BoundTcpListener { + listener: Arc::new(listener), + spec: self.spec, + }) + } +} + +fn log_listener_profile(spec: &ListenerBindSpec) { + info!(addr = %spec.addr, "Listening on TCP endpoint"); + if let Some(client_mss) = spec.options.client_mss { + info!( + addr = %spec.addr, + client_mss, + segment_multiplier = mss_segment_multiplier(client_mss), + "Client-facing TCP MSS configured" + ); + } + if let Some(fragment_size) = spec.tls_response_fragment_size { + info!( + addr = %spec.addr, + fragment_size, + bulk_mss = spec.options.client_mss, + "Initial FakeTLS response best-effort chunking configured" + ); + } +} + +fn print_configured_links( + config: &ProxyConfig, + plan: &std::collections::BTreeMap, + detected_ip_v4: Option, + detected_ip_v6: Option, +) { + for listener in &config.server.listeners { + let port = listener.port.unwrap_or(config.server.port); + let addr = SocketAddr::new(listener.ip, port); + if !plan.contains_key(&addr) || config.general.links.public_host.is_some() { + continue; + } + let public_host = if let Some(announce) = &listener.announce { + announce.clone() + } else if listener.ip.is_unspecified() { + if listener.ip.is_ipv4() { + detected_ip_v4 + } else { + detected_ip_v6 + } + .map(|ip| ip.to_string()) + .unwrap_or_else(|| listener.ip.to_string()) + } else { + listener.ip.to_string() + }; + if !config.general.links.show.is_empty() { + let link_port = config.general.links.public_port.unwrap_or(port); + print_proxy_links(&public_host, link_port, config); + } + } + + if config.general.links.show.is_empty() || config.general.links.public_host.is_none() { + return; + } + let host = config.general.links.public_host.as_deref().unwrap_or_default(); + let port = config + .general + .links + .public_port + .unwrap_or_else(|| default_link_port(config)); + print_proxy_links(host, port, config); +} + +/// Binds every eligible configured listener or fails without a partial inventory. +pub(crate) async fn bind_listeners( + config: &Arc, + detected_ip_v4: Option, + detected_ip_v6: Option, + startup_tracker: &Arc, +) -> Result> { + startup_tracker + .start_component( + COMPONENT_LISTENERS_BIND, + Some("bind TCP/Unix listeners".to_string()), + ) + .await; + let plan = listener_bind_plan(config).map_err(std::io::Error::other)?; + let mut prepared = Vec::with_capacity(plan.len()); + for spec in plan.values().cloned() { + prepared.push(prepare_listener(spec)?); + } + let mut listeners = Vec::with_capacity(prepared.len()); + for candidate in prepared { + let bound = candidate.activate()?; + log_listener_profile(&bound.spec); + listeners.push(bound); + } + print_configured_links(config, &plan, detected_ip_v4, detected_ip_v6); + + #[cfg(unix)] + let mut unix_listener_out = None; + #[cfg(unix)] + if let Some(unix_path) = &config.server.listen_unix_sock { + let _ = tokio::fs::remove_file(unix_path).await; + let unix_listener = UnixListener::bind(unix_path)?; + if let Some(perm_str) = &config.server.listen_unix_sock_perm { + match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) { + Ok(mode) => { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(mode); + if let Err(error_value) = std::fs::set_permissions(unix_path, permissions) { + error!( + path = %unix_path, + permissions = %perm_str, + error = %error_value, + "Failed to set Unix socket permissions" + ); + } else { + info!(path = %unix_path, permissions = %perm_str, "Listening on Unix socket"); + } + } + Err(error_value) => { + warn!( + path = %unix_path, + permissions = %perm_str, + error = %error_value, + "Invalid Unix socket permissions; keeping umask-derived mode" + ); + } + } + } else { + info!(path = %unix_path, "Listening on Unix socket"); + } + unix_listener_out = Some(unix_listener); + } + + #[cfg(unix)] + let has_unix_listener = unix_listener_out.is_some(); + #[cfg(not(unix))] + let has_unix_listener = false; + startup_tracker + .complete_component( + COMPONENT_LISTENERS_BIND, + Some(format!( + "listeners configured tcp={} unix={}", + listeners.len(), + has_unix_listener + )), + ) + .await; + + Ok(BoundListeners { + listeners, + #[cfg(unix)] + unix_listener: unix_listener_out, + }) +} diff --git a/src/maestro/listeners/control.rs b/src/maestro/listeners/control.rs new file mode 100644 index 0000000..dfa9028 --- /dev/null +++ b/src/maestro/listeners/control.rs @@ -0,0 +1,307 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::net::SocketAddr; +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::config::ProxyConfig; +use crate::maestro::generation::RuntimeGeneration; + +use super::accept::ListenerSlot; +use super::bind::{ + BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener, +}; +use super::plan::{ListenerBindSpec, listener_bind_plan}; +#[cfg(unix)] +use super::unix::UnixAcceptHandle; + +/// Process-owned listener inventory and accept-task lifecycle controller. +pub(crate) struct ListenerManager { + active_runtime: Arc>, + slots: BTreeMap, + #[cfg(unix)] + unix: Option, +} + +pub(crate) struct PreparedListenerTransition { + target_specs: BTreeMap, + additions: Vec, + removals: Vec, +} + +pub(crate) struct PendingListenerTransition { + target_specs: BTreeMap, + additions: Vec, + removals: Vec, +} + +impl ListenerManager { + /// Starts accept loops for the complete startup-bound inventory. + pub(crate) fn start( + bound: BoundListeners, + active_runtime: Arc>, + ) -> Self { + let mut slots = BTreeMap::new(); + for listener in bound.listeners { + let addr = listener.spec.addr; + slots.insert(addr, ListenerSlot::start(listener, active_runtime.clone())); + } + #[cfg(unix)] + let unix = bound + .unix_listener + .map(|listener| UnixAcceptHandle::start(listener, active_runtime.clone())); + Self { + active_runtime, + slots, + #[cfg(unix)] + unix, + } + } + + #[cfg(test)] + pub(crate) fn empty(active_runtime: Arc>) -> Self { + Self { + active_runtime, + slots: BTreeMap::new(), + #[cfg(unix)] + unix: None, + } + } + + /// Binds added endpoints without calling `listen(2)` or changing active tasks. + pub(crate) fn prepare_transition( + &self, + desired: &ProxyConfig, + ) -> Result, String> { + let target_specs = listener_bind_plan(desired)?; + let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect(); + let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect(); + if current_addresses == target_addresses + && self + .slots + .iter() + .all(|(addr, slot)| target_specs.get(addr) == Some(&slot.spec)) + { + return Ok(None); + } + for addr in current_addresses.intersection(&target_addresses) { + let current = &self.slots[addr].spec; + let desired_spec = &target_specs[addr]; + if current != desired_spec { + return Err(format!( + "listener {addr} bind policy changed at the same endpoint; process restart required" + )); + } + } + + let mut additions = Vec::new(); + for addr in target_addresses.difference(¤t_addresses) { + let spec = target_specs + .get(addr) + .expect("address originated from target listener plan") + .clone(); + additions.push(prepare_listener(spec).map_err(|error_value| { + format!("failed to prepare listener {addr}: {error_value}") + })?); + } + let removals = current_addresses + .difference(&target_addresses) + .copied() + .collect(); + Ok(Some(PreparedListenerTransition { + target_specs, + additions, + removals, + })) + } + + /// Activates additions and stops removed acceptors before the runtime swap. + pub(crate) async fn begin_transition( + &mut self, + prepared: PreparedListenerTransition, + ) -> Result { + let mut additions = Vec::with_capacity(prepared.additions.len()); + for candidate in prepared.additions { + additions.push(candidate.activate().map_err(|error_value| { + format!("failed to activate prepared listener: {error_value}") + })?); + } + + let mut stopped = Vec::new(); + for addr in &prepared.removals { + let stop_result = self + .slots + .get_mut(addr) + .expect("removal originated from active listener inventory") + .stop() + .await; + if let Err(error_value) = stop_result { + for stopped_addr in stopped { + if let Some(stopped_slot) = self.slots.get_mut(&stopped_addr) { + stopped_slot.restart(self.active_runtime.clone()); + } + } + self.slots + .get_mut(addr) + .expect("failed slot remains in active listener inventory") + .restart(self.active_runtime.clone()); + return Err(error_value); + } + stopped.push(*addr); + } + + Ok(PendingListenerTransition { + target_specs: prepared.target_specs, + additions, + removals: prepared.removals, + }) + } + + /// Publishes new acceptors after the runtime generation has been swapped. + pub(crate) fn finish_transition(&mut self, pending: PendingListenerTransition) { + for addr in pending.removals { + self.slots.remove(&addr); + } + for listener in pending.additions { + let addr = listener.spec.addr; + self.slots.insert( + addr, + ListenerSlot::start(listener, self.active_runtime.clone()), + ); + } + debug_assert_eq!( + self.slots + .iter() + .map(|(addr, slot)| (*addr, slot.spec.clone())) + .collect::>(), + pending.target_specs + ); + } + + /// Stops and joins every accept task before sockets are released. + pub(crate) async fn shutdown(&mut self) -> Result<(), String> { + let mut errors = Vec::new(); + for slot in self.slots.values_mut() { + if let Err(error_value) = slot.stop().await { + errors.push(error_value); + } + } + #[cfg(unix)] + if let Some(unix) = &mut self.unix + && let Err(error_value) = unix.stop().await + { + errors.push(error_value); + } + self.slots.clear(); + #[cfg(unix)] + { + self.unix = None; + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ListenerConfig, SynLimitMode}; + use crate::maestro::generation::test_runtime_generation; + use crate::transport::ListenOptions; + use tokio::net::{TcpListener, TcpStream}; + + fn listener_config(addr: SocketAddr) -> ListenerConfig { + ListenerConfig { + ip: addr.ip(), + port: Some(addr.port()), + client_mss: None, + synlimit: SynLimitMode::Off, + synlimit_seconds: 60, + synlimit_hitcount: 48, + synlimit_burst: 24, + synlimit_ios_seconds: 1, + synlimit_ios_hitcount: 12, + synlimit_ios_burst: 24, + synlimit_hashlimit_expire_ms: 60_000, + synlimit_hashlimit_size: 32_768, + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + } + } + + async fn bound_listener() -> (BoundTcpListener, SocketAddr) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let spec = ListenerBindSpec { + addr, + options: ListenOptions { + reuse_port: false, + ..Default::default() + }, + proxy_protocol: false, + tls_response_fragment_size: None, + }; + ( + BoundTcpListener { + listener: Arc::new(listener), + spec, + }, + addr, + ) + } + + #[tokio::test] + async fn candidate_bind_failure_keeps_old_listener_accepting() { + let runtime = test_runtime_generation(1, ProxyConfig::default()); + let active_runtime = Arc::new(ArcSwap::from(runtime.clone())); + let (old_listener, old_addr) = bound_listener().await; + let bound = BoundListeners { + listeners: vec![old_listener], + #[cfg(unix)] + unix_listener: None, + }; + let mut manager = ListenerManager::start(bound, active_runtime); + let blocker = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let blocked_addr = blocker.local_addr().unwrap(); + let mut desired = ProxyConfig::default(); + desired.server.listeners = vec![listener_config(blocked_addr)]; + + assert!(manager.prepare_transition(&desired).is_err()); + TcpStream::connect(old_addr).await.unwrap(); + + manager.shutdown().await.unwrap(); + runtime.stop_sessions().await; + } + + #[tokio::test] + async fn added_listener_is_dormant_until_transition_begins() { + let runtime = test_runtime_generation(1, ProxyConfig::default()); + let active_runtime = Arc::new(ArcSwap::from(runtime.clone())); + let (old_listener, _old_addr) = bound_listener().await; + let bound = BoundListeners { + listeners: vec![old_listener], + #[cfg(unix)] + unix_listener: None, + }; + let mut manager = ListenerManager::start(bound, active_runtime); + let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let new_addr = reservation.local_addr().unwrap(); + drop(reservation); + let mut desired = ProxyConfig::default(); + desired.server.listeners = vec![listener_config(new_addr)]; + + let prepared = manager.prepare_transition(&desired).unwrap().unwrap(); + assert!(TcpStream::connect(new_addr).await.is_err()); + let pending = manager.begin_transition(prepared).await.unwrap(); + manager.finish_transition(pending); + TcpStream::connect(new_addr).await.unwrap(); + + manager.shutdown().await.unwrap(); + runtime.stop_sessions().await; + } +} diff --git a/src/maestro/listeners/plan.rs b/src/maestro/listeners/plan.rs new file mode 100644 index 0000000..3f22daf --- /dev/null +++ b/src/maestro/listeners/plan.rs @@ -0,0 +1,174 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::net::SocketAddr; + +use crate::config::{ProxyConfig, ServerConfig, SynLimitMode}; +use crate::transport::ListenOptions; + +use super::tcp_mss_runtime_profile; + +/// Immutable socket and connection policy for one listener endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ListenerBindSpec { + pub(super) addr: SocketAddr, + pub(super) options: ListenOptions, + pub(super) proxy_protocol: bool, + pub(super) tls_response_fragment_size: Option, +} + +fn listener_port_or_legacy( + listener: &crate::config::ListenerConfig, + server: &ServerConfig, +) -> u16 { + listener.port.unwrap_or(server.port) +} + +/// Derives inbound listener intent without consulting transient outbound probes. +pub(crate) fn listener_bind_plan( + config: &ProxyConfig, +) -> Result, String> { + let mut plan = BTreeMap::new(); + let bulk_client_mss = config + .server + .client_mss_bulk_value() + .map_err(|error| format!("invalid server.client_mss_bulk: {error}"))?; + + for listener in &config.server.listeners { + let addr = SocketAddr::new( + listener.ip, + listener_port_or_legacy(listener, &config.server), + ); + if addr.is_ipv4() && !config.network.ipv4 { + continue; + } + if addr.is_ipv6() && config.network.ipv6 == Some(false) { + continue; + } + let configured_client_mss = listener + .effective_client_mss(&config.server) + .map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?; + #[cfg(target_os = "linux")] + let (client_mss, tls_response_fragment_size) = + tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss); + #[cfg(not(target_os = "linux"))] + let (client_mss, tls_response_fragment_size) = (configured_client_mss, None); + let spec = ListenerBindSpec { + addr, + options: ListenOptions { + reuse_port: listener.reuse_allow, + ipv6_only: listener.ip.is_ipv6(), + backlog: config.server.listen_backlog, + client_mss, + ..Default::default() + }, + proxy_protocol: listener + .proxy_protocol + .unwrap_or(config.server.proxy_protocol), + tls_response_fragment_size, + }; + if plan.insert(addr, spec).is_some() { + return Err(format!("duplicate effective listener endpoint: {addr}")); + } + } + + Ok(plan) +} + +fn any_synlimit_enabled(config: &ProxyConfig) -> bool { + config + .server + .listeners + .iter() + .any(|listener| listener.synlimit != SynLimitMode::Off) +} + +/// Returns whether an endpoint-only change can use coordinated process rebind. +pub(crate) fn listener_rebind_supported(old: &ProxyConfig, desired: &ProxyConfig) -> bool { + if any_synlimit_enabled(old) || any_synlimit_enabled(desired) { + return false; + } + let Ok(old_plan) = listener_bind_plan(old) else { + return false; + }; + let Ok(desired_plan) = listener_bind_plan(desired) else { + return false; + }; + let retained: BTreeSet<_> = old_plan + .keys() + .filter(|addr| desired_plan.contains_key(addr)) + .copied() + .collect(); + retained + .iter() + .all(|addr| old_plan.get(addr) == desired_plan.get(addr)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ListenerConfig; + + fn listener(ip: &str, port: u16) -> ListenerConfig { + ListenerConfig { + ip: ip.parse().unwrap(), + port: Some(port), + client_mss: None, + synlimit: SynLimitMode::Off, + synlimit_seconds: 60, + synlimit_hitcount: 48, + synlimit_burst: 24, + synlimit_ios_seconds: 1, + synlimit_ios_hitcount: 12, + synlimit_ios_burst: 24, + synlimit_hashlimit_expire_ms: 60_000, + synlimit_hashlimit_size: 32_768, + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + } + } + + #[test] + fn plan_depends_on_inbound_family_policy_only() { + let mut config = ProxyConfig::default(); + config.server.listeners = vec![listener("0.0.0.0", 443), listener("::", 443)]; + config.network.ipv4 = true; + config.network.ipv6 = None; + + let plan = listener_bind_plan(&config).unwrap(); + + assert_eq!(plan.len(), 2); + config.network.ipv6 = Some(false); + let plan = listener_bind_plan(&config).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan.keys().all(SocketAddr::is_ipv4)); + } + + #[test] + fn duplicate_effective_endpoint_is_rejected() { + let mut config = ProxyConfig::default(); + config.server.listeners = vec![listener("127.0.0.1", 443), listener("127.0.0.1", 443)]; + + assert!(listener_bind_plan(&config).is_err()); + } + + #[test] + fn retained_policy_change_is_not_rebindable() { + let mut old = ProxyConfig::default(); + old.server.listeners = vec![listener("127.0.0.1", 443)]; + let mut desired = old.clone(); + desired.server.listeners[0].proxy_protocol = Some(true); + + assert!(!listener_rebind_supported(&old, &desired)); + } + + #[test] + fn endpoint_move_without_synlimit_is_rebindable() { + let mut old = ProxyConfig::default(); + old.server.listeners = vec![listener("127.0.0.1", 443)]; + let mut desired = old.clone(); + desired.server.listeners[0].port = Some(444); + + assert!(listener_rebind_supported(&old, &desired)); + } +} diff --git a/src/maestro/listeners/unix.rs b/src/maestro/listeners/unix.rs index ba5091a..5a16bbf 100644 --- a/src/maestro/listeners/unix.rs +++ b/src/maestro/listeners/unix.rs @@ -5,113 +5,155 @@ use std::time::Duration; use arc_swap::ArcSwap; use tokio::net::UnixListener; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use tracing::{debug, error}; -use super::RuntimeGeneration; +use crate::maestro::generation::RuntimeGeneration; -pub(crate) fn spawn_unix_accept_loop( - listener: Option, +pub(super) struct UnixAcceptHandle { + _listener: Arc, + cancellation: CancellationToken, + task: Option>, +} + +async fn run_unix_accept_loop( + listener: Arc, active_runtime: Arc>, + cancellation: CancellationToken, ) { - let Some(listener) = listener else { - return; - }; - - tokio::spawn(async move { - let connection_counter = AtomicU64::new(1); - - loop { - match listener.accept().await { - Ok((stream, _)) => { - let runtime = active_runtime.load_full(); - if !*runtime.admission_rx.borrow() { - drop(stream); - continue; - } - - let config = runtime.config(); - let timeout_ms = config.server.accept_permit_timeout_ms; - let permit = if timeout_ms == 0 { - match runtime.max_connections.clone().acquire_owned().await { + let connection_counter = AtomicU64::new(1); + loop { + let accepted = tokio::select! { + biased; + _ = cancellation.cancelled() => return, + accepted = listener.accept() => accepted, + }; + match accepted { + Ok((stream, _)) => { + let runtime = active_runtime.load_full(); + if !*runtime.admission_rx.borrow() { + drop(stream); + continue; + } + let config = runtime.config(); + let timeout_ms = config.server.accept_permit_timeout_ms; + let acquire = runtime.max_connections.clone().acquire_owned(); + let permit = if timeout_ms == 0 { + tokio::select! { + biased; + _ = cancellation.cancelled() => return, + permit = acquire => match permit { Ok(permit) => permit, Err(_) => { error!("Connection limiter is closed"); - break; + return; } } - } else { - match tokio::time::timeout( - Duration::from_millis(timeout_ms), - runtime.max_connections.clone().acquire_owned(), + } + } else { + match tokio::select! { + biased; + _ = cancellation.cancelled() => return, + result = tokio::time::timeout(Duration::from_millis(timeout_ms), acquire) => result, + } { + Ok(Ok(permit)) => permit, + Ok(Err(_)) => { + error!("Connection limiter is closed"); + return; + } + Err(_) => { + runtime.stats.increment_accept_permit_timeout_total(); + debug!( + timeout_ms, + "Dropping accepted Unix connection: permit wait timeout" + ); + continue; + } + } + }; + + let connection_id = connection_counter.fetch_add(1, Ordering::Relaxed); + let fake_peer = + SocketAddr::from(([127, 0, 0, 1], (connection_id % 65535) as u16)); + let stats = runtime.stats.clone(); + let upstream_manager = runtime.upstream_manager.clone(); + let replay_checker = runtime.replay_checker.clone(); + let buffer_pool = runtime.buffer_pool.clone(); + let rng = runtime.rng.clone(); + let me_pool = runtime.me_pool.clone(); + let me_pool_runtime = runtime.me_pool_runtime.clone(); + let route_runtime = runtime.route_runtime.clone(); + let tls_cache = runtime.tls_cache.clone(); + let ip_tracker = runtime.ip_tracker.clone(); + let beobachten = runtime.beobachten.clone(); + let shared = runtime.proxy_shared.clone(); + let proxy_protocol_enabled = config.server.proxy_protocol; + + let _ = runtime.spawn_session(async move { + let _permit = permit; + if let Err(error_value) = + crate::proxy::client::handle_client_stream_with_shared_and_pool_runtime( + stream, + fake_peer, + config, + stats, + upstream_manager, + replay_checker, + buffer_pool, + rng, + me_pool, + Some(me_pool_runtime), + route_runtime, + tls_cache, + ip_tracker, + beobachten, + shared, + proxy_protocol_enabled, ) .await - { - Ok(Ok(permit)) => permit, - Ok(Err(_)) => { - error!("Connection limiter is closed"); - break; - } - Err(_) => { - runtime.stats.increment_accept_permit_timeout_total(); - debug!( - timeout_ms, - "Dropping accepted unix connection: permit wait timeout" - ); - drop(stream); - continue; - } - } - }; - - let connection_id = connection_counter.fetch_add(1, Ordering::Relaxed); - let fake_peer = - SocketAddr::from(([127, 0, 0, 1], (connection_id % 65535) as u16)); - let stats = runtime.stats.clone(); - let upstream_manager = runtime.upstream_manager.clone(); - let replay_checker = runtime.replay_checker.clone(); - let buffer_pool = runtime.buffer_pool.clone(); - let rng = runtime.rng.clone(); - let me_pool = runtime.me_pool.clone(); - let me_pool_runtime = runtime.me_pool_runtime.clone(); - let route_runtime = runtime.route_runtime.clone(); - let tls_cache = runtime.tls_cache.clone(); - let ip_tracker = runtime.ip_tracker.clone(); - let beobachten = runtime.beobachten.clone(); - let shared = runtime.proxy_shared.clone(); - let proxy_protocol_enabled = config.server.proxy_protocol; - - let _ = runtime.spawn_session(async move { - let _permit = permit; - if let Err(error) = - crate::proxy::client::handle_client_stream_with_shared_and_pool_runtime( - stream, - fake_peer, - config, - stats, - upstream_manager, - replay_checker, - buffer_pool, - rng, - me_pool, - Some(me_pool_runtime), - route_runtime, - tls_cache, - ip_tracker, - beobachten, - shared, - proxy_protocol_enabled, - ) - .await - { - debug!(error = %error, "Unix socket connection error"); - } - }); - } - Err(error) => { - error!(error = %error, "Unix socket accept error"); - tokio::time::sleep(Duration::from_millis(100)).await; + { + debug!(error = %error_value, "Unix socket connection error"); + } + }); + } + Err(error_value) => { + error!(error = %error_value, "Unix socket accept error"); + tokio::select! { + biased; + _ = cancellation.cancelled() => return, + _ = tokio::time::sleep(Duration::from_millis(100)) => {} } } } - }); + } +} + +impl UnixAcceptHandle { + pub(super) fn start( + listener: UnixListener, + active_runtime: Arc>, + ) -> Self { + let listener = Arc::new(listener); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(run_unix_accept_loop( + listener.clone(), + active_runtime, + cancellation.clone(), + )); + Self { + _listener: listener, + cancellation, + task: Some(task), + } + } + + pub(super) async fn stop(&mut self) -> Result<(), String> { + self.cancellation.cancel(); + if let Some(task) = self.task.take() { + task.await + .map_err(|error_value| format!("Unix listener task failed: {error_value}"))?; + } + Ok(()) + } } diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index e47fc90..7b1216f 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -3,63 +3,40 @@ #![allow(unused_assignments)] // Runtime orchestration modules. -// - helpers: CLI and shared startup/runtime helper routines. -// - tls_bootstrap: TLS front cache bootstrap and refresh tasks. -// - me_startup: Middle-End secret/config fetch and pool initialization. -// - connectivity: startup ME/DC connectivity diagnostics. -// - runtime_tasks: hot-reload and background task orchestration. // - admission: conditional-cast gate and route mode switching. -// - listeners: TCP/Unix listener bind and accept-loop orchestration. +// - bootstrap: configuration and tracing initialization. +// - connectivity: startup ME/DC connectivity diagnostics. +// - generation: runtime generation state and task ownership. +// - helpers: CLI and shared startup/runtime helper routines. +// - listeners: TCP/Unix listener planning, binding, and lifecycle control. +// - me_startup: Middle-End secret/config fetch and pool initialization. +// - orchestrator: process startup, listener activation, and shutdown sequencing. +// - reload: reload command coordination. +// - reload_supervisor: generation and listener transition supervision. +// - runtime_build: reload candidate construction. +// - runtime_startup: initial runtime generation preparation. +// - runtime_tasks: hot-reload and background task orchestration. // - shutdown: graceful shutdown sequence and uptime logging. +// - tls_bootstrap: TLS front cache bootstrap and refresh tasks. mod admission; +mod bootstrap; mod connectivity; pub(crate) mod generation; mod helpers; mod listeners; mod me_startup; +mod orchestrator; pub(crate) mod reload; mod reload_supervisor; pub(crate) mod runtime_build; +mod runtime_startup; mod runtime_tasks; mod shutdown; mod tls_bootstrap; -use arc_swap::ArcSwap; -use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tokio::sync::{RwLock, Semaphore, watch}; -use tracing::{error, info, warn}; -use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload}; +use tracing::error; -use crate::api; -use crate::config::{LogLevel, ProxyConfig, SynLimitMode}; -use crate::conntrack_control; -use crate::crypto::SecureRandom; -use crate::ip_tracker::UserIpTracker; -use crate::network::probe::{decide_network_capabilities, log_probe_result, run_probe}; -use crate::proxy::direct_buffer_budget::{ - DirectBufferBudget, resolve_direct_buffer_hard_limit, run_direct_buffer_budget_controller, -}; -use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; -use crate::proxy::shared_state::ProxySharedState; -use crate::startup::{ - COMPONENT_API_BOOTSTRAP, COMPONENT_CONFIG_LOAD, COMPONENT_DC_CONNECTIVITY_PING, - COMPONENT_ME_CONNECTIVITY_PING, COMPONENT_ME_POOL_CONSTRUCT, COMPONENT_ME_POOL_INIT_STAGE1, - COMPONENT_ME_PROXY_CONFIG_V4, COMPONENT_ME_PROXY_CONFIG_V6, COMPONENT_ME_SECRET_FETCH, - COMPONENT_NETWORK_PROBE, COMPONENT_TRACING_INIT, StartupMeStatus, StartupTracker, -}; -use crate::stats::beobachten::BeobachtenStore; -use crate::stats::telemetry::TelemetryPolicy; -use crate::stats::{QuotaStore, ReplayChecker, Stats}; -use crate::stream::BufferPool; -use crate::synlimit_control; -use crate::transport::UpstreamManager; -use crate::transport::middle_proxy::MePool; -use helpers::{ - parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path, - set_maestro_colors_enabled, -}; +use crate::config::{ProxyConfig, SynLimitMode}; #[cfg(unix)] use crate::daemon::{DaemonOptions, PidFile, drop_privileges}; @@ -67,7 +44,7 @@ use crate::daemon::{DaemonOptions, PidFile, drop_privileges}; /// Runs the full telemt runtime startup pipeline and blocks until shutdown. /// /// On Unix, daemon options should be handled before calling this function -/// (daemonization must happen before tokio runtime starts). +/// because daemonization must happen before the Tokio runtime starts. #[cfg(unix)] pub async fn run_with_daemon( daemon_opts: DaemonOptions, @@ -77,12 +54,11 @@ pub async fn run_with_daemon( /// Runs the full telemt runtime startup pipeline and blocks until shutdown. /// -/// This is the main entry point for non-daemon mode or when called as a library. +/// This is the main entry point for non-daemon mode or library callers. #[allow(dead_code)] pub async fn run() -> std::result::Result<(), Box> { #[cfg(unix)] { - // Parse CLI to get daemon options even in simple run() path let args: Vec = std::env::args().skip(1).collect(); let daemon_opts = crate::cli::parse_daemon_args(&args); run_inner(daemon_opts).await @@ -93,934 +69,6 @@ pub async fn run() -> std::result::Result<(), Box> { } } -// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound -// (for privilege drop); it is a no-op on other platforms. -async fn run_telemt_core( - privilege_drop_requested: bool, - drop_after_bind: impl FnOnce(), -) -> std::result::Result<(), Box> { - let process_started_at = Instant::now(); - let process_started_at_epoch_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let startup_tracker = Arc::new(StartupTracker::new(process_started_at_epoch_secs)); - startup_tracker - .start_component( - COMPONENT_CONFIG_LOAD, - Some("load and validate config".to_string()), - ) - .await; - let cli_args = parse_cli(); - let config_path_cli = cli_args.config_path; - let config_path_explicit = cli_args.config_path_explicit; - let data_path = cli_args.data_path; - let cli_silent = cli_args.silent; - let cli_log_level = cli_args.log_level; - let log_cli_options = cli_args.log_cli_options; - let startup_cwd = match std::env::current_dir() { - Ok(cwd) => cwd, - Err(e) => { - eprintln!("[telemt] Can't read current_dir: {}", e); - 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, - Err(e) => { - if config_path.exists() { - eprintln!("[telemt] Error: {}", e); - std::process::exit(1); - } else { - let default = ProxyConfig::default(); - - let serialized = - match toml::to_string_pretty(&default).or_else(|_| toml::to_string(&default)) { - Ok(value) => Some(value), - Err(serialize_error) => { - eprintln!( - "[telemt] Warning: failed to serialize default config: {}", - serialize_error - ); - None - } - }; - - if config_path_explicit { - if let Some(serialized) = serialized.as_ref() { - if let Err(write_error) = std::fs::write(&config_path, serialized) { - eprintln!( - "[telemt] Error: failed to create explicit config at {}: {}", - config_path.display(), - write_error - ); - std::process::exit(1); - } - eprintln!( - "[telemt] Created default config at {}", - config_path.display() - ); - } else { - eprintln!( - "[telemt] Warning: running with in-memory default config without writing to disk" - ); - } - } else { - 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(&runtime_base_dir) { - Ok(()) => match std::fs::write(&runtime_config_path, serialized) { - Ok(()) => { - config_path = runtime_config_path; - eprintln!( - "[telemt] Created default config at {}", - config_path.display() - ); - persisted = true; - } - Err(write_error) => { - eprintln!( - "[telemt] Warning: failed to write default config at {}: {}", - runtime_config_path.display(), - write_error - ); - } - }, - Err(create_error) => { - eprintln!( - "[telemt] Warning: failed to create {}: {}", - runtime_base_dir.display(), - create_error - ); - } - } - - if !persisted { - match std::fs::write(&fallback_config_path, serialized) { - Ok(()) => { - config_path = fallback_config_path; - eprintln!( - "[telemt] Created default config at {}", - config_path.display() - ); - persisted = true; - } - Err(write_error) => { - eprintln!( - "[telemt] Warning: failed to write default config at {}: {}", - fallback_config_path.display(), - write_error - ); - } - } - } - } - - if !persisted { - eprintln!( - "[telemt] Warning: running with in-memory default config without writing to disk" - ); - } - } - default - } - } - }; - - if let Err(e) = config.validate() { - eprintln!("[telemt] Invalid config: {}", e); - std::process::exit(1); - } - validate_synlimit_privilege_drop(&config, privilege_drop_requested)?; - - if let Some(p) = data_path { - config.general.data_path = Some(p); - } - - if let Some(ref data_path) = config.general.data_path { - if !data_path.is_absolute() { - eprintln!( - "[telemt] data_path must be absolute: {}", - data_path.display() - ); - std::process::exit(1); - } - - if data_path.exists() { - if !data_path.is_dir() { - eprintln!( - "[telemt] data_path exists but is not a directory: {}", - data_path.display() - ); - std::process::exit(1); - } - } else if let Err(e) = std::fs::create_dir_all(data_path) { - eprintln!( - "[telemt] Can't create data_path {}: {}", - data_path.display(), - e - ); - std::process::exit(1); - } - - if let Err(e) = std::env::set_current_dir(data_path) { - eprintln!( - "[telemt] Can't use data_path {}: {}", - data_path.display(), - e - ); - std::process::exit(1); - } - } - - if let Err(e) = crate::network::dns_overrides::install_entries(&config.network.dns_overrides) { - eprintln!("[telemt] Invalid network.dns_overrides: {}", e); - std::process::exit(1); - } - set_maestro_colors_enabled(!config.general.disable_colors); - startup_tracker - .complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string())) - .await; - - let has_rust_log = std::env::var("RUST_LOG").is_ok(); - let effective_log_level = if cli_silent { - LogLevel::Silent - } else if let Some(ref s) = cli_log_level { - LogLevel::from_str_loose(s) - } else { - config.general.log_level.clone() - }; - - let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level); - let log_destination = - match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) { - Ok(destination) => destination, - Err(error) => { - eprintln!("[telemt] {error}"); - std::process::exit(1); - } - }; - let (filter_layer, filter_handle) = - tracing_reload::Layer::new(EnvFilter::new(initial_filter_spec.clone())); - startup_tracker - .start_component( - COMPONENT_TRACING_INIT, - Some("initialize tracing subscriber".to_string()), - ) - .await; - - // Initialize logging based on destination - let _logging_guard: Option; - match log_destination { - crate::logging::LogDestination::Stderr => { - // Default: log to stderr (works with systemd journald) - let fmt_layer = if config.general.disable_colors { - fmt::Layer::default().with_ansi(false) - } else { - fmt::Layer::default().with_ansi(true) - }; - tracing_subscriber::registry() - .with(filter_layer) - .with(fmt_layer) - .init(); - _logging_guard = None; - } - #[cfg(unix)] - crate::logging::LogDestination::Syslog => { - // Syslog: for OpenRC/FreeBSD - let logging_opts = crate::logging::LoggingOptions { - destination: log_destination, - disable_colors: true, - }; - let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec); - _logging_guard = Some(guard); - } - crate::logging::LogDestination::File { .. } => { - // File logging with optional rotation - let logging_opts = crate::logging::LoggingOptions { - destination: log_destination, - disable_colors: true, - }; - let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec); - _logging_guard = Some(guard); - } - } - let runtime_log_filter = runtime_tasks::RuntimeLogFilter::new(filter_handle); - - startup_tracker - .complete_component( - COMPONENT_TRACING_INIT, - Some("tracing initialized".to_string()), - ) - .await; - - print_maestro_line(format!("Telemt MTProxy v{}", env!("CARGO_PKG_VERSION"))); - info!("Log level: {}", effective_log_level); - if config.general.disable_colors { - info!("Colors: disabled"); - } - info!( - "Modes: classic={} secure={} tls={}", - config.general.modes.classic, config.general.modes.secure, config.general.modes.tls - ); - if config.general.modes.classic { - warn!("Classic mode is vulnerable to DPI detection; enable only for legacy clients"); - } - info!("TLS domain: {}", config.censorship.tls_domain); - if let Some(ref sock) = config.censorship.mask_unix_sock { - info!("Mask: {} -> unix:{}", config.censorship.mask, sock); - if !std::path::Path::new(sock).exists() { - warn!( - "Unix socket '{}' does not exist yet. Masking will fail until it appears.", - sock - ); - } - } else { - info!( - "Mask: {} -> {}:{}", - config.censorship.mask, - config - .censorship - .mask_host - .as_deref() - .unwrap_or(&config.censorship.tls_domain), - config.censorship.mask_port - ); - } - - if config.censorship.tls_domain == "www.google.com" { - warn!("Using default tls_domain. Consider setting a custom domain."); - } - - let quota_store = Arc::new(QuotaStore::default()); - let stats = Arc::new(Stats::with_quota_store(quota_store.clone())); - let runtime_task_scope = generation::RuntimeTaskScope::new(); - stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry)); - let quota_state_path = config.general.quota_state_path.clone(); - crate::quota_state::load_quota_state("a_state_path, stats.as_ref()).await; - - let upstream_manager = Arc::new( - UpstreamManager::new( - config.upstreams.clone(), - 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, - stats.clone(), - ) - .with_dns_overrides(&config.network.dns_overrides)?, - ); - let ip_tracker = Arc::new(UserIpTracker::new()); - ip_tracker - .load_limits( - config.access.user_max_unique_ips_global_each, - &config.access.user_max_unique_ips, - ) - .await; - ip_tracker - .set_limit_policy( - config.access.user_max_unique_ips_mode, - config.access.user_max_unique_ips_window_secs, - ) - .await; - if config.access.user_max_unique_ips_global_each > 0 - || !config.access.user_max_unique_ips.is_empty() - { - info!( - global_each_limit = config.access.user_max_unique_ips_global_each, - explicit_user_limits = config.access.user_max_unique_ips.len(), - "User unique IP limits configured" - ); - } - if !config.network.dns_overrides.is_empty() { - info!( - "Runtime DNS overrides configured: {} entries", - config.network.dns_overrides.len() - ); - } - let direct_buffer_hard_limit = - resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await; - let direct_buffer_budget = DirectBufferBudget::new(direct_buffer_hard_limit); - info!( - hard_limit_bytes = direct_buffer_hard_limit, - configured_override_bytes = config.general.direct_relay_buffer_budget_max_bytes, - "Direct relay buffer budget initialized" - ); - let shared_state = - ProxySharedState::new_with_direct_buffer_budget(direct_buffer_budget.clone()); - shared_state.apply_user_enabled_config(&config.access.user_enabled); - shared_state.traffic_limiter.apply_policy( - config.access.user_rate_limits.clone(), - config.access.cidr_rate_limits.clone(), - ); - - let (detected_ips_tx, detected_ips_rx) = watch::channel((None::, None::)); - let initial_direct_first = config.general.use_middle_proxy && config.general.me2dc_fallback; - let initial_admission_open = !config.general.use_middle_proxy || initial_direct_first; - let (admission_tx, admission_rx) = watch::channel(initial_admission_open); - let (reload_control, reload_commands) = reload::ReloadControl::channel(1); - let (active_runtime_tx, active_runtime_rx) = - watch::channel(None::>>); - let (runtime_watch_tx, runtime_watch_rx) = - watch::channel(None::); - let initial_route_mode = if !config.general.use_middle_proxy || initial_direct_first { - RelayRouteMode::Direct - } else { - RelayRouteMode::Middle - }; - let route_runtime = Arc::new(RouteRuntimeController::new(initial_route_mode)); - let api_me_pool = Arc::new(RwLock::new(None::>)); - startup_tracker - .start_component( - COMPONENT_API_BOOTSTRAP, - Some("spawn API listener task".to_string()), - ) - .await; - - if config.server.api.enabled { - let listen = match config.server.api.listen.parse::() { - Ok(listen) => listen, - Err(error) => { - warn!( - error = %error, - listen = %config.server.api.listen, - "Invalid server.api.listen; API is disabled" - ); - SocketAddr::from(([127, 0, 0, 1], 0)) - } - }; - if listen.port() != 0 { - let stats_api = stats.clone(); - let ip_tracker_api = ip_tracker.clone(); - let me_pool_api = api_me_pool.clone(); - let upstream_manager_api = upstream_manager.clone(); - let route_runtime_api = route_runtime.clone(); - let proxy_shared_api = shared_state.clone(); - let config_path_api = config_path.clone(); - let quota_state_path_api = quota_state_path.clone(); - let startup_tracker_api = startup_tracker.clone(); - let detected_ips_rx_api = detected_ips_rx.clone(); - let reload_control_api = reload_control.clone(); - let active_runtime_rx_api = active_runtime_rx.clone(); - let runtime_watch_rx_api = runtime_watch_rx.clone(); - tokio::spawn(async move { - api::serve( - listen, - stats_api, - ip_tracker_api, - me_pool_api, - route_runtime_api, - proxy_shared_api, - upstream_manager_api, - config_path_api, - quota_state_path_api, - detected_ips_rx_api, - process_started_at_epoch_secs, - startup_tracker_api, - reload_control_api, - active_runtime_rx_api, - runtime_watch_rx_api, - ) - .await; - }); - startup_tracker - .complete_component( - COMPONENT_API_BOOTSTRAP, - Some(format!("api task spawned on {}", listen)), - ) - .await; - } else { - startup_tracker - .skip_component( - COMPONENT_API_BOOTSTRAP, - Some("server.api.listen has zero port".to_string()), - ) - .await; - } - } else { - startup_tracker - .skip_component( - COMPONENT_API_BOOTSTRAP, - Some("server.api.enabled is false".to_string()), - ) - .await; - } - - let mut tls_domains = Vec::with_capacity(1 + config.censorship.tls_domains.len()); - tls_domains.push(config.censorship.tls_domain.clone()); - for d in &config.censorship.tls_domains { - if !tls_domains.contains(d) { - tls_domains.push(d.clone()); - } - } - - let tls_cache = tls_bootstrap::bootstrap_tls_front( - &config, - &tls_domains, - upstream_manager.clone(), - &startup_tracker, - runtime_task_scope.clone(), - tls_bootstrap::TlsBootstrapPolicy::BestEffort, - ) - .await?; - - startup_tracker - .start_component( - COMPONENT_NETWORK_PROBE, - Some("probe network capabilities".to_string()), - ) - .await; - let probe = run_probe( - &config.network, - &config.upstreams, - config.general.middle_proxy_nat_probe, - config.general.stun_nat_probe_concurrency, - ) - .await?; - detected_ips_tx.send_replace(( - probe.detected_ipv4.map(IpAddr::V4), - probe.detected_ipv6.map(IpAddr::V6), - )); - let decision = - decide_network_capabilities(&config.network, &probe, config.general.middle_proxy_nat_ip); - log_probe_result(&probe, &decision); - startup_tracker - .complete_component( - COMPONENT_NETWORK_PROBE, - Some("network capabilities determined".to_string()), - ) - .await; - - let prefer_ipv6 = decision.prefer_ipv6(); - let mut use_middle_proxy = config.general.use_middle_proxy; - let beobachten = Arc::new(BeobachtenStore::new()); - let rng = Arc::new(SecureRandom::new()); - - // Connection concurrency limit (0 = unlimited) - let max_connections_limit = if config.server.max_connections == 0 { - Semaphore::MAX_PERMITS - } else { - config.server.max_connections as usize - }; - let max_connections = Arc::new(Semaphore::new(max_connections_limit)); - - let me2dc_fallback = config.general.me2dc_fallback; - let me_init_retry_attempts = config.general.me_init_retry_attempts; - if use_middle_proxy && !decision.ipv4_me && !decision.ipv6_me { - if me2dc_fallback { - warn!( - "No usable IP family for Middle Proxy detected; Direct-DC startup fallback is active while ME init retries continue" - ); - } else { - warn!( - "No usable IP family for Middle Proxy detected; me2dc_fallback=false, ME init retries stay active" - ); - } - } - - if use_middle_proxy { - startup_tracker - .set_me_status(StartupMeStatus::Initializing, COMPONENT_ME_SECRET_FETCH) - .await; - startup_tracker - .start_component( - COMPONENT_ME_SECRET_FETCH, - Some("fetch proxy-secret from source/cache".to_string()), - ) - .await; - startup_tracker - .set_me_retry_limit(if !me2dc_fallback || me_init_retry_attempts == 0 { - "unlimited".to_string() - } else { - me_init_retry_attempts.to_string() - }) - .await; - } else { - startup_tracker - .set_me_status(StartupMeStatus::Skipped, "skipped") - .await; - startup_tracker - .skip_component( - COMPONENT_ME_SECRET_FETCH, - Some("middle proxy mode disabled".to_string()), - ) - .await; - startup_tracker - .skip_component( - COMPONENT_ME_PROXY_CONFIG_V4, - Some("middle proxy mode disabled".to_string()), - ) - .await; - startup_tracker - .skip_component( - COMPONENT_ME_PROXY_CONFIG_V6, - Some("middle proxy mode disabled".to_string()), - ) - .await; - startup_tracker - .skip_component( - COMPONENT_ME_POOL_CONSTRUCT, - Some("middle proxy mode disabled".to_string()), - ) - .await; - startup_tracker - .skip_component( - COMPONENT_ME_POOL_INIT_STAGE1, - Some("middle proxy mode disabled".to_string()), - ) - .await; - } - - let (me_ready_tx, me_ready_rx) = watch::channel(0_u64); - let direct_first_startup = use_middle_proxy && me2dc_fallback; - - let me_pool: Option> = if direct_first_startup { - None - } else { - me_startup::initialize_me_pool( - use_middle_proxy, - &config, - &decision, - &probe, - &startup_tracker, - upstream_manager.clone(), - rng.clone(), - stats.clone(), - api_me_pool.clone(), - me_ready_tx.clone(), - runtime_task_scope.clone(), - ) - .await - }; - - // If ME failed to initialize, force direct-only mode. - if direct_first_startup { - startup_tracker.set_transport_mode("direct").await; - startup_tracker.set_degraded(true).await; - info!( - "Transport: Direct DC startup fallback active; Middle-End bootstrap continues in background" - ); - } else if me_pool.is_some() { - startup_tracker.set_transport_mode("middle_proxy").await; - startup_tracker.set_degraded(false).await; - info!("Transport: Middle-End Proxy - all DC-over-RPC"); - } else { - let _ = use_middle_proxy; - use_middle_proxy = false; - // Make runtime config reflect direct-only mode for handlers. - config.general.use_middle_proxy = false; - startup_tracker.set_transport_mode("direct").await; - startup_tracker.set_degraded(true).await; - if me2dc_fallback { - startup_tracker - .set_me_status(StartupMeStatus::Failed, "fallback_to_direct") - .await; - } else { - startup_tracker - .set_me_status(StartupMeStatus::Skipped, "skipped") - .await; - } - info!("Transport: Direct DC - TCP - standard DC-over-TCP"); - } - - // Freeze config after possible fallback decision - let config = Arc::new(config); - - let replay_checker = Arc::new(ReplayChecker::new( - config.access.replay_check_len, - Duration::from_secs(config.access.replay_window_secs), - )); - - let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096)); - - if direct_first_startup { - startup_tracker - .skip_component( - COMPONENT_ME_CONNECTIVITY_PING, - Some("deferred by direct-first startup".to_string()), - ) - .await; - startup_tracker - .skip_component( - COMPONENT_DC_CONNECTIVITY_PING, - Some("background health checks active".to_string()), - ) - .await; - } else { - connectivity::run_startup_connectivity( - &config, - &me_pool, - rng.clone(), - &startup_tracker, - upstream_manager.clone(), - prefer_ipv6, - &decision, - process_started_at, - api_me_pool.clone(), - ) - .await; - } - - let runtime_watches = runtime_tasks::spawn_runtime_tasks( - &config, - &config_path, - &probe, - prefer_ipv6, - decision.ipv4_dc, - decision.ipv6_dc, - &startup_tracker, - stats.clone(), - upstream_manager.clone(), - replay_checker.clone(), - me_pool.clone(), - rng.clone(), - ip_tracker.clone(), - beobachten.clone(), - me_pool.clone(), - shared_state.clone(), - me_ready_tx.clone(), - runtime_task_scope.clone(), - ) - .await; - let config_rx = runtime_watches.config_rx; - let log_level_rx = runtime_watches.log_level_rx; - let detected_ip_v4 = runtime_watches.detected_ip_v4; - let detected_ip_v6 = runtime_watches.detected_ip_v6; - runtime_log_filter.start( - has_rust_log, - &effective_log_level, - log_level_rx, - runtime_task_scope.clone(), - ); - - if direct_first_startup { - let config_bg = config.clone(); - let decision_bg = decision.clone(); - let probe_bg = probe.clone(); - let startup_tracker_bg = startup_tracker.clone(); - let upstream_manager_bg = upstream_manager.clone(); - let rng_bg = rng.clone(); - let stats_bg = stats.clone(); - let api_me_pool_bg = api_me_pool.clone(); - let me_ready_tx_bg = me_ready_tx.clone(); - let config_rx_bg = config_rx.clone(); - let task_scope_bg = runtime_task_scope.clone(); - runtime_task_scope.spawn(async move { - let mut bootstrap_attempt: u32 = 0; - loop { - bootstrap_attempt = bootstrap_attempt.saturating_add(1); - let pool = me_startup::initialize_me_pool( - true, - config_bg.as_ref(), - &decision_bg, - &probe_bg, - &startup_tracker_bg, - upstream_manager_bg.clone(), - rng_bg.clone(), - stats_bg.clone(), - api_me_pool_bg.clone(), - me_ready_tx_bg.clone(), - task_scope_bg.clone(), - ) - .await; - if let Some(pool) = pool { - runtime_tasks::spawn_middle_proxy_runtime_tasks( - config_bg.as_ref(), - config_rx_bg, - pool, - rng_bg, - me_ready_tx_bg, - task_scope_bg, - ); - break; - } - if me_init_retry_attempts > 0 && bootstrap_attempt >= me_init_retry_attempts { - break; - } - tokio::time::sleep(Duration::from_secs(2)).await; - } - }); - - let startup_tracker_ready = startup_tracker.clone(); - let api_me_pool_ready = api_me_pool.clone(); - let mut me_ready_rx_transport = me_ready_tx.subscribe(); - runtime_task_scope.spawn(async move { - if me_ready_rx_transport.changed().await.is_ok() { - if let Some(pool) = api_me_pool_ready.read().await.as_ref() { - pool.set_runtime_ready(true); - } - startup_tracker_ready - .set_transport_mode("middle_proxy") - .await; - startup_tracker_ready.set_degraded(false).await; - info!("Transport: Middle-End Proxy restored for new sessions"); - } - }); - } - - admission::configure_admission_gate( - &config, - me_pool.clone(), - api_me_pool.clone(), - route_runtime.clone(), - &admission_tx, - config_rx.clone(), - me_ready_rx, - runtime_task_scope.clone(), - ) - .await; - let _admission_tx_hold = admission_tx; - let conntrack_scope = runtime_task_scope.clone(); - runtime_task_scope.spawn(conntrack_control::run_conntrack_controller( - config_rx.clone(), - stats.clone(), - shared_state.clone(), - conntrack_scope.cancellation_token(), - )); - runtime_task_scope.spawn(run_direct_buffer_budget_controller( - direct_buffer_budget, - buffer_pool.clone(), - stats.clone(), - shared_state.clone(), - config.server.max_connections, - )); - - let runtime_generation = generation::RuntimeGeneration::new( - 1, - config_rx.clone(), - admission_rx.clone(), - stats.clone(), - upstream_manager.clone(), - replay_checker.clone(), - buffer_pool.clone(), - rng.clone(), - me_pool.clone(), - api_me_pool.clone(), - route_runtime.clone(), - tls_cache.clone(), - ip_tracker.clone(), - beobachten.clone(), - shared_state.clone(), - max_connections.clone(), - runtime_task_scope.clone(), - ); - let active_runtime = Arc::new(ArcSwap::from(runtime_generation)); - let reload_supervisor = reload_supervisor::ReloadSupervisor::spawn( - active_runtime.clone(), - reload_control, - reload_commands, - config_path.clone(), - quota_store, - detected_ips_tx, - runtime_log_filter, - runtime_watch_tx.clone(), - ); - - let bound = listeners::bind_listeners( - &config, - decision.ipv4_dc, - decision.ipv6_dc, - detected_ip_v4, - detected_ip_v6, - &startup_tracker, - ) - .await?; - let listeners = bound.listeners; - #[cfg(unix)] - let unix_listener = bound.unix_listener; - #[cfg(unix)] - let has_unix_listener = unix_listener.is_some(); - #[cfg(not(unix))] - let has_unix_listener = false; - - if listeners.is_empty() && !has_unix_listener { - error!("No listeners. Exiting."); - std::process::exit(1); - } - - synlimit_control::reconcile_synlimit_rules(&config) - .await - .map_err(std::io::Error::other)?; - - // On Unix, caller supplies privilege drop after bind and privileged firewall setup. - drop_after_bind(); - - runtime_tasks::spawn_metrics_if_configured(&config, &startup_tracker, active_runtime.clone()) - .await; - - runtime_watch_tx.send_replace(Some(active_runtime.load_full().watch_state())); - active_runtime_tx.send_replace(Some(active_runtime.clone())); - runtime_tasks::mark_runtime_ready(&startup_tracker).await; - - // Spawn signal handlers for SIGUSR1/SIGUSR2 (non-shutdown signals) - shutdown::spawn_signal_handlers(active_runtime.clone(), process_started_at); - - listeners::spawn_tcp_accept_loops(listeners, active_runtime.clone()); - #[cfg(unix)] - listeners::spawn_unix_accept_loop(unix_listener, active_runtime.clone()); - - shutdown::wait_for_shutdown( - process_started_at, - active_runtime, - quota_state_path, - reload_supervisor, - ) - .await; - - Ok(()) -} - fn validate_synlimit_privilege_drop( config: &ProxyConfig, privilege_drop_requested: bool, @@ -1043,8 +91,8 @@ fn validate_synlimit_privilege_drop( async fn run_inner( daemon_opts: DaemonOptions, ) -> std::result::Result<(), Box> { - // Acquire PID file if daemonizing or if explicitly requested - // Keep it alive until shutdown (underscore prefix = intentionally kept for RAII cleanup) + // Acquire PID file if daemonizing or if explicitly requested. + // Keep it alive until shutdown for RAII cleanup. let _pid_file = if daemon_opts.daemonize || daemon_opts.pid_file.is_some() { let mut pf = PidFile::new(daemon_opts.pid_file_path()); if let Err(e) = pf.acquire() { @@ -1059,12 +107,12 @@ async fn run_inner( let user = daemon_opts.user.clone(); let group = daemon_opts.group.clone(); - run_telemt_core(user.is_some() || group.is_some(), || { - if user.is_some() || group.is_some() { - if let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) { - error!(error = %e, "Failed to drop privileges"); - std::process::exit(1); - } + orchestrator::run_telemt_core(user.is_some() || group.is_some(), || { + if (user.is_some() || group.is_some()) + && let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) + { + error!(error = %e, "Failed to drop privileges"); + std::process::exit(1); } }) .await @@ -1072,7 +120,7 @@ async fn run_inner( #[cfg(not(unix))] async fn run_inner() -> std::result::Result<(), Box> { - run_telemt_core(false, || {}).await + orchestrator::run_telemt_core(false, || {}).await } #[cfg(test)] diff --git a/src/maestro/orchestrator.rs b/src/maestro/orchestrator.rs new file mode 100644 index 0000000..8798603 --- /dev/null +++ b/src/maestro/orchestrator.rs @@ -0,0 +1,342 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use tokio::sync::{RwLock, watch}; +use tracing::{error, info, warn}; + +use crate::api; +use crate::ip_tracker::UserIpTracker; +use crate::network::probe::{decide_network_capabilities, log_probe_result, run_probe}; +use crate::proxy::direct_buffer_budget::{ + DirectBufferBudget, resolve_direct_buffer_hard_limit, +}; +use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; +use crate::proxy::shared_state::ProxySharedState; +use crate::startup::{COMPONENT_API_BOOTSTRAP, COMPONENT_NETWORK_PROBE}; +use crate::stats::telemetry::TelemetryPolicy; +use crate::stats::{QuotaStore, Stats}; +use crate::synlimit_control; +use crate::transport::UpstreamManager; +use crate::transport::middle_proxy::MePool; + +use super::{ + bootstrap, generation, listeners, reload, reload_supervisor, runtime_startup, runtime_tasks, + shutdown, tls_bootstrap, +}; + +// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound +// and privileged firewall setup completes; it is a no-op on other platforms. +pub(super) async fn run_telemt_core( + privilege_drop_requested: bool, + drop_after_bind: impl FnOnce(), +) -> std::result::Result<(), Box> { + let bootstrap::BootstrapState { + process_started_at, + process_started_at_epoch_secs, + startup_tracker, + config, + config_path, + has_rust_log, + effective_log_level, + runtime_log_filter, + logging_guard: _logging_guard, + } = bootstrap::bootstrap(privilege_drop_requested).await?; + + let quota_store = Arc::new(QuotaStore::default()); + let stats = Arc::new(Stats::with_quota_store(quota_store.clone())); + let runtime_task_scope = generation::RuntimeTaskScope::new(); + stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry)); + let quota_state_path = config.general.quota_state_path.clone(); + crate::quota_state::load_quota_state("a_state_path, stats.as_ref()).await; + + let upstream_manager = Arc::new( + UpstreamManager::new( + config.upstreams.clone(), + 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, + stats.clone(), + ) + .with_dns_overrides(&config.network.dns_overrides)?, + ); + let ip_tracker = Arc::new(UserIpTracker::new()); + ip_tracker + .load_limits( + config.access.user_max_unique_ips_global_each, + &config.access.user_max_unique_ips, + ) + .await; + ip_tracker + .set_limit_policy( + config.access.user_max_unique_ips_mode, + config.access.user_max_unique_ips_window_secs, + ) + .await; + if config.access.user_max_unique_ips_global_each > 0 + || !config.access.user_max_unique_ips.is_empty() + { + info!( + global_each_limit = config.access.user_max_unique_ips_global_each, + explicit_user_limits = config.access.user_max_unique_ips.len(), + "User unique IP limits configured" + ); + } + if !config.network.dns_overrides.is_empty() { + info!( + "Runtime DNS overrides configured: {} entries", + config.network.dns_overrides.len() + ); + } + let direct_buffer_hard_limit = + resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await; + let direct_buffer_budget = DirectBufferBudget::new(direct_buffer_hard_limit); + info!( + hard_limit_bytes = direct_buffer_hard_limit, + configured_override_bytes = config.general.direct_relay_buffer_budget_max_bytes, + "Direct relay buffer budget initialized" + ); + let shared_state = + ProxySharedState::new_with_direct_buffer_budget(direct_buffer_budget.clone()); + shared_state.apply_user_enabled_config(&config.access.user_enabled); + shared_state.traffic_limiter.apply_policy( + config.access.user_rate_limits.clone(), + config.access.cidr_rate_limits.clone(), + ); + + let (detected_ips_tx, detected_ips_rx) = watch::channel((None::, None::)); + let initial_direct_first = config.general.use_middle_proxy && config.general.me2dc_fallback; + let initial_admission_open = !config.general.use_middle_proxy || initial_direct_first; + let (admission_tx, admission_rx) = watch::channel(initial_admission_open); + let (reload_control, reload_commands) = reload::ReloadControl::channel(1); + let (active_runtime_tx, active_runtime_rx) = + watch::channel(None::>>); + let (runtime_watch_tx, runtime_watch_rx) = + watch::channel(None::); + let initial_route_mode = if !config.general.use_middle_proxy || initial_direct_first { + RelayRouteMode::Direct + } else { + RelayRouteMode::Middle + }; + let route_runtime = Arc::new(RouteRuntimeController::new(initial_route_mode)); + let api_me_pool = Arc::new(RwLock::new(None::>)); + startup_tracker + .start_component( + COMPONENT_API_BOOTSTRAP, + Some("spawn API listener task".to_string()), + ) + .await; + + if config.server.api.enabled { + let listen = match config.server.api.listen.parse::() { + Ok(listen) => listen, + Err(error) => { + warn!( + error = %error, + listen = %config.server.api.listen, + "Invalid server.api.listen; API is disabled" + ); + SocketAddr::from(([127, 0, 0, 1], 0)) + } + }; + if listen.port() != 0 { + let stats_api = stats.clone(); + let ip_tracker_api = ip_tracker.clone(); + let me_pool_api = api_me_pool.clone(); + let upstream_manager_api = upstream_manager.clone(); + let route_runtime_api = route_runtime.clone(); + let proxy_shared_api = shared_state.clone(); + let config_path_api = config_path.clone(); + let quota_state_path_api = quota_state_path.clone(); + let startup_tracker_api = startup_tracker.clone(); + let detected_ips_rx_api = detected_ips_rx.clone(); + let reload_control_api = reload_control.clone(); + let active_runtime_rx_api = active_runtime_rx.clone(); + let runtime_watch_rx_api = runtime_watch_rx.clone(); + tokio::spawn(async move { + api::serve( + listen, + stats_api, + ip_tracker_api, + me_pool_api, + route_runtime_api, + proxy_shared_api, + upstream_manager_api, + config_path_api, + quota_state_path_api, + detected_ips_rx_api, + process_started_at_epoch_secs, + startup_tracker_api, + reload_control_api, + active_runtime_rx_api, + runtime_watch_rx_api, + ) + .await; + }); + startup_tracker + .complete_component( + COMPONENT_API_BOOTSTRAP, + Some(format!("api task spawned on {}", listen)), + ) + .await; + } else { + startup_tracker + .skip_component( + COMPONENT_API_BOOTSTRAP, + Some("server.api.listen has zero port".to_string()), + ) + .await; + } + } else { + startup_tracker + .skip_component( + COMPONENT_API_BOOTSTRAP, + Some("server.api.enabled is false".to_string()), + ) + .await; + } + + let mut tls_domains = Vec::with_capacity(1 + config.censorship.tls_domains.len()); + tls_domains.push(config.censorship.tls_domain.clone()); + for domain in &config.censorship.tls_domains { + if !tls_domains.contains(domain) { + tls_domains.push(domain.clone()); + } + } + + let tls_cache = tls_bootstrap::bootstrap_tls_front( + &config, + &tls_domains, + upstream_manager.clone(), + &startup_tracker, + runtime_task_scope.clone(), + tls_bootstrap::TlsBootstrapPolicy::BestEffort, + ) + .await?; + + startup_tracker + .start_component( + COMPONENT_NETWORK_PROBE, + Some("probe network capabilities".to_string()), + ) + .await; + let probe = run_probe( + &config.network, + &config.upstreams, + config.general.middle_proxy_nat_probe, + config.general.stun_nat_probe_concurrency, + ) + .await?; + detected_ips_tx.send_replace(( + probe.detected_ipv4.map(IpAddr::V4), + probe.detected_ipv6.map(IpAddr::V6), + )); + let decision = + decide_network_capabilities(&config.network, &probe, config.general.middle_proxy_nat_ip); + log_probe_result(&probe, &decision); + startup_tracker + .complete_component( + COMPONENT_NETWORK_PROBE, + Some("network capabilities determined".to_string()), + ) + .await; + + let runtime = runtime_startup::prepare_runtime( + config, + &config_path, + &probe, + &decision, + process_started_at, + &startup_tracker, + stats.clone(), + upstream_manager.clone(), + ip_tracker.clone(), + shared_state.clone(), + direct_buffer_budget, + route_runtime.clone(), + api_me_pool.clone(), + runtime_task_scope.clone(), + admission_tx, + &runtime_log_filter, + has_rust_log, + &effective_log_level, + ) + .await; + let _admission_tx_hold = runtime.admission_tx; + + let runtime_generation = generation::RuntimeGeneration::new( + 1, + runtime.config_rx.clone(), + admission_rx, + stats.clone(), + upstream_manager.clone(), + runtime.replay_checker, + runtime.buffer_pool, + runtime.rng, + runtime.me_pool, + api_me_pool, + route_runtime, + tls_cache, + ip_tracker, + runtime.beobachten, + shared_state, + runtime.max_connections, + runtime_task_scope, + ); + let active_runtime = Arc::new(ArcSwap::from(runtime_generation)); + let bound = listeners::bind_listeners( + &runtime.config, + runtime.detected_ip_v4, + runtime.detected_ip_v6, + &startup_tracker, + ) + .await?; + if bound.is_empty() { + error!("No listeners. Exiting."); + std::process::exit(1); + } + + synlimit_control::reconcile_synlimit_rules(&runtime.config) + .await + .map_err(std::io::Error::other)?; + + drop_after_bind(); + + runtime_tasks::spawn_metrics_if_configured( + &runtime.config, + &startup_tracker, + active_runtime.clone(), + ) + .await; + + runtime_watch_tx.send_replace(Some(active_runtime.load_full().watch_state())); + active_runtime_tx.send_replace(Some(active_runtime.clone())); + runtime_tasks::mark_runtime_ready(&startup_tracker).await; + + let listener_manager = listeners::ListenerManager::start(bound, active_runtime.clone()); + let reload_supervisor = reload_supervisor::ReloadSupervisor::spawn( + active_runtime.clone(), + reload_control, + reload_commands, + config_path, + quota_store, + detected_ips_tx, + runtime_log_filter, + runtime_watch_tx, + listener_manager, + ); + + shutdown::spawn_signal_handlers(active_runtime.clone(), process_started_at); + shutdown::wait_for_shutdown( + process_started_at, + active_runtime, + quota_state_path, + reload_supervisor, + ) + .await; + + Ok(()) +} diff --git a/src/maestro/reload_supervisor.rs b/src/maestro/reload_supervisor.rs index 8db02eb..f446c19 100644 --- a/src/maestro/reload_supervisor.rs +++ b/src/maestro/reload_supervisor.rs @@ -4,12 +4,14 @@ use std::time::Duration; use arc_swap::ArcSwap; use tokio::sync::watch; +use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; use crate::stats::QuotaStore; use super::generation::{RuntimeGeneration, RuntimeWatchState}; +use super::listeners::{ListenerManager, PreparedListenerTransition}; use super::reload::{ ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode, ReloadPhase, @@ -26,6 +28,7 @@ pub(crate) struct ReloadSupervisor { detected_ips_tx: watch::Sender<(Option, Option)>, runtime_log_filter: RuntimeLogFilter, runtime_watch_tx: watch::Sender>, + listener_manager: Arc>, } /// Process-owned handle that quiesces reloads before shutdown snapshots the runtime. @@ -33,16 +36,18 @@ pub(crate) struct ReloadSupervisorHandle { control: ReloadControl, shutdown: CancellationToken, join: tokio::task::JoinHandle<()>, + listener_manager: Arc>, } impl ReloadSupervisorHandle { /// Stops new submissions and waits for the accepted reload to finish. - pub(crate) async fn quiesce(self) { + pub(crate) async fn quiesce(self) -> Arc> { self.control.begin_shutdown().await; self.shutdown.cancel(); if let Err(error) = self.join.await { warn!(error = %error, "Reload supervisor failed while quiescing"); } + self.listener_manager } } @@ -99,7 +104,9 @@ impl ReloadSupervisor { detected_ips_tx: watch::Sender<(Option, Option)>, runtime_log_filter: RuntimeLogFilter, runtime_watch_tx: watch::Sender>, + listener_manager: ListenerManager, ) -> ReloadSupervisorHandle { + let listener_manager = Arc::new(Mutex::new(listener_manager)); let supervisor = Self { active_runtime, control, @@ -109,6 +116,7 @@ impl ReloadSupervisor { detected_ips_tx, runtime_log_filter, runtime_watch_tx, + listener_manager: listener_manager.clone(), }; let control = supervisor.control.clone(); let shutdown = CancellationToken::new(); @@ -117,6 +125,7 @@ impl ReloadSupervisor { control, shutdown, join, + listener_manager, } } @@ -171,18 +180,41 @@ impl ReloadSupervisor { } }; + let listener_transition = match self + .listener_manager + .lock() + .await + .prepare_transition(prepared.generation.config().as_ref()) + { + Ok(transition) => transition, + Err(error) => { + let _ = cleanup_candidate(&prepared.generation).await; + self.runtime_log_filter + .apply_reload(&old_runtime.config().general.log_level); + self.control.fail(command.reload_id, error).await; + return; + } + }; let revision_action = revision_gate_action( &command.config_revision, crate::api::config_store::current_revision_for_maestro(&self.config_path).await, command.request.failure_policy, ); - self.activate_prepared(command, old_runtime, prepared, revision_action, |entries| { - crate::network::dns_overrides::install_entries(entries) - .map_err(|error| error.to_string()) - }) + self.activate_prepared_with_transition( + command, + old_runtime, + prepared, + listener_transition, + revision_action, + |entries| { + crate::network::dns_overrides::install_entries(entries) + .map_err(|error| error.to_string()) + }, + ) .await; } + #[cfg(test)] async fn activate_prepared( &self, command: ReloadCommand, @@ -192,6 +224,41 @@ impl ReloadSupervisor { install_dns: InstallDns, ) where InstallDns: FnOnce(&[String]) -> Result<(), String>, + { + let listener_transition = match self + .listener_manager + .lock() + .await + .prepare_transition(prepared.generation.config().as_ref()) + { + Ok(transition) => transition, + Err(error) => { + let _ = cleanup_candidate(&prepared.generation).await; + self.control.fail(command.reload_id, error).await; + return; + } + }; + self.activate_prepared_with_transition( + command, + old_runtime, + prepared, + listener_transition, + revision_action, + install_dns, + ) + .await; + } + + async fn activate_prepared_with_transition( + &self, + command: ReloadCommand, + old_runtime: Arc, + prepared: PreparedRuntime, + listener_transition: Option, + revision_action: RevisionGateAction, + install_dns: InstallDns, + ) where + InstallDns: FnOnce(&[String]) -> Result<(), String>, { match revision_action { RevisionGateAction::Proceed => {} @@ -211,7 +278,6 @@ impl ReloadSupervisor { .mark_phase(command.reload_id, ReloadPhase::Activating) .await; let new_runtime = prepared.generation; - old_runtime.stop_accepting_sessions(); if let Err(error) = install_dns(&new_runtime.config().network.dns_overrides) { let message = format!("runtime DNS activation failed: {}", error); if command.request.failure_policy == ReloadFailurePolicy::Rollback { @@ -224,7 +290,34 @@ impl ReloadSupervisor { } self.control.add_warning(command.reload_id, message).await; } + let pending_listener_transition = if let Some(listener_transition) = listener_transition { + match self + .listener_manager + .lock() + .await + .begin_transition(listener_transition) + .await + { + Ok(pending) => Some(pending), + Err(error) => { + let _ = cleanup_candidate(&new_runtime).await; + self.runtime_log_filter + .apply_reload(&old_runtime.config().general.log_level); + self.control.fail(command.reload_id, error).await; + return; + } + } + } else { + None + }; + old_runtime.stop_accepting_sessions(); let replaced = self.active_runtime.swap(new_runtime.clone()); + if let Some(pending) = pending_listener_transition { + self.listener_manager + .lock() + .await + .finish_transition(pending); + } self.detected_ips_tx.send_replace(prepared.detected_ips); self.runtime_log_filter .apply_reload(&new_runtime.config().general.log_level); diff --git a/src/maestro/reload_supervisor_tests.rs b/src/maestro/reload_supervisor_tests.rs index d934016..24c0b4a 100644 --- a/src/maestro/reload_supervisor_tests.rs +++ b/src/maestro/reload_supervisor_tests.rs @@ -33,6 +33,7 @@ async fn fixture(request: ReloadRequest) -> ReloadFixture { .unwrap(); let (detected_ips_tx, _detected_ips_rx) = watch::channel((None, None)); let (runtime_watch_tx, runtime_watch_rx) = watch::channel(Some(old_runtime.watch_state())); + let listener_manager = Arc::new(Mutex::new(ListenerManager::empty(active_runtime.clone()))); let supervisor = Arc::new(ReloadSupervisor { active_runtime, control: control.clone(), @@ -42,6 +43,7 @@ async fn fixture(request: ReloadRequest) -> ReloadFixture { detected_ips_tx, runtime_log_filter: runtime_log_filter(), runtime_watch_tx, + listener_manager, }); let command = ReloadCommand { reload_id: accepted.reload_id, @@ -293,6 +295,7 @@ async fn quiesce_joins_idle_supervisor_and_rejects_later_submissions() { let (control, commands) = ReloadControl::channel(runtime.id); let (detected_ips_tx, _detected_ips_rx) = watch::channel((None, None)); let (runtime_watch_tx, _runtime_watch_rx) = watch::channel(Some(runtime.watch_state())); + let listener_manager = ListenerManager::empty(active_runtime.clone()); let handle = ReloadSupervisor::spawn( active_runtime, control.clone(), @@ -302,6 +305,7 @@ async fn quiesce_joins_idle_supervisor_and_rejects_later_submissions() { detected_ips_tx, runtime_log_filter(), runtime_watch_tx, + listener_manager, ); tokio::time::timeout(Duration::from_secs(1), handle.quiesce()) diff --git a/src/maestro/runtime_build.rs b/src/maestro/runtime_build.rs index 637c107..b4b843e 100644 --- a/src/maestro/runtime_build.rs +++ b/src/maestro/runtime_build.rs @@ -24,6 +24,7 @@ use crate::transport::middle_proxy::MePool; use super::admission; use super::generation::{RuntimeGeneration, RuntimeTaskScope}; +use super::listeners::listener_rebind_supported; use super::runtime_tasks::RuntimeLogFilter; use super::{me_startup, runtime_tasks, tls_bootstrap}; @@ -326,18 +327,19 @@ pub(crate) fn resolve_reload_config( let mut effective = desired.clone(); let mut fields = Vec::new(); let listener_identity_matches = listeners_have_same_bind_identity(&old.server, &desired.server); - let listener_process_fields_changed = !listener_identity_matches - || !listener_process_fields_equal(&old.server, &desired.server); - if old.server.port != desired.server.port + let global_listener_policy_changed = old.server.port != desired.server.port || old.server.listen_addr_ipv4 != desired.server.listen_addr_ipv4 || old.server.listen_addr_ipv6 != desired.server.listen_addr_ipv6 || old.server.listen_tcp != desired.server.listen_tcp || old.server.client_mss != desired.server.client_mss || old.server.client_mss_bulk != desired.server.client_mss_bulk || old.server.proxy_protocol != desired.server.proxy_protocol - || old.server.listen_backlog != desired.server.listen_backlog - || listener_process_fields_changed - { + || old.server.listen_backlog != desired.server.listen_backlog; + let listener_policy_changed = + listener_identity_matches && !listener_process_fields_equal(&old.server, &desired.server); + let unsupported_identity_change = !listener_identity_matches + && !listener_rebind_supported(old, desired); + if global_listener_policy_changed || listener_policy_changed || unsupported_identity_change { fields.push("server.listeners".to_string()); effective.server.port = old.server.port; effective.server.listen_addr_ipv4 = old.server.listen_addr_ipv4.clone(); diff --git a/src/maestro/runtime_build_tests.rs b/src/maestro/runtime_build_tests.rs index 142d864..988491b 100644 --- a/src/maestro/runtime_build_tests.rs +++ b/src/maestro/runtime_build_tests.rs @@ -1,5 +1,26 @@ use super::*; +fn test_listener(port: u16) -> crate::config::ListenerConfig { + crate::config::ListenerConfig { + ip: "127.0.0.1".parse().unwrap(), + port: Some(port), + client_mss: None, + synlimit: crate::config::SynLimitMode::Off, + synlimit_seconds: 60, + synlimit_hitcount: 48, + synlimit_burst: 24, + synlimit_ios_seconds: 1, + synlimit_ios_hitcount: 12, + synlimit_ios_burst: 24, + synlimit_hashlimit_expire_ms: 60_000, + synlimit_hashlimit_size: 32_768, + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + } +} + #[test] fn process_socket_and_logging_changes_are_deferred() { let old = ProxyConfig::default(); @@ -129,3 +150,35 @@ fn strict_middle_proxy_requires_a_prepared_pool() { assert!(!strict_middle_proxy_unavailable(true, true, false)); assert!(!strict_middle_proxy_unavailable(false, false, false)); } + +#[test] +fn endpoint_only_listener_move_is_runtime_rebindable() { + let mut old = ProxyConfig::default(); + old.server.listeners = vec![test_listener(443)]; + let mut desired = old.clone(); + desired.server.listeners[0].port = Some(8443); + + let resolved = resolve_reload_config(&old, &desired); + + assert!(resolved.deferred_process_fields.is_empty()); + assert_eq!(resolved.effective.server.listeners[0].port, Some(8443)); + assert!(resolved.runtime_changed); +} + +#[test] +fn synlimited_endpoint_move_remains_restart_only() { + let mut old = ProxyConfig::default(); + old.server.listeners = vec![test_listener(443)]; + old.server.listeners[0].synlimit = crate::config::SynLimitMode::Nftables; + let mut desired = old.clone(); + desired.server.listeners[0].port = Some(8443); + + let resolved = resolve_reload_config(&old, &desired); + + assert_eq!( + resolved.deferred_process_fields, + vec!["server.listeners".to_string()] + ); + assert_eq!(resolved.effective.server.listeners[0].port, Some(443)); + assert!(!resolved.runtime_changed); +} diff --git a/src/maestro/runtime_startup.rs b/src/maestro/runtime_startup.rs new file mode 100644 index 0000000..4e6343a --- /dev/null +++ b/src/maestro/runtime_startup.rs @@ -0,0 +1,369 @@ +use std::net::IpAddr; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::{RwLock, Semaphore, watch}; +use tracing::{info, warn}; + +use crate::config::{LogLevel, ProxyConfig}; +use crate::conntrack_control; +use crate::crypto::SecureRandom; +use crate::ip_tracker::UserIpTracker; +use crate::network::probe::{NetworkDecision, NetworkProbe}; +use crate::proxy::direct_buffer_budget::{ + DirectBufferBudget, run_direct_buffer_budget_controller, +}; +use crate::proxy::route_mode::RouteRuntimeController; +use crate::proxy::shared_state::ProxySharedState; +use crate::startup::{ + COMPONENT_DC_CONNECTIVITY_PING, COMPONENT_ME_CONNECTIVITY_PING, + COMPONENT_ME_POOL_CONSTRUCT, COMPONENT_ME_POOL_INIT_STAGE1, COMPONENT_ME_PROXY_CONFIG_V4, + COMPONENT_ME_PROXY_CONFIG_V6, COMPONENT_ME_SECRET_FETCH, StartupMeStatus, StartupTracker, +}; +use crate::stats::beobachten::BeobachtenStore; +use crate::stats::{ReplayChecker, Stats}; +use crate::stream::BufferPool; +use crate::transport::UpstreamManager; +use crate::transport::middle_proxy::MePool; + +use super::admission; +use super::generation::RuntimeTaskScope; +use super::{connectivity, me_startup, runtime_tasks}; + +pub(super) struct RuntimeStartupState { + pub(super) config: Arc, + pub(super) beobachten: Arc, + pub(super) rng: Arc, + pub(super) max_connections: Arc, + pub(super) me_pool: Option>, + pub(super) replay_checker: Arc, + pub(super) buffer_pool: Arc, + pub(super) config_rx: watch::Receiver>, + pub(super) detected_ip_v4: Option, + pub(super) detected_ip_v6: Option, + pub(super) admission_tx: watch::Sender, +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_runtime( + mut config: ProxyConfig, + config_path: &Path, + probe: &NetworkProbe, + decision: &NetworkDecision, + process_started_at: Instant, + startup_tracker: &Arc, + stats: Arc, + upstream_manager: Arc, + ip_tracker: Arc, + shared_state: Arc, + direct_buffer_budget: Arc, + route_runtime: Arc, + api_me_pool: Arc>>>, + runtime_task_scope: RuntimeTaskScope, + admission_tx: watch::Sender, + runtime_log_filter: &runtime_tasks::RuntimeLogFilter, + has_rust_log: bool, + effective_log_level: &LogLevel, +) -> RuntimeStartupState { + let prefer_ipv6 = decision.prefer_ipv6(); + let mut use_middle_proxy = config.general.use_middle_proxy; + let beobachten = Arc::new(BeobachtenStore::new()); + let rng = Arc::new(SecureRandom::new()); + + let max_connections_limit = if config.server.max_connections == 0 { + Semaphore::MAX_PERMITS + } else { + config.server.max_connections as usize + }; + let max_connections = Arc::new(Semaphore::new(max_connections_limit)); + + let me2dc_fallback = config.general.me2dc_fallback; + let me_init_retry_attempts = config.general.me_init_retry_attempts; + if use_middle_proxy && !decision.ipv4_me && !decision.ipv6_me { + if me2dc_fallback { + warn!( + "No usable IP family for Middle Proxy detected; Direct-DC startup fallback is active while ME init retries continue" + ); + } else { + warn!( + "No usable IP family for Middle Proxy detected; me2dc_fallback=false, ME init retries stay active" + ); + } + } + + if use_middle_proxy { + startup_tracker + .set_me_status(StartupMeStatus::Initializing, COMPONENT_ME_SECRET_FETCH) + .await; + startup_tracker + .start_component( + COMPONENT_ME_SECRET_FETCH, + Some("fetch proxy-secret from source/cache".to_string()), + ) + .await; + startup_tracker + .set_me_retry_limit(if !me2dc_fallback || me_init_retry_attempts == 0 { + "unlimited".to_string() + } else { + me_init_retry_attempts.to_string() + }) + .await; + } else { + startup_tracker + .set_me_status(StartupMeStatus::Skipped, "skipped") + .await; + startup_tracker + .skip_component( + COMPONENT_ME_SECRET_FETCH, + Some("middle proxy mode disabled".to_string()), + ) + .await; + startup_tracker + .skip_component( + COMPONENT_ME_PROXY_CONFIG_V4, + Some("middle proxy mode disabled".to_string()), + ) + .await; + startup_tracker + .skip_component( + COMPONENT_ME_PROXY_CONFIG_V6, + Some("middle proxy mode disabled".to_string()), + ) + .await; + startup_tracker + .skip_component( + COMPONENT_ME_POOL_CONSTRUCT, + Some("middle proxy mode disabled".to_string()), + ) + .await; + startup_tracker + .skip_component( + COMPONENT_ME_POOL_INIT_STAGE1, + Some("middle proxy mode disabled".to_string()), + ) + .await; + } + + let (me_ready_tx, me_ready_rx) = watch::channel(0_u64); + let direct_first_startup = use_middle_proxy && me2dc_fallback; + + let me_pool: Option> = if direct_first_startup { + None + } else { + me_startup::initialize_me_pool( + use_middle_proxy, + &config, + decision, + probe, + startup_tracker, + upstream_manager.clone(), + rng.clone(), + stats.clone(), + api_me_pool.clone(), + me_ready_tx.clone(), + runtime_task_scope.clone(), + ) + .await + }; + + if direct_first_startup { + startup_tracker.set_transport_mode("direct").await; + startup_tracker.set_degraded(true).await; + info!( + "Transport: Direct DC startup fallback active; Middle-End bootstrap continues in background" + ); + } else if me_pool.is_some() { + startup_tracker.set_transport_mode("middle_proxy").await; + startup_tracker.set_degraded(false).await; + info!("Transport: Middle-End Proxy - all DC-over-RPC"); + } else { + let _ = use_middle_proxy; + use_middle_proxy = false; + config.general.use_middle_proxy = false; + startup_tracker.set_transport_mode("direct").await; + startup_tracker.set_degraded(true).await; + if me2dc_fallback { + startup_tracker + .set_me_status(StartupMeStatus::Failed, "fallback_to_direct") + .await; + } else { + startup_tracker + .set_me_status(StartupMeStatus::Skipped, "skipped") + .await; + } + info!("Transport: Direct DC - TCP - standard DC-over-TCP"); + } + + let config = Arc::new(config); + let replay_checker = Arc::new(ReplayChecker::new( + config.access.replay_check_len, + Duration::from_secs(config.access.replay_window_secs), + )); + let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096)); + + if direct_first_startup { + startup_tracker + .skip_component( + COMPONENT_ME_CONNECTIVITY_PING, + Some("deferred by direct-first startup".to_string()), + ) + .await; + startup_tracker + .skip_component( + COMPONENT_DC_CONNECTIVITY_PING, + Some("background health checks active".to_string()), + ) + .await; + } else { + connectivity::run_startup_connectivity( + &config, + &me_pool, + rng.clone(), + startup_tracker, + upstream_manager.clone(), + prefer_ipv6, + decision, + process_started_at, + api_me_pool.clone(), + ) + .await; + } + + let runtime_watches = runtime_tasks::spawn_runtime_tasks( + &config, + config_path, + probe, + prefer_ipv6, + decision.ipv4_dc, + decision.ipv6_dc, + startup_tracker, + stats.clone(), + upstream_manager.clone(), + replay_checker.clone(), + me_pool.clone(), + rng.clone(), + ip_tracker.clone(), + beobachten.clone(), + me_pool.clone(), + shared_state.clone(), + me_ready_tx.clone(), + runtime_task_scope.clone(), + ) + .await; + let config_rx = runtime_watches.config_rx; + let log_level_rx = runtime_watches.log_level_rx; + let detected_ip_v4 = runtime_watches.detected_ip_v4; + let detected_ip_v6 = runtime_watches.detected_ip_v6; + runtime_log_filter.start( + has_rust_log, + effective_log_level, + log_level_rx, + runtime_task_scope.clone(), + ); + + if direct_first_startup { + let config_bg = config.clone(); + let decision_bg = decision.clone(); + let probe_bg = probe.clone(); + let startup_tracker_bg = startup_tracker.clone(); + let upstream_manager_bg = upstream_manager.clone(); + let rng_bg = rng.clone(); + let stats_bg = stats.clone(); + let api_me_pool_bg = api_me_pool.clone(); + let me_ready_tx_bg = me_ready_tx.clone(); + let config_rx_bg = config_rx.clone(); + let task_scope_bg = runtime_task_scope.clone(); + runtime_task_scope.spawn(async move { + let mut bootstrap_attempt: u32 = 0; + loop { + bootstrap_attempt = bootstrap_attempt.saturating_add(1); + let pool = me_startup::initialize_me_pool( + true, + config_bg.as_ref(), + &decision_bg, + &probe_bg, + &startup_tracker_bg, + upstream_manager_bg.clone(), + rng_bg.clone(), + stats_bg.clone(), + api_me_pool_bg.clone(), + me_ready_tx_bg.clone(), + task_scope_bg.clone(), + ) + .await; + if let Some(pool) = pool { + runtime_tasks::spawn_middle_proxy_runtime_tasks( + config_bg.as_ref(), + config_rx_bg, + pool, + rng_bg, + me_ready_tx_bg, + task_scope_bg, + ); + break; + } + if me_init_retry_attempts > 0 && bootstrap_attempt >= me_init_retry_attempts { + break; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }); + + let startup_tracker_ready = startup_tracker.clone(); + let api_me_pool_ready = api_me_pool.clone(); + let mut me_ready_rx_transport = me_ready_tx.subscribe(); + runtime_task_scope.spawn(async move { + if me_ready_rx_transport.changed().await.is_ok() { + if let Some(pool) = api_me_pool_ready.read().await.as_ref() { + pool.set_runtime_ready(true); + } + startup_tracker_ready + .set_transport_mode("middle_proxy") + .await; + startup_tracker_ready.set_degraded(false).await; + info!("Transport: Middle-End Proxy restored for new sessions"); + } + }); + } + + admission::configure_admission_gate( + &config, + me_pool.clone(), + api_me_pool, + route_runtime, + &admission_tx, + config_rx.clone(), + me_ready_rx, + runtime_task_scope.clone(), + ) + .await; + let conntrack_scope = runtime_task_scope.clone(); + runtime_task_scope.spawn(conntrack_control::run_conntrack_controller( + config_rx.clone(), + stats.clone(), + shared_state.clone(), + conntrack_scope.cancellation_token(), + )); + runtime_task_scope.spawn(run_direct_buffer_budget_controller( + direct_buffer_budget, + buffer_pool.clone(), + stats, + shared_state, + config.server.max_connections, + )); + + RuntimeStartupState { + config, + beobachten, + rng, + max_connections, + me_pool, + replay_checker, + buffer_pool, + config_rx, + detected_ip_v4, + detected_ip_v6, + admission_tx, + } +} diff --git a/src/maestro/shutdown.rs b/src/maestro/shutdown.rs index 33d94d3..d061518 100644 --- a/src/maestro/shutdown.rs +++ b/src/maestro/shutdown.rs @@ -95,7 +95,7 @@ async fn perform_shutdown( let shutdown_started_at = Instant::now(); info!(signal = %signal, "Received shutdown signal"); - reload_supervisor.quiesce().await; + let listener_manager = reload_supervisor.quiesce().await; let runtime = active_runtime.load_full(); let stats = runtime.stats.as_ref(); @@ -108,6 +108,10 @@ async fn perform_shutdown( let uptime_secs = process_started_at.elapsed().as_secs(); info!("Uptime: {}", format_uptime(uptime_secs)); + if let Err(error) = listener_manager.lock().await.shutdown().await { + warn!(error = %error, "Failed to stop one or more listener tasks cleanly"); + } + // Graceful ME pool shutdown runtime.stop_sessions().await; runtime.stop_background_tasks().await; diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 1debee4..474b807 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1,4 +1,4 @@ -//! MTProto Handshake +//! MTProto handshake authentication, TLS fronting, and nonce derivation. #![allow(dead_code)] @@ -10,8 +10,7 @@ use std::collections::hash_map::DefaultHasher; #[cfg(test)] use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hash, Hasher}; -use std::net::SocketAddr; -use std::net::{IpAddr, Ipv6Addr}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::sync::Arc; #[cfg(test)] use std::sync::Mutex; @@ -33,10 +32,52 @@ use crate::tls_front::{TlsFrontCache, emulator}; #[cfg(test)] use rand::RngExt; +// Handshake submodules. +// - auth_candidates: access-secret decoding and candidate selection. +// - auth_probe: scanner throttling and sticky authentication state. +// - mtproto: direct MTProto obfuscation handshake. +// - nonce: Telegram-side nonce generation and encryption. +// - session: authenticated session key ownership. +// - tls_auth: FakeTLS authentication material parsing. +// - tls_handshake: FakeTLS policy and response orchestration. +// - tls_validation: bounded user candidate validation. +mod auth_candidates; +mod auth_probe; +mod mtproto; +mod nonce; +mod session; mod tls_auth; +mod tls_handshake; +mod tls_validation; +use self::auth_candidates::*; +use self::auth_probe::*; use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate}; +pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState}; +#[cfg(test)] +pub use self::mtproto::handle_mtproto_handshake; +pub use self::mtproto::handle_mtproto_handshake_with_shared; +#[allow(unused_imports)] +pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce}; +pub use self::session::HandshakeSuccess; +#[cfg(test)] +pub use self::tls_handshake::handle_tls_handshake; +pub use self::tls_handshake::handle_tls_handshake_with_shared; +pub(crate) use self::tls_handshake::handle_tls_handshake_with_shared_and_options; + +#[cfg(test)] +pub(crate) use self::auth_probe::{ + auth_probe_fail_streak_for_testing_in_shared, auth_probe_is_throttled_for_testing_in_shared, + auth_probe_record_failure_for_testing, auth_probe_saturation_is_throttled_at_for_testing_in_shared, + auth_probe_saturation_is_throttled_for_testing_in_shared, + auth_probe_saturation_state_for_testing_in_shared, + auth_probe_saturation_state_lock_for_testing_in_shared, auth_probe_state_for_testing_in_shared, + clear_auth_probe_state_for_testing_in_shared, clear_unknown_sni_warn_state_for_testing_in_shared, + clear_warned_secrets_for_testing_in_shared, should_emit_unknown_sni_warn_for_testing_in_shared, + warned_secrets_for_testing_in_shared, +}; + const ACCESS_SECRET_BYTES: usize = 16; const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5; #[cfg(test)] @@ -59,6 +100,16 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8; const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64; const RECENT_USER_RING_SCAN_LIMIT: usize = 32; +#[cfg(test)] +const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; +#[cfg(not(test))] +const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 25; + +#[cfg(test)] +const AUTH_PROBE_BACKOFF_MAX_MS: u64 = 16; +#[cfg(not(test))] +const AUTH_PROBE_BACKOFF_MAX_MS: u64 = 1_000; + /// Controls how the authenticated FakeTLS response is written to a client. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct TlsResponseWriteOptions { @@ -79,2011 +130,6 @@ impl TlsResponseWriteOptions { } } -#[cfg(test)] -const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; -#[cfg(not(test))] -const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 25; - -#[cfg(test)] -const AUTH_PROBE_BACKOFF_MAX_MS: u64 = 16; -#[cfg(not(test))] -const AUTH_PROBE_BACKOFF_MAX_MS: u64 = 1_000; - -#[derive(Clone, Copy)] -pub(crate) struct AuthProbeState { - fail_streak: u32, - blocked_until: Instant, - last_seen: Instant, -} - -#[derive(Clone, Copy)] -pub(crate) struct AuthProbeSaturationState { - fail_streak: u32, - blocked_until: Instant, - last_seen: Instant, -} -fn unknown_sni_warn_state_lock_in( - shared: &ProxySharedState, -) -> std::sync::MutexGuard<'_, Option> { - shared - .handshake - .unknown_sni_warn_next_allowed - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> bool { - let mut guard = unknown_sni_warn_state_lock_in(shared); - if let Some(next_allowed) = *guard - && now < next_allowed - { - return false; - } - *guard = Some(now + Duration::from_secs(UNKNOWN_SNI_WARN_COOLDOWN_SECS)); - true -} - -struct MtprotoCandidateValidation { - proto_tag: ProtoTag, - dc_idx: i16, - dec_key: [u8; 32], - dec_iv: u128, - enc_key: [u8; 32], - enc_iv: u128, - decryptor: AesCtr, - encryptor: AesCtr, -} - -fn sni_hint_hash(sni: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - for byte in sni.bytes() { - hasher.write_u8(byte.to_ascii_lowercase()); - } - hasher.finish() -} - -fn ip_prefix_hint_key(peer_ip: IpAddr) -> u64 { - match peer_ip { - // Keep /24 granularity for IPv4 to avoid over-merging unrelated clients. - IpAddr::V4(ip) => { - let [a, b, c, _] = ip.octets(); - u64::from_be_bytes([0x04, a, b, c, 0, 0, 0, 0]) - } - // Keep /56 granularity for IPv6 to retain stability while limiting bucket size. - IpAddr::V6(ip) => { - let octets = ip.octets(); - u64::from_be_bytes([ - 0x06, octets[0], octets[1], octets[2], octets[3], octets[4], octets[5], octets[6], - ]) - } - } -} - -fn sticky_hint_get_by_ip(shared: &ProxySharedState, peer_ip: IpAddr) -> Option { - shared - .handshake - .sticky_user_by_ip - .get(&peer_ip) - .map(|entry| *entry) -} - -fn sticky_hint_get_by_ip_prefix(shared: &ProxySharedState, peer_ip: IpAddr) -> Option { - shared - .handshake - .sticky_user_by_ip_prefix - .get(&ip_prefix_hint_key(peer_ip)) - .map(|entry| *entry) -} - -fn sticky_hint_get_by_sni(shared: &ProxySharedState, sni: &str) -> Option { - let key = sni_hint_hash(sni); - shared - .handshake - .sticky_user_by_sni_hash - .get(&key) - .map(|entry| *entry) -} - -fn sticky_hint_record_success_in( - shared: &ProxySharedState, - peer_ip: IpAddr, - user_id: u32, - sni: Option<&str>, -) { - if shared.handshake.sticky_user_by_ip.len() > STICKY_HINT_MAX_ENTRIES { - shared.handshake.sticky_user_by_ip.clear(); - } - shared.handshake.sticky_user_by_ip.insert(peer_ip, user_id); - - if shared.handshake.sticky_user_by_ip_prefix.len() > STICKY_HINT_MAX_ENTRIES { - shared.handshake.sticky_user_by_ip_prefix.clear(); - } - shared - .handshake - .sticky_user_by_ip_prefix - .insert(ip_prefix_hint_key(peer_ip), user_id); - - if let Some(sni) = sni { - if shared.handshake.sticky_user_by_sni_hash.len() > STICKY_HINT_MAX_ENTRIES { - shared.handshake.sticky_user_by_sni_hash.clear(); - } - shared - .handshake - .sticky_user_by_sni_hash - .insert(sni_hint_hash(sni), user_id); - } -} - -fn record_recent_user_success_in(shared: &ProxySharedState, user_id: u32) { - let ring = &shared.handshake.recent_user_ring; - if ring.is_empty() { - return; - } - let seq = shared - .handshake - .recent_user_ring_seq - .fetch_add(1, Ordering::Relaxed); - let idx = (seq as usize) % ring.len(); - ring[idx].store(user_id.saturating_add(1), Ordering::Relaxed); -} - -fn mark_candidate_if_new(tried_user_ids: &mut [u32], tried_len: &mut usize, user_id: u32) -> bool { - if tried_user_ids[..*tried_len].contains(&user_id) { - return false; - } - if *tried_len < tried_user_ids.len() { - tried_user_ids[*tried_len] = user_id; - *tried_len += 1; - } - true -} - -fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> usize { - if total_users == 0 { - return 0; - } - if !overload { - return total_users; - } - let cap = if has_hint { - OVERLOAD_CANDIDATE_BUDGET_HINTED - } else { - OVERLOAD_CANDIDATE_BUDGET_UNHINTED - }; - total_users.min(cap.max(1)) -} - -fn validate_mtproto_secret_candidate( - handshake: &[u8; HANDSHAKE_LEN], - dec_prekey: &[u8; PREKEY_LEN], - dec_iv: u128, - enc_prekey: &[u8; PREKEY_LEN], - enc_iv: u128, - secret: &[u8; ACCESS_SECRET_BYTES], - config: &ProxyConfig, - is_tls: bool, -) -> Option { - let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); - dec_key_input.extend_from_slice(dec_prekey); - dec_key_input.extend_from_slice(secret); - let dec_key = Zeroizing::new(sha256(&dec_key_input)); - - let mut decryptor = AesCtr::new(&dec_key, dec_iv); - let mut decrypted = *handshake; - decryptor.apply(&mut decrypted); - - let tag_bytes: [u8; 4] = [ - decrypted[PROTO_TAG_POS], - decrypted[PROTO_TAG_POS + 1], - decrypted[PROTO_TAG_POS + 2], - decrypted[PROTO_TAG_POS + 3], - ]; - let proto_tag = ProtoTag::from_bytes(tag_bytes)?; - if !mode_enabled_for_proto(config, proto_tag, is_tls) { - return None; - } - - let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]); - - let mut enc_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); - enc_key_input.extend_from_slice(enc_prekey); - enc_key_input.extend_from_slice(secret); - let enc_key = Zeroizing::new(sha256(&enc_key_input)); - - let encryptor = AesCtr::new(&enc_key, enc_iv); - - Some(MtprotoCandidateValidation { - proto_tag, - dc_idx, - dec_key: *dec_key, - dec_iv, - enc_key: *enc_key, - enc_iv, - decryptor, - encryptor, - }) -} - -fn normalize_auth_probe_ip(peer_ip: IpAddr) -> IpAddr { - match peer_ip { - IpAddr::V4(ip) => IpAddr::V4(ip), - IpAddr::V6(ip) => { - let [a, b, c, d, _, _, _, _] = ip.segments(); - IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) - } - } -} - -fn auth_probe_backoff(fail_streak: u32) -> Duration { - if fail_streak < AUTH_PROBE_BACKOFF_START_FAILS { - return Duration::ZERO; - } - let shift = (fail_streak - AUTH_PROBE_BACKOFF_START_FAILS).min(10); - let multiplier = 1u64.checked_shl(shift).unwrap_or(u64::MAX); - let ms = AUTH_PROBE_BACKOFF_BASE_MS - .saturating_mul(multiplier) - .min(AUTH_PROBE_BACKOFF_MAX_MS); - Duration::from_millis(ms) -} - -fn auth_probe_state_expired(state: &AuthProbeState, now: Instant) -> bool { - let retention = Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS); - now.duration_since(state.last_seen) > retention -} - -fn auth_probe_eviction_offset_in( - shared: &ProxySharedState, - peer_ip: IpAddr, - now: Instant, -) -> usize { - let hasher_state = &shared.handshake.auth_probe_eviction_hasher; - let mut hasher = hasher_state.build_hasher(); - peer_ip.hash(&mut hasher); - now.hash(&mut hasher); - hasher.finish() as usize -} - -fn auth_probe_scan_start_offset_in( - shared: &ProxySharedState, - peer_ip: IpAddr, - now: Instant, - state_len: usize, - scan_limit: usize, -) -> usize { - if state_len == 0 || scan_limit == 0 { - return 0; - } - - auth_probe_eviction_offset_in(shared, peer_ip, now) % state_len -} - -fn auth_probe_is_throttled_in(shared: &ProxySharedState, peer_ip: IpAddr, now: Instant) -> bool { - let peer_ip = normalize_auth_probe_ip(peer_ip); - let state = &shared.handshake.auth_probe; - let Some(entry) = state.get(&peer_ip) else { - return false; - }; - if auth_probe_state_expired(&entry, now) { - drop(entry); - state.remove(&peer_ip); - return false; - } - now < entry.blocked_until -} - -fn auth_probe_saturation_grace_exhausted_in( - shared: &ProxySharedState, - peer_ip: IpAddr, - now: Instant, -) -> bool { - let peer_ip = normalize_auth_probe_ip(peer_ip); - let state = &shared.handshake.auth_probe; - let Some(entry) = state.get(&peer_ip) else { - return false; - }; - if auth_probe_state_expired(&entry, now) { - drop(entry); - state.remove(&peer_ip); - return false; - } - - entry.fail_streak >= AUTH_PROBE_BACKOFF_START_FAILS + AUTH_PROBE_SATURATION_GRACE_FAILS -} - -fn auth_probe_should_apply_preauth_throttle_in( - shared: &ProxySharedState, - peer_ip: IpAddr, - now: Instant, -) -> bool { - if !auth_probe_is_throttled_in(shared, peer_ip, now) { - return false; - } - - if !auth_probe_saturation_is_throttled_in(shared, now) { - return true; - } - - auth_probe_saturation_grace_exhausted_in(shared, peer_ip, now) -} - -fn auth_probe_saturation_is_throttled_in(shared: &ProxySharedState, now: Instant) -> bool { - let mut guard = shared - .handshake - .auth_probe_saturation - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - let Some(state) = guard.as_mut() else { - return false; - }; - - if now.duration_since(state.last_seen) > Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS) { - *guard = None; - return false; - } - - if now < state.blocked_until { - return true; - } - - false -} - -fn auth_probe_note_saturation_in(shared: &ProxySharedState, now: Instant) { - let mut guard = shared - .handshake - .auth_probe_saturation - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - match guard.as_mut() { - Some(state) - if now.duration_since(state.last_seen) - <= Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS) => - { - state.fail_streak = state.fail_streak.saturating_add(1); - state.last_seen = now; - state.blocked_until = now + auth_probe_backoff(state.fail_streak); - } - _ => { - let fail_streak = AUTH_PROBE_BACKOFF_START_FAILS; - *guard = Some(AuthProbeSaturationState { - fail_streak, - blocked_until: now + auth_probe_backoff(fail_streak), - last_seen: now, - }); - } - } -} - -fn auth_probe_note_expensive_invalid_scan_in( - shared: &ProxySharedState, - now: Instant, - validation_checks: usize, - overload: bool, -) { - if overload || validation_checks < EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD { - return; - } - - auth_probe_note_saturation_in(shared, now); -} - -fn auth_probe_record_failure_in(shared: &ProxySharedState, peer_ip: IpAddr, now: Instant) { - let peer_ip = normalize_auth_probe_ip(peer_ip); - let state = &shared.handshake.auth_probe; - auth_probe_record_failure_with_state_in(shared, state, peer_ip, now); -} - -fn auth_probe_record_failure_with_state_in( - shared: &ProxySharedState, - state: &DashMap, - peer_ip: IpAddr, - now: Instant, -) { - let make_new_state = || AuthProbeState { - fail_streak: 1, - blocked_until: now + auth_probe_backoff(1), - last_seen: now, - }; - - let update_existing = |entry: &mut AuthProbeState| { - if auth_probe_state_expired(entry, now) { - *entry = make_new_state(); - } else { - entry.fail_streak = entry.fail_streak.saturating_add(1); - entry.last_seen = now; - entry.blocked_until = now + auth_probe_backoff(entry.fail_streak); - } - }; - - match state.entry(peer_ip) { - Entry::Occupied(mut entry) => { - update_existing(entry.get_mut()); - return; - } - Entry::Vacant(_) => {} - } - - if state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES { - let mut rounds = 0usize; - while state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES { - rounds += 1; - if rounds > 8 { - auth_probe_note_saturation_in(shared, now); - let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None; - for entry in state.iter().take(AUTH_PROBE_PRUNE_SCAN_LIMIT) { - let key = *entry.key(); - let fail_streak = entry.value().fail_streak; - let last_seen = entry.value().last_seen; - match eviction_candidate { - Some((_, current_fail, current_seen)) - if fail_streak > current_fail - || (fail_streak == current_fail && last_seen >= current_seen) => {} - _ => eviction_candidate = Some((key, fail_streak, last_seen)), - } - } - - let Some((evict_key, _, _)) = eviction_candidate else { - return; - }; - state.remove(&evict_key); - break; - } - - let mut stale_keys = Vec::new(); - let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None; - let state_len = state.len(); - let scan_limit = state_len.min(AUTH_PROBE_PRUNE_SCAN_LIMIT); - - if state_len <= AUTH_PROBE_PRUNE_SCAN_LIMIT { - for entry in state.iter() { - let key = *entry.key(); - let fail_streak = entry.value().fail_streak; - let last_seen = entry.value().last_seen; - match eviction_candidate { - Some((_, current_fail, current_seen)) - if fail_streak > current_fail - || (fail_streak == current_fail && last_seen >= current_seen) => {} - _ => eviction_candidate = Some((key, fail_streak, last_seen)), - } - if auth_probe_state_expired(entry.value(), now) { - stale_keys.push(key); - } - } - } else { - let start_offset = - auth_probe_scan_start_offset_in(shared, peer_ip, now, state_len, scan_limit); - let mut scanned = 0usize; - for entry in state.iter().skip(start_offset) { - let key = *entry.key(); - let fail_streak = entry.value().fail_streak; - let last_seen = entry.value().last_seen; - match eviction_candidate { - Some((_, current_fail, current_seen)) - if fail_streak > current_fail - || (fail_streak == current_fail && last_seen >= current_seen) => {} - _ => eviction_candidate = Some((key, fail_streak, last_seen)), - } - if auth_probe_state_expired(entry.value(), now) { - stale_keys.push(key); - } - scanned += 1; - if scanned >= scan_limit { - break; - } - } - - if scanned < scan_limit { - for entry in state.iter().take(scan_limit - scanned) { - let key = *entry.key(); - let fail_streak = entry.value().fail_streak; - let last_seen = entry.value().last_seen; - match eviction_candidate { - Some((_, current_fail, current_seen)) - if fail_streak > current_fail - || (fail_streak == current_fail - && last_seen >= current_seen) => {} - _ => eviction_candidate = Some((key, fail_streak, last_seen)), - } - if auth_probe_state_expired(entry.value(), now) { - stale_keys.push(key); - } - } - } - } - - for stale_key in stale_keys { - state.remove(&stale_key); - } - - if state.len() < AUTH_PROBE_TRACK_MAX_ENTRIES { - break; - } - - let Some((evict_key, _, _)) = eviction_candidate else { - auth_probe_note_saturation_in(shared, now); - return; - }; - state.remove(&evict_key); - auth_probe_note_saturation_in(shared, now); - } - } - - match state.entry(peer_ip) { - Entry::Occupied(mut entry) => { - update_existing(entry.get_mut()); - } - Entry::Vacant(entry) => { - entry.insert(make_new_state()); - } - } -} - -fn auth_probe_record_success_in(shared: &ProxySharedState, peer_ip: IpAddr) { - let peer_ip = normalize_auth_probe_ip(peer_ip); - let state = &shared.handshake.auth_probe; - state.remove(&peer_ip); -} - -#[cfg(test)] -pub(crate) fn auth_probe_record_failure_for_testing( - shared: &ProxySharedState, - peer_ip: IpAddr, - now: Instant, -) { - auth_probe_record_failure_in(shared, peer_ip, now); -} - -#[cfg(test)] -pub(crate) fn auth_probe_fail_streak_for_testing_in_shared( - shared: &ProxySharedState, - peer_ip: IpAddr, -) -> Option { - let peer_ip = normalize_auth_probe_ip(peer_ip); - shared - .handshake - .auth_probe - .get(&peer_ip) - .map(|entry| entry.fail_streak) -} - -#[cfg(test)] -pub(crate) fn clear_auth_probe_state_for_testing_in_shared(shared: &ProxySharedState) { - shared.handshake.auth_probe.clear(); - match shared.handshake.auth_probe_saturation.lock() { - Ok(mut saturation) => { - *saturation = None; - } - Err(poisoned) => { - let mut saturation = poisoned.into_inner(); - *saturation = None; - shared.handshake.auth_probe_saturation.clear_poison(); - } - } -} - -fn warn_invalid_secret_once_in( - shared: &ProxySharedState, - name: &str, - reason: &str, - expected: usize, - got: Option, -) { - let key = (name.to_string(), reason.to_string()); - let should_warn = match shared.handshake.invalid_secret_warned.lock() { - Ok(mut guard) => { - if !guard.contains(&key) && guard.len() >= WARNED_SECRET_MAX_ENTRIES { - false - } else { - guard.insert(key) - } - } - Err(_) => true, - }; - - if !should_warn { - return; - } - - match got { - Some(actual) => { - warn!( - user = %name, - expected = expected, - got = actual, - "Skipping user: access secret has unexpected length" - ); - } - None => { - warn!( - user = %name, - "Skipping user: access secret is not valid hex" - ); - } - } -} - -fn decode_user_secret(shared: &ProxySharedState, name: &str, secret_hex: &str) -> Option> { - match hex::decode(secret_hex) { - Ok(bytes) if bytes.len() == ACCESS_SECRET_BYTES => Some(bytes), - Ok(bytes) => { - warn_invalid_secret_once_in( - shared, - name, - "invalid_length", - ACCESS_SECRET_BYTES, - Some(bytes.len()), - ); - None - } - Err(_) => { - warn_invalid_secret_once_in(shared, name, "invalid_hex", ACCESS_SECRET_BYTES, None); - None - } - } -} - -// Decide whether a client-supplied proto tag is allowed given the configured -// proxy modes and the transport that carried the handshake. -// -// A common mistake is to treat `modes.tls` and `modes.secure` as interchangeable -// even though they correspond to different transport profiles: `modes.tls` is -// for the TLS-fronted (EE-TLS) path, while `modes.secure` is for direct MTProto -// over TCP (DD). Enforcing this separation prevents an attacker from using a -// TLS-capable client to bypass the operator intent for the direct MTProto mode, -// and vice versa. -fn mode_enabled_for_proto(config: &ProxyConfig, proto_tag: ProtoTag, is_tls: bool) -> bool { - match proto_tag { - ProtoTag::Secure => { - if is_tls { - config.general.modes.tls - } else { - config.general.modes.secure - } - } - ProtoTag::Intermediate | ProtoTag::Abridged => config.general.modes.classic, - } -} - -fn decode_user_secrets_in( - shared: &ProxySharedState, - config: &ProxyConfig, - preferred_user: Option<&str>, -) -> Vec<(String, Vec)> { - let mut secrets = Vec::with_capacity(config.access.users.len()); - - if let Some(preferred) = preferred_user - && let Some(secret_hex) = config.access.users.get(preferred) - && let Some(bytes) = decode_user_secret(shared, preferred, secret_hex) - { - secrets.push((preferred.to_string(), bytes)); - } - - for (name, secret_hex) in &config.access.users { - if preferred_user.is_some_and(|preferred| preferred == name.as_str()) { - continue; - } - if let Some(bytes) = decode_user_secret(shared, name, secret_hex) { - secrets.push((name.clone(), bytes)); - } - } - - secrets -} - -#[cfg(test)] -pub(crate) fn auth_probe_state_for_testing_in_shared( - shared: &ProxySharedState, -) -> &DashMap { - &shared.handshake.auth_probe -} - -#[cfg(test)] -pub(crate) fn auth_probe_saturation_state_for_testing_in_shared( - shared: &ProxySharedState, -) -> &Mutex> { - &shared.handshake.auth_probe_saturation -} - -#[cfg(test)] -pub(crate) fn auth_probe_saturation_state_lock_for_testing_in_shared( - shared: &ProxySharedState, -) -> std::sync::MutexGuard<'_, Option> { - shared - .handshake - .auth_probe_saturation - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -#[cfg(test)] -pub(crate) fn clear_unknown_sni_warn_state_for_testing_in_shared(shared: &ProxySharedState) { - let mut guard = shared - .handshake - .unknown_sni_warn_next_allowed - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *guard = None; -} - -#[cfg(test)] -pub(crate) fn should_emit_unknown_sni_warn_for_testing_in_shared( - shared: &ProxySharedState, - now: Instant, -) -> bool { - should_emit_unknown_sni_warn_in(shared, now) -} - -#[cfg(test)] -pub(crate) fn clear_warned_secrets_for_testing_in_shared(shared: &ProxySharedState) { - if let Ok(mut guard) = shared.handshake.invalid_secret_warned.lock() { - guard.clear(); - } -} - -#[cfg(test)] -pub(crate) fn warned_secrets_for_testing_in_shared( - shared: &ProxySharedState, -) -> &Mutex> { - &shared.handshake.invalid_secret_warned -} - -#[cfg(test)] -pub(crate) fn auth_probe_is_throttled_for_testing_in_shared( - shared: &ProxySharedState, - peer_ip: IpAddr, -) -> bool { - auth_probe_is_throttled_in(shared, peer_ip, Instant::now()) -} - -#[cfg(test)] -pub(crate) fn auth_probe_saturation_is_throttled_for_testing_in_shared( - shared: &ProxySharedState, -) -> bool { - auth_probe_saturation_is_throttled_in(shared, Instant::now()) -} - -#[cfg(test)] -pub(crate) fn auth_probe_saturation_is_throttled_at_for_testing_in_shared( - shared: &ProxySharedState, - now: Instant, -) -> bool { - auth_probe_saturation_is_throttled_in(shared, now) -} - -#[inline] -fn find_matching_tls_domain<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> { - if config.censorship.tls_domain.eq_ignore_ascii_case(sni) { - return Some(config.censorship.tls_domain.as_str()); - } - - for domain in &config.censorship.tls_domains { - if domain.eq_ignore_ascii_case(sni) { - return Some(domain.as_str()); - } - } - - None -} - -async fn maybe_apply_server_hello_delay(config: &ProxyConfig) { - if config.censorship.server_hello_delay_max_ms == 0 { - return; - } - - let min = config.censorship.server_hello_delay_min_ms; - let max = config.censorship.server_hello_delay_max_ms.max(min); - let delay_ms = if max == min { - max - } else { - crate::proxy::masking::sample_lognormal_percentile_bounded(min, max, &mut rand::rng()) - }; - - if delay_ms > 0 { - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } -} - -/// Result of successful handshake -/// -/// Key material (`dec_key`, `dec_iv`, `enc_key`, `enc_iv`) is -/// zeroized on drop. -#[derive(Debug)] -pub struct HandshakeSuccess { - /// Authenticated user name - pub user: String, - /// Target datacenter index - pub dc_idx: i16, - /// Protocol variant (abridged/intermediate/secure) - pub proto_tag: ProtoTag, - /// Decryption key and IV (for reading from client) - pub dec_key: [u8; 32], - pub dec_iv: u128, - /// Encryption key and IV (for writing to client) - pub enc_key: [u8; 32], - pub enc_iv: u128, - /// Client address - pub peer: SocketAddr, - /// Whether TLS was used - pub is_tls: bool, -} - -impl Drop for HandshakeSuccess { - fn drop(&mut self) { - self.dec_key.zeroize(); - self.dec_iv.zeroize(); - self.enc_key.zeroize(); - self.enc_iv.zeroize(); - } -} - -/// Handle fake TLS handshake -#[cfg(test)] -pub async fn handle_tls_handshake( - handshake: &[u8], - reader: R, - mut writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - rng: &SecureRandom, - tls_cache: Option>, -) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> -where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, -{ - let shared = ProxySharedState::new(); - handle_tls_handshake_impl( - handshake, - reader, - writer, - peer, - config, - replay_checker, - rng, - tls_cache, - shared.as_ref(), - TlsResponseWriteOptions::default(), - ) - .await -} - -pub async fn handle_tls_handshake_with_shared( - handshake: &[u8], - reader: R, - writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - rng: &SecureRandom, - tls_cache: Option>, - shared: &ProxySharedState, -) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> -where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, -{ - handle_tls_handshake_impl( - handshake, - reader, - writer, - peer, - config, - replay_checker, - rng, - tls_cache, - shared, - TlsResponseWriteOptions::default(), - ) - .await -} - -/// Handles FakeTLS with optional best-effort initial-response chunking. -pub(crate) async fn handle_tls_handshake_with_shared_and_options( - handshake: &[u8], - reader: R, - writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - rng: &SecureRandom, - tls_cache: Option>, - shared: &ProxySharedState, - response_write_options: TlsResponseWriteOptions, -) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> -where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, -{ - handle_tls_handshake_impl( - handshake, - reader, - writer, - peer, - config, - replay_checker, - rng, - tls_cache, - shared, - response_write_options, - ) - .await -} - -async fn handle_tls_handshake_impl( - handshake: &[u8], - reader: R, - mut writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - rng: &SecureRandom, - tls_cache: Option>, - shared: &ProxySharedState, - response_write_options: TlsResponseWriteOptions, -) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> -where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, -{ - debug!(peer = %peer, handshake_len = handshake.len(), "Processing TLS handshake"); - - let throttle_now = Instant::now(); - if auth_probe_should_apply_preauth_throttle_in(shared, peer.ip(), throttle_now) { - maybe_apply_server_hello_delay(config).await; - debug!(peer = %peer, "TLS handshake rejected by pre-auth probe throttle"); - return HandshakeResult::BadClient { reader, writer }; - } - - if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!(peer = %peer, "TLS handshake too short"); - return HandshakeResult::BadClient { reader, writer }; - } - - let client_sni = tls::extract_sni_from_client_hello(handshake); - let preferred_user_hint = client_sni - .as_deref() - .filter(|sni| config.access.users.contains_key(*sni)); - let matched_tls_domain = client_sni - .as_deref() - .and_then(|sni| find_matching_tls_domain(config, sni)); - - let alpn_list = if config.censorship.alpn_enforce { - tls::extract_alpn_from_client_hello(handshake) - } else { - Vec::new() - }; - let selected_alpn = if config.censorship.alpn_enforce { - if alpn_list.iter().any(|p| p == b"h2") { - Some(b"h2".to_vec()) - } else if alpn_list.iter().any(|p| p == b"http/1.1") { - Some(b"http/1.1".to_vec()) - } else if !alpn_list.is_empty() { - maybe_apply_server_hello_delay(config).await; - debug!(peer = %peer, "Client ALPN list has no supported protocol; using masking fallback"); - return HandshakeResult::BadClient { reader, writer }; - } else { - None - } - } else { - None - }; - // Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous: - // this avoids leaking certificate payload on malformed probes. - let client_tls_version = tls::detect_client_hello_tls_version(handshake) - .unwrap_or(tls::ClientHelloTlsVersion::Tls13); - - if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() { - let sni = client_sni.as_deref().unwrap_or_default(); - match config.censorship.unknown_sni_action { - UnknownSniAction::Accept => { - debug!( - peer = %peer, - sni = %sni, - unknown_sni = true, - unknown_sni_action = ?config.censorship.unknown_sni_action, - "TLS handshake accepted by unknown SNI policy" - ); - } - action @ (UnknownSniAction::Drop - | UnknownSniAction::Mask - | UnknownSniAction::RejectHandshake) => { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - // For Drop/Mask we apply the synthetic ServerHello delay so - // the fail-closed path is timing-indistinguishable from the - // success path. For RejectHandshake we deliberately skip the - // delay: a stock modern nginx with `ssl_reject_handshake on;` - // responds with the alert essentially immediately, so - // injecting 8-24ms here would itself become a distinguisher - // against the public baseline we are trying to blend into. - if !matches!(action, UnknownSniAction::RejectHandshake) { - maybe_apply_server_hello_delay(config).await; - } - let log_now = Instant::now(); - if should_emit_unknown_sni_warn_in(shared, log_now) { - warn!( - peer = %peer, - sni = %sni, - unknown_sni = true, - unknown_sni_action = ?action, - "TLS handshake rejected by unknown SNI policy" - ); - } else { - info!( - peer = %peer, - sni = %sni, - unknown_sni = true, - unknown_sni_action = ?action, - "TLS handshake rejected by unknown SNI policy" - ); - } - if matches!(action, UnknownSniAction::RejectHandshake) { - // TLS alert record layer: - // 0x15 ContentType.alert - // 0x03 0x03 legacy_record_version = TLS 1.2 - // (matches what modern nginx emits in - // the first server -> client record, - // per RFC 8446 5.1 guidance) - // 0x00 0x02 length = 2 - // Alert payload: - // 0x02 AlertLevel.fatal - // 0x70 AlertDescription.unrecognized_name (112, RFC 6066) - const TLS_ALERT_UNRECOGNIZED_NAME: [u8; 7] = - [0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70]; - if let Err(e) = writer.write_all(&TLS_ALERT_UNRECOGNIZED_NAME).await { - debug!( - peer = %peer, - error = %e, - "Failed to write unrecognized_name TLS alert" - ); - } else { - let _ = writer.flush().await; - } - } - return match action { - UnknownSniAction::Drop | UnknownSniAction::RejectHandshake => { - HandshakeResult::Error(ProxyError::UnknownTlsSni) - } - UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, - UnknownSniAction::Accept => unreachable!(), - }; - } - } - } - - let mut validation_digest = [0u8; tls::TLS_DIGEST_LEN]; - let mut validation_session_id = [0u8; 32]; - let mut validation_session_id_len = 0usize; - let mut validated_user = String::new(); - let mut validated_secret = [0u8; ACCESS_SECRET_BYTES]; - let mut validated_user_id: Option = None; - - if let Some(snapshot) = config.runtime_user_auth() { - let parsed = match parse_tls_auth_material( - handshake, - config.access.ignore_time_skew, - config.access.replay_window_secs, - ) { - Some(parsed) => parsed, - None => { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!(peer = %peer, "TLS handshake auth material parsing failed"); - return HandshakeResult::BadClient { reader, writer }; - } - }; - - let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); - let preferred_user_id = preferred_user_hint.and_then(|user| snapshot.user_id_by_name(user)); - let sticky_sni_hint = client_sni - .as_deref() - .and_then(|sni| sticky_hint_get_by_sni(shared, sni)); - let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); - let sni_candidates = client_sni - .as_deref() - .and_then(|sni| snapshot.sni_candidates(sni)); - let sni_initial_candidates = client_sni - .as_deref() - .and_then(|sni| snapshot.sni_initial_candidates(sni)); - - let has_hint = sticky_ip_hint.is_some() - || preferred_user_id.is_some() - || sticky_sni_hint.is_some() - || sticky_prefix_hint.is_some() - || sni_candidates.is_some_and(|ids| !ids.is_empty()) - || sni_initial_candidates.is_some_and(|ids| !ids.is_empty()); - let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); - let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); - - let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP]; - let mut tried_len = 0usize; - let mut validation_checks = 0usize; - let mut budget_exhausted = false; - - macro_rules! try_user_id { - ($user_id:expr) => {{ - if validation_checks >= candidate_budget { - budget_exhausted = true; - false - } else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) { - false - } else if let Some(entry) = snapshot.entry_by_id($user_id) { - validation_checks = validation_checks.saturating_add(1); - if let Some(candidate) = - validate_tls_secret_candidate(&parsed, handshake, &entry.secret) - { - validation_digest = candidate.digest; - validation_session_id = candidate.session_id; - validation_session_id_len = candidate.session_id_len; - validated_secret.copy_from_slice(&entry.secret); - validated_user = entry.user.clone(); - validated_user_id = Some($user_id); - true - } else { - false - } - } else { - false - } - }}; - } - - let mut matched = false; - if let Some(user_id) = sticky_ip_hint { - matched = try_user_id!(user_id); - } - - if !matched && let Some(user_id) = preferred_user_id { - matched = try_user_id!(user_id); - } - - if !matched && let Some(user_id) = sticky_sni_hint { - matched = try_user_id!(user_id); - } - - if !matched && let Some(user_id) = sticky_prefix_hint { - matched = try_user_id!(user_id); - } - - if !matched - && !budget_exhausted - && let Some(candidate_ids) = sni_candidates - { - for &user_id in candidate_ids { - if try_user_id!(user_id) { - matched = true; - break; - } - if budget_exhausted { - break; - } - } - } - - if !matched - && !budget_exhausted - && let Some(candidate_ids) = sni_initial_candidates - { - for &user_id in candidate_ids { - if try_user_id!(user_id) { - matched = true; - break; - } - if budget_exhausted { - break; - } - } - } - - if !matched && !budget_exhausted { - let ring = &shared.handshake.recent_user_ring; - if !ring.is_empty() { - let next_seq = shared - .handshake - .recent_user_ring_seq - .load(Ordering::Relaxed); - let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT); - for offset in 0..scan_limit { - let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len(); - let encoded_user_id = ring[idx].load(Ordering::Relaxed); - if encoded_user_id == 0 { - continue; - } - if try_user_id!(encoded_user_id - 1) { - matched = true; - break; - } - if budget_exhausted { - break; - } - } - } - } - - if !matched && !budget_exhausted { - for idx in 0..snapshot.entries().len() { - let Some(user_id) = u32::try_from(idx).ok() else { - break; - }; - if try_user_id!(user_id) { - matched = true; - break; - } - if budget_exhausted { - break; - } - } - } - - shared - .handshake - .auth_expensive_checks_total - .fetch_add(validation_checks as u64, Ordering::Relaxed); - if budget_exhausted { - shared - .handshake - .auth_budget_exhausted_total - .fetch_add(1, Ordering::Relaxed); - } - - if !matched { - let failure_now = Instant::now(); - auth_probe_note_expensive_invalid_scan_in( - shared, - failure_now, - validation_checks, - overload, - ); - auth_probe_record_failure_in(shared, peer.ip(), failure_now); - maybe_apply_server_hello_delay(config).await; - debug!( - peer = %peer, - ignore_time_skew = config.access.ignore_time_skew, - budget_exhausted = budget_exhausted, - candidate_budget = candidate_budget, - validation_checks = validation_checks, - "TLS handshake validation failed - no matching user, time skew, or budget exhausted" - ); - return HandshakeResult::BadClient { reader, writer }; - } - } else { - let secrets = decode_user_secrets_in(shared, config, preferred_user_hint); - let validation = match tls::validate_tls_handshake_with_replay_window( - handshake, - &secrets, - config.access.ignore_time_skew, - config.access.replay_window_secs, - ) { - Some(v) => v, - None => { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!( - peer = %peer, - ignore_time_skew = config.access.ignore_time_skew, - "TLS handshake validation failed - no matching user or time skew" - ); - return HandshakeResult::BadClient { reader, writer }; - } - }; - let secret = match secrets.iter().find(|(name, _)| *name == validation.user) { - Some((_, s)) if s.len() == ACCESS_SECRET_BYTES => s, - _ => { - maybe_apply_server_hello_delay(config).await; - return HandshakeResult::BadClient { reader, writer }; - } - }; - - validation_digest = validation.digest; - validation_session_id_len = validation.session_id.len(); - if validation_session_id_len > validation_session_id.len() { - maybe_apply_server_hello_delay(config).await; - return HandshakeResult::BadClient { reader, writer }; - } - validation_session_id[..validation_session_id_len].copy_from_slice(&validation.session_id); - validated_user = validation.user; - validated_secret.copy_from_slice(secret); - } - - if config - .access - .is_user_source_ip_denied(validated_user.as_str(), peer.ip()) - { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!( - peer = %peer, - user = %validated_user, - "TLS handshake rejected: client source IP on per-user deny list (access.user_source_deny)" - ); - return HandshakeResult::BadClient { reader, writer }; - } - - // Reject known replay digests before expensive cache/domain/ALPN policy work. - let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN]; - if replay_checker.check_tls_digest(digest_half) { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!(peer = %peer, "TLS replay attack detected (duplicate digest)"); - return HandshakeResult::BadClient { reader, writer }; - } - - let cached_entry = if config.censorship.tls_emulation { - if let Some(cache) = tls_cache.as_ref() { - let selected_domain = - matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); - let cached_entry = cache.get(selected_domain).await; - Some(cached_entry) - } else { - None - } - } else { - None - }; - - let preferred_key_share_group = cached_entry - .as_ref() - .and_then(|cached_entry| emulator::profiled_server_hello_key_share_group(cached_entry)); - let Some(server_key_share) = - tls::build_server_hello_key_share(handshake, preferred_key_share_group, rng) - else { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!( - peer = %peer, - "TLS handshake rejected: ClientHello did not offer a usable TLS 1.3 key_share" - ); - return HandshakeResult::BadClient { reader, writer }; - }; - - let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() { - if cached_entry.server_hello_template.cipher_suite == [0, 0] { - [0x13, 0x01] - } else { - cached_entry.server_hello_template.cipher_suite - } - } else { - [0x13, 0x01] - }; - let Some(selected_cipher_suite) = - tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite) - else { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!( - peer = %peer, - "TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite" - ); - return HandshakeResult::BadClient { reader, writer }; - }; - - let cached = if let Some(cached_entry) = cached_entry { - let use_full_cert_payload = if config.censorship.serverhello_compact - && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) - { - if let Some(cache) = tls_cache.as_ref() { - cache - .take_full_cert_budget_for_ip( - peer.ip(), - Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), - ) - .await - } else { - true - } - } else { - true - }; - Some((cached_entry, use_full_cert_payload)) - } else { - None - }; - - // Add replay digest only for policy-valid handshakes. - replay_checker.add_tls_digest(digest_half); - - let validation_session_id_slice = &validation_session_id[..validation_session_id_len]; - - let response = if let Some((cached_entry, use_full_cert_payload)) = cached { - emulator::build_emulated_server_hello( - &validated_secret, - &validation_digest, - validation_session_id_slice, - &cached_entry, - use_full_cert_payload, - config.censorship.serverhello_compact, - client_tls_version, - selected_cipher_suite, - &server_key_share, - rng, - selected_alpn.clone(), - config.censorship.tls_new_session_tickets, - ) - } else { - tls::build_server_hello_with_cipher( - &validated_secret, - &validation_digest, - validation_session_id_slice, - config.censorship.fake_cert_len, - rng, - selected_cipher_suite, - &server_key_share, - selected_alpn.clone(), - config.censorship.tls_new_session_tickets, - ) - }; - - // Apply the same optional delay budget used by reject paths to reduce - // distinguishability between success and fail-closed handshakes. - maybe_apply_server_hello_delay(config).await; - - debug!(peer = %peer, response_len = response.len(), "Sending TLS ServerHello"); - - if let Err(e) = write_tls_response(&mut writer, &response, response_write_options).await { - warn!(peer = %peer, error = %e, "Failed to write TLS ServerHello"); - return HandshakeResult::Error(ProxyError::Io(e)); - } - - debug!( - peer = %peer, - user = %validated_user, - "TLS handshake successful" - ); - - auth_probe_record_success_in(shared, peer.ip()); - - if let Some(user_id) = validated_user_id { - sticky_hint_record_success_in(shared, peer.ip(), user_id, client_sni.as_deref()); - record_recent_user_success_in(shared, user_id); - } - - HandshakeResult::Success(( - FakeTlsReader::new(reader), - FakeTlsWriter::new(writer), - validated_user, - )) -} - -async fn write_tls_response( - writer: &mut W, - response: &[u8], - options: TlsResponseWriteOptions, -) -> std::io::Result<()> { - #[cfg(target_os = "linux")] - if let (Some(fd), Some(fragment_size)) = (options.socket_fd, options.fragment_size) { - return crate::transport::socket::send_tcp_fragmented_fd( - fd, - response, - usize::from(fragment_size), - ) - .await; - } - - let _ = options; - writer.write_all(response).await?; - writer.flush().await -} - -/// Handle MTProto obfuscation handshake -#[cfg(test)] -pub async fn handle_mtproto_handshake( - handshake: &[u8; HANDSHAKE_LEN], - reader: R, - writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - is_tls: bool, - preferred_user: Option<&str>, -) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> -where - R: AsyncRead + Unpin + Send, - W: AsyncWrite + Unpin + Send, -{ - let shared = ProxySharedState::new(); - handle_mtproto_handshake_impl( - handshake, - reader, - writer, - peer, - config, - replay_checker, - is_tls, - preferred_user, - shared.as_ref(), - ) - .await -} - -pub async fn handle_mtproto_handshake_with_shared( - handshake: &[u8; HANDSHAKE_LEN], - reader: R, - writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - is_tls: bool, - preferred_user: Option<&str>, - shared: &ProxySharedState, -) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> -where - R: AsyncRead + Unpin + Send, - W: AsyncWrite + Unpin + Send, -{ - handle_mtproto_handshake_impl( - handshake, - reader, - writer, - peer, - config, - replay_checker, - is_tls, - preferred_user, - shared, - ) - .await -} - -async fn handle_mtproto_handshake_impl( - handshake: &[u8; HANDSHAKE_LEN], - reader: R, - writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - is_tls: bool, - preferred_user: Option<&str>, - shared: &ProxySharedState, -) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> -where - R: AsyncRead + Unpin + Send, - W: AsyncWrite + Unpin + Send, -{ - let handshake_fingerprint = { - let digest = sha256(&handshake[..8]); - hex::encode(&digest[..4]) - }; - trace!( - peer = %peer, - handshake_fingerprint = %handshake_fingerprint, - "MTProto handshake prefix" - ); - - let throttle_now = Instant::now(); - if auth_probe_should_apply_preauth_throttle_in(shared, peer.ip(), throttle_now) { - maybe_apply_server_hello_delay(config).await; - debug!(peer = %peer, "MTProto handshake rejected by pre-auth probe throttle"); - return HandshakeResult::BadClient { reader, writer }; - } - - let dec_prekey_iv = &handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN]; - let mut dec_prekey = [0u8; PREKEY_LEN]; - dec_prekey.copy_from_slice(&dec_prekey_iv[..PREKEY_LEN]); - let mut dec_iv_arr = [0u8; IV_LEN]; - dec_iv_arr.copy_from_slice(&dec_prekey_iv[PREKEY_LEN..]); - let dec_iv = u128::from_be_bytes(dec_iv_arr); - - let mut enc_prekey_iv = [0u8; PREKEY_LEN + IV_LEN]; - for idx in 0..enc_prekey_iv.len() { - enc_prekey_iv[idx] = dec_prekey_iv[dec_prekey_iv.len() - 1 - idx]; - } - let mut enc_prekey = [0u8; PREKEY_LEN]; - enc_prekey.copy_from_slice(&enc_prekey_iv[..PREKEY_LEN]); - let mut enc_iv_arr = [0u8; IV_LEN]; - enc_iv_arr.copy_from_slice(&enc_prekey_iv[PREKEY_LEN..]); - let enc_iv = u128::from_be_bytes(enc_iv_arr); - - if let Some(snapshot) = config.runtime_user_auth() { - let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); - let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); - let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user)); - let has_hint = - sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some(); - let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); - let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); - - let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP]; - let mut tried_len = 0usize; - let mut validation_checks = 0usize; - let mut budget_exhausted = false; - - let mut matched_user = String::new(); - let mut matched_user_id = None; - let mut matched_validation = None; - - macro_rules! try_user_id { - ($user_id:expr) => {{ - if validation_checks >= candidate_budget { - budget_exhausted = true; - false - } else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) { - false - } else if let Some(entry) = snapshot.entry_by_id($user_id) { - validation_checks = validation_checks.saturating_add(1); - if let Some(validation) = validate_mtproto_secret_candidate( - handshake, - &dec_prekey, - dec_iv, - &enc_prekey, - enc_iv, - &entry.secret, - config, - is_tls, - ) { - matched_user = entry.user.clone(); - matched_user_id = Some($user_id); - matched_validation = Some(validation); - true - } else { - false - } - } else { - false - } - }}; - } - - let mut matched = false; - if let Some(user_id) = sticky_ip_hint { - matched = try_user_id!(user_id); - } - - if !matched && let Some(user_id) = preferred_user_id { - matched = try_user_id!(user_id); - } - - if !matched && let Some(user_id) = sticky_prefix_hint { - matched = try_user_id!(user_id); - } - - if !matched && !budget_exhausted { - let ring = &shared.handshake.recent_user_ring; - if !ring.is_empty() { - let next_seq = shared - .handshake - .recent_user_ring_seq - .load(Ordering::Relaxed); - let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT); - for offset in 0..scan_limit { - let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len(); - let encoded_user_id = ring[idx].load(Ordering::Relaxed); - if encoded_user_id == 0 { - continue; - } - if try_user_id!(encoded_user_id - 1) { - matched = true; - break; - } - if budget_exhausted { - break; - } - } - } - } - - if !matched && !budget_exhausted { - for idx in 0..snapshot.entries().len() { - let Some(user_id) = u32::try_from(idx).ok() else { - break; - }; - if try_user_id!(user_id) { - matched = true; - break; - } - if budget_exhausted { - break; - } - } - } - - shared - .handshake - .auth_expensive_checks_total - .fetch_add(validation_checks as u64, Ordering::Relaxed); - if budget_exhausted { - shared - .handshake - .auth_budget_exhausted_total - .fetch_add(1, Ordering::Relaxed); - } - - if !matched { - let failure_now = Instant::now(); - auth_probe_note_expensive_invalid_scan_in( - shared, - failure_now, - validation_checks, - overload, - ); - auth_probe_record_failure_in(shared, peer.ip(), failure_now); - maybe_apply_server_hello_delay(config).await; - debug!( - peer = %peer, - budget_exhausted = budget_exhausted, - candidate_budget = candidate_budget, - validation_checks = validation_checks, - "MTProto handshake: no matching user found" - ); - return HandshakeResult::BadClient { reader, writer }; - } - - let Some(validation) = matched_validation else { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!( - peer = %peer, - user = %matched_user, - "MTProto handshake matched user without validation material" - ); - return HandshakeResult::BadClient { reader, writer }; - }; - - if config - .access - .is_user_source_ip_denied(matched_user.as_str(), peer.ip()) - { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!( - peer = %peer, - user = %matched_user, - "MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)" - ); - return HandshakeResult::BadClient { reader, writer }; - } - - // Apply replay tracking only after successful authentication. - // - // This ordering prevents an attacker from producing invalid handshakes that - // still collide with a valid handshake's replay slot and thus evict a valid - // entry from the cache. We accept the cost of performing the full - // authentication check first to avoid poisoning the replay cache. - if replay_checker.check_and_add_handshake(dec_prekey_iv) { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!(peer = %peer, user = %matched_user, "MTProto replay attack detected"); - return HandshakeResult::BadClient { reader, writer }; - } - - let dec_key = Zeroizing::new(validation.dec_key); - let enc_key = Zeroizing::new(validation.enc_key); - let success = HandshakeSuccess { - user: matched_user.clone(), - dc_idx: validation.dc_idx, - proto_tag: validation.proto_tag, - dec_key: *dec_key, - dec_iv: validation.dec_iv, - enc_key: *enc_key, - enc_iv: validation.enc_iv, - peer, - is_tls, - }; - - debug!( - peer = %peer, - user = %matched_user, - dc = validation.dc_idx, - proto = ?validation.proto_tag, - tls = is_tls, - "MTProto handshake successful" - ); - - auth_probe_record_success_in(shared, peer.ip()); - if let Some(user_id) = matched_user_id { - sticky_hint_record_success_in(shared, peer.ip(), user_id, None); - record_recent_user_success_in(shared, user_id); - } - - let max_pending = config.general.crypto_pending_buffer; - return HandshakeResult::Success(( - CryptoReader::new(reader, validation.decryptor), - CryptoWriter::new(writer, validation.encryptor, max_pending), - success, - )); - } else { - let decoded_users = decode_user_secrets_in(shared, config, preferred_user); - let mut validation_checks = 0usize; - - for (user, secret) in decoded_users { - if secret.len() != ACCESS_SECRET_BYTES { - continue; - } - validation_checks = validation_checks.saturating_add(1); - - let mut secret_arr = [0u8; ACCESS_SECRET_BYTES]; - secret_arr.copy_from_slice(&secret); - let Some(validation) = validate_mtproto_secret_candidate( - handshake, - &dec_prekey, - dec_iv, - &enc_prekey, - enc_iv, - &secret_arr, - config, - is_tls, - ) else { - continue; - }; - - shared - .handshake - .auth_expensive_checks_total - .fetch_add(validation_checks as u64, Ordering::Relaxed); - - if config - .access - .is_user_source_ip_denied(user.as_str(), peer.ip()) - { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!( - peer = %peer, - user = %user, - "MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)" - ); - return HandshakeResult::BadClient { reader, writer }; - } - - // Apply replay tracking only after successful authentication. - // - // This ordering prevents an attacker from producing invalid handshakes that - // still collide with a valid handshake's replay slot and thus evict a valid - // entry from the cache. We accept the cost of performing the full - // authentication check first to avoid poisoning the replay cache. - if replay_checker.check_and_add_handshake(dec_prekey_iv) { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - warn!(peer = %peer, user = %user, "MTProto replay attack detected"); - return HandshakeResult::BadClient { reader, writer }; - } - - let dec_key = Zeroizing::new(validation.dec_key); - let enc_key = Zeroizing::new(validation.enc_key); - let success = HandshakeSuccess { - user: user.clone(), - dc_idx: validation.dc_idx, - proto_tag: validation.proto_tag, - dec_key: *dec_key, - dec_iv: validation.dec_iv, - enc_key: *enc_key, - enc_iv: validation.enc_iv, - peer, - is_tls, - }; - - debug!( - peer = %peer, - user = %user, - dc = validation.dc_idx, - proto = ?validation.proto_tag, - tls = is_tls, - "MTProto handshake successful" - ); - - auth_probe_record_success_in(shared, peer.ip()); - - let max_pending = config.general.crypto_pending_buffer; - return HandshakeResult::Success(( - CryptoReader::new(reader, validation.decryptor), - CryptoWriter::new(writer, validation.encryptor, max_pending), - success, - )); - } - - shared - .handshake - .auth_expensive_checks_total - .fetch_add(validation_checks as u64, Ordering::Relaxed); - } - - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!(peer = %peer, "MTProto handshake: no matching user found"); - HandshakeResult::BadClient { reader, writer } -} - -/// Generate nonce for Telegram connection -pub fn generate_tg_nonce( - proto_tag: ProtoTag, - dc_idx: i16, - client_enc_key: &[u8; 32], - client_enc_iv: u128, - rng: &SecureRandom, - fast_mode: bool, -) -> ([u8; HANDSHAKE_LEN], [u8; 32], u128, [u8; 32], u128) { - loop { - let bytes = rng.bytes(HANDSHAKE_LEN); - let Ok(mut nonce): Result<[u8; HANDSHAKE_LEN], _> = bytes.try_into() else { - continue; - }; - - if RESERVED_NONCE_FIRST_BYTES.contains(&nonce[0]) { - continue; - } - - let first_four: [u8; 4] = [nonce[0], nonce[1], nonce[2], nonce[3]]; - if RESERVED_NONCE_BEGINNINGS.contains(&first_four) { - continue; - } - - let continue_four: [u8; 4] = [nonce[4], nonce[5], nonce[6], nonce[7]]; - if RESERVED_NONCE_CONTINUES.contains(&continue_four) { - continue; - } - - nonce[PROTO_TAG_POS..PROTO_TAG_POS + 4].copy_from_slice(&proto_tag.to_bytes()); - // CRITICAL: write dc_idx so upstream DC knows where to route - nonce[DC_IDX_POS..DC_IDX_POS + 2].copy_from_slice(&dc_idx.to_le_bytes()); - - if fast_mode { - let mut key_iv = Zeroizing::new(Vec::with_capacity(KEY_LEN + IV_LEN)); - key_iv.extend_from_slice(client_enc_key); - key_iv.extend_from_slice(&client_enc_iv.to_be_bytes()); - // Python/C compatibility requires reversed enc_key+enc_iv nonce bytes. - key_iv.reverse(); - nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN].copy_from_slice(&key_iv); - } - - let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN]; - let dec_key_iv = Zeroizing::new(enc_key_iv.iter().rev().copied().collect::>()); - - let mut tg_enc_key = [0u8; 32]; - tg_enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]); - let mut tg_enc_iv_arr = [0u8; IV_LEN]; - tg_enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]); - let tg_enc_iv = u128::from_be_bytes(tg_enc_iv_arr); - - let mut tg_dec_key = [0u8; 32]; - tg_dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]); - let mut tg_dec_iv_arr = [0u8; IV_LEN]; - tg_dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]); - let tg_dec_iv = u128::from_be_bytes(tg_dec_iv_arr); - - return (nonce, tg_enc_key, tg_enc_iv, tg_dec_key, tg_dec_iv); - } -} - -/// Encrypt nonce for sending to Telegram and return cipher objects with correct counter state -pub fn encrypt_tg_nonce_with_ciphers(nonce: &[u8; HANDSHAKE_LEN]) -> (Vec, AesCtr, AesCtr) { - let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN]; - let dec_key_iv = Zeroizing::new(enc_key_iv.iter().rev().copied().collect::>()); - - let mut enc_key = [0u8; 32]; - enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]); - let mut enc_iv_arr = [0u8; IV_LEN]; - enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]); - let enc_iv = u128::from_be_bytes(enc_iv_arr); - - let mut dec_key = [0u8; 32]; - dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]); - let mut dec_iv_arr = [0u8; IV_LEN]; - dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]); - let dec_iv = u128::from_be_bytes(dec_iv_arr); - - let mut encryptor = AesCtr::new(&enc_key, enc_iv); - // Encryption advances the nonce counter from zero to four. - let encrypted_full = encryptor.encrypt(nonce); - - let mut result = nonce[..PROTO_TAG_POS].to_vec(); - result.extend_from_slice(&encrypted_full[PROTO_TAG_POS..]); - - let decryptor = AesCtr::new(&dec_key, dec_iv); - enc_key.zeroize(); - dec_key.zeroize(); - - (result, encryptor, decryptor) -} - -/// Encrypt nonce for sending to Telegram (legacy function for compatibility) -pub fn encrypt_tg_nonce(nonce: &[u8; HANDSHAKE_LEN]) -> Vec { - let (encrypted, _, _) = encrypt_tg_nonce_with_ciphers(nonce); - encrypted -} - #[cfg(test)] #[path = "tests/handshake_security_tests.rs"] mod security_tests; @@ -2140,9 +186,7 @@ mod handshake_key_material_zeroization_security_tests; #[path = "tests/handshake_baseline_invariant_tests.rs"] mod handshake_baseline_invariant_tests; -/// Compile-time guard: HandshakeSuccess holds cryptographic key material and -/// must never be Copy. A Copy impl would allow silent key duplication, -/// undermining the zeroize-on-drop guarantee. +/// Compile-time guard preventing silent duplication of session key material. mod compile_time_security_checks { use super::HandshakeSuccess; use static_assertions::assert_not_impl_all; diff --git a/src/proxy/handshake/auth_candidates.rs b/src/proxy/handshake/auth_candidates.rs new file mode 100644 index 0000000..a0ef56a --- /dev/null +++ b/src/proxy/handshake/auth_candidates.rs @@ -0,0 +1,292 @@ +use super::*; + +pub(super) struct MtprotoCandidateValidation { + pub(super) proto_tag: ProtoTag, + pub(super) dc_idx: i16, + pub(super) dec_key: [u8; 32], + pub(super) dec_iv: u128, + pub(super) enc_key: [u8; 32], + pub(super) enc_iv: u128, + pub(super) decryptor: AesCtr, + pub(super) encryptor: AesCtr, +} + +pub(super) fn sni_hint_hash(sni: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + for byte in sni.bytes() { + hasher.write_u8(byte.to_ascii_lowercase()); + } + hasher.finish() +} + +pub(super) fn ip_prefix_hint_key(peer_ip: IpAddr) -> u64 { + match peer_ip { + // Keep /24 granularity for IPv4 to avoid over-merging unrelated clients. + IpAddr::V4(ip) => { + let [a, b, c, _] = ip.octets(); + u64::from_be_bytes([0x04, a, b, c, 0, 0, 0, 0]) + } + // Keep /56 granularity for IPv6 to retain stability while limiting bucket size. + IpAddr::V6(ip) => { + let octets = ip.octets(); + u64::from_be_bytes([ + 0x06, octets[0], octets[1], octets[2], octets[3], octets[4], octets[5], octets[6], + ]) + } + } +} + +pub(super) fn sticky_hint_get_by_ip(shared: &ProxySharedState, peer_ip: IpAddr) -> Option { + shared + .handshake + .sticky_user_by_ip + .get(&peer_ip) + .map(|entry| *entry) +} + +pub(super) fn sticky_hint_get_by_ip_prefix(shared: &ProxySharedState, peer_ip: IpAddr) -> Option { + shared + .handshake + .sticky_user_by_ip_prefix + .get(&ip_prefix_hint_key(peer_ip)) + .map(|entry| *entry) +} + +pub(super) fn sticky_hint_get_by_sni(shared: &ProxySharedState, sni: &str) -> Option { + let key = sni_hint_hash(sni); + shared + .handshake + .sticky_user_by_sni_hash + .get(&key) + .map(|entry| *entry) +} + +pub(super) fn sticky_hint_record_success_in( + shared: &ProxySharedState, + peer_ip: IpAddr, + user_id: u32, + sni: Option<&str>, +) { + if shared.handshake.sticky_user_by_ip.len() > STICKY_HINT_MAX_ENTRIES { + shared.handshake.sticky_user_by_ip.clear(); + } + shared.handshake.sticky_user_by_ip.insert(peer_ip, user_id); + + if shared.handshake.sticky_user_by_ip_prefix.len() > STICKY_HINT_MAX_ENTRIES { + shared.handshake.sticky_user_by_ip_prefix.clear(); + } + shared + .handshake + .sticky_user_by_ip_prefix + .insert(ip_prefix_hint_key(peer_ip), user_id); + + if let Some(sni) = sni { + if shared.handshake.sticky_user_by_sni_hash.len() > STICKY_HINT_MAX_ENTRIES { + shared.handshake.sticky_user_by_sni_hash.clear(); + } + shared + .handshake + .sticky_user_by_sni_hash + .insert(sni_hint_hash(sni), user_id); + } +} + +pub(super) fn record_recent_user_success_in(shared: &ProxySharedState, user_id: u32) { + let ring = &shared.handshake.recent_user_ring; + if ring.is_empty() { + return; + } + let seq = shared + .handshake + .recent_user_ring_seq + .fetch_add(1, Ordering::Relaxed); + let idx = (seq as usize) % ring.len(); + ring[idx].store(user_id.saturating_add(1), Ordering::Relaxed); +} + +pub(super) fn mark_candidate_if_new(tried_user_ids: &mut [u32], tried_len: &mut usize, user_id: u32) -> bool { + if tried_user_ids[..*tried_len].contains(&user_id) { + return false; + } + if *tried_len < tried_user_ids.len() { + tried_user_ids[*tried_len] = user_id; + *tried_len += 1; + } + true +} + +pub(super) fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> usize { + if total_users == 0 { + return 0; + } + if !overload { + return total_users; + } + let cap = if has_hint { + OVERLOAD_CANDIDATE_BUDGET_HINTED + } else { + OVERLOAD_CANDIDATE_BUDGET_UNHINTED + }; + total_users.min(cap.max(1)) +} + +pub(super) fn validate_mtproto_secret_candidate( + handshake: &[u8; HANDSHAKE_LEN], + dec_prekey: &[u8; PREKEY_LEN], + dec_iv: u128, + enc_prekey: &[u8; PREKEY_LEN], + enc_iv: u128, + secret: &[u8; ACCESS_SECRET_BYTES], + config: &ProxyConfig, + is_tls: bool, +) -> Option { + let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); + dec_key_input.extend_from_slice(dec_prekey); + dec_key_input.extend_from_slice(secret); + let dec_key = Zeroizing::new(sha256(&dec_key_input)); + + let mut decryptor = AesCtr::new(&dec_key, dec_iv); + let mut decrypted = *handshake; + decryptor.apply(&mut decrypted); + + let tag_bytes: [u8; 4] = [ + decrypted[PROTO_TAG_POS], + decrypted[PROTO_TAG_POS + 1], + decrypted[PROTO_TAG_POS + 2], + decrypted[PROTO_TAG_POS + 3], + ]; + let proto_tag = ProtoTag::from_bytes(tag_bytes)?; + if !mode_enabled_for_proto(config, proto_tag, is_tls) { + return None; + } + + let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]); + + let mut enc_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); + enc_key_input.extend_from_slice(enc_prekey); + enc_key_input.extend_from_slice(secret); + let enc_key = Zeroizing::new(sha256(&enc_key_input)); + + let encryptor = AesCtr::new(&enc_key, enc_iv); + + Some(MtprotoCandidateValidation { + proto_tag, + dc_idx, + dec_key: *dec_key, + dec_iv, + enc_key: *enc_key, + enc_iv, + decryptor, + encryptor, + }) +} + +pub(super) fn warn_invalid_secret_once_in( + shared: &ProxySharedState, + name: &str, + reason: &str, + expected: usize, + got: Option, +) { + let key = (name.to_string(), reason.to_string()); + let should_warn = match shared.handshake.invalid_secret_warned.lock() { + Ok(mut guard) => { + if !guard.contains(&key) && guard.len() >= WARNED_SECRET_MAX_ENTRIES { + false + } else { + guard.insert(key) + } + } + Err(_) => true, + }; + + if !should_warn { + return; + } + + match got { + Some(actual) => { + warn!( + user = %name, + expected = expected, + got = actual, + "Skipping user: access secret has unexpected length" + ); + } + None => { + warn!( + user = %name, + "Skipping user: access secret is not valid hex" + ); + } + } +} + +pub(super) fn decode_user_secret(shared: &ProxySharedState, name: &str, secret_hex: &str) -> Option> { + match hex::decode(secret_hex) { + Ok(bytes) if bytes.len() == ACCESS_SECRET_BYTES => Some(bytes), + Ok(bytes) => { + warn_invalid_secret_once_in( + shared, + name, + "invalid_length", + ACCESS_SECRET_BYTES, + Some(bytes.len()), + ); + None + } + Err(_) => { + warn_invalid_secret_once_in(shared, name, "invalid_hex", ACCESS_SECRET_BYTES, None); + None + } + } +} + +// Decide whether a client-supplied proto tag is allowed given the configured +// proxy modes and the transport that carried the handshake. +// +// A common mistake is to treat `modes.tls` and `modes.secure` as interchangeable +// even though they correspond to different transport profiles: `modes.tls` is +// for the TLS-fronted (EE-TLS) path, while `modes.secure` is for direct MTProto +// over TCP (DD). Enforcing this separation prevents an attacker from using a +// TLS-capable client to bypass the operator intent for the direct MTProto mode, +// and vice versa. +pub(super) fn mode_enabled_for_proto(config: &ProxyConfig, proto_tag: ProtoTag, is_tls: bool) -> bool { + match proto_tag { + ProtoTag::Secure => { + if is_tls { + config.general.modes.tls + } else { + config.general.modes.secure + } + } + ProtoTag::Intermediate | ProtoTag::Abridged => config.general.modes.classic, + } +} + +pub(super) fn decode_user_secrets_in( + shared: &ProxySharedState, + config: &ProxyConfig, + preferred_user: Option<&str>, +) -> Vec<(String, Vec)> { + let mut secrets = Vec::with_capacity(config.access.users.len()); + + if let Some(preferred) = preferred_user + && let Some(secret_hex) = config.access.users.get(preferred) + && let Some(bytes) = decode_user_secret(shared, preferred, secret_hex) + { + secrets.push((preferred.to_string(), bytes)); + } + + for (name, secret_hex) in &config.access.users { + if preferred_user.is_some_and(|preferred| preferred == name.as_str()) { + continue; + } + if let Some(bytes) = decode_user_secret(shared, name, secret_hex) { + secrets.push((name.clone(), bytes)); + } + } + + secrets +} + diff --git a/src/proxy/handshake/auth_probe.rs b/src/proxy/handshake/auth_probe.rs new file mode 100644 index 0000000..3e9b81b --- /dev/null +++ b/src/proxy/handshake/auth_probe.rs @@ -0,0 +1,508 @@ +use super::*; + +pub(crate) struct AuthProbeState { + pub(super) fail_streak: u32, + pub(super) blocked_until: Instant, + pub(super) last_seen: Instant, +} + +#[derive(Clone, Copy)] +pub(crate) struct AuthProbeSaturationState { + pub(super) fail_streak: u32, + pub(super) blocked_until: Instant, + pub(super) last_seen: Instant, +} +pub(super) fn unknown_sni_warn_state_lock_in( + shared: &ProxySharedState, +) -> std::sync::MutexGuard<'_, Option> { + shared + .handshake + .unknown_sni_warn_next_allowed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +pub(super) fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> bool { + let mut guard = unknown_sni_warn_state_lock_in(shared); + if let Some(next_allowed) = *guard + && now < next_allowed + { + return false; + } + *guard = Some(now + Duration::from_secs(UNKNOWN_SNI_WARN_COOLDOWN_SECS)); + true +} + +pub(super) fn normalize_auth_probe_ip(peer_ip: IpAddr) -> IpAddr { + match peer_ip { + IpAddr::V4(ip) => IpAddr::V4(ip), + IpAddr::V6(ip) => { + let [a, b, c, d, _, _, _, _] = ip.segments(); + IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) + } + } +} + +pub(super) fn auth_probe_backoff(fail_streak: u32) -> Duration { + if fail_streak < AUTH_PROBE_BACKOFF_START_FAILS { + return Duration::ZERO; + } + let shift = (fail_streak - AUTH_PROBE_BACKOFF_START_FAILS).min(10); + let multiplier = 1u64.checked_shl(shift).unwrap_or(u64::MAX); + let ms = AUTH_PROBE_BACKOFF_BASE_MS + .saturating_mul(multiplier) + .min(AUTH_PROBE_BACKOFF_MAX_MS); + Duration::from_millis(ms) +} + +pub(super) fn auth_probe_state_expired(state: &AuthProbeState, now: Instant) -> bool { + let retention = Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS); + now.duration_since(state.last_seen) > retention +} + +pub(super) fn auth_probe_eviction_offset_in( + shared: &ProxySharedState, + peer_ip: IpAddr, + now: Instant, +) -> usize { + let hasher_state = &shared.handshake.auth_probe_eviction_hasher; + let mut hasher = hasher_state.build_hasher(); + peer_ip.hash(&mut hasher); + now.hash(&mut hasher); + hasher.finish() as usize +} + +pub(super) fn auth_probe_scan_start_offset_in( + shared: &ProxySharedState, + peer_ip: IpAddr, + now: Instant, + state_len: usize, + scan_limit: usize, +) -> usize { + if state_len == 0 || scan_limit == 0 { + return 0; + } + + auth_probe_eviction_offset_in(shared, peer_ip, now) % state_len +} + +pub(super) fn auth_probe_is_throttled_in(shared: &ProxySharedState, peer_ip: IpAddr, now: Instant) -> bool { + let peer_ip = normalize_auth_probe_ip(peer_ip); + let state = &shared.handshake.auth_probe; + let Some(entry) = state.get(&peer_ip) else { + return false; + }; + if auth_probe_state_expired(&entry, now) { + drop(entry); + state.remove(&peer_ip); + return false; + } + now < entry.blocked_until +} + +pub(super) fn auth_probe_saturation_grace_exhausted_in( + shared: &ProxySharedState, + peer_ip: IpAddr, + now: Instant, +) -> bool { + let peer_ip = normalize_auth_probe_ip(peer_ip); + let state = &shared.handshake.auth_probe; + let Some(entry) = state.get(&peer_ip) else { + return false; + }; + if auth_probe_state_expired(&entry, now) { + drop(entry); + state.remove(&peer_ip); + return false; + } + + entry.fail_streak >= AUTH_PROBE_BACKOFF_START_FAILS + AUTH_PROBE_SATURATION_GRACE_FAILS +} + +pub(super) fn auth_probe_should_apply_preauth_throttle_in( + shared: &ProxySharedState, + peer_ip: IpAddr, + now: Instant, +) -> bool { + if !auth_probe_is_throttled_in(shared, peer_ip, now) { + return false; + } + + if !auth_probe_saturation_is_throttled_in(shared, now) { + return true; + } + + auth_probe_saturation_grace_exhausted_in(shared, peer_ip, now) +} + +pub(super) fn auth_probe_saturation_is_throttled_in(shared: &ProxySharedState, now: Instant) -> bool { + let mut guard = shared + .handshake + .auth_probe_saturation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let Some(state) = guard.as_mut() else { + return false; + }; + + if now.duration_since(state.last_seen) > Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS) { + *guard = None; + return false; + } + + if now < state.blocked_until { + return true; + } + + false +} + +pub(super) fn auth_probe_note_saturation_in(shared: &ProxySharedState, now: Instant) { + let mut guard = shared + .handshake + .auth_probe_saturation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + match guard.as_mut() { + Some(state) + if now.duration_since(state.last_seen) + <= Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS) => + { + state.fail_streak = state.fail_streak.saturating_add(1); + state.last_seen = now; + state.blocked_until = now + auth_probe_backoff(state.fail_streak); + } + _ => { + let fail_streak = AUTH_PROBE_BACKOFF_START_FAILS; + *guard = Some(AuthProbeSaturationState { + fail_streak, + blocked_until: now + auth_probe_backoff(fail_streak), + last_seen: now, + }); + } + } +} + +pub(super) fn auth_probe_note_expensive_invalid_scan_in( + shared: &ProxySharedState, + now: Instant, + validation_checks: usize, + overload: bool, +) { + if overload || validation_checks < EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD { + return; + } + + auth_probe_note_saturation_in(shared, now); +} + +pub(super) fn auth_probe_record_failure_in(shared: &ProxySharedState, peer_ip: IpAddr, now: Instant) { + let peer_ip = normalize_auth_probe_ip(peer_ip); + let state = &shared.handshake.auth_probe; + auth_probe_record_failure_with_state_in(shared, state, peer_ip, now); +} + +pub(super) fn auth_probe_record_failure_with_state_in( + shared: &ProxySharedState, + state: &DashMap, + peer_ip: IpAddr, + now: Instant, +) { + let make_new_state = || AuthProbeState { + fail_streak: 1, + blocked_until: now + auth_probe_backoff(1), + last_seen: now, + }; + + let update_existing = |entry: &mut AuthProbeState| { + if auth_probe_state_expired(entry, now) { + *entry = make_new_state(); + } else { + entry.fail_streak = entry.fail_streak.saturating_add(1); + entry.last_seen = now; + entry.blocked_until = now + auth_probe_backoff(entry.fail_streak); + } + }; + + match state.entry(peer_ip) { + Entry::Occupied(mut entry) => { + update_existing(entry.get_mut()); + return; + } + Entry::Vacant(_) => {} + } + + if state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES { + let mut rounds = 0usize; + while state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES { + rounds += 1; + if rounds > 8 { + auth_probe_note_saturation_in(shared, now); + let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None; + for entry in state.iter().take(AUTH_PROBE_PRUNE_SCAN_LIMIT) { + let key = *entry.key(); + let fail_streak = entry.value().fail_streak; + let last_seen = entry.value().last_seen; + match eviction_candidate { + Some((_, current_fail, current_seen)) + if fail_streak > current_fail + || (fail_streak == current_fail && last_seen >= current_seen) => {} + _ => eviction_candidate = Some((key, fail_streak, last_seen)), + } + } + + let Some((evict_key, _, _)) = eviction_candidate else { + return; + }; + state.remove(&evict_key); + break; + } + + let mut stale_keys = Vec::new(); + let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None; + let state_len = state.len(); + let scan_limit = state_len.min(AUTH_PROBE_PRUNE_SCAN_LIMIT); + + if state_len <= AUTH_PROBE_PRUNE_SCAN_LIMIT { + for entry in state.iter() { + let key = *entry.key(); + let fail_streak = entry.value().fail_streak; + let last_seen = entry.value().last_seen; + match eviction_candidate { + Some((_, current_fail, current_seen)) + if fail_streak > current_fail + || (fail_streak == current_fail && last_seen >= current_seen) => {} + _ => eviction_candidate = Some((key, fail_streak, last_seen)), + } + if auth_probe_state_expired(entry.value(), now) { + stale_keys.push(key); + } + } + } else { + let start_offset = + auth_probe_scan_start_offset_in(shared, peer_ip, now, state_len, scan_limit); + let mut scanned = 0usize; + for entry in state.iter().skip(start_offset) { + let key = *entry.key(); + let fail_streak = entry.value().fail_streak; + let last_seen = entry.value().last_seen; + match eviction_candidate { + Some((_, current_fail, current_seen)) + if fail_streak > current_fail + || (fail_streak == current_fail && last_seen >= current_seen) => {} + _ => eviction_candidate = Some((key, fail_streak, last_seen)), + } + if auth_probe_state_expired(entry.value(), now) { + stale_keys.push(key); + } + scanned += 1; + if scanned >= scan_limit { + break; + } + } + + if scanned < scan_limit { + for entry in state.iter().take(scan_limit - scanned) { + let key = *entry.key(); + let fail_streak = entry.value().fail_streak; + let last_seen = entry.value().last_seen; + match eviction_candidate { + Some((_, current_fail, current_seen)) + if fail_streak > current_fail + || (fail_streak == current_fail + && last_seen >= current_seen) => {} + _ => eviction_candidate = Some((key, fail_streak, last_seen)), + } + if auth_probe_state_expired(entry.value(), now) { + stale_keys.push(key); + } + } + } + } + + for stale_key in stale_keys { + state.remove(&stale_key); + } + + if state.len() < AUTH_PROBE_TRACK_MAX_ENTRIES { + break; + } + + let Some((evict_key, _, _)) = eviction_candidate else { + auth_probe_note_saturation_in(shared, now); + return; + }; + state.remove(&evict_key); + auth_probe_note_saturation_in(shared, now); + } + } + + match state.entry(peer_ip) { + Entry::Occupied(mut entry) => { + update_existing(entry.get_mut()); + } + Entry::Vacant(entry) => { + entry.insert(make_new_state()); + } + } +} + +pub(super) fn auth_probe_record_success_in(shared: &ProxySharedState, peer_ip: IpAddr) { + let peer_ip = normalize_auth_probe_ip(peer_ip); + let state = &shared.handshake.auth_probe; + state.remove(&peer_ip); +} + +#[cfg(test)] +pub(crate) fn auth_probe_record_failure_for_testing( + shared: &ProxySharedState, + peer_ip: IpAddr, + now: Instant, +) { + auth_probe_record_failure_in(shared, peer_ip, now); +} + +#[cfg(test)] +pub(crate) fn auth_probe_fail_streak_for_testing_in_shared( + shared: &ProxySharedState, + peer_ip: IpAddr, +) -> Option { + let peer_ip = normalize_auth_probe_ip(peer_ip); + shared + .handshake + .auth_probe + .get(&peer_ip) + .map(|entry| entry.fail_streak) +} + +#[cfg(test)] +pub(crate) fn clear_auth_probe_state_for_testing_in_shared(shared: &ProxySharedState) { + shared.handshake.auth_probe.clear(); + match shared.handshake.auth_probe_saturation.lock() { + Ok(mut saturation) => { + *saturation = None; + } + Err(poisoned) => { + let mut saturation = poisoned.into_inner(); + *saturation = None; + shared.handshake.auth_probe_saturation.clear_poison(); + } + } +} + +#[cfg(test)] +pub(crate) fn auth_probe_state_for_testing_in_shared( + shared: &ProxySharedState, +) -> &DashMap { + &shared.handshake.auth_probe +} + +#[cfg(test)] +pub(crate) fn auth_probe_saturation_state_for_testing_in_shared( + shared: &ProxySharedState, +) -> &Mutex> { + &shared.handshake.auth_probe_saturation +} + +#[cfg(test)] +pub(crate) fn auth_probe_saturation_state_lock_for_testing_in_shared( + shared: &ProxySharedState, +) -> std::sync::MutexGuard<'_, Option> { + shared + .handshake + .auth_probe_saturation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(test)] +pub(crate) fn clear_unknown_sni_warn_state_for_testing_in_shared(shared: &ProxySharedState) { + let mut guard = shared + .handshake + .unknown_sni_warn_next_allowed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = None; +} + +#[cfg(test)] +pub(crate) fn should_emit_unknown_sni_warn_for_testing_in_shared( + shared: &ProxySharedState, + now: Instant, +) -> bool { + should_emit_unknown_sni_warn_in(shared, now) +} + +#[cfg(test)] +pub(crate) fn clear_warned_secrets_for_testing_in_shared(shared: &ProxySharedState) { + if let Ok(mut guard) = shared.handshake.invalid_secret_warned.lock() { + guard.clear(); + } +} + +#[cfg(test)] +pub(crate) fn warned_secrets_for_testing_in_shared( + shared: &ProxySharedState, +) -> &Mutex> { + &shared.handshake.invalid_secret_warned +} + +#[cfg(test)] +pub(crate) fn auth_probe_is_throttled_for_testing_in_shared( + shared: &ProxySharedState, + peer_ip: IpAddr, +) -> bool { + auth_probe_is_throttled_in(shared, peer_ip, Instant::now()) +} + +#[cfg(test)] +pub(crate) fn auth_probe_saturation_is_throttled_for_testing_in_shared( + shared: &ProxySharedState, +) -> bool { + auth_probe_saturation_is_throttled_in(shared, Instant::now()) +} + +#[cfg(test)] +pub(crate) fn auth_probe_saturation_is_throttled_at_for_testing_in_shared( + shared: &ProxySharedState, + now: Instant, +) -> bool { + auth_probe_saturation_is_throttled_in(shared, now) +} + +#[inline] +pub(super) fn find_matching_tls_domain<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> { + if config.censorship.tls_domain.eq_ignore_ascii_case(sni) { + return Some(config.censorship.tls_domain.as_str()); + } + + for domain in &config.censorship.tls_domains { + if domain.eq_ignore_ascii_case(sni) { + return Some(domain.as_str()); + } + } + + None +} + +pub(super) async fn maybe_apply_server_hello_delay(config: &ProxyConfig) { + if config.censorship.server_hello_delay_max_ms == 0 { + return; + } + + let min = config.censorship.server_hello_delay_min_ms; + let max = config.censorship.server_hello_delay_max_ms.max(min); + let delay_ms = if max == min { + max + } else { + crate::proxy::masking::sample_lognormal_percentile_bounded(min, max, &mut rand::rng()) + }; + + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } +} + + diff --git a/src/proxy/handshake/mtproto.rs b/src/proxy/handshake/mtproto.rs new file mode 100644 index 0000000..ca57063 --- /dev/null +++ b/src/proxy/handshake/mtproto.rs @@ -0,0 +1,420 @@ +use super::*; + +/// Handle MTProto obfuscation handshake +#[cfg(test)] +pub async fn handle_mtproto_handshake( + handshake: &[u8; HANDSHAKE_LEN], + reader: R, + writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + is_tls: bool, + preferred_user: Option<&str>, +) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> +where + R: AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, +{ + let shared = ProxySharedState::new(); + handle_mtproto_handshake_impl( + handshake, + reader, + writer, + peer, + config, + replay_checker, + is_tls, + preferred_user, + shared.as_ref(), + ) + .await +} + +pub async fn handle_mtproto_handshake_with_shared( + handshake: &[u8; HANDSHAKE_LEN], + reader: R, + writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + is_tls: bool, + preferred_user: Option<&str>, + shared: &ProxySharedState, +) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> +where + R: AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, +{ + handle_mtproto_handshake_impl( + handshake, + reader, + writer, + peer, + config, + replay_checker, + is_tls, + preferred_user, + shared, + ) + .await +} + +async fn handle_mtproto_handshake_impl( + handshake: &[u8; HANDSHAKE_LEN], + reader: R, + writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + is_tls: bool, + preferred_user: Option<&str>, + shared: &ProxySharedState, +) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> +where + R: AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, +{ + let handshake_fingerprint = { + let digest = sha256(&handshake[..8]); + hex::encode(&digest[..4]) + }; + trace!( + peer = %peer, + handshake_fingerprint = %handshake_fingerprint, + "MTProto handshake prefix" + ); + + let throttle_now = Instant::now(); + if auth_probe_should_apply_preauth_throttle_in(shared, peer.ip(), throttle_now) { + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "MTProto handshake rejected by pre-auth probe throttle"); + return HandshakeResult::BadClient { reader, writer }; + } + + let dec_prekey_iv = &handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN]; + let mut dec_prekey = [0u8; PREKEY_LEN]; + dec_prekey.copy_from_slice(&dec_prekey_iv[..PREKEY_LEN]); + let mut dec_iv_arr = [0u8; IV_LEN]; + dec_iv_arr.copy_from_slice(&dec_prekey_iv[PREKEY_LEN..]); + let dec_iv = u128::from_be_bytes(dec_iv_arr); + + let mut enc_prekey_iv = [0u8; PREKEY_LEN + IV_LEN]; + for idx in 0..enc_prekey_iv.len() { + enc_prekey_iv[idx] = dec_prekey_iv[dec_prekey_iv.len() - 1 - idx]; + } + let mut enc_prekey = [0u8; PREKEY_LEN]; + enc_prekey.copy_from_slice(&enc_prekey_iv[..PREKEY_LEN]); + let mut enc_iv_arr = [0u8; IV_LEN]; + enc_iv_arr.copy_from_slice(&enc_prekey_iv[PREKEY_LEN..]); + let enc_iv = u128::from_be_bytes(enc_iv_arr); + + if let Some(snapshot) = config.runtime_user_auth() { + let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); + let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); + let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user)); + let has_hint = + sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some(); + let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); + let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); + + let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP]; + let mut tried_len = 0usize; + let mut validation_checks = 0usize; + let mut budget_exhausted = false; + + let mut matched_user = String::new(); + let mut matched_user_id = None; + let mut matched_validation = None; + + macro_rules! try_user_id { + ($user_id:expr) => {{ + if validation_checks >= candidate_budget { + budget_exhausted = true; + false + } else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) { + false + } else if let Some(entry) = snapshot.entry_by_id($user_id) { + validation_checks = validation_checks.saturating_add(1); + if let Some(validation) = validate_mtproto_secret_candidate( + handshake, + &dec_prekey, + dec_iv, + &enc_prekey, + enc_iv, + &entry.secret, + config, + is_tls, + ) { + matched_user = entry.user.clone(); + matched_user_id = Some($user_id); + matched_validation = Some(validation); + true + } else { + false + } + } else { + false + } + }}; + } + + let mut matched = false; + if let Some(user_id) = sticky_ip_hint { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = preferred_user_id { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = sticky_prefix_hint { + matched = try_user_id!(user_id); + } + + if !matched && !budget_exhausted { + let ring = &shared.handshake.recent_user_ring; + if !ring.is_empty() { + let next_seq = shared + .handshake + .recent_user_ring_seq + .load(Ordering::Relaxed); + let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT); + for offset in 0..scan_limit { + let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len(); + let encoded_user_id = ring[idx].load(Ordering::Relaxed); + if encoded_user_id == 0 { + continue; + } + if try_user_id!(encoded_user_id - 1) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + } + + if !matched && !budget_exhausted { + for idx in 0..snapshot.entries().len() { + let Some(user_id) = u32::try_from(idx).ok() else { + break; + }; + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + if budget_exhausted { + shared + .handshake + .auth_budget_exhausted_total + .fetch_add(1, Ordering::Relaxed); + } + + if !matched { + let failure_now = Instant::now(); + auth_probe_note_expensive_invalid_scan_in( + shared, + failure_now, + validation_checks, + overload, + ); + auth_probe_record_failure_in(shared, peer.ip(), failure_now); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + budget_exhausted = budget_exhausted, + candidate_budget = candidate_budget, + validation_checks = validation_checks, + "MTProto handshake: no matching user found" + ); + return HandshakeResult::BadClient { reader, writer }; + } + + let Some(validation) = matched_validation else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %matched_user, + "MTProto handshake matched user without validation material" + ); + return HandshakeResult::BadClient { reader, writer }; + }; + + if config + .access + .is_user_source_ip_denied(matched_user.as_str(), peer.ip()) + { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %matched_user, + "MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)" + ); + return HandshakeResult::BadClient { reader, writer }; + } + + // Apply replay tracking only after successful authentication. + // + // This ordering prevents an attacker from producing invalid handshakes that + // still collide with a valid handshake's replay slot and thus evict a valid + // entry from the cache. We accept the cost of performing the full + // authentication check first to avoid poisoning the replay cache. + if replay_checker.check_and_add_handshake(dec_prekey_iv) { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!(peer = %peer, user = %matched_user, "MTProto replay attack detected"); + return HandshakeResult::BadClient { reader, writer }; + } + + let dec_key = Zeroizing::new(validation.dec_key); + let enc_key = Zeroizing::new(validation.enc_key); + let success = HandshakeSuccess { + user: matched_user.clone(), + dc_idx: validation.dc_idx, + proto_tag: validation.proto_tag, + dec_key: *dec_key, + dec_iv: validation.dec_iv, + enc_key: *enc_key, + enc_iv: validation.enc_iv, + peer, + is_tls, + }; + + debug!( + peer = %peer, + user = %matched_user, + dc = validation.dc_idx, + proto = ?validation.proto_tag, + tls = is_tls, + "MTProto handshake successful" + ); + + auth_probe_record_success_in(shared, peer.ip()); + if let Some(user_id) = matched_user_id { + sticky_hint_record_success_in(shared, peer.ip(), user_id, None); + record_recent_user_success_in(shared, user_id); + } + + let max_pending = config.general.crypto_pending_buffer; + return HandshakeResult::Success(( + CryptoReader::new(reader, validation.decryptor), + CryptoWriter::new(writer, validation.encryptor, max_pending), + success, + )); + } else { + let decoded_users = decode_user_secrets_in(shared, config, preferred_user); + let mut validation_checks = 0usize; + + for (user, secret) in decoded_users { + if secret.len() != ACCESS_SECRET_BYTES { + continue; + } + validation_checks = validation_checks.saturating_add(1); + + let mut secret_arr = [0u8; ACCESS_SECRET_BYTES]; + secret_arr.copy_from_slice(&secret); + let Some(validation) = validate_mtproto_secret_candidate( + handshake, + &dec_prekey, + dec_iv, + &enc_prekey, + enc_iv, + &secret_arr, + config, + is_tls, + ) else { + continue; + }; + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + + if config + .access + .is_user_source_ip_denied(user.as_str(), peer.ip()) + { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %user, + "MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)" + ); + return HandshakeResult::BadClient { reader, writer }; + } + + // Apply replay tracking only after successful authentication. + // + // This ordering prevents an attacker from producing invalid handshakes that + // still collide with a valid handshake's replay slot and thus evict a valid + // entry from the cache. We accept the cost of performing the full + // authentication check first to avoid poisoning the replay cache. + if replay_checker.check_and_add_handshake(dec_prekey_iv) { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!(peer = %peer, user = %user, "MTProto replay attack detected"); + return HandshakeResult::BadClient { reader, writer }; + } + + let dec_key = Zeroizing::new(validation.dec_key); + let enc_key = Zeroizing::new(validation.enc_key); + let success = HandshakeSuccess { + user: user.clone(), + dc_idx: validation.dc_idx, + proto_tag: validation.proto_tag, + dec_key: *dec_key, + dec_iv: validation.dec_iv, + enc_key: *enc_key, + enc_iv: validation.enc_iv, + peer, + is_tls, + }; + + debug!( + peer = %peer, + user = %user, + dc = validation.dc_idx, + proto = ?validation.proto_tag, + tls = is_tls, + "MTProto handshake successful" + ); + + auth_probe_record_success_in(shared, peer.ip()); + + let max_pending = config.general.crypto_pending_buffer; + return HandshakeResult::Success(( + CryptoReader::new(reader, validation.decryptor), + CryptoWriter::new(writer, validation.encryptor, max_pending), + success, + )); + } + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + } + + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "MTProto handshake: no matching user found"); + HandshakeResult::BadClient { reader, writer } +} + diff --git a/src/proxy/handshake/nonce.rs b/src/proxy/handshake/nonce.rs new file mode 100644 index 0000000..9f595d8 --- /dev/null +++ b/src/proxy/handshake/nonce.rs @@ -0,0 +1,99 @@ +use super::*; + +pub fn generate_tg_nonce( + proto_tag: ProtoTag, + dc_idx: i16, + client_enc_key: &[u8; 32], + client_enc_iv: u128, + rng: &SecureRandom, + fast_mode: bool, +) -> ([u8; HANDSHAKE_LEN], [u8; 32], u128, [u8; 32], u128) { + loop { + let bytes = rng.bytes(HANDSHAKE_LEN); + let Ok(mut nonce): Result<[u8; HANDSHAKE_LEN], _> = bytes.try_into() else { + continue; + }; + + if RESERVED_NONCE_FIRST_BYTES.contains(&nonce[0]) { + continue; + } + + let first_four: [u8; 4] = [nonce[0], nonce[1], nonce[2], nonce[3]]; + if RESERVED_NONCE_BEGINNINGS.contains(&first_four) { + continue; + } + + let continue_four: [u8; 4] = [nonce[4], nonce[5], nonce[6], nonce[7]]; + if RESERVED_NONCE_CONTINUES.contains(&continue_four) { + continue; + } + + nonce[PROTO_TAG_POS..PROTO_TAG_POS + 4].copy_from_slice(&proto_tag.to_bytes()); + // CRITICAL: write dc_idx so upstream DC knows where to route + nonce[DC_IDX_POS..DC_IDX_POS + 2].copy_from_slice(&dc_idx.to_le_bytes()); + + if fast_mode { + let mut key_iv = Zeroizing::new(Vec::with_capacity(KEY_LEN + IV_LEN)); + key_iv.extend_from_slice(client_enc_key); + key_iv.extend_from_slice(&client_enc_iv.to_be_bytes()); + // Python/C compatibility requires reversed enc_key+enc_iv nonce bytes. + key_iv.reverse(); + nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN].copy_from_slice(&key_iv); + } + + let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN]; + let dec_key_iv = Zeroizing::new(enc_key_iv.iter().rev().copied().collect::>()); + + let mut tg_enc_key = [0u8; 32]; + tg_enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]); + let mut tg_enc_iv_arr = [0u8; IV_LEN]; + tg_enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]); + let tg_enc_iv = u128::from_be_bytes(tg_enc_iv_arr); + + let mut tg_dec_key = [0u8; 32]; + tg_dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]); + let mut tg_dec_iv_arr = [0u8; IV_LEN]; + tg_dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]); + let tg_dec_iv = u128::from_be_bytes(tg_dec_iv_arr); + + return (nonce, tg_enc_key, tg_enc_iv, tg_dec_key, tg_dec_iv); + } +} + +/// Encrypt nonce for sending to Telegram and return cipher objects with correct counter state +pub fn encrypt_tg_nonce_with_ciphers(nonce: &[u8; HANDSHAKE_LEN]) -> (Vec, AesCtr, AesCtr) { + let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN]; + let dec_key_iv = Zeroizing::new(enc_key_iv.iter().rev().copied().collect::>()); + + let mut enc_key = [0u8; 32]; + enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]); + let mut enc_iv_arr = [0u8; IV_LEN]; + enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]); + let enc_iv = u128::from_be_bytes(enc_iv_arr); + + let mut dec_key = [0u8; 32]; + dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]); + let mut dec_iv_arr = [0u8; IV_LEN]; + dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]); + let dec_iv = u128::from_be_bytes(dec_iv_arr); + + let mut encryptor = AesCtr::new(&enc_key, enc_iv); + // Encryption advances the nonce counter from zero to four. + let encrypted_full = encryptor.encrypt(nonce); + + let mut result = nonce[..PROTO_TAG_POS].to_vec(); + result.extend_from_slice(&encrypted_full[PROTO_TAG_POS..]); + + let decryptor = AesCtr::new(&dec_key, dec_iv); + enc_key.zeroize(); + dec_key.zeroize(); + + (result, encryptor, decryptor) +} + +/// Encrypt nonce for sending to Telegram (legacy function for compatibility) +pub fn encrypt_tg_nonce(nonce: &[u8; HANDSHAKE_LEN]) -> Vec { + let (encrypted, _, _) = encrypt_tg_nonce_with_ciphers(nonce); + encrypted +} + diff --git a/src/proxy/handshake/session.rs b/src/proxy/handshake/session.rs new file mode 100644 index 0000000..2bebc79 --- /dev/null +++ b/src/proxy/handshake/session.rs @@ -0,0 +1,35 @@ +use super::*; + +/// Result of successful handshake +/// +/// Key material (`dec_key`, `dec_iv`, `enc_key`, `enc_iv`) is +/// zeroized on drop. +#[derive(Debug)] +pub struct HandshakeSuccess { + /// Authenticated user name + pub user: String, + /// Target datacenter index + pub dc_idx: i16, + /// Protocol variant (abridged/intermediate/secure) + pub proto_tag: ProtoTag, + /// Decryption key and IV (for reading from client) + pub dec_key: [u8; 32], + pub dec_iv: u128, + /// Encryption key and IV (for writing to client) + pub enc_key: [u8; 32], + pub enc_iv: u128, + /// Client address + pub peer: SocketAddr, + /// Whether TLS was used + pub is_tls: bool, +} + +impl Drop for HandshakeSuccess { + fn drop(&mut self) { + self.dec_key.zeroize(); + self.dec_iv.zeroize(); + self.enc_key.zeroize(); + self.enc_iv.zeroize(); + } +} + diff --git a/src/proxy/handshake/tls_handshake.rs b/src/proxy/handshake/tls_handshake.rs new file mode 100644 index 0000000..500946b --- /dev/null +++ b/src/proxy/handshake/tls_handshake.rs @@ -0,0 +1,425 @@ +use super::*; + +/// Handle fake TLS handshake +#[cfg(test)] +pub async fn handle_tls_handshake( + handshake: &[u8], + reader: R, + mut writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + rng: &SecureRandom, + tls_cache: Option>, +) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let shared = ProxySharedState::new(); + handle_tls_handshake_impl( + handshake, + reader, + writer, + peer, + config, + replay_checker, + rng, + tls_cache, + shared.as_ref(), + TlsResponseWriteOptions::default(), + ) + .await +} + +pub async fn handle_tls_handshake_with_shared( + handshake: &[u8], + reader: R, + writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + rng: &SecureRandom, + tls_cache: Option>, + shared: &ProxySharedState, +) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + handle_tls_handshake_impl( + handshake, + reader, + writer, + peer, + config, + replay_checker, + rng, + tls_cache, + shared, + TlsResponseWriteOptions::default(), + ) + .await +} + +/// Handles FakeTLS with optional best-effort initial-response chunking. +pub(crate) async fn handle_tls_handshake_with_shared_and_options( + handshake: &[u8], + reader: R, + writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + rng: &SecureRandom, + tls_cache: Option>, + shared: &ProxySharedState, + response_write_options: TlsResponseWriteOptions, +) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + handle_tls_handshake_impl( + handshake, + reader, + writer, + peer, + config, + replay_checker, + rng, + tls_cache, + shared, + response_write_options, + ) + .await +} + +async fn handle_tls_handshake_impl( + handshake: &[u8], + reader: R, + mut writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + rng: &SecureRandom, + tls_cache: Option>, + shared: &ProxySharedState, + response_write_options: TlsResponseWriteOptions, +) -> HandshakeResult<(FakeTlsReader, FakeTlsWriter, String), R, W> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + debug!(peer = %peer, handshake_len = handshake.len(), "Processing TLS handshake"); + + let throttle_now = Instant::now(); + if auth_probe_should_apply_preauth_throttle_in(shared, peer.ip(), throttle_now) { + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "TLS handshake rejected by pre-auth probe throttle"); + return HandshakeResult::BadClient { reader, writer }; + } + + if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "TLS handshake too short"); + return HandshakeResult::BadClient { reader, writer }; + } + + let client_sni = tls::extract_sni_from_client_hello(handshake); + let preferred_user_hint = client_sni + .as_deref() + .filter(|sni| config.access.users.contains_key(*sni)); + let matched_tls_domain = client_sni + .as_deref() + .and_then(|sni| find_matching_tls_domain(config, sni)); + + let alpn_list = if config.censorship.alpn_enforce { + tls::extract_alpn_from_client_hello(handshake) + } else { + Vec::new() + }; + let selected_alpn = if config.censorship.alpn_enforce { + if alpn_list.iter().any(|p| p == b"h2") { + Some(b"h2".to_vec()) + } else if alpn_list.iter().any(|p| p == b"http/1.1") { + Some(b"http/1.1".to_vec()) + } else if !alpn_list.is_empty() { + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "Client ALPN list has no supported protocol; using masking fallback"); + return HandshakeResult::BadClient { reader, writer }; + } else { + None + } + } else { + None + }; + // Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous: + // this avoids leaking certificate payload on malformed probes. + let client_tls_version = tls::detect_client_hello_tls_version(handshake) + .unwrap_or(tls::ClientHelloTlsVersion::Tls13); + + if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() { + let sni = client_sni.as_deref().unwrap_or_default(); + match config.censorship.unknown_sni_action { + UnknownSniAction::Accept => { + debug!( + peer = %peer, + sni = %sni, + unknown_sni = true, + unknown_sni_action = ?config.censorship.unknown_sni_action, + "TLS handshake accepted by unknown SNI policy" + ); + } + action @ (UnknownSniAction::Drop + | UnknownSniAction::Mask + | UnknownSniAction::RejectHandshake) => { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + // For Drop/Mask we apply the synthetic ServerHello delay so + // the fail-closed path is timing-indistinguishable from the + // success path. For RejectHandshake we deliberately skip the + // delay: a stock modern nginx with `ssl_reject_handshake on;` + // responds with the alert essentially immediately, so + // injecting 8-24ms here would itself become a distinguisher + // against the public baseline we are trying to blend into. + if !matches!(action, UnknownSniAction::RejectHandshake) { + maybe_apply_server_hello_delay(config).await; + } + let log_now = Instant::now(); + if should_emit_unknown_sni_warn_in(shared, log_now) { + warn!( + peer = %peer, + sni = %sni, + unknown_sni = true, + unknown_sni_action = ?action, + "TLS handshake rejected by unknown SNI policy" + ); + } else { + info!( + peer = %peer, + sni = %sni, + unknown_sni = true, + unknown_sni_action = ?action, + "TLS handshake rejected by unknown SNI policy" + ); + } + if matches!(action, UnknownSniAction::RejectHandshake) { + // TLS alert record layer: + // 0x15 ContentType.alert + // 0x03 0x03 legacy_record_version = TLS 1.2 + // (matches what modern nginx emits in + // the first server -> client record, + // per RFC 8446 5.1 guidance) + // 0x00 0x02 length = 2 + // Alert payload: + // 0x02 AlertLevel.fatal + // 0x70 AlertDescription.unrecognized_name (112, RFC 6066) + const TLS_ALERT_UNRECOGNIZED_NAME: [u8; 7] = + [0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70]; + if let Err(e) = writer.write_all(&TLS_ALERT_UNRECOGNIZED_NAME).await { + debug!( + peer = %peer, + error = %e, + "Failed to write unrecognized_name TLS alert" + ); + } else { + let _ = writer.flush().await; + } + } + return match action { + UnknownSniAction::Drop | UnknownSniAction::RejectHandshake => { + HandshakeResult::Error(ProxyError::UnknownTlsSni) + } + UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, + UnknownSniAction::Accept => unreachable!(), + }; + } + } + } + + + let Some(validation) = tls_validation::validate_tls_client( + handshake, + peer, + config, + shared, + preferred_user_hint, + &client_sni, + ) + .await + else { + return HandshakeResult::BadClient { reader, writer }; + }; + let tls_validation::TlsClientValidation { + digest: validation_digest, + session_id: validation_session_id, + session_id_len: validation_session_id_len, + user: validated_user, + secret: validated_secret, + user_id: validated_user_id, + } = validation; + // Reject known replay digests before expensive cache/domain/ALPN policy work. + let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN]; + if replay_checker.check_tls_digest(digest_half) { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!(peer = %peer, "TLS replay attack detected (duplicate digest)"); + return HandshakeResult::BadClient { reader, writer }; + } + + let cached_entry = if config.censorship.tls_emulation { + if let Some(cache) = tls_cache.as_ref() { + let selected_domain = + matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); + let cached_entry = cache.get(selected_domain).await; + Some(cached_entry) + } else { + None + } + } else { + None + }; + + let preferred_key_share_group = cached_entry + .as_ref() + .and_then(|cached_entry| emulator::profiled_server_hello_key_share_group(cached_entry)); + let Some(server_key_share) = + tls::build_server_hello_key_share(handshake, preferred_key_share_group, rng) + else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + "TLS handshake rejected: ClientHello did not offer a usable TLS 1.3 key_share" + ); + return HandshakeResult::BadClient { reader, writer }; + }; + + let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() { + if cached_entry.server_hello_template.cipher_suite == [0, 0] { + [0x13, 0x01] + } else { + cached_entry.server_hello_template.cipher_suite + } + } else { + [0x13, 0x01] + }; + let Some(selected_cipher_suite) = + tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite) + else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + "TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite" + ); + return HandshakeResult::BadClient { reader, writer }; + }; + + let cached = if let Some(cached_entry) = cached_entry { + let use_full_cert_payload = if config.censorship.serverhello_compact + && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) + { + if let Some(cache) = tls_cache.as_ref() { + cache + .take_full_cert_budget_for_ip( + peer.ip(), + Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), + ) + .await + } else { + true + } + } else { + true + }; + Some((cached_entry, use_full_cert_payload)) + } else { + None + }; + + // Add replay digest only for policy-valid handshakes. + replay_checker.add_tls_digest(digest_half); + + let validation_session_id_slice = &validation_session_id[..validation_session_id_len]; + + let response = if let Some((cached_entry, use_full_cert_payload)) = cached { + emulator::build_emulated_server_hello( + &validated_secret, + &validation_digest, + validation_session_id_slice, + &cached_entry, + use_full_cert_payload, + config.censorship.serverhello_compact, + client_tls_version, + selected_cipher_suite, + &server_key_share, + rng, + selected_alpn.clone(), + config.censorship.tls_new_session_tickets, + ) + } else { + tls::build_server_hello_with_cipher( + &validated_secret, + &validation_digest, + validation_session_id_slice, + config.censorship.fake_cert_len, + rng, + selected_cipher_suite, + &server_key_share, + selected_alpn.clone(), + config.censorship.tls_new_session_tickets, + ) + }; + + // Apply the same optional delay budget used by reject paths to reduce + // distinguishability between success and fail-closed handshakes. + maybe_apply_server_hello_delay(config).await; + + debug!(peer = %peer, response_len = response.len(), "Sending TLS ServerHello"); + + if let Err(e) = write_tls_response(&mut writer, &response, response_write_options).await { + warn!(peer = %peer, error = %e, "Failed to write TLS ServerHello"); + return HandshakeResult::Error(ProxyError::Io(e)); + } + + debug!( + peer = %peer, + user = %validated_user, + "TLS handshake successful" + ); + + auth_probe_record_success_in(shared, peer.ip()); + + if let Some(user_id) = validated_user_id { + sticky_hint_record_success_in(shared, peer.ip(), user_id, client_sni.as_deref()); + record_recent_user_success_in(shared, user_id); + } + + HandshakeResult::Success(( + FakeTlsReader::new(reader), + FakeTlsWriter::new(writer), + validated_user, + )) +} + +async fn write_tls_response( + writer: &mut W, + response: &[u8], + options: TlsResponseWriteOptions, +) -> std::io::Result<()> { + #[cfg(target_os = "linux")] + if let (Some(fd), Some(fragment_size)) = (options.socket_fd, options.fragment_size) { + return crate::transport::socket::send_tcp_fragmented_fd( + fd, + response, + usize::from(fragment_size), + ) + .await; + } + + let _ = options; + writer.write_all(response).await?; + writer.flush().await +} + diff --git a/src/proxy/handshake/tls_validation.rs b/src/proxy/handshake/tls_validation.rs new file mode 100644 index 0000000..4b83c08 --- /dev/null +++ b/src/proxy/handshake/tls_validation.rs @@ -0,0 +1,276 @@ +use super::*; + +pub(super) struct TlsClientValidation { + pub(super) digest: [u8; tls::TLS_DIGEST_LEN], + pub(super) session_id: [u8; 32], + pub(super) session_id_len: usize, + pub(super) user: String, + pub(super) secret: [u8; ACCESS_SECRET_BYTES], + pub(super) user_id: Option, +} + +pub(super) async fn validate_tls_client( + handshake: &[u8], + peer: SocketAddr, + config: &ProxyConfig, + shared: &ProxySharedState, + preferred_user_hint: Option<&str>, + client_sni: &Option, +) -> Option { + let mut validation_digest = [0u8; tls::TLS_DIGEST_LEN]; + let mut validation_session_id = [0u8; 32]; + let mut validation_session_id_len = 0usize; + let mut validated_user = String::new(); + let mut validated_secret = [0u8; ACCESS_SECRET_BYTES]; + let mut validated_user_id: Option = None; + + if let Some(snapshot) = config.runtime_user_auth() { + let parsed = match parse_tls_auth_material( + handshake, + config.access.ignore_time_skew, + config.access.replay_window_secs, + ) { + Some(parsed) => parsed, + None => { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "TLS handshake auth material parsing failed"); + return None; + } + }; + + let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); + let preferred_user_id = preferred_user_hint.and_then(|user| snapshot.user_id_by_name(user)); + let sticky_sni_hint = client_sni + .as_deref() + .and_then(|sni| sticky_hint_get_by_sni(shared, sni)); + let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); + let sni_candidates = client_sni + .as_deref() + .and_then(|sni| snapshot.sni_candidates(sni)); + let sni_initial_candidates = client_sni + .as_deref() + .and_then(|sni| snapshot.sni_initial_candidates(sni)); + + let has_hint = sticky_ip_hint.is_some() + || preferred_user_id.is_some() + || sticky_sni_hint.is_some() + || sticky_prefix_hint.is_some() + || sni_candidates.is_some_and(|ids| !ids.is_empty()) + || sni_initial_candidates.is_some_and(|ids| !ids.is_empty()); + let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); + let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); + + let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP]; + let mut tried_len = 0usize; + let mut validation_checks = 0usize; + let mut budget_exhausted = false; + + macro_rules! try_user_id { + ($user_id:expr) => {{ + if validation_checks >= candidate_budget { + budget_exhausted = true; + false + } else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) { + false + } else if let Some(entry) = snapshot.entry_by_id($user_id) { + validation_checks = validation_checks.saturating_add(1); + if let Some(candidate) = + validate_tls_secret_candidate(&parsed, handshake, &entry.secret) + { + validation_digest = candidate.digest; + validation_session_id = candidate.session_id; + validation_session_id_len = candidate.session_id_len; + validated_secret.copy_from_slice(&entry.secret); + validated_user = entry.user.clone(); + validated_user_id = Some($user_id); + true + } else { + false + } + } else { + false + } + }}; + } + + let mut matched = false; + if let Some(user_id) = sticky_ip_hint { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = preferred_user_id { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = sticky_sni_hint { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = sticky_prefix_hint { + matched = try_user_id!(user_id); + } + + if !matched + && !budget_exhausted + && let Some(candidate_ids) = sni_candidates + { + for &user_id in candidate_ids { + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + if !matched + && !budget_exhausted + && let Some(candidate_ids) = sni_initial_candidates + { + for &user_id in candidate_ids { + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + if !matched && !budget_exhausted { + let ring = &shared.handshake.recent_user_ring; + if !ring.is_empty() { + let next_seq = shared + .handshake + .recent_user_ring_seq + .load(Ordering::Relaxed); + let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT); + for offset in 0..scan_limit { + let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len(); + let encoded_user_id = ring[idx].load(Ordering::Relaxed); + if encoded_user_id == 0 { + continue; + } + if try_user_id!(encoded_user_id - 1) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + } + + if !matched && !budget_exhausted { + for idx in 0..snapshot.entries().len() { + let Some(user_id) = u32::try_from(idx).ok() else { + break; + }; + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + if budget_exhausted { + shared + .handshake + .auth_budget_exhausted_total + .fetch_add(1, Ordering::Relaxed); + } + + if !matched { + let failure_now = Instant::now(); + auth_probe_note_expensive_invalid_scan_in( + shared, + failure_now, + validation_checks, + overload, + ); + auth_probe_record_failure_in(shared, peer.ip(), failure_now); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + ignore_time_skew = config.access.ignore_time_skew, + budget_exhausted = budget_exhausted, + candidate_budget = candidate_budget, + validation_checks = validation_checks, + "TLS handshake validation failed - no matching user, time skew, or budget exhausted" + ); + return None; + } + } else { + let secrets = decode_user_secrets_in(shared, config, preferred_user_hint); + let validation = match tls::validate_tls_handshake_with_replay_window( + handshake, + &secrets, + config.access.ignore_time_skew, + config.access.replay_window_secs, + ) { + Some(v) => v, + None => { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + ignore_time_skew = config.access.ignore_time_skew, + "TLS handshake validation failed - no matching user or time skew" + ); + return None; + } + }; + let secret = match secrets.iter().find(|(name, _)| *name == validation.user) { + Some((_, s)) if s.len() == ACCESS_SECRET_BYTES => s, + _ => { + maybe_apply_server_hello_delay(config).await; + return None; + } + }; + + validation_digest = validation.digest; + validation_session_id_len = validation.session_id.len(); + if validation_session_id_len > validation_session_id.len() { + maybe_apply_server_hello_delay(config).await; + return None; + } + validation_session_id[..validation_session_id_len].copy_from_slice(&validation.session_id); + validated_user = validation.user; + validated_secret.copy_from_slice(secret); + } + + if config + .access + .is_user_source_ip_denied(validated_user.as_str(), peer.ip()) + { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %validated_user, + "TLS handshake rejected: client source IP on per-user deny list (access.user_source_deny)" + ); + return None; + } + + Some(TlsClientValidation { + digest: validation_digest, + session_id: validation_session_id, + session_id_len: validation_session_id_len, + user: validated_user, + secret: validated_secret, + user_id: validated_user_id, + }) +} diff --git a/src/proxy/tests/handshake_key_material_zeroization_security_tests.rs b/src/proxy/tests/handshake_key_material_zeroization_security_tests.rs index 7176b1c..f7fb1d9 100644 --- a/src/proxy/tests/handshake_key_material_zeroization_security_tests.rs +++ b/src/proxy/tests/handshake_key_material_zeroization_security_tests.rs @@ -1,7 +1,7 @@ use super::*; fn handshake_source() -> &'static str { - include_str!("../handshake.rs") + include_str!("../handshake/auth_candidates.rs") } #[test] diff --git a/src/synlimit_control/pf.rs b/src/synlimit_control/pf.rs index fc43216..c6474bd 100644 --- a/src/synlimit_control/pf.rs +++ b/src/synlimit_control/pf.rs @@ -125,3 +125,7 @@ mod tests { assert!(!is_pf_anchor_hook_line("anchor \"other\" all")); } } + +#[cfg(test)] +#[path = "pf_runtime_tests.rs"] +mod runtime_tests; diff --git a/src/synlimit_control/pf_runtime_tests.rs b/src/synlimit_control/pf_runtime_tests.rs new file mode 100644 index 0000000..6b75b27 --- /dev/null +++ b/src/synlimit_control/pf_runtime_tests.rs @@ -0,0 +1,83 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::path::Path; +use std::time::Duration; + +use super::*; +use crate::synlimit_control::model::{ + SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_namespace, +}; + +fn rule(ip: IpAddr, hitcount: u32) -> SynLimitRule { + SynLimitRule { + ip: Some(ip), + port: 24443, + generic_seconds: 60, + generic_hitcount: hitcount, + generic_burst: 24, + ios_seconds: 1, + ios_hitcount: 12, + ios_burst: 24, + hashlimit_expire_ms: 60_000, + hashlimit_size: 32_768, + } +} + +fn targets(low_family: &str) -> SynLimitTargets { + let (v4_rate, v6_rate) = match low_family { + "v4" => (2, 100), + "v6" => (100, 2), + _ => panic!("TELEMT_PF_LOW_FAMILY must be v4 or v6"), + }; + SynLimitTargets { + pf_v4: vec![rule( + IpAddr::V4(Ipv4Addr::new(198, 18, 1, 1)), + v4_rate, + )], + pf_v6: vec![rule( + IpAddr::V6("fd00:18:1::1".parse::().unwrap()), + v6_rate, + )], + ..Default::default() + } +} + +fn write_metadata(path: &str, namespace: &SynLimitNamespace) { + std::fs::write(path, format!("anchor={}\n", namespace.pf_anchor)).unwrap(); +} + +async fn wait_for_file(path: &Path) { + tokio::time::timeout(Duration::from_secs(30), async { + while !path.exists() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("PF runtime-test release barrier timed out"); +} + +#[tokio::test] +#[ignore = "requires native FreeBSD PF and VNET test harness"] +async fn production_pf_runtime_role() { + let role = std::env::var("TELEMT_PF_ROLE").expect("TELEMT_PF_ROLE is required"); + let low_family = std::env::var("TELEMT_PF_LOW_FAMILY").unwrap_or_else(|_| "v4".to_string()); + let targets = targets(&low_family); + let namespace = synlimit_namespace(&targets).expect("PF namespace missing"); + let metadata = std::env::var("TELEMT_PF_META").expect("TELEMT_PF_META is required"); + match role.as_str() { + "render" => { + let script_path = std::env::var("TELEMT_PF_SCRIPT").unwrap(); + std::fs::write(script_path, pf_synlimit_script(&targets)).unwrap(); + write_metadata(&metadata, &namespace); + } + "apply-wait" => { + apply_synlimit_rules(&targets, &namespace).await.unwrap(); + write_metadata(&metadata, &namespace); + let barrier = std::env::var("TELEMT_PF_BARRIER").unwrap(); + let release = std::env::var("TELEMT_PF_RELEASE").unwrap(); + std::fs::write(&barrier, b"ready").unwrap(); + wait_for_file(Path::new(&release)).await; + assert!(clear_rules(&namespace).await.unwrap()); + } + _ => panic!("TELEMT_PF_ROLE must be render or apply-wait"), + } +} diff --git a/src/transport/socket.rs b/src/transport/socket.rs index ce084ef..8d9a0c7 100644 --- a/src/transport/socket.rs +++ b/src/transport/socket.rs @@ -278,7 +278,7 @@ pub fn normalize_ip(addr: SocketAddr) -> SocketAddr { } /// Socket options for server listening -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ListenOptions { /// Enable SO_REUSEADDR pub reuse_addr: bool, @@ -304,8 +304,8 @@ impl Default for ListenOptions { } } -/// Create a listening socket with the specified options -pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result { +/// Binds a server socket without making it externally accepting. +pub(crate) fn bind_listener_socket(addr: SocketAddr, options: &ListenOptions) -> Result { let domain = if addr.is_ipv4() { Domain::IPV4 } else { @@ -342,7 +342,21 @@ pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result Result<()> { + socket.listen(backlog as i32) +} + +/// Create a listening socket with the specified options +pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result { + let socket = bind_listener_socket(addr, options)?; + activate_listener_socket(&socket, options.backlog)?; debug!(addr = %addr, "Created listening socket"); diff --git a/src/transport/socket/fragmented_send.rs b/src/transport/socket/fragmented_send.rs index 623efd0..37df436 100644 --- a/src/transport/socket/fragmented_send.rs +++ b/src/transport/socket/fragmented_send.rs @@ -4,6 +4,83 @@ use std::os::fd::{AsRawFd, BorrowedFd, RawFd}; use tokio::io::Interest; use tokio::io::unix::AsyncFd; +struct TcpMaxSegmentGuard { + fd: RawFd, + original: libc::c_int, + changed: bool, +} + +fn tcp_max_segment(fd: RawFd) -> Result { + let mut value: libc::c_int = 0; + let mut length = std::mem::size_of::() as libc::socklen_t; + let rc = unsafe { + libc::getsockopt( + fd, + libc::IPPROTO_TCP, + libc::TCP_MAXSEG, + &mut value as *mut libc::c_int as *mut libc::c_void, + &mut length, + ) + }; + if rc != 0 { + return Err(Error::last_os_error()); + } + Ok(value) +} + +fn set_tcp_max_segment(fd: RawFd, value: libc::c_int) -> Result<()> { + let rc = unsafe { + libc::setsockopt( + fd, + libc::IPPROTO_TCP, + libc::TCP_MAXSEG, + &value as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(Error::last_os_error()); + } + Ok(()) +} + +impl TcpMaxSegmentGuard { + fn install(fd: RawFd, requested: usize) -> Result { + let requested = libc::c_int::try_from(requested).map_err(|_| { + Error::new( + ErrorKind::InvalidInput, + "TCP fragment size exceeds the platform integer range", + ) + })?; + let original = tcp_max_segment(fd)?; + let changed = requested < original; + if changed { + set_tcp_max_segment(fd, requested)?; + } + Ok(Self { + fd, + original, + changed, + }) + } + + fn restore(mut self) -> Result<()> { + if self.changed { + set_tcp_max_segment(self.fd, self.original)?; + self.changed = false; + } + Ok(()) + } +} + +impl Drop for TcpMaxSegmentGuard { + fn drop(&mut self) { + if self.changed { + let _ = set_tcp_max_segment(self.fd, self.original); + } + } +} + fn force_tcp_push(fd: RawFd) -> Result<()> { let enabled: libc::c_int = 1; let rc = unsafe { @@ -25,8 +102,9 @@ fn force_tcp_push(fd: RawFd) -> Result<()> { /// /// `fd` must refer to a connected, nonblocking TCP socket and remain valid for /// the duration of this call. The caller retains ownership of the original fd. -/// `MSG_EOR` is only a best-effort Linux hint for TCP: offloads, loss, and -/// retransmission may coalesce these write boundaries on the wire. +/// The accepted socket is clamped to the requested `TCP_MAXSEG` for this send +/// and restored on success, error, or task cancellation. `MSG_EOR` remains only +/// a best-effort Linux hint: offloads may still coalesce capture boundaries. pub(crate) async fn send_tcp_fragmented_fd( fd: RawFd, data: &[u8], @@ -49,42 +127,52 @@ pub(crate) async fn send_tcp_fragmented_fd( let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) }; let duplicated_fd = borrowed_fd.try_clone_to_owned()?; let async_fd = AsyncFd::with_interest(duplicated_fd, Interest::WRITABLE)?; + let mss_guard = TcpMaxSegmentGuard::install(async_fd.get_ref().as_raw_fd(), fragment_size)?; - for fragment in data.chunks(fragment_size) { - let mut offset = 0; - while offset < fragment.len() { - let mut writable = async_fd.writable().await?; - let sent = match writable.try_io(|inner| { - let remaining = &fragment[offset..]; - let sent = unsafe { - libc::send( - inner.get_ref().as_raw_fd(), - remaining.as_ptr().cast::(), - remaining.len(), - libc::MSG_DONTWAIT | libc::MSG_EOR | libc::MSG_NOSIGNAL, - ) + let send_result = async { + for fragment in data.chunks(fragment_size) { + let mut offset = 0; + while offset < fragment.len() { + let mut writable = async_fd.writable().await?; + let sent = match writable.try_io(|inner| { + let remaining = &fragment[offset..]; + let sent = unsafe { + libc::send( + inner.get_ref().as_raw_fd(), + remaining.as_ptr().cast::(), + remaining.len(), + libc::MSG_DONTWAIT | libc::MSG_EOR | libc::MSG_NOSIGNAL, + ) + }; + if sent < 0 { + Err(Error::last_os_error()) + } else if sent == 0 { + Err(Error::new( + ErrorKind::WriteZero, + "fragmented TCP send returned zero", + )) + } else { + Ok(sent as usize) + } + }) { + Ok(Ok(sent)) => sent, + Ok(Err(error)) if error.kind() == ErrorKind::Interrupted => continue, + Ok(Err(error)) => return Err(error), + Err(_) => continue, }; - if sent < 0 { - Err(Error::last_os_error()) - } else if sent == 0 { - Err(Error::new( - ErrorKind::WriteZero, - "fragmented TCP send returned zero", - )) - } else { - Ok(sent as usize) - } - }) { - Ok(Ok(sent)) => sent, - Ok(Err(error)) if error.kind() == ErrorKind::Interrupted => continue, - Ok(Err(error)) => return Err(error), - Err(_) => continue, - }; - offset += sent; - force_tcp_push(async_fd.get_ref().as_raw_fd())?; + offset += sent; + force_tcp_push(async_fd.get_ref().as_raw_fd())?; + } } + Ok(()) } + .await; - Ok(()) + let restore_result = mss_guard.restore(); + send_result.and(restore_result) } + +#[cfg(test)] +#[path = "fragmented_send_wire_tests.rs"] +mod wire_tests; diff --git a/src/transport/socket/fragmented_send_wire_tests.rs b/src/transport/socket/fragmented_send_wire_tests.rs new file mode 100644 index 0000000..744816b --- /dev/null +++ b/src/transport/socket/fragmented_send_wire_tests.rs @@ -0,0 +1,232 @@ +use std::net::SocketAddr; +use std::os::fd::{AsRawFd, BorrowedFd}; +use std::path::Path; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::config::ProxyConfig; +use crate::crypto::{SecureRandom, sha256_hmac}; +use crate::error::HandshakeResult; +use crate::protocol::constants::TLS_VERSION; +use crate::protocol::tls; +use crate::proxy::handshake::{ + TlsResponseWriteOptions, handle_tls_handshake_with_shared_and_options, +}; +use crate::proxy::shared_state::ProxySharedState; +use crate::stats::ReplayChecker; +use crate::transport::socket::{ListenOptions, create_listener}; + +const SECRET: [u8; 16] = [0x56; 16]; +const SECRET_HEX: &str = "56565656565656565656565656565656"; +const BULK_PAYLOAD_LEN: usize = 8192; + +fn make_valid_tls_client_hello(tls_len: usize) -> Vec { + const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01]; + const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033; + const TLS_EXTENSION_PADDING: u16 = 0x0015; + const X25519_KEY_SHARE_LEN: usize = 32; + let fill = 0x42_u8; + let session_id_len = 32_usize; + let mut extensions = Vec::new(); + let mut key_share = Vec::new(); + key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes()); + key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes()); + key_share.push(9); + key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0); + let mut key_share_extension = Vec::new(); + key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes()); + key_share_extension.extend_from_slice(&key_share); + extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes()); + extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes()); + extensions.extend_from_slice(&key_share_extension); + let base_tls_len = 4 + + 2 + + 32 + + 1 + + session_id_len + + 2 + + TLS_AES_128_GCM_SHA256.len() + + 1 + + 1 + + 2 + + extensions.len(); + let padding_len = tls_len + .checked_sub(base_tls_len + 4) + .expect("wire ClientHello must leave room for padding"); + extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes()); + extensions.extend_from_slice(&(padding_len as u16).to_be_bytes()); + extensions.resize(extensions.len() + padding_len, fill); + + let body_len = tls_len - 4; + let mut body = Vec::with_capacity(body_len); + body.extend_from_slice(&TLS_VERSION); + body.extend_from_slice(&[fill; 32]); + body.push(session_id_len as u8); + body.extend_from_slice(&[fill; 32]); + body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes()); + body.extend_from_slice(&TLS_AES_128_GCM_SHA256); + body.push(1); + body.push(0); + body.extend_from_slice(&(extensions.len() as u16).to_be_bytes()); + body.extend_from_slice(&extensions); + let mut handshake = Vec::with_capacity(5 + tls_len); + handshake.push(0x16); + handshake.extend_from_slice(&[0x03, 0x01]); + handshake.extend_from_slice(&(tls_len as u16).to_be_bytes()); + handshake.push(0x01); + handshake.extend_from_slice(&(body_len as u32).to_be_bytes()[1..].as_ref()); + handshake.extend_from_slice(&body); + handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); + let digest = sha256_hmac(&SECRET, &handshake); + handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] + .copy_from_slice(&digest); + handshake +} + +async fn wait_for_file(path: &Path) { + tokio::time::timeout(Duration::from_secs(10), async { + while !path.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("wire-test release barrier timed out"); +} + +async fn run_server(addr: SocketAddr, fragment_size: u16, fake_cert_len: usize) { + let socket = create_listener( + addr, + &ListenOptions { + reuse_port: false, + client_mss: Some(1400), + ..Default::default() + }, + ) + .unwrap(); + let listener = TcpListener::from_std(socket.into()).unwrap(); + let (mut server, peer) = listener.accept().await.unwrap(); + let mut header = [0_u8; 5]; + server.read_exact(&mut header).await.unwrap(); + let body_len = u16::from_be_bytes([header[3], header[4]]) as usize; + let mut client_hello = Vec::with_capacity(5 + body_len); + client_hello.extend_from_slice(&header); + client_hello.resize(5 + body_len, 0); + server.read_exact(&mut client_hello[5..]).await.unwrap(); + let barrier = std::env::var("TELEMT_WIRE_BARRIER").unwrap(); + let release = std::env::var("TELEMT_WIRE_RELEASE").unwrap(); + std::fs::write(&barrier, b"ready").unwrap(); + wait_for_file(Path::new(&release)).await; + + let raw_fd = server.as_raw_fd(); + let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap(); + let (read_half, write_half) = server.into_split(); + let mut config = ProxyConfig::default(); + config.general.beobachten = false; + config.access.ignore_time_skew = true; + config.censorship.fake_cert_len = fake_cert_len; + config + .access + .users + .insert("wire".to_string(), SECRET_HEX.to_string()); + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let shared = ProxySharedState::new(); + let (tls_reader, mut tls_writer, user) = match + handle_tls_handshake_with_shared_and_options( + &client_hello, + read_half, + write_half, + peer, + &config, + &replay_checker, + &rng, + None, + &shared, + TlsResponseWriteOptions::tcp(raw_fd, Some(fragment_size)), + ) + .await + { + HandshakeResult::Success(result) => result, + _ => panic!("wire-test FakeTLS authentication failed"), + }; + assert_eq!(user, "wire"); + tls_writer.write_all(&vec![0xA5; BULK_PAYLOAD_LEN]).await.unwrap(); + tls_writer.shutdown().await.unwrap(); + drop(tls_reader); + // SAFETY: the write half still owns the accepted socket while it is borrowed. + let borrowed_fd = unsafe { BorrowedFd::borrow_raw(raw_fd) }; + let mss_after = socket2::SockRef::from(&borrowed_fd).tcp_mss().unwrap(); + let metadata = std::env::var("TELEMT_WIRE_SERVER_META").unwrap(); + std::fs::write( + metadata, + format!("configured_bulk_mss=1400\nmss_before={mss_before}\nmss_after={mss_after}\n"), + ) + .unwrap(); +} + +async fn run_client(addr: SocketAddr) { + let mut client = loop { + match TcpStream::connect(addr).await { + Ok(stream) => break stream, + Err(_) => tokio::time::sleep(Duration::from_millis(20)).await, + } + }; + client + .write_all(&make_valid_tls_client_hello(600)) + .await + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + let mut offset = 0; + let mut bulk_record_start = None; + while offset < response.len() { + assert!(response.len() - offset >= 5, "truncated TLS record header"); + let payload_len = u16::from_be_bytes([response[offset + 3], response[offset + 4]]) as usize; + let end = offset + 5 + payload_len; + assert!(end <= response.len(), "truncated TLS record payload"); + if response[offset] == 0x17 + && payload_len == BULK_PAYLOAD_LEN + && response[offset + 5..end].iter().all(|byte| *byte == 0xA5) + { + bulk_record_start = Some(offset); + } + offset = end; + } + let initial_response_bytes = bulk_record_start.expect("bulk TLS record missing"); + let metadata = std::env::var("TELEMT_WIRE_CLIENT_META").unwrap(); + std::fs::write( + metadata, + format!( + "initial_response_bytes={initial_response_bytes}\ntotal_response_bytes={}\n", + response.len() + ), + ) + .unwrap(); +} + +#[tokio::test] +#[ignore = "requires privileged netns/veth packet-capture harness"] +async fn fake_tls_fragmentation_wire_role() { + let role = std::env::var("TELEMT_WIRE_ROLE").expect("TELEMT_WIRE_ROLE is required"); + let addr = std::env::var("TELEMT_WIRE_ADDR") + .unwrap_or_else(|_| "198.18.0.1:24443".to_string()) + .parse() + .unwrap(); + match role.as_str() { + "server" => { + let fragment_size = std::env::var("TELEMT_WIRE_FRAGMENT") + .unwrap() + .parse() + .unwrap(); + let fake_cert_len = std::env::var("TELEMT_WIRE_FAKE_CERT_LEN") + .unwrap() + .parse() + .unwrap(); + run_server(addr, fragment_size, fake_cert_len).await; + } + "client" => run_client(addr).await, + _ => panic!("TELEMT_WIRE_ROLE must be server or client"), + } +} diff --git a/src/transport/socket/tests.rs b/src/transport/socket/tests.rs index 843712b..4e9f851 100644 --- a/src/transport/socket/tests.rs +++ b/src/transport/socket/tests.rs @@ -249,7 +249,34 @@ async fn test_chunked_send_preserves_stream_and_configured_mss() { ); assert_eq!( mss_after, mss_before, - "chunked send must not change the configured socket MSS" + "chunked send must restore the configured socket MSS" + ); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn test_chunked_send_restores_mss_after_send_error() { + use std::os::fd::AsRawFd; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = listener.accept().await.unwrap(); + let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap(); + let shutdown_result = unsafe { libc::shutdown(server.as_raw_fd(), libc::SHUT_WR) }; + assert_eq!(shutdown_result, 0); + + let error = send_tcp_fragmented_fd(server.as_raw_fd(), &[0xA5; 4096], 92) + .await + .unwrap_err(); + + assert!(matches!( + error.kind(), + ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::NotConnected + )); + assert_eq!( + socket2::SockRef::from(&server).tcp_mss().unwrap(), + mss_before ); } @@ -279,17 +306,24 @@ async fn test_chunked_send_has_no_fd_growth_after_success_and_cancellation_stres use std::sync::Arc; let baseline_fds = std::fs::read_dir("/proc/self/fd").unwrap().count(); - - let blocked_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let blocked_addr = blocked_listener.local_addr().unwrap(); - let _blocked_client = TcpStream::connect(blocked_addr).await.unwrap(); - let (blocked_server, _) = blocked_listener.accept().await.unwrap(); - socket2::SockRef::from(&blocked_server) - .set_send_buffer_size(4 * 1024) - .unwrap(); - let blocked_fd = blocked_server.as_raw_fd(); let payload = Arc::new(vec![0xA5; 1024 * 1024]); + + let options = ListenOptions { + reuse_port: false, + client_mss: Some(1400), + ..Default::default() + }; + let blocked_socket = create_listener("127.0.0.1:0".parse().unwrap(), &options).unwrap(); + let blocked_listener = TcpListener::from_std(blocked_socket.into()).unwrap(); + let blocked_addr = blocked_listener.local_addr().unwrap(); for _ in 0..5_000 { + let blocked_client = TcpStream::connect(blocked_addr).await.unwrap(); + let (blocked_server, _) = blocked_listener.accept().await.unwrap(); + let blocked_mss_before = socket2::SockRef::from(&blocked_server).tcp_mss().unwrap(); + socket2::SockRef::from(&blocked_server) + .set_send_buffer_size(4 * 1024) + .unwrap(); + let blocked_fd = blocked_server.as_raw_fd(); let payload = payload.clone(); let sender = tokio::spawn(async move { send_tcp_fragmented_fd(blocked_fd, payload.as_slice(), 92).await @@ -297,29 +331,43 @@ async fn test_chunked_send_has_no_fd_growth_after_success_and_cancellation_stres tokio::task::yield_now().await; sender.abort(); let _ = sender.await; + assert_eq!( + socket2::SockRef::from(&blocked_server).tcp_mss().unwrap(), + blocked_mss_before, + "cancellation must restore the accepted socket MSS" + ); + drop(blocked_server); + drop(blocked_client); } - let success_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let success_socket = create_listener("127.0.0.1:0".parse().unwrap(), &options).unwrap(); + let success_listener = TcpListener::from_std(success_socket.into()).unwrap(); let success_addr = success_listener.local_addr().unwrap(); - let mut success_client = TcpStream::connect(success_addr).await.unwrap(); - let (success_server, _) = success_listener.accept().await.unwrap(); - let success_fd = success_server.as_raw_fd(); - let reader = tokio::spawn(async move { - let mut received = vec![0_u8; 5_000]; - success_client.read_exact(&mut received).await.unwrap(); - received - }); - for _ in 0..5_000 { + for iteration in 0..5_000 { + let mut success_client = TcpStream::connect(success_addr).await.unwrap(); + let (success_server, _) = success_listener.accept().await.unwrap(); + let success_mss_before = socket2::SockRef::from(&success_server).tcp_mss().unwrap(); + let success_fd = success_server.as_raw_fd(); send_tcp_fragmented_fd(success_fd, &[0x5A], 92) .await - .unwrap(); + .unwrap_or_else(|error| { + panic!( + "success cycle {iteration} failed with MSS {success_mss_before}: {error}" + ) + }); + let mut received = [0_u8; 1]; + success_client.read_exact(&mut received).await.unwrap(); + assert_eq!(received, [0x5A]); + assert_eq!( + socket2::SockRef::from(&success_server).tcp_mss().unwrap(), + success_mss_before, + "successful send must restore the accepted socket MSS" + ); + drop(success_server); + drop(success_client); } - assert!(reader.await.unwrap().iter().all(|byte| *byte == 0x5A)); - drop(success_server); drop(success_listener); - drop(blocked_server); - drop(_blocked_client); drop(blocked_listener); let final_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();