From 09f56dede221b90161c72d480cb0c58b0c124f23 Mon Sep 17 00:00:00 2001 From: Vladislav Yaroslavlev Date: Tue, 24 Feb 2026 05:57:53 +0300 Subject: [PATCH 01/36] fix: resolve clippy warnings Reduce clippy warnings from54 to16 by fixing mechanical issues: - collapsible_if: collapse nested if-let chains with let-chains - clone_on_copy: remove unnecessary .clone() on Copy types - manual_clamp: replace .max().min() with .clamp() - unnecessary_cast: remove redundant type casts - collapsible_else_if: flatten else-if chains - contains_vs_iter_any: replace .iter().any() with .contains() - unnecessary_closure: replace .or_else(|| x) with .or(x) - useless_conversion: remove redundant .into() calls - is_none_or: replace .map_or(true, ...) with .is_none_or(...) - while_let_loop: convert loop with if-let-break to while-let Remaining16 warnings are design-level issues (too_many_arguments, await_holding_lock, type_complexity, new_ret_no_self) that require architectural changes to fix. --- src/config/defaults.rs | 6 +- src/config/load.rs | 26 ++++---- src/config/types.rs | 9 +-- src/crypto/aes.rs | 16 ++--- src/crypto/hash.rs | 2 + src/crypto/random.rs | 2 +- src/error.rs | 11 +--- src/main.rs | 16 +++-- src/network/probe.rs | 29 +++++---- src/network/stun.rs | 9 +-- src/protocol/constants.rs | 5 +- src/protocol/frame.rs | 2 +- src/protocol/tls.rs | 14 ++--- src/proxy/client.rs | 38 ++++++------ src/proxy/direct_relay.rs | 8 +-- src/proxy/masking.rs | 10 ++-- src/proxy/middle_relay.rs | 18 +++--- src/stats/mod.rs | 8 +-- src/stream/crypto_stream.rs | 13 ++-- src/stream/frame_codec.rs | 2 +- src/stream/frame_stream.rs | 4 +- src/stream/tls_stream.rs | 16 ++--- src/tls_front/cache.rs | 50 ++++++++-------- src/tls_front/emulator.rs | 20 +++---- src/tls_front/fetcher.rs | 2 +- src/transport/middle_proxy/codec.rs | 5 +- src/transport/middle_proxy/config_updater.rs | 44 +++++++------- src/transport/middle_proxy/handshake.rs | 24 ++++---- src/transport/middle_proxy/health.rs | 8 +-- src/transport/middle_proxy/pool.rs | 62 +++++++++----------- src/transport/middle_proxy/pool_nat.rs | 32 +++++----- src/transport/middle_proxy/secret.rs | 26 ++++---- src/transport/middle_proxy/send.rs | 10 ++-- src/transport/socket.rs | 20 +++---- src/transport/socks.rs | 24 ++++---- src/transport/upstream.rs | 25 ++++---- src/util/ip.rs | 62 ++++++++++---------- src/util/time.rs | 16 ++--- 38 files changed, 336 insertions(+), 358 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index a0443fc..b9b0da1 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -171,15 +171,15 @@ pub(crate) fn default_cache_public_ip_path() -> String { } pub(crate) fn default_proxy_secret_reload_secs() -> u64 { - 1 * 60 * 60 + 60 * 60 } pub(crate) fn default_proxy_config_reload_secs() -> u64 { - 1 * 60 * 60 + 60 * 60 } pub(crate) fn default_update_every_secs() -> u64 { - 1 * 30 * 60 + 30 * 60 } pub(crate) fn default_me_reinit_drain_timeout_secs() -> u64 { diff --git a/src/config/load.rs b/src/config/load.rs index dce5fbc..750e0dc 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -278,23 +278,25 @@ impl ProxyConfig { reuse_allow: false, }); } - if let Some(ipv6_str) = &config.server.listen_addr_ipv6 { - if let Ok(ipv6) = ipv6_str.parse::() { - config.server.listeners.push(ListenerConfig { - ip: ipv6, - 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, + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + }); } } // Migration: announce_ip → announce for each listener. for listener in &mut config.server.listeners { - if listener.announce.is_none() && listener.announce_ip.is_some() { - listener.announce = Some(listener.announce_ip.unwrap().to_string()); + if listener.announce.is_none() + && let Some(ip) = listener.announce_ip.take() + { + listener.announce = Some(ip.to_string()); } } diff --git a/src/config/types.rs b/src/config/types.rs index 8d573df..c9ceea4 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -677,9 +677,10 @@ pub struct ListenerConfig { /// - `show_link = "*"` — show links for all users /// - `show_link = ["a", "b"]` — show links for specific users /// - omitted — show no links (default) -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub enum ShowLink { /// Don't show any links (default when omitted). + #[default] None, /// Show links for all configured users. All, @@ -687,12 +688,6 @@ pub enum ShowLink { Specific(Vec), } -impl Default for ShowLink { - fn default() -> Self { - ShowLink::None - } -} - impl ShowLink { /// Returns true if no links should be shown. pub fn is_empty(&self) -> bool { diff --git a/src/crypto/aes.rs b/src/crypto/aes.rs index 674e4cb..deda730 100644 --- a/src/crypto/aes.rs +++ b/src/crypto/aes.rs @@ -23,13 +23,13 @@ type Aes256Ctr = Ctr128BE; // ============= AES-256-CTR ============= /// AES-256-CTR encryptor/decryptor -/// +/// /// CTR mode is symmetric — encryption and decryption are the same operation. /// /// **Zeroize note:** The inner `Aes256Ctr` cipher state (expanded key schedule -/// + counter) is opaque and cannot be zeroized. If you need to protect key -/// material, zeroize the `[u8; 32]` key and `u128` IV at the call site -/// before dropping them. +/// + counter) is opaque and cannot be zeroized. If you need to protect key +/// material, zeroize the `[u8; 32]` key and `u128` IV at the call site +/// before dropping them. pub struct AesCtr { cipher: Aes256Ctr, } @@ -149,7 +149,7 @@ impl AesCbc { /// /// CBC Encryption: C[i] = AES_Encrypt(P[i] XOR C[i-1]), where C[-1] = IV pub fn encrypt(&self, data: &[u8]) -> Result> { - if data.len() % Self::BLOCK_SIZE != 0 { + if !data.len().is_multiple_of(Self::BLOCK_SIZE) { return Err(ProxyError::Crypto( format!("CBC data must be aligned to 16 bytes, got {}", data.len()) )); @@ -180,7 +180,7 @@ impl AesCbc { /// /// CBC Decryption: P[i] = AES_Decrypt(C[i]) XOR C[i-1], where C[-1] = IV pub fn decrypt(&self, data: &[u8]) -> Result> { - if data.len() % Self::BLOCK_SIZE != 0 { + if !data.len().is_multiple_of(Self::BLOCK_SIZE) { return Err(ProxyError::Crypto( format!("CBC data must be aligned to 16 bytes, got {}", data.len()) )); @@ -209,7 +209,7 @@ impl AesCbc { /// Encrypt data in-place pub fn encrypt_in_place(&self, data: &mut [u8]) -> Result<()> { - if data.len() % Self::BLOCK_SIZE != 0 { + if !data.len().is_multiple_of(Self::BLOCK_SIZE) { return Err(ProxyError::Crypto( format!("CBC data must be aligned to 16 bytes, got {}", data.len()) )); @@ -242,7 +242,7 @@ impl AesCbc { /// Decrypt data in-place pub fn decrypt_in_place(&self, data: &mut [u8]) -> Result<()> { - if data.len() % Self::BLOCK_SIZE != 0 { + if !data.len().is_multiple_of(Self::BLOCK_SIZE) { return Err(ProxyError::Crypto( format!("CBC data must be aligned to 16 bytes, got {}", data.len()) )); diff --git a/src/crypto/hash.rs b/src/crypto/hash.rs index d3f6f55..fa3e441 100644 --- a/src/crypto/hash.rs +++ b/src/crypto/hash.rs @@ -64,6 +64,7 @@ pub fn crc32c(data: &[u8]) -> u32 { /// /// Returned buffer layout (IPv4): /// nonce_srv | nonce_clt | clt_ts | srv_ip | clt_port | purpose | clt_ip | srv_port | secret | nonce_srv | [clt_v6 | srv_v6] | nonce_clt +#[allow(clippy::too_many_arguments)] pub fn build_middleproxy_prekey( nonce_srv: &[u8; 16], nonce_clt: &[u8; 16], @@ -108,6 +109,7 @@ pub fn build_middleproxy_prekey( /// Uses MD5 + SHA-1 as mandated by the Telegram Middle Proxy protocol. /// These algorithms are NOT replaceable here — changing them would break /// interoperability with Telegram's middle proxy infrastructure. +#[allow(clippy::too_many_arguments)] pub fn derive_middleproxy_keys( nonce_srv: &[u8; 16], nonce_clt: &[u8; 16], diff --git a/src/crypto/random.rs b/src/crypto/random.rs index 0dd5f1a..6313610 100644 --- a/src/crypto/random.rs +++ b/src/crypto/random.rs @@ -95,7 +95,7 @@ impl SecureRandom { return 0; } - let bytes_needed = (k + 7) / 8; + let bytes_needed = k.div_ceil(8); let bytes = self.bytes(bytes_needed.min(8)); let mut result = 0u64; diff --git a/src/error.rs b/src/error.rs index eaebd88..e4d66b9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -91,7 +91,7 @@ impl From for std::io::Error { std::io::Error::new(std::io::ErrorKind::UnexpectedEof, err) } StreamError::Poisoned { .. } => { - std::io::Error::new(std::io::ErrorKind::Other, err) + std::io::Error::other(err) } StreamError::BufferOverflow { .. } => { std::io::Error::new(std::io::ErrorKind::OutOfMemory, err) @@ -100,7 +100,7 @@ impl From for std::io::Error { std::io::Error::new(std::io::ErrorKind::InvalidData, err) } StreamError::PartialRead { .. } | StreamError::PartialWrite { .. } => { - std::io::Error::new(std::io::ErrorKind::Other, err) + std::io::Error::other(err) } } } @@ -135,12 +135,7 @@ impl Recoverable for StreamError { } fn can_continue(&self) -> bool { - match self { - Self::Poisoned { .. } => false, - Self::UnexpectedEof => false, - Self::BufferOverflow { .. } => false, - _ => true, - } + !matches!(self, Self::Poisoned { .. } | Self::UnexpectedEof | Self::BufferOverflow { .. }) } } diff --git a/src/main.rs b/src/main.rs index 0d1eccc..7264239 100644 --- a/src/main.rs +++ b/src/main.rs @@ -301,7 +301,7 @@ async fn main() -> std::result::Result<(), Box> { match crate::transport::middle_proxy::fetch_proxy_secret(proxy_secret_path).await { Ok(proxy_secret) => { info!( - secret_len = proxy_secret.len() as usize, // ← ЯВНЫЙ ТИП usize + secret_len = proxy_secret.len(), key_sig = format_args!( "0x{:08x}", if proxy_secret.len() >= 4 { @@ -597,14 +597,12 @@ match crate::transport::middle_proxy::fetch_proxy_secret(proxy_secret_path).awai } else { info!(" IPv4 in use / IPv6 is fallback"); } - } else { - if v6_works && !v4_works { - info!(" IPv6 only / IPv4 unavailable)"); - } else if v4_works && !v6_works { - info!(" IPv4 only / IPv6 unavailable)"); - } else if !v6_works && !v4_works { - info!(" No DC connectivity"); - } + } else if v6_works && !v4_works { + info!(" IPv6 only / IPv4 unavailable)"); + } else if v4_works && !v6_works { + info!(" IPv4 only / IPv6 unavailable)"); + } else if !v6_works && !v4_works { + info!(" No DC connectivity"); } info!(" via {}", upstream_result.upstream_name); diff --git a/src/network/probe.rs b/src/network/probe.rs index eda69b8..c52b340 100644 --- a/src/network/probe.rs +++ b/src/network/probe.rs @@ -95,23 +95,21 @@ pub async fn run_probe(config: &NetworkConfig, stun_addr: Option, nat_pr } pub fn decide_network_capabilities(config: &NetworkConfig, probe: &NetworkProbe) -> NetworkDecision { - let mut decision = NetworkDecision::default(); + let ipv4_dc = config.ipv4 && probe.detected_ipv4.is_some(); + let ipv6_dc = config.ipv6.unwrap_or(probe.detected_ipv6.is_some()) && probe.detected_ipv6.is_some(); - decision.ipv4_dc = config.ipv4 && probe.detected_ipv4.is_some(); - decision.ipv6_dc = config.ipv6.unwrap_or(probe.detected_ipv6.is_some()) && probe.detected_ipv6.is_some(); - - decision.ipv4_me = config.ipv4 + let ipv4_me = config.ipv4 && probe.detected_ipv4.is_some() && (!probe.ipv4_is_bogon || probe.reflected_ipv4.is_some()); let ipv6_enabled = config.ipv6.unwrap_or(probe.detected_ipv6.is_some()); - decision.ipv6_me = ipv6_enabled + let ipv6_me = ipv6_enabled && probe.detected_ipv6.is_some() && (!probe.ipv6_is_bogon || probe.reflected_ipv6.is_some()); - decision.effective_prefer = match config.prefer { - 6 if decision.ipv6_me || decision.ipv6_dc => 6, - 4 if decision.ipv4_me || decision.ipv4_dc => 4, + let effective_prefer = match config.prefer { + 6 if ipv6_me || ipv6_dc => 6, + 4 if ipv4_me || ipv4_dc => 4, 6 => { warn!("prefer=6 requested but IPv6 unavailable; falling back to IPv4"); 4 @@ -119,10 +117,17 @@ pub fn decide_network_capabilities(config: &NetworkConfig, probe: &NetworkProbe) _ => 4, }; - let me_families = decision.ipv4_me as u8 + decision.ipv6_me as u8; - decision.effective_multipath = config.multipath && me_families >= 2; + let me_families = ipv4_me as u8 + ipv6_me as u8; + let effective_multipath = config.multipath && me_families >= 2; - decision + NetworkDecision { + ipv4_dc, + ipv6_dc, + ipv4_me, + ipv6_me, + effective_prefer, + effective_multipath, + } } fn detect_local_ip_v4() -> Option { diff --git a/src/network/stun.rs b/src/network/stun.rs index c47aa49..5bda495 100644 --- a/src/network/stun.rs +++ b/src/network/stun.rs @@ -198,16 +198,11 @@ async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result true, - (false, IpFamily::V6) => true, - _ => false, - }) - .next(); + .find(|a| matches!((a.is_ipv4(), family), (true, IpFamily::V4) | (false, IpFamily::V6))); Ok(target) } diff --git a/src/protocol/constants.rs b/src/protocol/constants.rs index e6ddbaf..9e79206 100644 --- a/src/protocol/constants.rs +++ b/src/protocol/constants.rs @@ -160,7 +160,7 @@ pub const MAX_TLS_CHUNK_SIZE: usize = 16384 + 256; /// Secure Intermediate payload is expected to be 4-byte aligned. pub fn is_valid_secure_payload_len(data_len: usize) -> bool { - data_len % 4 == 0 + data_len.is_multiple_of(4) } /// Compute Secure Intermediate payload length from wire length. @@ -179,7 +179,7 @@ pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize { is_valid_secure_payload_len(data_len), "Secure payload must be 4-byte aligned, got {data_len}" ); - (rng.range(3) + 1) as usize + rng.range(3) + 1 } // ============= Timeouts ============= @@ -231,7 +231,6 @@ pub static RESERVED_NONCE_CONTINUES: &[[u8; 4]] = &[ // ============= RPC Constants (for Middle Proxy) ============= /// RPC Proxy Request - /// RPC Flags (from Erlang mtp_rpc.erl) pub const RPC_FLAG_NOT_ENCRYPTED: u32 = 0x2; pub const RPC_FLAG_HAS_AD_TAG: u32 = 0x8; diff --git a/src/protocol/frame.rs b/src/protocol/frame.rs index a332be0..dd59ba9 100644 --- a/src/protocol/frame.rs +++ b/src/protocol/frame.rs @@ -85,7 +85,7 @@ impl FrameMode { pub fn validate_message_length(len: usize) -> bool { use super::constants::{MIN_MSG_LEN, MAX_MSG_LEN, PADDING_FILLER}; - len >= MIN_MSG_LEN && len <= MAX_MSG_LEN && len % PADDING_FILLER.len() == 0 + (MIN_MSG_LEN..=MAX_MSG_LEN).contains(&len) && len.is_multiple_of(PADDING_FILLER.len()) } #[cfg(test)] diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 091092a..fbe7ad5 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -335,7 +335,7 @@ pub fn validate_tls_handshake( // This is a quirk in some clients that use uptime instead of real time let is_boot_time = timestamp < 60 * 60 * 24 * 1000; // < ~2.7 years in seconds - if !is_boot_time && (time_diff < TIME_SKEW_MIN || time_diff > TIME_SKEW_MAX) { + if !is_boot_time && !(TIME_SKEW_MIN..=TIME_SKEW_MAX).contains(&time_diff) { continue; } } @@ -393,7 +393,7 @@ pub fn build_server_hello( ) -> Vec { const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = 16640; // RFC 8446 §5.2 upper bound - let fake_cert_len = fake_cert_len.max(MIN_APP_DATA).min(MAX_APP_DATA); + let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA); let x25519_key = gen_fake_x25519_key(rng); // Build ServerHello @@ -525,10 +525,10 @@ pub fn extract_sni_from_client_hello(handshake: &[u8]) -> Option { if sn_pos + name_len > sn_end { break; } - if name_type == 0 && name_len > 0 { - if let Ok(host) = std::str::from_utf8(&handshake[sn_pos..sn_pos + name_len]) { - return Some(host.to_string()); - } + if name_type == 0 && name_len > 0 + && let Ok(host) = std::str::from_utf8(&handshake[sn_pos..sn_pos + name_len]) + { + return Some(host.to_string()); } sn_pos += name_len; } @@ -571,7 +571,7 @@ pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec> { let list_len = u16::from_be_bytes([handshake[pos], handshake[pos+1]]) as usize; let mut lp = pos + 2; let list_end = (pos + 2).saturating_add(list_len).min(pos + elen); - while lp + 1 <= list_end { + while lp < list_end { let plen = handshake[lp] as usize; lp += 1; if lp + plen > list_end { break; } diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 051ce9e..483f6e0 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -594,18 +594,18 @@ impl RunningClientHandler { peer_addr: SocketAddr, ip_tracker: &UserIpTracker, ) -> Result<()> { - if let Some(expiration) = config.access.user_expirations.get(user) { - if chrono::Utc::now() > *expiration { - return Err(ProxyError::UserExpired { - user: user.to_string(), - }); - } + if let Some(expiration) = config.access.user_expirations.get(user) + && chrono::Utc::now() > *expiration + { + return Err(ProxyError::UserExpired { + user: user.to_string(), + }); } // IP limit check if let Err(reason) = ip_tracker.check_and_add(user, peer_addr.ip()).await { warn!( - user = %user, + user = %user, ip = %peer_addr.ip(), reason = %reason, "IP limit exceeded" @@ -615,20 +615,20 @@ impl RunningClientHandler { }); } - if let Some(limit) = config.access.user_max_tcp_conns.get(user) { - if stats.get_user_curr_connects(user) >= *limit as u64 { - return Err(ProxyError::ConnectionLimitExceeded { - user: user.to_string(), - }); - } + if let Some(limit) = config.access.user_max_tcp_conns.get(user) + && stats.get_user_curr_connects(user) >= *limit as u64 + { + return Err(ProxyError::ConnectionLimitExceeded { + user: user.to_string(), + }); } - if let Some(quota) = config.access.user_data_quota.get(user) { - if stats.get_user_total_octets(user) >= *quota { - return Err(ProxyError::DataQuotaExceeded { - user: user.to_string(), - }); - } + if let Some(quota) = config.access.user_data_quota.get(user) + && stats.get_user_total_octets(user) >= *quota + { + return Err(ProxyError::DataQuotaExceeded { + user: user.to_string(), + }); } Ok(()) diff --git a/src/proxy/direct_relay.rs b/src/proxy/direct_relay.rs index 630937b..e50623d 100644 --- a/src/proxy/direct_relay.rs +++ b/src/proxy/direct_relay.rs @@ -118,10 +118,10 @@ fn get_dc_addr_static(dc_idx: i16, config: &ProxyConfig) -> Result { // Unknown DC requested by client without override: log and fall back. if !config.dc_overrides.contains_key(&dc_key) { warn!(dc_idx = dc_idx, "Requested non-standard DC with no override; falling back to default cluster"); - if let Some(path) = &config.general.unknown_dc_log_path { - if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "dc_idx={dc_idx}"); - } + if let Some(path) = &config.general.unknown_dc_log_path + && let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) + { + let _ = writeln!(file, "dc_idx={dc_idx}"); } } diff --git a/src/proxy/masking.rs b/src/proxy/masking.rs index 78ef806..72175fe 100644 --- a/src/proxy/masking.rs +++ b/src/proxy/masking.rs @@ -19,12 +19,12 @@ const MASK_BUFFER_SIZE: usize = 8192; /// Detect client type based on initial data fn detect_client_type(data: &[u8]) -> &'static str { // Check for HTTP request - if data.len() > 4 { - if data.starts_with(b"GET ") || data.starts_with(b"POST") || + if data.len() > 4 + && (data.starts_with(b"GET ") || data.starts_with(b"POST") || data.starts_with(b"HEAD") || data.starts_with(b"PUT ") || - data.starts_with(b"DELETE") || data.starts_with(b"OPTIONS") { - return "HTTP"; - } + data.starts_with(b"DELETE") || data.starts_with(b"OPTIONS")) + { + return "HTTP"; } // Check for TLS ClientHello (0x16 = handshake, 0x03 0x01-0x03 = TLS version) diff --git a/src/proxy/middle_relay.rs b/src/proxy/middle_relay.rs index a6a11e1..f089442 100644 --- a/src/proxy/middle_relay.rs +++ b/src/proxy/middle_relay.rs @@ -393,13 +393,13 @@ where .unwrap_or_else(|e| Err(ProxyError::Proxy(format!("ME writer join error: {e}")))); // When client closes, but ME channel stopped as unregistered - it isnt error - if client_closed { - if matches!( + if client_closed + && matches!( writer_result, Err(ProxyError::Proxy(ref msg)) if msg == "ME connection lost" - ) { - writer_result = Ok(()); - } + ) + { + writer_result = Ok(()); } let result = match (main_result, c2me_result, writer_result) { @@ -549,7 +549,7 @@ where match proto_tag { ProtoTag::Abridged => { - if data.len() % 4 != 0 { + if !data.len().is_multiple_of(4) { return Err(ProxyError::Proxy(format!( "Abridged payload must be 4-byte aligned, got {}", data.len() @@ -567,7 +567,7 @@ where frame_buf.push(first); frame_buf.extend_from_slice(data); client_writer - .write_all(&frame_buf) + .write_all(frame_buf) .await .map_err(ProxyError::Io)?; } else if len_words < (1 << 24) { @@ -581,7 +581,7 @@ where frame_buf.extend_from_slice(&[first, lw[0], lw[1], lw[2]]); frame_buf.extend_from_slice(data); client_writer - .write_all(&frame_buf) + .write_all(frame_buf) .await .map_err(ProxyError::Io)?; } else { @@ -618,7 +618,7 @@ where rng.fill(&mut frame_buf[start..]); } client_writer - .write_all(&frame_buf) + .write_all(frame_buf) .await .map_err(ProxyError::Io)?; } diff --git a/src/stats/mod.rs b/src/stats/mod.rs index 31e9d4f..a58996d 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -326,10 +326,10 @@ impl ReplayShard { // Use key.as_ref() to get &[u8] — avoids Borrow ambiguity // between Borrow<[u8]> and Borrow> - if let Some(entry) = self.cache.peek(key.as_ref()) { - if entry.seq == queue_seq { - self.cache.pop(key.as_ref()); - } + if let Some(entry) = self.cache.peek(key.as_ref()) + && entry.seq == queue_seq + { + self.cache.pop(key.as_ref()); } } } diff --git a/src/stream/crypto_stream.rs b/src/stream/crypto_stream.rs index 67d8c95..5303fe5 100644 --- a/src/stream/crypto_stream.rs +++ b/src/stream/crypto_stream.rs @@ -47,7 +47,7 @@ //! - when upstream is Pending but pending still has room: accept `to_accept` bytes and //! encrypt+append ciphertext directly into pending (in-place encryption of appended range) -//! Encrypted stream wrappers using AES-CTR +//! Encrypted stream wrappers using AES-CTR //! //! This module provides stateful async stream wrappers that handle //! encryption/decryption with proper partial read/write handling. @@ -153,9 +153,9 @@ impl CryptoReader { fn take_poison_error(&mut self) -> io::Error { match &mut self.state { CryptoReaderState::Poisoned { error } => error.take().unwrap_or_else(|| { - io::Error::new(ErrorKind::Other, "stream previously poisoned") + io::Error::other("stream previously poisoned") }), - _ => io::Error::new(ErrorKind::Other, "stream not poisoned"), + _ => io::Error::other("stream not poisoned"), } } } @@ -168,6 +168,7 @@ impl AsyncRead for CryptoReader { ) -> Poll> { let this = self.get_mut(); + #[allow(clippy::never_loop)] loop { match &mut this.state { CryptoReaderState::Poisoned { .. } => { @@ -485,14 +486,14 @@ impl CryptoWriter { fn take_poison_error(&mut self) -> io::Error { match &mut self.state { CryptoWriterState::Poisoned { error } => error.take().unwrap_or_else(|| { - io::Error::new(ErrorKind::Other, "stream previously poisoned") + io::Error::other("stream previously poisoned") }), - _ => io::Error::new(ErrorKind::Other, "stream not poisoned"), + _ => io::Error::other("stream not poisoned"), } } /// Ensure we are in Flushing state and return mutable pending buffer. - fn ensure_pending<'a>(state: &'a mut CryptoWriterState, max_pending: usize) -> &'a mut PendingCiphertext { + fn ensure_pending(state: &mut CryptoWriterState, max_pending: usize) -> &mut PendingCiphertext { if matches!(state, CryptoWriterState::Idle) { *state = CryptoWriterState::Flushing { pending: PendingCiphertext::new(max_pending), diff --git a/src/stream/frame_codec.rs b/src/stream/frame_codec.rs index 3de8257..2ff7de7 100644 --- a/src/stream/frame_codec.rs +++ b/src/stream/frame_codec.rs @@ -139,7 +139,7 @@ fn encode_abridged(frame: &Frame, dst: &mut BytesMut) -> io::Result<()> { let data = &frame.data; // Validate alignment - if data.len() % 4 != 0 { + if !data.len().is_multiple_of(4) { return Err(Error::new( ErrorKind::InvalidInput, format!("abridged frame must be 4-byte aligned, got {} bytes", data.len()) diff --git a/src/stream/frame_stream.rs b/src/stream/frame_stream.rs index b66c2cd..c729162 100644 --- a/src/stream/frame_stream.rs +++ b/src/stream/frame_stream.rs @@ -78,7 +78,7 @@ impl AbridgedFrameWriter { impl AbridgedFrameWriter { /// Write a frame pub async fn write_frame(&mut self, data: &[u8], meta: &FrameMeta) -> Result<()> { - if data.len() % 4 != 0 { + if !data.len().is_multiple_of(4) { return Err(Error::new( ErrorKind::InvalidInput, format!("Abridged frame must be aligned to 4 bytes, got {}", data.len()), @@ -331,7 +331,7 @@ impl MtprotoFrameReader { } // Validate length - if len < MIN_MSG_LEN || len > MAX_MSG_LEN || len % PADDING_FILLER.len() != 0 { + if !(MIN_MSG_LEN..=MAX_MSG_LEN).contains(&len) || !len.is_multiple_of(PADDING_FILLER.len()) { return Err(Error::new( ErrorKind::InvalidData, format!("Invalid message length: {}", len), diff --git a/src/stream/tls_stream.rs b/src/stream/tls_stream.rs index fa165db..fe28542 100644 --- a/src/stream/tls_stream.rs +++ b/src/stream/tls_stream.rs @@ -135,7 +135,7 @@ impl TlsRecordHeader { } /// Build header bytes - fn to_bytes(&self) -> [u8; 5] { + fn to_bytes(self) -> [u8; 5] { [ self.record_type, self.version[0], @@ -260,9 +260,9 @@ impl FakeTlsReader { fn take_poison_error(&mut self) -> io::Error { match &mut self.state { TlsReaderState::Poisoned { error } => error.take().unwrap_or_else(|| { - io::Error::new(ErrorKind::Other, "stream previously poisoned") + io::Error::other("stream previously poisoned") }), - _ => io::Error::new(ErrorKind::Other, "stream not poisoned"), + _ => io::Error::other("stream not poisoned"), } } } @@ -297,7 +297,7 @@ impl AsyncRead for FakeTlsReader { TlsReaderState::Poisoned { error } => { this.state = TlsReaderState::Poisoned { error: None }; let err = error.unwrap_or_else(|| { - io::Error::new(ErrorKind::Other, "stream previously poisoned") + io::Error::other("stream previously poisoned") }); return Poll::Ready(Err(err)); } @@ -616,9 +616,9 @@ impl FakeTlsWriter { fn take_poison_error(&mut self) -> io::Error { match &mut self.state { TlsWriterState::Poisoned { error } => error.take().unwrap_or_else(|| { - io::Error::new(ErrorKind::Other, "stream previously poisoned") + io::Error::other("stream previously poisoned") }), - _ => io::Error::new(ErrorKind::Other, "stream not poisoned"), + _ => io::Error::other("stream not poisoned"), } } @@ -682,7 +682,7 @@ impl AsyncWrite for FakeTlsWriter { TlsWriterState::Poisoned { error } => { this.state = TlsWriterState::Poisoned { error: None }; let err = error.unwrap_or_else(|| { - Error::new(ErrorKind::Other, "stream previously poisoned") + Error::other("stream previously poisoned") }); return Poll::Ready(Err(err)); } @@ -771,7 +771,7 @@ impl AsyncWrite for FakeTlsWriter { TlsWriterState::Poisoned { error } => { this.state = TlsWriterState::Poisoned { error: None }; let err = error.unwrap_or_else(|| { - Error::new(ErrorKind::Other, "stream previously poisoned") + Error::other("stream previously poisoned") }); return Poll::Ready(Err(err)); } diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index a425a35..23e60db 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -115,32 +115,32 @@ impl TlsFrontCache { if !name.ends_with(".json") { continue; } - if let Ok(data) = tokio::fs::read(entry.path()).await { - if let Ok(mut cached) = serde_json::from_slice::(&data) { - if cached.domain.is_empty() - || cached.domain.len() > 255 - || !cached.domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') - { - warn!(file = %name, "Skipping TLS cache entry with invalid domain"); - continue; - } - // fetched_at is skipped during deserialization; approximate with file mtime if available. - if let Ok(meta) = entry.metadata().await { - if let Ok(modified) = meta.modified() { - cached.fetched_at = modified; - } - } - // Drop entries older than 72h - if let Ok(age) = cached.fetched_at.elapsed() { - if age > Duration::from_secs(72 * 3600) { - warn!(domain = %cached.domain, "Skipping stale TLS cache entry (>72h)"); - continue; - } - } - let domain = cached.domain.clone(); - self.set(&domain, cached).await; - loaded += 1; + if let Ok(data) = tokio::fs::read(entry.path()).await + && let Ok(mut cached) = serde_json::from_slice::(&data) + { + if cached.domain.is_empty() + || cached.domain.len() > 255 + || !cached.domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + { + warn!(file = %name, "Skipping TLS cache entry with invalid domain"); + continue; } + // fetched_at is skipped during deserialization; approximate with file mtime if available. + if let Ok(meta) = entry.metadata().await + && let Ok(modified) = meta.modified() + { + cached.fetched_at = modified; + } + // Drop entries older than 72h + if let Ok(age) = cached.fetched_at.elapsed() + && age > Duration::from_secs(72 * 3600) + { + warn!(domain = %cached.domain, "Skipping stale TLS cache entry (>72h)"); + continue; + } + let domain = cached.domain.clone(); + self.set(&domain, cached).await; + loaded += 1; } } } diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 25d2a8c..c8c18ac 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -12,7 +12,7 @@ fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { sizes .iter() .map(|&size| { - let base = size.max(MIN_APP_DATA).min(MAX_APP_DATA); + let base = size.clamp(MIN_APP_DATA, MAX_APP_DATA); let jitter_range = ((base as f64) * 0.03).round() as i64; if jitter_range == 0 { return base; @@ -50,7 +50,7 @@ fn ensure_payload_capacity(mut sizes: Vec, payload_len: usize) -> Vec 17 { + let body_len = size - 17; + rec.extend_from_slice(&rng.bytes(body_len)); + rec.push(0x16); // inner content type marker (handshake) + rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag } else { - if size > 17 { - let body_len = size - 17; - rec.extend_from_slice(&rng.bytes(body_len)); - rec.push(0x16); // inner content type marker (handshake) - rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag - } else { - rec.extend_from_slice(&rng.bytes(size)); - } + rec.extend_from_slice(&rng.bytes(size)); } app_data.extend_from_slice(&rec); } diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 4678ea3..7ac4b42 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -384,7 +384,7 @@ async fn fetch_via_raw_tls( for _ in 0..4 { match timeout(connect_timeout, read_tls_record(&mut stream)).await { Ok(Ok(rec)) => records.push(rec), - Ok(Err(e)) => return Err(e.into()), + Ok(Err(e)) => return Err(e), Err(_) => break, } if records.len() >= 3 && records.iter().any(|(t, _)| *t == TLS_RECORD_APPLICATION) { diff --git a/src/transport/middle_proxy/codec.rs b/src/transport/middle_proxy/codec.rs index 6d83761..6df0466 100644 --- a/src/transport/middle_proxy/codec.rs +++ b/src/transport/middle_proxy/codec.rs @@ -165,11 +165,10 @@ fn process_pid16() -> u16 { } fn process_utime() -> u32 { - let utime = std::time::SystemTime::now() + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_secs() as u32; - utime + .as_secs() as u32 } pub(crate) fn cbc_encrypt_padded( diff --git a/src/transport/middle_proxy/config_updater.rs b/src/transport/middle_proxy/config_updater.rs index 96d5f91..56d5b81 100644 --- a/src/transport/middle_proxy/config_updater.rs +++ b/src/transport/middle_proxy/config_updater.rs @@ -40,14 +40,16 @@ pub struct ProxyConfigData { } fn parse_host_port(s: &str) -> Option<(IpAddr, u16)> { - if let Some(bracket_end) = s.rfind(']') { - if s.starts_with('[') && bracket_end + 1 < s.len() && s.as_bytes().get(bracket_end + 1) == Some(&b':') { - let host = &s[1..bracket_end]; - let port_str = &s[bracket_end + 2..]; - let ip = host.parse::().ok()?; - let port = port_str.parse::().ok()?; - return Some((ip, port)); - } + if let Some(bracket_end) = s.rfind(']') + && s.starts_with('[') + && bracket_end + 1 < s.len() + && s.as_bytes().get(bracket_end + 1) == Some(&b':') + { + let host = &s[1..bracket_end]; + let port_str = &s[bracket_end + 2..]; + let ip = host.parse::().ok()?; + let port = port_str.parse::().ok()?; + return Some((ip, port)); } let idx = s.rfind(':')?; @@ -84,20 +86,18 @@ pub async fn fetch_proxy_config(url: &str) -> Result { .map_err(|e| crate::error::ProxyError::Proxy(format!("fetch_proxy_config GET failed: {e}")))? ; - if let Some(date) = resp.headers().get(reqwest::header::DATE) { - if let Ok(date_str) = date.to_str() { - if let Ok(server_time) = httpdate::parse_http_date(date_str) { - if let Ok(skew) = SystemTime::now().duration_since(server_time).or_else(|e| { - server_time.duration_since(SystemTime::now()).map_err(|_| e) - }) { - let skew_secs = skew.as_secs(); - if skew_secs > 60 { - warn!(skew_secs, "Time skew >60s detected from fetch_proxy_config Date header"); - } else if skew_secs > 30 { - warn!(skew_secs, "Time skew >30s detected from fetch_proxy_config Date header"); - } - } - } + if let Some(date) = resp.headers().get(reqwest::header::DATE) + && let Ok(date_str) = date.to_str() + && let Ok(server_time) = httpdate::parse_http_date(date_str) + && let Ok(skew) = SystemTime::now().duration_since(server_time).or_else(|e| { + server_time.duration_since(SystemTime::now()).map_err(|_| e) + }) + { + let skew_secs = skew.as_secs(); + if skew_secs > 60 { + warn!(skew_secs, "Time skew >60s detected from fetch_proxy_config Date header"); + } else if skew_secs > 30 { + warn!(skew_secs, "Time skew >30s detected from fetch_proxy_config Date header"); } } diff --git a/src/transport/middle_proxy/handshake.rs b/src/transport/middle_proxy/handshake.rs index 95a9d6e..d9bcdde 100644 --- a/src/transport/middle_proxy/handshake.rs +++ b/src/transport/middle_proxy/handshake.rs @@ -47,21 +47,21 @@ impl MePool { pub(crate) async fn connect_tcp(&self, addr: SocketAddr) -> Result<(TcpStream, f64)> { let start = Instant::now(); let connect_fut = async { - if addr.is_ipv6() { - if let Some(v6) = self.detected_ipv6 { - match TcpSocket::new_v6() { - Ok(sock) => { - if let Err(e) = sock.bind(SocketAddr::new(IpAddr::V6(v6), 0)) { - debug!(error = %e, bind_ip = %v6, "ME IPv6 bind failed, falling back to default bind"); - } else { - match sock.connect(addr).await { - Ok(stream) => return Ok(stream), - Err(e) => debug!(error = %e, target = %addr, "ME IPv6 bound connect failed, retrying default connect"), - } + if addr.is_ipv6() + && let Some(v6) = self.detected_ipv6 + { + match TcpSocket::new_v6() { + Ok(sock) => { + if let Err(e) = sock.bind(SocketAddr::new(IpAddr::V6(v6), 0)) { + debug!(error = %e, bind_ip = %v6, "ME IPv6 bind failed, falling back to default bind"); + } else { + match sock.connect(addr).await { + Ok(stream) => return Ok(stream), + Err(e) => debug!(error = %e, target = %addr, "ME IPv6 bound connect failed, retrying default connect"), } } - Err(e) => debug!(error = %e, "ME IPv6 socket creation failed, falling back to default connect"), } + Err(e) => debug!(error = %e, "ME IPv6 socket creation failed, falling back to default connect"), } } TcpStream::connect(addr).await diff --git a/src/transport/middle_proxy/health.rs b/src/transport/middle_proxy/health.rs index e73e5f1..4bb7e64 100644 --- a/src/transport/middle_proxy/health.rs +++ b/src/transport/middle_proxy/health.rs @@ -92,10 +92,10 @@ async fn check_family( let key = (dc, family); let now = Instant::now(); - if let Some(ts) = next_attempt.get(&key) { - if now < *ts { - continue; - } + if let Some(ts) = next_attempt.get(&key) + && now < *ts + { + continue; } let max_concurrent = pool.me_reconnect_max_concurrent_per_dc.max(1) as usize; diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index 2047e80..06fdc96 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -498,10 +498,10 @@ impl MePool { let mut guard = self.proxy_map_v4.write().await; let keys: Vec = guard.keys().cloned().collect(); for k in keys.iter().cloned().filter(|k| *k > 0) { - if !guard.contains_key(&-k) { - if let Some(addrs) = guard.get(&k).cloned() { - guard.insert(-k, addrs); - } + if !guard.contains_key(&-k) + && let Some(addrs) = guard.get(&k).cloned() + { + guard.insert(-k, addrs); } } } @@ -509,10 +509,10 @@ impl MePool { let mut guard = self.proxy_map_v6.write().await; let keys: Vec = guard.keys().cloned().collect(); for k in keys.iter().cloned().filter(|k| *k > 0) { - if !guard.contains_key(&-k) { - if let Some(addrs) = guard.get(&k).cloned() { - guard.insert(-k, addrs); - } + if !guard.contains_key(&-k) + && let Some(addrs) = guard.get(&k).cloned() + { + guard.insert(-k, addrs); } } } @@ -760,13 +760,12 @@ impl MePool { cancel_reader_token.clone(), ) .await; - if let Some(pool) = pool.upgrade() { - if cleanup_for_reader + if let Some(pool) = pool.upgrade() + && cleanup_for_reader .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) .is_ok() - { - pool.remove_writer_and_close_clients(writer_id).await; - } + { + pool.remove_writer_and_close_clients(writer_id).await; } if let Err(e) = res { warn!(error = %e, "ME reader ended"); @@ -834,13 +833,12 @@ impl MePool { stats_ping.increment_me_keepalive_failed(); debug!("ME ping failed, removing dead writer"); cancel_ping.cancel(); - if let Some(pool) = pool_ping.upgrade() { - if cleanup_for_ping + if let Some(pool) = pool_ping.upgrade() + && cleanup_for_ping .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) .is_ok() - { - pool.remove_writer_and_close_clients(writer_id).await; - } + { + pool.remove_writer_and_close_clients(writer_id).await; } break; } @@ -943,24 +941,20 @@ impl MePool { let pool = Arc::downgrade(self); tokio::spawn(async move { let deadline = timeout.map(|t| Instant::now() + t); - loop { - if let Some(p) = pool.upgrade() { - if let Some(deadline_at) = deadline { - if Instant::now() >= deadline_at { - warn!(writer_id, "Drain timeout, force-closing"); - p.stats.increment_pool_force_close_total(); - let _ = p.remove_writer_and_close_clients(writer_id).await; - break; - } - } - if p.registry.is_writer_empty(writer_id).await { - let _ = p.remove_writer_only(writer_id).await; - break; - } - tokio::time::sleep(Duration::from_secs(1)).await; - } else { + while let Some(p) = pool.upgrade() { + if let Some(deadline_at) = deadline + && Instant::now() >= deadline_at + { + warn!(writer_id, "Drain timeout, force-closing"); + p.stats.increment_pool_force_close_total(); + let _ = p.remove_writer_and_close_clients(writer_id).await; break; } + if p.registry.is_writer_empty(writer_id).await { + let _ = p.remove_writer_only(writer_id).await; + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; } }); } diff --git a/src/transport/middle_proxy/pool_nat.rs b/src/transport/middle_proxy/pool_nat.rs index 4d9e2a1..9936707 100644 --- a/src/transport/middle_proxy/pool_nat.rs +++ b/src/transport/middle_proxy/pool_nat.rs @@ -25,7 +25,7 @@ impl MePool { pub(super) fn translate_ip_for_nat(&self, ip: IpAddr) -> IpAddr { let nat_ip = self .nat_ip_cfg - .or_else(|| self.nat_ip_detected.try_read().ok().and_then(|g| (*g).clone())); + .or_else(|| self.nat_ip_detected.try_read().ok().and_then(|g| *g)); let Some(nat_ip) = nat_ip else { return ip; @@ -75,7 +75,7 @@ impl MePool { return None; } - if let Some(ip) = self.nat_ip_detected.read().await.clone() { + if let Some(ip) = *self.nat_ip_detected.read().await { return Some(ip); } @@ -102,17 +102,17 @@ impl MePool { ) -> Option { const STUN_CACHE_TTL: Duration = Duration::from_secs(600); // Backoff window - if let Some(until) = *self.stun_backoff_until.read().await { - if Instant::now() < until { - if let Ok(cache) = self.nat_reflection_cache.try_lock() { - let slot = match family { - IpFamily::V4 => cache.v4, - IpFamily::V6 => cache.v6, - }; - return slot.map(|(_, addr)| addr); - } - return None; + if let Some(until) = *self.stun_backoff_until.read().await + && Instant::now() < until + { + if let Ok(cache) = self.nat_reflection_cache.try_lock() { + let slot = match family { + IpFamily::V4 => cache.v4, + IpFamily::V6 => cache.v6, + }; + return slot.map(|(_, addr)| addr); } + return None; } if let Ok(mut cache) = self.nat_reflection_cache.try_lock() { @@ -120,10 +120,10 @@ impl MePool { IpFamily::V4 => &mut cache.v4, IpFamily::V6 => &mut cache.v6, }; - if let Some((ts, addr)) = slot { - if ts.elapsed() < STUN_CACHE_TTL { - return Some(*addr); - } + if let Some((ts, addr)) = slot + && ts.elapsed() < STUN_CACHE_TTL + { + return Some(*addr); } } diff --git a/src/transport/middle_proxy/secret.rs b/src/transport/middle_proxy/secret.rs index 9641143..69a3198 100644 --- a/src/transport/middle_proxy/secret.rs +++ b/src/transport/middle_proxy/secret.rs @@ -63,20 +63,18 @@ pub async fn download_proxy_secret() -> Result> { ))); } - if let Some(date) = resp.headers().get(reqwest::header::DATE) { - if let Ok(date_str) = date.to_str() { - if let Ok(server_time) = httpdate::parse_http_date(date_str) { - if let Ok(skew) = SystemTime::now().duration_since(server_time).or_else(|e| { - server_time.duration_since(SystemTime::now()).map_err(|_| e) - }) { - let skew_secs = skew.as_secs(); - if skew_secs > 60 { - warn!(skew_secs, "Time skew >60s detected from proxy-secret Date header"); - } else if skew_secs > 30 { - warn!(skew_secs, "Time skew >30s detected from proxy-secret Date header"); - } - } - } + if let Some(date) = resp.headers().get(reqwest::header::DATE) + && let Ok(date_str) = date.to_str() + && let Ok(server_time) = httpdate::parse_http_date(date_str) + && let Ok(skew) = SystemTime::now().duration_since(server_time).or_else(|e| { + server_time.duration_since(SystemTime::now()).map_err(|_| e) + }) + { + let skew_secs = skew.as_secs(); + if skew_secs > 60 { + warn!(skew_secs, "Time skew >60s detected from proxy-secret Date header"); + } else if skew_secs > 30 { + warn!(skew_secs, "Time skew >30s detected from proxy-secret Date header"); } } diff --git a/src/transport/middle_proxy/send.rs b/src/transport/middle_proxy/send.rs index 56bd17a..8867212 100644 --- a/src/transport/middle_proxy/send.rs +++ b/src/transport/middle_proxy/send.rs @@ -242,10 +242,10 @@ impl MePool { } if preferred.is_empty() { let def = self.default_dc.load(Ordering::Relaxed); - if def != 0 { - if let Some(v) = map_guard.get(&def) { - preferred.extend(v.iter().map(|(ip, port)| SocketAddr::new(*ip, *port))); - } + if def != 0 + && let Some(v) = map_guard.get(&def) + { + preferred.extend(v.iter().map(|(ip, port)| SocketAddr::new(*ip, *port))); } } @@ -267,7 +267,7 @@ impl MePool { if !self.writer_accepts_new_binding(w) { continue; } - if preferred.iter().any(|p| *p == w.addr) { + if preferred.contains(&w.addr) { out.push(idx); } } diff --git a/src/transport/socket.rs b/src/transport/socket.rs index 0a20c3c..f1f8d5c 100644 --- a/src/transport/socket.rs +++ b/src/transport/socket.rs @@ -136,17 +136,17 @@ pub fn resolve_interface_ip(name: &str, want_ipv6: bool) -> Option { if let Ok(addrs) = getifaddrs() { for iface in addrs { - if iface.interface_name == name { - if let Some(address) = iface.address { - if let Some(v4) = address.as_sockaddr_in() { - if !want_ipv6 { - return Some(IpAddr::V4(v4.ip())); - } - } else if let Some(v6) = address.as_sockaddr_in6() { - if want_ipv6 { - return Some(IpAddr::V6(v6.ip().clone())); - } + if iface.interface_name == name + && let Some(address) = iface.address + { + if let Some(v4) = address.as_sockaddr_in() { + if !want_ipv6 { + return Some(IpAddr::V4(v4.ip())); } + } else if let Some(v6) = address.as_sockaddr_in6() + && want_ipv6 + { + return Some(IpAddr::V6(v6.ip())); } } } diff --git a/src/transport/socks.rs b/src/transport/socks.rs index 188d369..8196b52 100644 --- a/src/transport/socks.rs +++ b/src/transport/socks.rs @@ -27,11 +27,11 @@ pub async fn connect_socks4( buf.extend_from_slice(user); buf.push(0); // NULL - stream.write_all(&buf).await.map_err(|e| ProxyError::Io(e))?; + stream.write_all(&buf).await.map_err(ProxyError::Io)?; // Response: VN (1) | CD (1) | DSTPORT (2) | DSTIP (4) let mut resp = [0u8; 8]; - stream.read_exact(&mut resp).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut resp).await.map_err(ProxyError::Io)?; if resp[1] != 90 { return Err(ProxyError::Proxy(format!("SOCKS4 request rejected: code {}", resp[1]))); @@ -56,10 +56,10 @@ pub async fn connect_socks5( let mut buf = vec![5u8, methods.len() as u8]; buf.extend_from_slice(&methods); - stream.write_all(&buf).await.map_err(|e| ProxyError::Io(e))?; + stream.write_all(&buf).await.map_err(ProxyError::Io)?; let mut resp = [0u8; 2]; - stream.read_exact(&mut resp).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut resp).await.map_err(ProxyError::Io)?; if resp[0] != 5 { return Err(ProxyError::Proxy("Invalid SOCKS5 version".to_string())); @@ -80,10 +80,10 @@ pub async fn connect_socks5( auth_buf.push(p_bytes.len() as u8); auth_buf.extend_from_slice(p_bytes); - stream.write_all(&auth_buf).await.map_err(|e| ProxyError::Io(e))?; + stream.write_all(&auth_buf).await.map_err(ProxyError::Io)?; let mut auth_resp = [0u8; 2]; - stream.read_exact(&mut auth_resp).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut auth_resp).await.map_err(ProxyError::Io)?; if auth_resp[1] != 0 { return Err(ProxyError::Proxy("SOCKS5 authentication failed".to_string())); @@ -112,11 +112,11 @@ pub async fn connect_socks5( req.extend_from_slice(&target.port().to_be_bytes()); - stream.write_all(&req).await.map_err(|e| ProxyError::Io(e))?; + stream.write_all(&req).await.map_err(ProxyError::Io)?; // Response let mut head = [0u8; 4]; - stream.read_exact(&mut head).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut head).await.map_err(ProxyError::Io)?; if head[1] != 0 { return Err(ProxyError::Proxy(format!("SOCKS5 request failed: code {}", head[1]))); @@ -126,17 +126,17 @@ pub async fn connect_socks5( match head[3] { 1 => { // IPv4 let mut addr = [0u8; 4 + 2]; - stream.read_exact(&mut addr).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut addr).await.map_err(ProxyError::Io)?; }, 3 => { // Domain let mut len = [0u8; 1]; - stream.read_exact(&mut len).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut len).await.map_err(ProxyError::Io)?; let mut addr = vec![0u8; len[0] as usize + 2]; - stream.read_exact(&mut addr).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut addr).await.map_err(ProxyError::Io)?; }, 4 => { // IPv6 let mut addr = [0u8; 16 + 2]; - stream.read_exact(&mut addr).await.map_err(|e| ProxyError::Io(e))?; + stream.read_exact(&mut addr).await.map_err(ProxyError::Io)?; }, _ => return Err(ProxyError::Proxy("Invalid address type in SOCKS5 response".to_string())), } diff --git a/src/transport/upstream.rs b/src/transport/upstream.rs index 887fa99..e2198a8 100644 --- a/src/transport/upstream.rs +++ b/src/transport/upstream.rs @@ -57,9 +57,10 @@ impl LatencyEma { // ============= Per-DC IP Preference Tracking ============= /// Tracks which IP version works for each DC -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum IpPreference { /// Not yet tested + #[default] Unknown, /// IPv6 works PreferV6, @@ -71,12 +72,6 @@ pub enum IpPreference { Unavailable, } -impl Default for IpPreference { - fn default() -> Self { - Self::Unknown - } -} - // ============= Upstream State ============= #[derive(Debug)] @@ -112,7 +107,7 @@ impl UpstreamState { if abs_dc == 0 { return None; } - if abs_dc >= 1 && abs_dc <= NUM_DCS { + if (1..=NUM_DCS).contains(&abs_dc) { Some(abs_dc - 1) } else { // Unknown DC → default cluster (DC 2, index 1) @@ -122,10 +117,10 @@ impl UpstreamState { /// Get latency for a specific DC, falling back to average across all known DCs fn effective_latency(&self, dc_idx: Option) -> Option { - if let Some(di) = dc_idx.and_then(Self::dc_array_idx) { - if let Some(ms) = self.dc_latency[di].get() { - return Some(ms); - } + if let Some(di) = dc_idx.and_then(Self::dc_array_idx) + && let Some(ms) = self.dc_latency[di].get() + { + return Some(ms); } let (sum, count) = self.dc_latency.iter() @@ -582,7 +577,7 @@ impl UpstreamManager { let result = tokio::time::timeout( Duration::from_secs(DC_PING_TIMEOUT_SECS), - self.ping_single_dc(&upstream_config, Some(bind_rr.clone()), addr_v6) + self.ping_single_dc(upstream_config, Some(bind_rr.clone()), addr_v6) ).await; let ping_result = match result { @@ -633,7 +628,7 @@ impl UpstreamManager { let result = tokio::time::timeout( Duration::from_secs(DC_PING_TIMEOUT_SECS), - self.ping_single_dc(&upstream_config, Some(bind_rr.clone()), addr_v4) + self.ping_single_dc(upstream_config, Some(bind_rr.clone()), addr_v4) ).await; let ping_result = match result { @@ -696,7 +691,7 @@ impl UpstreamManager { } let result = tokio::time::timeout( Duration::from_secs(DC_PING_TIMEOUT_SECS), - self.ping_single_dc(&upstream_config, Some(bind_rr.clone()), addr) + self.ping_single_dc(upstream_config, Some(bind_rr.clone()), addr) ).await; let ping_result = match result { diff --git a/src/util/ip.rs b/src/util/ip.rs index f3e774f..36a5759 100644 --- a/src/util/ip.rs +++ b/src/util/ip.rs @@ -67,54 +67,56 @@ pub async fn detect_ip() -> IpInfo { // Try to get local interface IP first (default gateway interface) // We connect to Google DNS to find out which interface is used for routing - if let Some(ip) = get_local_ip("8.8.8.8:80") { - if ip.is_ipv4() && !ip.is_loopback() { - info.ipv4 = Some(ip); - debug!(ip = %ip, "Detected local IPv4 address via routing"); - } + if let Some(ip) = get_local_ip("8.8.8.8:80") + && ip.is_ipv4() + && !ip.is_loopback() + { + info.ipv4 = Some(ip); + debug!(ip = %ip, "Detected local IPv4 address via routing"); } - if let Some(ip) = get_local_ipv6("[2001:4860:4860::8888]:80") { - if ip.is_ipv6() && !ip.is_loopback() { - info.ipv6 = Some(ip); - debug!(ip = %ip, "Detected local IPv6 address via routing"); - } + if let Some(ip) = get_local_ipv6("[2001:4860:4860::8888]:80") + && ip.is_ipv6() + && !ip.is_loopback() + { + info.ipv6 = Some(ip); + debug!(ip = %ip, "Detected local IPv6 address via routing"); } - - // If local detection failed or returned private IP (and we want public), + + // If local detection failed or returned private IP (and we want public), // or just as a fallback/verification, we might want to check external services. - // However, the requirement is: "if IP for listening is not set... it should be IP from interface... + // However, the requirement is: "if IP for listening is not set... it should be IP from interface... // if impossible - request external resources". - + // So if we found a local IP, we might be good. But often servers are behind NAT. // If the local IP is private, we probably want the public IP for the tg:// link. // Let's check if the detected IPs are private. - - let need_external_v4 = info.ipv4.map_or(true, |ip| is_private_ip(ip)); - let need_external_v6 = info.ipv6.map_or(true, |ip| is_private_ip(ip)); + + let need_external_v4 = info.ipv4.is_none_or(is_private_ip); + let need_external_v6 = info.ipv6.is_none_or(is_private_ip); if need_external_v4 { debug!("Local IPv4 is private or missing, checking external services..."); for url in IPV4_URLS { - if let Some(ip) = fetch_ip(url).await { - if ip.is_ipv4() { - info.ipv4 = Some(ip); - debug!(ip = %ip, "Detected public IPv4 address"); - break; - } + if let Some(ip) = fetch_ip(url).await + && ip.is_ipv4() + { + info.ipv4 = Some(ip); + debug!(ip = %ip, "Detected public IPv4 address"); + break; } } } - + if need_external_v6 { debug!("Local IPv6 is private or missing, checking external services..."); for url in IPV6_URLS { - if let Some(ip) = fetch_ip(url).await { - if ip.is_ipv6() { - info.ipv6 = Some(ip); - debug!(ip = %ip, "Detected public IPv6 address"); - break; - } + if let Some(ip) = fetch_ip(url).await + && ip.is_ipv6() + { + info.ipv6 = Some(ip); + debug!(ip = %ip, "Detected public IPv6 address"); + break; } } } diff --git a/src/util/time.rs b/src/util/time.rs index 310b015..07ea0ba 100644 --- a/src/util/time.rs +++ b/src/util/time.rs @@ -67,15 +67,15 @@ pub async fn check_time_sync() -> Option { #[allow(dead_code)] pub async fn time_sync_task(check_interval: Duration) -> ! { loop { - if let Some(result) = check_time_sync().await { - if result.is_skewed { - error!( - "System clock is off by {} seconds. Please sync your clock.", - result.skew_secs - ); - } + if let Some(result) = check_time_sync().await + && result.is_skewed + { + error!( + "System clock is off by {} seconds. Please sync your clock.", + result.skew_secs + ); } - + tokio::time::sleep(check_interval).await; } } \ No newline at end of file From 50e15896b33cd5f7fb5ee09c83e91f9f5d9c224f Mon Sep 17 00:00:00 2001 From: Dimasssss Date: Tue, 24 Feb 2026 09:02:47 +0300 Subject: [PATCH 02/36] Update config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 раза добавил параметр me_reinit_drain_timeout_secs --- config.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/config.toml b/config.toml index 375cd7f..fd76fed 100644 --- a/config.toml +++ b/config.toml @@ -46,7 +46,6 @@ update_every = 7200 # Resolve the active updater interval crypto_pending_buffer = 262144 # Max pending ciphertext buffer per client writer (bytes). Controls FakeTLS backpressure vs throughput. max_client_frame = 16777216 # Maximum allowed client MTProto frame size (bytes). desync_all_full = false # Emit full crypto-desync forensic logs for every event. When false, full forensic details are emitted once per key window. -me_reinit_drain_timeout_secs = 300 # Drain timeout in seconds for stale ME writers after endpoint map changes. Set to 0 to keep stale writers draining indefinitely (no force-close). auto_degradation_enabled = true # Enable auto-degradation from ME to Direct-DC. degradation_min_unavailable_dc_groups = 2 # Minimum unavailable ME DC groups before degrading. hardswap = true # Enable C-like hard-swap for ME pool generations. When true, Telemt prewarms a new generation and switches once full coverage is reached. From d2f08fb70791a4297ab96f22b6471524a545ac54 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:19:39 +0300 Subject: [PATCH 03/36] ME Soft Reinit tuning Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/defaults.rs | 20 ++ src/config/load.rs | 78 +++++++ src/config/types.rs | 25 ++ src/main.rs | 43 ++-- src/transport/middle_proxy/config_updater.rs | 228 ++++++++++++++++--- src/transport/middle_proxy/secret.rs | 52 +++-- 6 files changed, 386 insertions(+), 60 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index b9b0da1..6b80ede 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -182,6 +182,26 @@ pub(crate) fn default_update_every_secs() -> u64 { 30 * 60 } +pub(crate) fn default_me_config_stable_snapshots() -> u8 { + 2 +} + +pub(crate) fn default_me_config_apply_cooldown_secs() -> u64 { + 300 +} + +pub(crate) fn default_proxy_secret_stable_snapshots() -> u8 { + 2 +} + +pub(crate) fn default_proxy_secret_rotate_runtime() -> bool { + true +} + +pub(crate) fn default_proxy_secret_len_max() -> usize { + 256 +} + pub(crate) fn default_me_reinit_drain_timeout_secs() -> u64 { 120 } diff --git a/src/config/load.rs b/src/config/load.rs index 750e0dc..be34efa 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -147,6 +147,24 @@ impl ProxyConfig { } } + 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.proxy_secret_stable_snapshots == 0 { + return Err(ProxyError::Config( + "general.proxy_secret_stable_snapshots 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(), @@ -462,6 +480,66 @@ mod tests { 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#" diff --git a/src/config/types.rs b/src/config/types.rs index c9ceea4..bd9697e 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -267,6 +267,26 @@ pub struct GeneralConfig { #[serde(default)] pub update_every: Option, + /// 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, + + /// 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, + + /// 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")] @@ -346,6 +366,11 @@ impl Default for GeneralConfig { hardswap: default_hardswap(), fast_mode_min_tls_record: default_fast_mode_min_tls_record(), update_every: Some(default_update_every_secs()), + me_config_stable_snapshots: default_me_config_stable_snapshots(), + me_config_apply_cooldown_secs: default_me_config_apply_cooldown_secs(), + proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(), + proxy_secret_rotate_runtime: default_proxy_secret_rotate_runtime(), + proxy_secret_len_max: default_proxy_secret_len_max(), me_pool_drain_ttl_secs: default_me_pool_drain_ttl_secs(), me_pool_min_fresh_ratio: default_me_pool_min_fresh_ratio(), me_reinit_drain_timeout_secs: default_me_reinit_drain_timeout_secs(), diff --git a/src/main.rs b/src/main.rs index 7264239..1c7b39c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -298,25 +298,30 @@ async fn main() -> std::result::Result<(), Box> { // proxy-secret is from: https://core.telegram.org/getProxySecret // ============================================================= let proxy_secret_path = config.general.proxy_secret_path.as_deref(); -match crate::transport::middle_proxy::fetch_proxy_secret(proxy_secret_path).await { - Ok(proxy_secret) => { - info!( - secret_len = proxy_secret.len(), - key_sig = format_args!( - "0x{:08x}", - if proxy_secret.len() >= 4 { - u32::from_le_bytes([ - proxy_secret[0], - proxy_secret[1], - proxy_secret[2], - proxy_secret[3], - ]) - } else { - 0 - } - ), - "Proxy-secret loaded" - ); + match crate::transport::middle_proxy::fetch_proxy_secret( + proxy_secret_path, + config.general.proxy_secret_len_max, + ) + .await + { + Ok(proxy_secret) => { + info!( + secret_len = proxy_secret.len(), + key_sig = format_args!( + "0x{:08x}", + if proxy_secret.len() >= 4 { + u32::from_le_bytes([ + proxy_secret[0], + proxy_secret[1], + proxy_secret[2], + proxy_secret[3], + ]) + } else { + 0 + } + ), + "Proxy-secret loaded" + ); // Load ME config (v4/v6) + default DC let mut cfg_v4 = fetch_proxy_config( diff --git a/src/transport/middle_proxy/config_updater.rs b/src/transport/middle_proxy/config_updater.rs index 56d5b81..fc9ed3d 100644 --- a/src/transport/middle_proxy/config_updater.rs +++ b/src/transport/middle_proxy/config_updater.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; @@ -11,7 +12,7 @@ use crate::config::ProxyConfig; use crate::error::Result; use super::MePool; -use super::secret::download_proxy_secret; +use super::secret::download_proxy_secret_with_max_len; use crate::crypto::SecureRandom; use std::time::SystemTime; @@ -39,6 +40,92 @@ pub struct ProxyConfigData { pub default_dc: Option, } +#[derive(Debug, Default)] +struct StableSnapshot { + candidate_hash: Option, + candidate_hits: u8, + applied_hash: Option, +} + +impl StableSnapshot { + fn observe(&mut self, hash: u64) -> u8 { + if self.candidate_hash == Some(hash) { + self.candidate_hits = self.candidate_hits.saturating_add(1); + } else { + self.candidate_hash = Some(hash); + self.candidate_hits = 1; + } + self.candidate_hits + } + + fn is_applied(&self, hash: u64) -> bool { + self.applied_hash == Some(hash) + } + + fn mark_applied(&mut self, hash: u64) { + self.applied_hash = Some(hash); + } +} + +#[derive(Debug, Default)] +struct UpdaterState { + config_v4: StableSnapshot, + config_v6: StableSnapshot, + secret: StableSnapshot, + last_map_apply_at: Option, +} + +fn hash_proxy_config(cfg: &ProxyConfigData) -> u64 { + let mut hasher = DefaultHasher::new(); + cfg.default_dc.hash(&mut hasher); + + let mut by_dc: Vec<(i32, Vec<(IpAddr, u16)>)> = + cfg.map.iter().map(|(dc, addrs)| (*dc, addrs.clone())).collect(); + by_dc.sort_by_key(|(dc, _)| *dc); + for (dc, mut addrs) in by_dc { + dc.hash(&mut hasher); + addrs.sort_unstable(); + for (ip, port) in addrs { + ip.hash(&mut hasher); + port.hash(&mut hasher); + } + } + + hasher.finish() +} + +fn hash_secret(secret: &[u8]) -> u64 { + let mut hasher = DefaultHasher::new(); + secret.hash(&mut hasher); + hasher.finish() +} + +fn map_apply_cooldown_ready( + last_applied: Option, + cooldown: Duration, +) -> bool { + if cooldown.is_zero() { + return true; + } + match last_applied { + Some(ts) => ts.elapsed() >= cooldown, + None => true, + } +} + +fn map_apply_cooldown_remaining_secs( + last_applied: tokio::time::Instant, + cooldown: Duration, +) -> u64 { + if cooldown.is_zero() { + return 0; + } + cooldown + .checked_sub(last_applied.elapsed()) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + fn parse_host_port(s: &str) -> Option<(IpAddr, u16)> { if let Some(bracket_end) = s.rfind(']') && s.starts_with('[') @@ -130,7 +217,12 @@ pub async fn fetch_proxy_config(url: &str) -> Result { Ok(ProxyConfigData { map, default_dc }) } -async fn run_update_cycle(pool: &Arc, rng: &Arc, cfg: &ProxyConfig) { +async fn run_update_cycle( + pool: &Arc, + rng: &Arc, + cfg: &ProxyConfig, + state: &mut UpdaterState, +) { pool.update_runtime_reinit_policy( cfg.general.hardswap, cfg.general.me_pool_drain_ttl_secs, @@ -138,33 +230,93 @@ async fn run_update_cycle(pool: &Arc, rng: &Arc, cfg: &Pro cfg.general.me_pool_min_fresh_ratio, ); + let required_cfg_snapshots = cfg.general.me_config_stable_snapshots.max(1); + let required_secret_snapshots = cfg.general.proxy_secret_stable_snapshots.max(1); + let apply_cooldown = Duration::from_secs(cfg.general.me_config_apply_cooldown_secs); let mut maps_changed = false; - // Update proxy config v4 + let mut ready_v4: Option<(ProxyConfigData, u64)> = None; let cfg_v4 = retry_fetch("https://core.telegram.org/getProxyConfig").await; if let Some(cfg_v4) = cfg_v4 { - let changed = pool.update_proxy_maps(cfg_v4.map.clone(), None).await; - if let Some(dc) = cfg_v4.default_dc { - pool.default_dc - .store(dc, std::sync::atomic::Ordering::Relaxed); - } - if changed { - maps_changed = true; - info!("ME config updated (v4)"); + let cfg_v4_hash = hash_proxy_config(&cfg_v4); + let stable_hits = state.config_v4.observe(cfg_v4_hash); + if stable_hits < required_cfg_snapshots { + debug!( + stable_hits, + required_cfg_snapshots, + snapshot = format_args!("0x{cfg_v4_hash:016x}"), + "ME config v4 candidate observed" + ); + } else if state.config_v4.is_applied(cfg_v4_hash) { + debug!( + snapshot = format_args!("0x{cfg_v4_hash:016x}"), + "ME config v4 stable snapshot already applied" + ); } else { - debug!("ME config v4 unchanged"); + ready_v4 = Some((cfg_v4, cfg_v4_hash)); } } - // Update proxy config v6 (optional) + let mut ready_v6: Option<(ProxyConfigData, u64)> = None; let cfg_v6 = retry_fetch("https://core.telegram.org/getProxyConfigV6").await; if let Some(cfg_v6) = cfg_v6 { - let changed = pool.update_proxy_maps(HashMap::new(), Some(cfg_v6.map)).await; - if changed { - maps_changed = true; - info!("ME config updated (v6)"); + let cfg_v6_hash = hash_proxy_config(&cfg_v6); + let stable_hits = state.config_v6.observe(cfg_v6_hash); + if stable_hits < required_cfg_snapshots { + debug!( + stable_hits, + required_cfg_snapshots, + snapshot = format_args!("0x{cfg_v6_hash:016x}"), + "ME config v6 candidate observed" + ); + } else if state.config_v6.is_applied(cfg_v6_hash) { + debug!( + snapshot = format_args!("0x{cfg_v6_hash:016x}"), + "ME config v6 stable snapshot already applied" + ); } else { - debug!("ME config v6 unchanged"); + ready_v6 = Some((cfg_v6, cfg_v6_hash)); + } + } + + if ready_v4.is_some() || ready_v6.is_some() { + if map_apply_cooldown_ready(state.last_map_apply_at, apply_cooldown) { + let update_v4 = ready_v4 + .as_ref() + .map(|(snapshot, _)| snapshot.map.clone()) + .unwrap_or_default(); + let update_v6 = ready_v6 + .as_ref() + .map(|(snapshot, _)| snapshot.map.clone()); + + let changed = pool.update_proxy_maps(update_v4, update_v6).await; + + if let Some((snapshot, hash)) = ready_v4 { + if let Some(dc) = snapshot.default_dc { + pool.default_dc + .store(dc, std::sync::atomic::Ordering::Relaxed); + } + state.config_v4.mark_applied(hash); + } + + if let Some((_snapshot, hash)) = ready_v6 { + state.config_v6.mark_applied(hash); + } + + state.last_map_apply_at = Some(tokio::time::Instant::now()); + + if changed { + maps_changed = true; + info!("ME config update applied after stable-gate"); + } else { + debug!("ME config stable-gate applied with no map delta"); + } + } else if let Some(last) = state.last_map_apply_at { + let wait_secs = map_apply_cooldown_remaining_secs(last, apply_cooldown); + debug!( + wait_secs, + "ME config stable snapshot deferred by cooldown" + ); } } @@ -175,14 +327,37 @@ async fn run_update_cycle(pool: &Arc, rng: &Arc, cfg: &Pro pool.reset_stun_state(); - // Update proxy-secret - match download_proxy_secret().await { - Ok(secret) => { - if pool.update_secret(secret).await { - info!("proxy-secret updated and pool reconnect scheduled"); + if cfg.general.proxy_secret_rotate_runtime { + match download_proxy_secret_with_max_len(cfg.general.proxy_secret_len_max).await { + Ok(secret) => { + let secret_hash = hash_secret(&secret); + let stable_hits = state.secret.observe(secret_hash); + if stable_hits < required_secret_snapshots { + debug!( + stable_hits, + required_secret_snapshots, + snapshot = format_args!("0x{secret_hash:016x}"), + "proxy-secret candidate observed" + ); + } else if state.secret.is_applied(secret_hash) { + debug!( + snapshot = format_args!("0x{secret_hash:016x}"), + "proxy-secret stable snapshot already applied" + ); + } else { + let rotated = pool.update_secret(secret).await; + state.secret.mark_applied(secret_hash); + if rotated { + info!("proxy-secret rotated after stable-gate"); + } else { + debug!("proxy-secret stable snapshot confirmed as unchanged"); + } + } } + Err(e) => warn!(error = %e, "proxy-secret update failed"), } - Err(e) => warn!(error = %e, "proxy-secret update failed"), + } else { + debug!("proxy-secret runtime rotation disabled by config"); } } @@ -191,6 +366,7 @@ pub async fn me_config_updater( rng: Arc, mut config_rx: watch::Receiver>, ) { + let mut state = UpdaterState::default(); let mut update_every_secs = config_rx .borrow() .general @@ -207,7 +383,7 @@ pub async fn me_config_updater( tokio::select! { _ = &mut sleep => { let cfg = config_rx.borrow().clone(); - run_update_cycle(&pool, &rng, cfg.as_ref()).await; + run_update_cycle(&pool, &rng, cfg.as_ref(), &mut state).await; let refreshed_secs = cfg.general.effective_update_every_secs().max(1); if refreshed_secs != update_every_secs { info!( @@ -245,7 +421,7 @@ pub async fn me_config_updater( ); update_every_secs = new_secs; update_every = Duration::from_secs(update_every_secs); - run_update_cycle(&pool, &rng, cfg.as_ref()).await; + run_update_cycle(&pool, &rng, cfg.as_ref(), &mut state).await; next_tick = tokio::time::Instant::now() + update_every; } else { info!( diff --git a/src/transport/middle_proxy/secret.rs b/src/transport/middle_proxy/secret.rs index 69a3198..4991d32 100644 --- a/src/transport/middle_proxy/secret.rs +++ b/src/transport/middle_proxy/secret.rs @@ -4,12 +4,42 @@ use httpdate; use crate::error::{ProxyError, Result}; +pub const PROXY_SECRET_MIN_LEN: usize = 32; + +pub(super) fn validate_proxy_secret_len(data_len: usize, max_len: usize) -> Result<()> { + if max_len < PROXY_SECRET_MIN_LEN { + return Err(ProxyError::Proxy(format!( + "proxy-secret max length is invalid: {} bytes (must be >= {})", + max_len, + PROXY_SECRET_MIN_LEN + ))); + } + + if data_len < PROXY_SECRET_MIN_LEN { + return Err(ProxyError::Proxy(format!( + "proxy-secret too short: {} bytes (need >= {})", + data_len, + PROXY_SECRET_MIN_LEN + ))); + } + + if data_len > max_len { + return Err(ProxyError::Proxy(format!( + "proxy-secret too long: {} bytes (limit = {})", + data_len, + max_len + ))); + } + + Ok(()) +} + /// Fetch Telegram proxy-secret binary. -pub async fn fetch_proxy_secret(cache_path: Option<&str>) -> Result> { +pub async fn fetch_proxy_secret(cache_path: Option<&str>, max_len: usize) -> Result> { let cache = cache_path.unwrap_or("proxy-secret"); // 1) Try fresh download first. - match download_proxy_secret().await { + match download_proxy_secret_with_max_len(max_len).await { Ok(data) => { if let Err(e) = tokio::fs::write(cache, &data).await { warn!(error = %e, "Failed to cache proxy-secret (non-fatal)"); @@ -24,9 +54,9 @@ pub async fn fetch_proxy_secret(cache_path: Option<&str>) -> Result> { } } - // 2) Fallback to cache/file regardless of age; require len>=32. + // 2) Fallback to cache/file regardless of age; require len in bounds. match tokio::fs::read(cache).await { - Ok(data) if data.len() >= 32 => { + Ok(data) if validate_proxy_secret_len(data.len(), max_len).is_ok() => { let age_hours = tokio::fs::metadata(cache) .await .ok() @@ -41,17 +71,14 @@ pub async fn fetch_proxy_secret(cache_path: Option<&str>) -> Result> { ); Ok(data) } - Ok(data) => Err(ProxyError::Proxy(format!( - "Cached proxy-secret too short: {} bytes (need >= 32)", - data.len() - ))), + Ok(data) => validate_proxy_secret_len(data.len(), max_len).map(|_| data), Err(e) => Err(ProxyError::Proxy(format!( "Failed to read proxy-secret cache after download failure: {e}" ))), } } -pub async fn download_proxy_secret() -> Result> { +pub async fn download_proxy_secret_with_max_len(max_len: usize) -> Result> { let resp = reqwest::get("https://core.telegram.org/getProxySecret") .await .map_err(|e| ProxyError::Proxy(format!("Failed to download proxy-secret: {e}")))?; @@ -84,12 +111,7 @@ pub async fn download_proxy_secret() -> Result> { .map_err(|e| ProxyError::Proxy(format!("Read proxy-secret body: {e}")))? .to_vec(); - if data.len() < 32 { - return Err(ProxyError::Proxy(format!( - "proxy-secret too short: {} bytes (need >= 32)", - data.len() - ))); - } + validate_proxy_secret_len(data.len(), max_len)?; info!(len = data.len(), "Downloaded proxy-secret OK"); Ok(data) From c13c1cf7e3944a9393bdd0e86f51094c15354ff1 Mon Sep 17 00:00:00 2001 From: Dimasssss Date: Tue, 24 Feb 2026 18:39:46 +0300 Subject: [PATCH 04/36] Update config.toml --- config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.toml b/config.toml index fd76fed..48a9047 100644 --- a/config.toml +++ b/config.toml @@ -23,7 +23,7 @@ middle_proxy_nat_stun = "stun.l.google.com:19302" # Optional fallback STUN servers list. middle_proxy_nat_stun_servers = ["stun1.l.google.com:19302", "stun2.l.google.com:19302"] # Desired number of concurrent ME writers in pool. -middle_proxy_pool_size = 16 +middle_proxy_pool_size = 8 # Pre-initialized warm-standby ME connections kept idle. middle_proxy_warm_standby = 8 # Ignore STUN/interface mismatch and keep ME enabled even if IP differs. From b1cd7f97273b17da8b7429578d5e264c18c1fa76 Mon Sep 17 00:00:00 2001 From: badcdd <114914117+badcdd@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:59:37 +0300 Subject: [PATCH 05/36] fix similar username in discovered items --- tools/zbx_telemt_template.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/zbx_telemt_template.yaml b/tools/zbx_telemt_template.yaml index 3f3b5bc..493f3f2 100644 --- a/tools/zbx_telemt_template.yaml +++ b/tools/zbx_telemt_template.yaml @@ -172,7 +172,7 @@ zabbix_export: preprocessing: - type: PROMETHEUS_PATTERN parameters: - - 'telemt_user_connections_current{user=~"{#TELEMT_USER}"}' + - 'telemt_user_connections_current{user="{#TELEMT_USER}"}' - value - '' master_item: @@ -188,7 +188,7 @@ zabbix_export: preprocessing: - type: PROMETHEUS_PATTERN parameters: - - 'telemt_user_msgs_from_client{user=~"{#TELEMT_USER}"}' + - 'telemt_user_msgs_from_client{user="{#TELEMT_USER}"}' - value - '' master_item: @@ -204,7 +204,7 @@ zabbix_export: preprocessing: - type: PROMETHEUS_PATTERN parameters: - - 'telemt_user_msgs_to_client{user=~"{#TELEMT_USER}"}' + - 'telemt_user_msgs_to_client{user="{#TELEMT_USER}"}' - value - '' master_item: @@ -221,7 +221,7 @@ zabbix_export: preprocessing: - type: PROMETHEUS_PATTERN parameters: - - 'telemt_user_octets_from_client{user=~"{#TELEMT_USER}"}' + - 'telemt_user_octets_from_client{user="{#TELEMT_USER}"}' - value - '' master_item: @@ -238,7 +238,7 @@ zabbix_export: preprocessing: - type: PROMETHEUS_PATTERN parameters: - - 'telemt_user_octets_to_client{user=~"{#TELEMT_USER}"}' + - 'telemt_user_octets_to_client{user="{#TELEMT_USER}"}' - value - '' master_item: @@ -254,7 +254,7 @@ zabbix_export: preprocessing: - type: PROMETHEUS_PATTERN parameters: - - 'telemt_user_connections_total{user=~"{#TELEMT_USER}"}' + - 'telemt_user_connections_total{user="{#TELEMT_USER}"}' - value - '' master_item: From ee07325eba54d7f7132517f83c371861c0227e6d Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:12:44 +0300 Subject: [PATCH 06/36] Update Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index fd1d892..6bce323 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.0.13" +version = "3.0.14" edition = "2024" [dependencies] From b00b87032b989fdc323714b4721a9a44507a386f Mon Sep 17 00:00:00 2001 From: Dimasssss Date: Tue, 24 Feb 2026 22:10:49 +0300 Subject: [PATCH 07/36] Update config.toml --- config.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config.toml b/config.toml index 48a9047..0bebe1d 100644 --- a/config.toml +++ b/config.toml @@ -52,6 +52,12 @@ hardswap = true # Enable C-like hard-swap for ME pool me_pool_drain_ttl_secs = 90 # 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. me_pool_min_fresh_ratio = 0.8 # Minimum desired-DC coverage ratio required before draining stale writers. Range: 0.0..=1.0. me_reinit_drain_timeout_secs = 120 # Drain timeout in seconds for stale ME writers after endpoint map changes. Set to 0 to keep stale writers draining indefinitely (no force-close). +me_config_stable_snapshots = 2 # Number of identical getProxyConfig snapshots required before applying ME map updates. +me_config_apply_cooldown_secs = 300 # Cooldown in seconds between applied ME map updates. +proxy_secret_rotate_runtime = true # Enable runtime proxy-secret rotation from getProxySecret. +proxy_secret_stable_snapshots = 2 # Number of identical getProxySecret snapshots required before runtime secret rotation. +proxy_secret_len_max = 256 # Maximum allowed proxy-secret length in bytes for startup and runtime refresh. + [general.modes] classic = false From 692d9476b99d09fb3ed5014a31dd4a10f286e52d Mon Sep 17 00:00:00 2001 From: Dimasssss Date: Tue, 24 Feb 2026 22:11:15 +0300 Subject: [PATCH 08/36] Update config.toml --- config.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/config.toml b/config.toml index 0bebe1d..c1dc73f 100644 --- a/config.toml +++ b/config.toml @@ -58,7 +58,6 @@ proxy_secret_rotate_runtime = true # Enable runtime proxy-secret rotation proxy_secret_stable_snapshots = 2 # Number of identical getProxySecret snapshots required before runtime secret rotation. proxy_secret_len_max = 256 # Maximum allowed proxy-secret length in bytes for startup and runtime refresh. - [general.modes] classic = false secure = false From 4a95f6d1959dca06f5078fe8fba4c6cf377fe3fe Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:59:59 +0300 Subject: [PATCH 09/36] ME Pool Health + Rotation Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/defaults.rs | 4 + src/config/load.rs | 46 +++++ src/config/types.rs | 10 + src/main.rs | 24 +-- src/transport/middle_proxy/health.rs | 110 +++++++---- src/transport/middle_proxy/pool.rs | 241 +++++++++++++++++++++---- src/transport/middle_proxy/rotation.rs | 105 +++++++---- 7 files changed, 424 insertions(+), 116 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 6b80ede..4f563ba 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -182,6 +182,10 @@ pub(crate) fn default_update_every_secs() -> u64 { 30 * 60 } +pub(crate) fn default_me_reinit_every_secs() -> u64 { + 15 * 60 +} + pub(crate) fn default_me_config_stable_snapshots() -> u8 { 2 } diff --git a/src/config/load.rs b/src/config/load.rs index be34efa..c18c84f 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -147,6 +147,12 @@ impl ProxyConfig { } } + 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_config_stable_snapshots == 0 { return Err(ProxyError::Config( "general.me_config_stable_snapshots must be > 0".to_string(), @@ -480,6 +486,46 @@ mod tests { 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_config_stable_snapshots_zero_is_rejected() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index bd9697e..03417c5 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -267,6 +267,10 @@ pub struct GeneralConfig { #[serde(default)] pub update_every: Option, + /// Periodic ME pool reinitialization interval in seconds. + #[serde(default = "default_me_reinit_every_secs")] + pub me_reinit_every_secs: 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, @@ -366,6 +370,7 @@ impl Default for GeneralConfig { hardswap: default_hardswap(), fast_mode_min_tls_record: default_fast_mode_min_tls_record(), update_every: Some(default_update_every_secs()), + me_reinit_every_secs: default_me_reinit_every_secs(), me_config_stable_snapshots: default_me_config_stable_snapshots(), me_config_apply_cooldown_secs: default_me_config_apply_cooldown_secs(), proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(), @@ -392,6 +397,11 @@ impl GeneralConfig { .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. pub fn effective_me_pool_force_close_secs(&self) -> u64 { diff --git a/src/main.rs b/src/main.rs index 1c7b39c..d9a692d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -391,18 +391,6 @@ async fn main() -> std::result::Result<(), Box> { .await; }); - // Periodic ME connection rotation - let pool_clone_rot = pool.clone(); - let rng_clone_rot = rng.clone(); - tokio::spawn(async move { - crate::transport::middle_proxy::me_rotation_task( - pool_clone_rot, - rng_clone_rot, - std::time::Duration::from_secs(1800), - ) - .await; - }); - Some(pool) } Err(e) => { @@ -712,6 +700,18 @@ async fn main() -> std::result::Result<(), Box> { ) .await; }); + + let pool_clone_rot = pool.clone(); + let rng_clone_rot = rng.clone(); + let config_rx_clone_rot = config_rx.clone(); + tokio::spawn(async move { + crate::transport::middle_proxy::me_rotation_task( + pool_clone_rot, + rng_clone_rot, + config_rx_clone_rot, + ) + .await; + }); } let mut listeners = Vec::new(); diff --git a/src/transport/middle_proxy/health.rs b/src/transport/middle_proxy/health.rs index 4bb7e64..dde3354 100644 --- a/src/transport/middle_proxy/health.rs +++ b/src/transport/middle_proxy/health.rs @@ -1,10 +1,9 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; -use rand::seq::SliceRandom; use rand::Rng; use crate::crypto::SecureRandom; @@ -64,31 +63,43 @@ async fn check_family( IpFamily::V4 => pool.proxy_map_v4.read().await.clone(), IpFamily::V6 => pool.proxy_map_v6.read().await.clone(), }; - let writer_addrs: HashSet = pool + + let mut dc_endpoints = HashMap::>::new(); + for (dc, addrs) in map { + let entry = dc_endpoints.entry(dc.abs()).or_default(); + for (ip, port) in addrs { + entry.push(SocketAddr::new(ip, port)); + } + } + for endpoints in dc_endpoints.values_mut() { + endpoints.sort_unstable(); + endpoints.dedup(); + } + + let mut live_addr_counts = HashMap::::new(); + for writer in pool .writers .read() .await .iter() .filter(|w| !w.draining.load(std::sync::atomic::Ordering::Relaxed)) - .map(|w| w.addr) - .collect(); + { + *live_addr_counts.entry(writer.addr).or_insert(0) += 1; + } - let entries: Vec<(i32, Vec)> = map - .iter() - .map(|(dc, addrs)| { - let list = addrs - .iter() - .map(|(ip, port)| SocketAddr::new(*ip, *port)) - .collect::>(); - (*dc, list) - }) - .collect(); - - for (dc, dc_addrs) in entries { - let has_coverage = dc_addrs.iter().any(|a| writer_addrs.contains(a)); - if has_coverage { + for (dc, endpoints) in dc_endpoints { + if endpoints.is_empty() { continue; } + let required = MePool::required_writers_for_dc(endpoints.len()); + let alive = endpoints + .iter() + .map(|addr| *live_addr_counts.get(addr).unwrap_or(&0)) + .sum::(); + if alive >= required { + continue; + } + let missing = required - alive; let key = (dc, family); let now = Instant::now(); @@ -104,32 +115,45 @@ async fn check_family( } *inflight.entry(key).or_insert(0) += 1; - let mut shuffled = dc_addrs.clone(); - shuffled.shuffle(&mut rand::rng()); - let mut success = false; - for addr in shuffled { - let res = tokio::time::timeout(pool.me_one_timeout, pool.connect_one(addr, rng.as_ref())).await; + let mut restored = 0usize; + for _ in 0..missing { + let res = tokio::time::timeout( + pool.me_one_timeout, + pool.connect_endpoints_round_robin(&endpoints, rng.as_ref()), + ) + .await; match res { - Ok(Ok(())) => { - info!(%addr, dc = %dc, ?family, "ME reconnected for DC coverage"); + Ok(true) => { + restored += 1; pool.stats.increment_me_reconnect_success(); - backoff.insert(key, pool.me_reconnect_backoff_base.as_millis() as u64); - let jitter = pool.me_reconnect_backoff_base.as_millis() as u64 / JITTER_FRAC_NUM; - let wait = pool.me_reconnect_backoff_base - + Duration::from_millis(rand::rng().random_range(0..=jitter.max(1))); - next_attempt.insert(key, now + wait); - success = true; - break; } - Ok(Err(e)) => { + Ok(false) => { pool.stats.increment_me_reconnect_attempt(); - debug!(%addr, dc = %dc, error = %e, ?family, "ME reconnect failed") + debug!(dc = %dc, ?family, "ME round-robin reconnect failed") + } + Err(_) => { + pool.stats.increment_me_reconnect_attempt(); + debug!(dc = %dc, ?family, "ME reconnect timed out"); } - Err(_) => debug!(%addr, dc = %dc, ?family, "ME reconnect timed out"), } } - if !success { - pool.stats.increment_me_reconnect_attempt(); + + let now_alive = alive + restored; + if now_alive >= required { + info!( + dc = %dc, + ?family, + alive = now_alive, + required, + endpoint_count = endpoints.len(), + "ME writer floor restored for DC" + ); + backoff.insert(key, pool.me_reconnect_backoff_base.as_millis() as u64); + let jitter = pool.me_reconnect_backoff_base.as_millis() as u64 / JITTER_FRAC_NUM; + let wait = pool.me_reconnect_backoff_base + + Duration::from_millis(rand::rng().random_range(0..=jitter.max(1))); + next_attempt.insert(key, now + wait); + } else { let curr = *backoff.get(&key).unwrap_or(&(pool.me_reconnect_backoff_base.as_millis() as u64)); let next_ms = (curr.saturating_mul(2)).min(pool.me_reconnect_backoff_cap.as_millis() as u64); backoff.insert(key, next_ms); @@ -137,7 +161,15 @@ async fn check_family( let wait = Duration::from_millis(next_ms) + Duration::from_millis(rand::rng().random_range(0..=jitter.max(1))); next_attempt.insert(key, now + wait); - warn!(dc = %dc, backoff_ms = next_ms, ?family, "DC has no ME coverage, scheduled reconnect"); + warn!( + dc = %dc, + ?family, + alive = now_alive, + required, + endpoint_count = endpoints.len(), + backoff_ms = next_ms, + "DC writer floor is below required level, scheduled reconnect" + ); } if let Some(v) = inflight.get_mut(&key) { *v = v.saturating_sub(1); diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index 06fdc96..223d488 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -75,6 +75,7 @@ pub struct MePool { pub(super) rtt_stats: Arc>>, pub(super) nat_reflection_cache: Arc>, pub(super) writer_available: Arc, + pub(super) refill_inflight: Arc>>, pub(super) conn_count: AtomicUsize, pub(super) stats: Arc, pub(super) generation: AtomicU64, @@ -180,6 +181,7 @@ impl MePool { rtt_stats: Arc::new(Mutex::new(HashMap::new())), nat_reflection_cache: Arc::new(Mutex::new(NatReflectionCache::default())), writer_available: Arc::new(Notify::new()), + refill_inflight: Arc::new(Mutex::new(HashSet::new())), conn_count: AtomicUsize::new(0), generation: AtomicU64::new(1), hardswap: AtomicBool::new(hardswap), @@ -324,34 +326,66 @@ impl MePool { out } + pub(super) fn required_writers_for_dc(endpoint_count: usize) -> usize { + endpoint_count.max(3) + } + + pub(super) async fn connect_endpoints_round_robin( + self: &Arc, + endpoints: &[SocketAddr], + rng: &SecureRandom, + ) -> bool { + if endpoints.is_empty() { + return false; + } + let start = (self.rr.fetch_add(1, Ordering::Relaxed) as usize) % endpoints.len(); + for offset in 0..endpoints.len() { + let idx = (start + offset) % endpoints.len(); + let addr = endpoints[idx]; + match self.connect_one(addr, rng).await { + Ok(()) => return true, + Err(e) => debug!(%addr, error = %e, "ME connect failed during round-robin warmup"), + } + } + false + } + async fn warmup_generation_for_all_dcs( self: &Arc, rng: &SecureRandom, generation: u64, desired_by_dc: &HashMap>, ) { - for endpoints in desired_by_dc.values() { + for (dc, endpoints) in desired_by_dc { if endpoints.is_empty() { continue; } - let has_fresh = { - let ws = self.writers.read().await; - ws.iter().any(|w| { - !w.draining.load(Ordering::Relaxed) - && w.generation == generation - && endpoints.contains(&w.addr) - }) - }; + let mut endpoint_list: Vec = endpoints.iter().copied().collect(); + endpoint_list.sort_unstable(); + let required = Self::required_writers_for_dc(endpoint_list.len()); - if has_fresh { - continue; - } + loop { + let fresh_count = { + let ws = self.writers.read().await; + ws.iter() + .filter(|w| !w.draining.load(Ordering::Relaxed)) + .filter(|w| w.generation == generation) + .filter(|w| endpoints.contains(&w.addr)) + .count() + }; + if fresh_count >= required { + break; + } - let mut shuffled: Vec = endpoints.iter().copied().collect(); - shuffled.shuffle(&mut rand::rng()); - for addr in shuffled { - if self.connect_one(addr, rng).await.is_ok() { + if !self.connect_endpoints_round_robin(&endpoint_list, rng).await { + warn!( + dc = *dc, + fresh_count, + required, + endpoint_count = endpoint_list.len(), + "ME warmup stopped: unable to reach required writer floor for DC" + ); break; } } @@ -364,7 +398,7 @@ impl MePool { ) { let desired_by_dc = self.desired_dc_endpoints().await; if desired_by_dc.is_empty() { - warn!("ME endpoint map is empty after update; skipping stale writer drain"); + warn!("ME endpoint map is empty; skipping stale writer drain"); return; } @@ -403,19 +437,26 @@ impl MePool { } if hardswap { - let fresh_writer_addrs: HashSet = writers - .iter() - .filter(|w| !w.draining.load(Ordering::Relaxed)) - .filter(|w| w.generation == generation) - .map(|w| w.addr) - .collect(); - let (fresh_ratio, fresh_missing_dc) = - Self::coverage_ratio(&desired_by_dc, &fresh_writer_addrs); + let mut fresh_missing_dc = Vec::<(i32, usize, usize)>::new(); + for (dc, endpoints) in &desired_by_dc { + if endpoints.is_empty() { + continue; + } + let required = Self::required_writers_for_dc(endpoints.len()); + let fresh_count = writers + .iter() + .filter(|w| !w.draining.load(Ordering::Relaxed)) + .filter(|w| w.generation == generation) + .filter(|w| endpoints.contains(&w.addr)) + .count(); + if fresh_count < required { + fresh_missing_dc.push((*dc, fresh_count, required)); + } + } if !fresh_missing_dc.is_empty() { warn!( previous_generation, generation, - fresh_ratio = format_args!("{fresh_ratio:.3}"), missing_dc = ?fresh_missing_dc, "ME hardswap pending: fresh generation coverage incomplete" ); @@ -425,7 +466,7 @@ impl MePool { warn!( missing_dc = ?missing_dc, // Keep stale writers alive when fresh coverage is incomplete. - "ME reinit coverage incomplete after map update; keeping stale writers" + "ME reinit coverage incomplete; keeping stale writers" ); return; } @@ -450,7 +491,7 @@ impl MePool { drop(writers); if stale_writer_ids.is_empty() { - debug!("ME map update completed with no stale writers"); + debug!("ME reinit cycle completed with no stale writers"); return; } @@ -464,7 +505,7 @@ impl MePool { coverage_ratio = format_args!("{coverage_ratio:.3}"), min_ratio = format_args!("{min_ratio:.3}"), drain_timeout_secs, - "ME map update covered; draining stale writers" + "ME reinit cycle covered; draining stale writers" ); self.stats.increment_pool_swap_total(); for writer_id in stale_writer_ids { @@ -473,6 +514,134 @@ impl MePool { } } + pub async fn zero_downtime_reinit_periodic( + self: &Arc, + rng: &SecureRandom, + ) { + self.zero_downtime_reinit_after_map_change(rng).await; + } + + async fn endpoints_for_same_dc(&self, addr: SocketAddr) -> Vec { + let mut target_dc = HashSet::::new(); + let mut endpoints = HashSet::::new(); + + if self.decision.ipv4_me { + let map = self.proxy_map_v4.read().await.clone(); + for (dc, addrs) in &map { + if addrs + .iter() + .any(|(ip, port)| SocketAddr::new(*ip, *port) == addr) + { + target_dc.insert(dc.abs()); + } + } + for dc in &target_dc { + for key in [*dc, -*dc] { + if let Some(addrs) = map.get(&key) { + for (ip, port) in addrs { + endpoints.insert(SocketAddr::new(*ip, *port)); + } + } + } + } + } + + if self.decision.ipv6_me { + let map = self.proxy_map_v6.read().await.clone(); + for (dc, addrs) in &map { + if addrs + .iter() + .any(|(ip, port)| SocketAddr::new(*ip, *port) == addr) + { + target_dc.insert(dc.abs()); + } + } + for dc in &target_dc { + for key in [*dc, -*dc] { + if let Some(addrs) = map.get(&key) { + for (ip, port) in addrs { + endpoints.insert(SocketAddr::new(*ip, *port)); + } + } + } + } + } + + let mut sorted: Vec = endpoints.into_iter().collect(); + sorted.sort_unstable(); + sorted + } + + async fn refill_writer_after_loss(self: &Arc, addr: SocketAddr) -> bool { + let fast_retries = self.me_reconnect_fast_retry_count.max(1); + + for attempt in 0..fast_retries { + self.stats.increment_me_reconnect_attempt(); + match self.connect_one(addr, self.rng.as_ref()).await { + Ok(()) => { + self.stats.increment_me_reconnect_success(); + info!( + %addr, + attempt = attempt + 1, + "ME writer restored on the same endpoint" + ); + return true; + } + Err(e) => { + debug!( + %addr, + attempt = attempt + 1, + error = %e, + "ME immediate same-endpoint reconnect failed" + ); + } + } + } + + let dc_endpoints = self.endpoints_for_same_dc(addr).await; + if dc_endpoints.is_empty() { + return false; + } + + for attempt in 0..fast_retries { + self.stats.increment_me_reconnect_attempt(); + if self + .connect_endpoints_round_robin(&dc_endpoints, self.rng.as_ref()) + .await + { + self.stats.increment_me_reconnect_success(); + info!( + %addr, + attempt = attempt + 1, + "ME writer restored via DC fallback endpoint" + ); + return true; + } + } + + false + } + + pub(crate) fn trigger_immediate_refill(self: &Arc, addr: SocketAddr) { + let pool = Arc::clone(self); + tokio::spawn(async move { + { + let mut guard = pool.refill_inflight.lock().await; + if !guard.insert(addr) { + return; + } + } + + let restored = pool.refill_writer_after_loss(addr).await; + if !restored { + warn!(%addr, "ME immediate refill failed"); + } + + let mut guard = pool.refill_inflight.lock().await; + guard.remove(&addr); + }); + } + pub async fn update_proxy_maps( &self, new_v4: HashMap>, @@ -880,16 +1049,21 @@ impl MePool { } } - async fn remove_writer_only(&self, writer_id: u64) -> Vec { + async fn remove_writer_only(self: &Arc, writer_id: u64) -> Vec { let mut close_tx: Option> = None; + let mut removed_addr: Option = None; + let mut trigger_refill = false; { let mut ws = self.writers.write().await; if let Some(pos) = ws.iter().position(|w| w.id == writer_id) { let w = ws.remove(pos); - if w.draining.load(Ordering::Relaxed) { + let was_draining = w.draining.load(Ordering::Relaxed); + if was_draining { self.stats.decrement_pool_drain_active(); } w.cancel.cancel(); + removed_addr = Some(w.addr); + trigger_refill = !was_draining; close_tx = Some(w.tx.clone()); self.conn_count.fetch_sub(1, Ordering::Relaxed); } @@ -897,6 +1071,11 @@ impl MePool { if let Some(tx) = close_tx { let _ = tx.send(WriterCommand::Close).await; } + if trigger_refill + && let Some(addr) = removed_addr + { + self.trigger_immediate_refill(addr); + } self.rtt_stats.lock().await.remove(&writer_id); self.registry.writer_lost(writer_id).await } diff --git a/src/transport/middle_proxy/rotation.rs b/src/transport/middle_proxy/rotation.rs index e141fc4..cf5f70d 100644 --- a/src/transport/middle_proxy/rotation.rs +++ b/src/transport/middle_proxy/rotation.rs @@ -1,50 +1,87 @@ use std::sync::Arc; -use std::sync::atomic::Ordering; use std::time::Duration; +use tokio::sync::watch; use tracing::{info, warn}; +use crate::config::ProxyConfig; use crate::crypto::SecureRandom; use super::MePool; -/// Periodically refresh ME connections to avoid long-lived degradation. -pub async fn me_rotation_task(pool: Arc, rng: Arc, interval: Duration) { - let interval = interval.max(Duration::from_secs(600)); +/// Periodically reinitialize ME generations and swap them after full warmup. +pub async fn me_rotation_task( + pool: Arc, + rng: Arc, + mut config_rx: watch::Receiver>, +) { + let mut interval_secs = config_rx + .borrow() + .general + .effective_me_reinit_every_secs() + .max(1); + let mut interval = Duration::from_secs(interval_secs); + let mut next_tick = tokio::time::Instant::now() + interval; + + info!(interval_secs, "ME periodic reinit task started"); + loop { - tokio::time::sleep(interval).await; + let sleep = tokio::time::sleep_until(next_tick); + tokio::pin!(sleep); - let candidate = { - let ws = pool.writers.read().await; - if ws.is_empty() { - None - } else { - let idx = (pool.rr.load(std::sync::atomic::Ordering::Relaxed) as usize) % ws.len(); - ws.get(idx).cloned() - } - }; - - let Some(w) = candidate else { - continue; - }; - - info!(addr = %w.addr, writer_id = w.id, "Rotating ME connection"); - match pool.connect_one(w.addr, rng.as_ref()).await { - Ok(()) => { - tokio::time::sleep(Duration::from_secs(2)).await; - let ws = pool.writers.read().await; - let new_alive = ws.iter().any(|nw| - nw.id != w.id && nw.addr == w.addr && !nw.degraded.load(Ordering::Relaxed) && !nw.draining.load(Ordering::Relaxed) - ); - drop(ws); - if new_alive { - pool.mark_writer_draining(w.id).await; - } else { - warn!(addr = %w.addr, writer_id = w.id, "New writer died, keeping old"); + tokio::select! { + _ = &mut sleep => { + pool.zero_downtime_reinit_periodic(rng.as_ref()).await; + let refreshed_secs = config_rx + .borrow() + .general + .effective_me_reinit_every_secs() + .max(1); + if refreshed_secs != interval_secs { + info!( + old_me_reinit_every_secs = interval_secs, + new_me_reinit_every_secs = refreshed_secs, + "ME periodic reinit interval changed" + ); + interval_secs = refreshed_secs; + interval = Duration::from_secs(interval_secs); } + next_tick = tokio::time::Instant::now() + interval; } - Err(e) => { - warn!(addr = %w.addr, writer_id = w.id, error = %e, "ME rotation connect failed"); + changed = config_rx.changed() => { + if changed.is_err() { + warn!("ME periodic reinit task stopped: config channel closed"); + break; + } + let new_secs = config_rx + .borrow() + .general + .effective_me_reinit_every_secs() + .max(1); + if new_secs == interval_secs { + continue; + } + + if new_secs < interval_secs { + info!( + old_me_reinit_every_secs = interval_secs, + new_me_reinit_every_secs = new_secs, + "ME periodic reinit interval decreased, running immediate reinit" + ); + interval_secs = new_secs; + interval = Duration::from_secs(interval_secs); + pool.zero_downtime_reinit_periodic(rng.as_ref()).await; + next_tick = tokio::time::Instant::now() + interval; + } else { + info!( + old_me_reinit_every_secs = interval_secs, + new_me_reinit_every_secs = new_secs, + "ME periodic reinit interval increased" + ); + interval_secs = new_secs; + interval = Duration::from_secs(interval_secs); + next_tick = tokio::time::Instant::now() + interval; + } } } } From 7538967d3c4b56b4d122c7839e35d8cb25b0d4d0 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:36:33 +0300 Subject: [PATCH 10/36] ME Hardswap being softer Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/defaults.rs | 16 ++ src/config/load.rs | 141 +++++++++++++++++ src/config/types.rs | 20 +++ src/main.rs | 4 + src/transport/middle_proxy/config_updater.rs | 8 + src/transport/middle_proxy/pool.rs | 158 +++++++++++++++++-- 6 files changed, 332 insertions(+), 15 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 4f563ba..d43ace9 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -186,6 +186,22 @@ pub(crate) fn default_me_reinit_every_secs() -> u64 { 15 * 60 } +pub(crate) fn default_me_hardswap_warmup_delay_min_ms() -> u64 { + 1000 +} + +pub(crate) fn default_me_hardswap_warmup_delay_max_ms() -> u64 { + 2000 +} + +pub(crate) fn default_me_hardswap_warmup_extra_passes() -> u8 { + 3 +} + +pub(crate) fn default_me_hardswap_warmup_pass_backoff_base_ms() -> u64 { + 500 +} + pub(crate) fn default_me_config_stable_snapshots() -> u8 { 2 } diff --git a/src/config/load.rs b/src/config/load.rs index c18c84f..5698a71 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -153,6 +153,32 @@ impl ProxyConfig { )); } + 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(), @@ -526,6 +552,121 @@ mod tests { let _ = std::fs::remove_file(path); } + #[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#" diff --git a/src/config/types.rs b/src/config/types.rs index 03417c5..0cda9f4 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -271,6 +271,22 @@ pub struct GeneralConfig { #[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, @@ -371,6 +387,10 @@ impl Default for GeneralConfig { fast_mode_min_tls_record: default_fast_mode_min_tls_record(), update_every: Some(default_update_every_secs()), 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(), proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(), diff --git a/src/main.rs b/src/main.rs index d9a692d..3bcbf3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -373,6 +373,10 @@ async fn main() -> std::result::Result<(), Box> { config.general.me_pool_drain_ttl_secs, config.general.effective_me_pool_force_close_secs(), config.general.me_pool_min_fresh_ratio, + config.general.me_hardswap_warmup_delay_min_ms, + config.general.me_hardswap_warmup_delay_max_ms, + config.general.me_hardswap_warmup_extra_passes, + config.general.me_hardswap_warmup_pass_backoff_base_ms, ); let pool_size = config.general.middle_proxy_pool_size.max(1); diff --git a/src/transport/middle_proxy/config_updater.rs b/src/transport/middle_proxy/config_updater.rs index fc9ed3d..4e8e63f 100644 --- a/src/transport/middle_proxy/config_updater.rs +++ b/src/transport/middle_proxy/config_updater.rs @@ -228,6 +228,10 @@ async fn run_update_cycle( cfg.general.me_pool_drain_ttl_secs, cfg.general.effective_me_pool_force_close_secs(), cfg.general.me_pool_min_fresh_ratio, + cfg.general.me_hardswap_warmup_delay_min_ms, + cfg.general.me_hardswap_warmup_delay_max_ms, + cfg.general.me_hardswap_warmup_extra_passes, + cfg.general.me_hardswap_warmup_pass_backoff_base_ms, ); let required_cfg_snapshots = cfg.general.me_config_stable_snapshots.max(1); @@ -407,6 +411,10 @@ pub async fn me_config_updater( cfg.general.me_pool_drain_ttl_secs, cfg.general.effective_me_pool_force_close_secs(), cfg.general.me_pool_min_fresh_ratio, + cfg.general.me_hardswap_warmup_delay_min_ms, + cfg.general.me_hardswap_warmup_delay_max_ms, + cfg.general.me_hardswap_warmup_extra_passes, + cfg.general.me_hardswap_warmup_pass_backoff_base_ms, ); let new_secs = cfg.general.effective_update_every_secs().max(1); if new_secs == update_every_secs { diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index 223d488..aa14e5b 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -83,6 +83,10 @@ pub struct MePool { pub(super) me_pool_drain_ttl_secs: AtomicU64, pub(super) me_pool_force_close_secs: AtomicU64, pub(super) me_pool_min_fresh_ratio_permille: AtomicU32, + pub(super) me_hardswap_warmup_delay_min_ms: AtomicU64, + pub(super) me_hardswap_warmup_delay_max_ms: AtomicU64, + pub(super) me_hardswap_warmup_extra_passes: AtomicU32, + pub(super) me_hardswap_warmup_pass_backoff_base_ms: AtomicU64, pool_size: usize, } @@ -140,6 +144,10 @@ impl MePool { me_pool_drain_ttl_secs: u64, me_pool_force_close_secs: u64, me_pool_min_fresh_ratio: f32, + me_hardswap_warmup_delay_min_ms: u64, + me_hardswap_warmup_delay_max_ms: u64, + me_hardswap_warmup_extra_passes: u8, + me_hardswap_warmup_pass_backoff_base_ms: u64, ) -> Arc { Arc::new(Self { registry: Arc::new(ConnRegistry::new()), @@ -188,6 +196,10 @@ impl MePool { me_pool_drain_ttl_secs: AtomicU64::new(me_pool_drain_ttl_secs), me_pool_force_close_secs: AtomicU64::new(me_pool_force_close_secs), me_pool_min_fresh_ratio_permille: AtomicU32::new(Self::ratio_to_permille(me_pool_min_fresh_ratio)), + me_hardswap_warmup_delay_min_ms: AtomicU64::new(me_hardswap_warmup_delay_min_ms), + me_hardswap_warmup_delay_max_ms: AtomicU64::new(me_hardswap_warmup_delay_max_ms), + me_hardswap_warmup_extra_passes: AtomicU32::new(me_hardswap_warmup_extra_passes as u32), + me_hardswap_warmup_pass_backoff_base_ms: AtomicU64::new(me_hardswap_warmup_pass_backoff_base_ms), }) } @@ -205,6 +217,10 @@ impl MePool { drain_ttl_secs: u64, force_close_secs: u64, min_fresh_ratio: f32, + hardswap_warmup_delay_min_ms: u64, + hardswap_warmup_delay_max_ms: u64, + hardswap_warmup_extra_passes: u8, + hardswap_warmup_pass_backoff_base_ms: u64, ) { self.hardswap.store(hardswap, Ordering::Relaxed); self.me_pool_drain_ttl_secs.store(drain_ttl_secs, Ordering::Relaxed); @@ -212,6 +228,14 @@ impl MePool { .store(force_close_secs, Ordering::Relaxed); self.me_pool_min_fresh_ratio_permille .store(Self::ratio_to_permille(min_fresh_ratio), Ordering::Relaxed); + self.me_hardswap_warmup_delay_min_ms + .store(hardswap_warmup_delay_min_ms, Ordering::Relaxed); + self.me_hardswap_warmup_delay_max_ms + .store(hardswap_warmup_delay_max_ms, Ordering::Relaxed); + self.me_hardswap_warmup_extra_passes + .store(hardswap_warmup_extra_passes as u32, Ordering::Relaxed); + self.me_hardswap_warmup_pass_backoff_base_ms + .store(hardswap_warmup_pass_backoff_base_ms, Ordering::Relaxed); } pub fn reset_stun_state(&self) { @@ -330,6 +354,49 @@ impl MePool { endpoint_count.max(3) } + fn hardswap_warmup_connect_delay_ms(&self) -> u64 { + let min_ms = self + .me_hardswap_warmup_delay_min_ms + .load(Ordering::Relaxed); + let max_ms = self + .me_hardswap_warmup_delay_max_ms + .load(Ordering::Relaxed); + let (min_ms, max_ms) = if min_ms <= max_ms { + (min_ms, max_ms) + } else { + (max_ms, min_ms) + }; + if min_ms == max_ms { + return min_ms; + } + rand::rng().random_range(min_ms..=max_ms) + } + + fn hardswap_warmup_backoff_ms(&self, pass_idx: usize) -> u64 { + let base_ms = self + .me_hardswap_warmup_pass_backoff_base_ms + .load(Ordering::Relaxed); + let cap_ms = (self.me_reconnect_backoff_cap.as_millis() as u64).max(base_ms); + let shift = (pass_idx as u32).min(20); + let scaled = base_ms.saturating_mul(1u64 << shift); + let core = scaled.min(cap_ms); + let jitter = (core / 2).max(1); + core.saturating_add(rand::rng().random_range(0..=jitter)) + } + + async fn fresh_writer_count_for_endpoints( + &self, + generation: u64, + endpoints: &HashSet, + ) -> usize { + let ws = self.writers.read().await; + ws.iter() + .filter(|w| !w.draining.load(Ordering::Relaxed)) + .filter(|w| w.generation == generation) + .filter(|w| endpoints.contains(&w.addr)) + .count() + } + pub(super) async fn connect_endpoints_round_robin( self: &Arc, endpoints: &[SocketAddr], @@ -356,6 +423,12 @@ impl MePool { generation: u64, desired_by_dc: &HashMap>, ) { + let extra_passes = self + .me_hardswap_warmup_extra_passes + .load(Ordering::Relaxed) + .min(10) as usize; + let total_passes = 1 + extra_passes; + for (dc, endpoints) in desired_by_dc { if endpoints.is_empty() { continue; @@ -364,30 +437,85 @@ impl MePool { let mut endpoint_list: Vec = endpoints.iter().copied().collect(); endpoint_list.sort_unstable(); let required = Self::required_writers_for_dc(endpoint_list.len()); + let mut completed = false; + let mut last_fresh_count = self + .fresh_writer_count_for_endpoints(generation, endpoints) + .await; - loop { - let fresh_count = { - let ws = self.writers.read().await; - ws.iter() - .filter(|w| !w.draining.load(Ordering::Relaxed)) - .filter(|w| w.generation == generation) - .filter(|w| endpoints.contains(&w.addr)) - .count() - }; - if fresh_count >= required { + for pass_idx in 0..total_passes { + if last_fresh_count >= required { + completed = true; break; } - if !self.connect_endpoints_round_robin(&endpoint_list, rng).await { - warn!( + let missing = required.saturating_sub(last_fresh_count); + debug!( + dc = *dc, + pass = pass_idx + 1, + total_passes, + fresh_count = last_fresh_count, + required, + missing, + endpoint_count = endpoint_list.len(), + "ME hardswap warmup pass started" + ); + + for attempt_idx in 0..missing { + let delay_ms = self.hardswap_warmup_connect_delay_ms(); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + + let connected = self.connect_endpoints_round_robin(&endpoint_list, rng).await; + debug!( dc = *dc, - fresh_count, + pass = pass_idx + 1, + total_passes, + attempt = attempt_idx + 1, + delay_ms, + connected, + "ME hardswap warmup connect attempt finished" + ); + } + + last_fresh_count = self + .fresh_writer_count_for_endpoints(generation, endpoints) + .await; + if last_fresh_count >= required { + completed = true; + info!( + dc = *dc, + pass = pass_idx + 1, + total_passes, + fresh_count = last_fresh_count, required, - endpoint_count = endpoint_list.len(), - "ME warmup stopped: unable to reach required writer floor for DC" + "ME hardswap warmup floor reached for DC" ); break; } + + if pass_idx + 1 < total_passes { + let backoff_ms = self.hardswap_warmup_backoff_ms(pass_idx); + debug!( + dc = *dc, + pass = pass_idx + 1, + total_passes, + fresh_count = last_fresh_count, + required, + backoff_ms, + "ME hardswap warmup pass incomplete, delaying next pass" + ); + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + } + } + + if !completed { + warn!( + dc = *dc, + fresh_count = last_fresh_count, + required, + endpoint_count = endpoint_list.len(), + total_passes, + "ME warmup stopped: unable to reach required writer floor for DC" + ); } } } From 25ab79406ffd629961806a17ca6a48626d1f6b36 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:28:26 +0300 Subject: [PATCH 11/36] Update Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6bce323..994e11f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.0.14" +version = "3.1.0" edition = "2024" [dependencies] From 866c2fbd96b048ead4dde3688270d0c8785963ea Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:29:58 +0300 Subject: [PATCH 12/36] Update Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 994e11f..b6ef28d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.1.0" +version = "3.0.15" edition = "2024" [dependencies] From e9a42810157f5d84456ab45e3f0000ec0d0f0c4e Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:31:12 +0300 Subject: [PATCH 13/36] Delete proxy-secret Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- proxy-secret | 1 - 1 file changed, 1 deletion(-) delete mode 100644 proxy-secret diff --git a/proxy-secret b/proxy-secret deleted file mode 100644 index ef77163..0000000 --- a/proxy-secret +++ /dev/null @@ -1 +0,0 @@ -ʖxHl~,D0d]UJUAM'!FnRZD>ϳF>yZfa*ߜڋ o8zM:dq>\3w}n\TĐy'VIil&] \ No newline at end of file From c6c3d71b08954cc0279fd23d40a7fec08a01c072 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:26:01 +0300 Subject: [PATCH 14/36] ME Pool Flap-Detect in statistics --- src/metrics.rs | 93 ++++++++++++++++++++++++++++++ src/stats/mod.rs | 49 ++++++++++++++++ src/transport/middle_proxy/pool.rs | 10 ++++ 3 files changed, 152 insertions(+) diff --git a/src/metrics.rs b/src/metrics.rs index 53ddd5d..0051858 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -199,6 +199,95 @@ fn render_metrics(stats: &Stats) -> String { stats.get_pool_stale_pick_total() ); + let _ = writeln!(out, "# HELP telemt_me_writer_removed_total Total ME writer removals"); + let _ = writeln!(out, "# TYPE telemt_me_writer_removed_total counter"); + let _ = writeln!( + out, + "telemt_me_writer_removed_total {}", + stats.get_me_writer_removed_total() + ); + + let _ = writeln!( + out, + "# HELP telemt_me_writer_removed_unexpected_total Unexpected ME writer removals that triggered refill" + ); + let _ = writeln!(out, "# TYPE telemt_me_writer_removed_unexpected_total counter"); + let _ = writeln!( + out, + "telemt_me_writer_removed_unexpected_total {}", + stats.get_me_writer_removed_unexpected_total() + ); + + let _ = writeln!(out, "# HELP telemt_me_refill_triggered_total Immediate ME refill runs started"); + let _ = writeln!(out, "# TYPE telemt_me_refill_triggered_total counter"); + let _ = writeln!( + out, + "telemt_me_refill_triggered_total {}", + stats.get_me_refill_triggered_total() + ); + + let _ = writeln!( + out, + "# HELP telemt_me_refill_skipped_inflight_total Immediate ME refill skips due to inflight dedup" + ); + let _ = writeln!(out, "# TYPE telemt_me_refill_skipped_inflight_total counter"); + let _ = writeln!( + out, + "telemt_me_refill_skipped_inflight_total {}", + stats.get_me_refill_skipped_inflight_total() + ); + + let _ = writeln!(out, "# HELP telemt_me_refill_failed_total Immediate ME refill failures"); + let _ = writeln!(out, "# TYPE telemt_me_refill_failed_total counter"); + let _ = writeln!( + out, + "telemt_me_refill_failed_total {}", + stats.get_me_refill_failed_total() + ); + + let _ = writeln!( + out, + "# HELP telemt_me_writer_restored_same_endpoint_total Refilled ME writer restored on the same endpoint" + ); + let _ = writeln!(out, "# TYPE telemt_me_writer_restored_same_endpoint_total counter"); + let _ = writeln!( + out, + "telemt_me_writer_restored_same_endpoint_total {}", + stats.get_me_writer_restored_same_endpoint_total() + ); + + let _ = writeln!( + out, + "# HELP telemt_me_writer_restored_fallback_total Refilled ME writer restored via fallback endpoint" + ); + let _ = writeln!(out, "# TYPE telemt_me_writer_restored_fallback_total counter"); + let _ = writeln!( + out, + "telemt_me_writer_restored_fallback_total {}", + stats.get_me_writer_restored_fallback_total() + ); + + let unresolved_writer_losses = stats + .get_me_writer_removed_unexpected_total() + .saturating_sub( + stats + .get_me_writer_restored_same_endpoint_total() + .saturating_add(stats.get_me_writer_restored_fallback_total()), + ); + let _ = writeln!( + out, + "# HELP telemt_me_writer_removed_unexpected_minus_restored_total Unexpected writer removals not yet compensated by restore" + ); + let _ = writeln!( + out, + "# TYPE telemt_me_writer_removed_unexpected_minus_restored_total gauge" + ); + let _ = writeln!( + out, + "telemt_me_writer_removed_unexpected_minus_restored_total {}", + unresolved_writer_losses + ); + let _ = writeln!(out, "# HELP telemt_user_connections_total Per-user total connections"); let _ = writeln!(out, "# TYPE telemt_user_connections_total counter"); let _ = writeln!(out, "# HELP telemt_user_connections_current Per-user active connections"); @@ -277,6 +366,10 @@ mod tests { assert!(output.contains("# TYPE telemt_connections_total counter")); assert!(output.contains("# TYPE telemt_connections_bad_total counter")); assert!(output.contains("# TYPE telemt_handshake_timeouts_total counter")); + assert!(output.contains("# TYPE telemt_me_writer_removed_total counter")); + assert!(output.contains( + "# TYPE telemt_me_writer_removed_unexpected_minus_restored_total gauge" + )); } #[tokio::test] diff --git a/src/stats/mod.rs b/src/stats/mod.rs index a58996d..5f4c98e 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -43,6 +43,13 @@ pub struct Stats { pool_drain_active: AtomicU64, pool_force_close_total: AtomicU64, pool_stale_pick_total: AtomicU64, + me_writer_removed_total: AtomicU64, + me_writer_removed_unexpected_total: AtomicU64, + me_refill_triggered_total: AtomicU64, + me_refill_skipped_inflight_total: AtomicU64, + me_refill_failed_total: AtomicU64, + me_writer_restored_same_endpoint_total: AtomicU64, + me_writer_restored_fallback_total: AtomicU64, user_stats: DashMap, start_time: parking_lot::RwLock>, } @@ -142,6 +149,27 @@ impl Stats { pub fn increment_pool_stale_pick_total(&self) { self.pool_stale_pick_total.fetch_add(1, Ordering::Relaxed); } + pub fn increment_me_writer_removed_total(&self) { + self.me_writer_removed_total.fetch_add(1, Ordering::Relaxed); + } + pub fn increment_me_writer_removed_unexpected_total(&self) { + self.me_writer_removed_unexpected_total.fetch_add(1, Ordering::Relaxed); + } + pub fn increment_me_refill_triggered_total(&self) { + self.me_refill_triggered_total.fetch_add(1, Ordering::Relaxed); + } + pub fn increment_me_refill_skipped_inflight_total(&self) { + self.me_refill_skipped_inflight_total.fetch_add(1, Ordering::Relaxed); + } + pub fn increment_me_refill_failed_total(&self) { + self.me_refill_failed_total.fetch_add(1, Ordering::Relaxed); + } + pub fn increment_me_writer_restored_same_endpoint_total(&self) { + self.me_writer_restored_same_endpoint_total.fetch_add(1, Ordering::Relaxed); + } + pub fn increment_me_writer_restored_fallback_total(&self) { + self.me_writer_restored_fallback_total.fetch_add(1, Ordering::Relaxed); + } pub fn get_connects_all(&self) -> u64 { self.connects_all.load(Ordering::Relaxed) } pub fn get_connects_bad(&self) -> u64 { self.connects_bad.load(Ordering::Relaxed) } pub fn get_me_keepalive_sent(&self) -> u64 { self.me_keepalive_sent.load(Ordering::Relaxed) } @@ -195,6 +223,27 @@ impl Stats { pub fn get_pool_stale_pick_total(&self) -> u64 { self.pool_stale_pick_total.load(Ordering::Relaxed) } + pub fn get_me_writer_removed_total(&self) -> u64 { + self.me_writer_removed_total.load(Ordering::Relaxed) + } + pub fn get_me_writer_removed_unexpected_total(&self) -> u64 { + self.me_writer_removed_unexpected_total.load(Ordering::Relaxed) + } + pub fn get_me_refill_triggered_total(&self) -> u64 { + self.me_refill_triggered_total.load(Ordering::Relaxed) + } + pub fn get_me_refill_skipped_inflight_total(&self) -> u64 { + self.me_refill_skipped_inflight_total.load(Ordering::Relaxed) + } + pub fn get_me_refill_failed_total(&self) -> u64 { + self.me_refill_failed_total.load(Ordering::Relaxed) + } + pub fn get_me_writer_restored_same_endpoint_total(&self) -> u64 { + self.me_writer_restored_same_endpoint_total.load(Ordering::Relaxed) + } + pub fn get_me_writer_restored_fallback_total(&self) -> u64 { + self.me_writer_restored_fallback_total.load(Ordering::Relaxed) + } pub fn increment_user_connects(&self, user: &str) { self.user_stats.entry(user.to_string()).or_default() diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index aa14e5b..e5aebe4 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -708,6 +708,7 @@ impl MePool { match self.connect_one(addr, self.rng.as_ref()).await { Ok(()) => { self.stats.increment_me_reconnect_success(); + self.stats.increment_me_writer_restored_same_endpoint_total(); info!( %addr, attempt = attempt + 1, @@ -728,6 +729,7 @@ impl MePool { let dc_endpoints = self.endpoints_for_same_dc(addr).await; if dc_endpoints.is_empty() { + self.stats.increment_me_refill_failed_total(); return false; } @@ -738,6 +740,7 @@ impl MePool { .await { self.stats.increment_me_reconnect_success(); + self.stats.increment_me_writer_restored_fallback_total(); info!( %addr, attempt = attempt + 1, @@ -747,6 +750,7 @@ impl MePool { } } + self.stats.increment_me_refill_failed_total(); false } @@ -756,9 +760,11 @@ impl MePool { { let mut guard = pool.refill_inflight.lock().await; if !guard.insert(addr) { + pool.stats.increment_me_refill_skipped_inflight_total(); return; } } + pool.stats.increment_me_refill_triggered_total(); let restored = pool.refill_writer_after_loss(addr).await; if !restored { @@ -1189,9 +1195,13 @@ impl MePool { if was_draining { self.stats.decrement_pool_drain_active(); } + self.stats.increment_me_writer_removed_total(); w.cancel.cancel(); removed_addr = Some(w.addr); trigger_refill = !was_draining; + if trigger_refill { + self.stats.increment_me_writer_removed_unexpected_total(); + } close_tx = Some(w.tx.clone()); self.conn_count.fetch_sub(1, Ordering::Relaxed); } From 53ec96b04029c7e2406e52095ab5317662a2ad80 Mon Sep 17 00:00:00 2001 From: Dimasssss Date: Wed, 25 Feb 2026 01:37:55 +0300 Subject: [PATCH 15/36] Update config.toml --- config.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.toml b/config.toml index c1dc73f..f3df049 100644 --- a/config.toml +++ b/config.toml @@ -49,6 +49,10 @@ desync_all_full = false # Emit full crypto-desync forensic log auto_degradation_enabled = true # Enable auto-degradation from ME to Direct-DC. degradation_min_unavailable_dc_groups = 2 # Minimum unavailable ME DC groups before degrading. hardswap = true # Enable C-like hard-swap for ME pool generations. When true, Telemt prewarms a new generation and switches once full coverage is reached. +default_me_hardswap_warmup_delay_min_ms = 1000 # Minimum delay in ms between hardswap warmup connect attempts. +default_me_hardswap_warmup_delay_max_ms = 2000 # Maximum delay in ms between hardswap warmup connect attempts. +default_me_hardswap_warmup_extra_passes = 3 # Additional warmup passes in the same hardswap cycle after the base pass. +default_me_hardswap_warmup_pass_backoff_base_ms = 500 # Base backoff in ms between hardswap warmup passes when floor is still incomplete. me_pool_drain_ttl_secs = 90 # 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. me_pool_min_fresh_ratio = 0.8 # Minimum desired-DC coverage ratio required before draining stale writers. Range: 0.0..=1.0. me_reinit_drain_timeout_secs = 120 # Drain timeout in seconds for stale ME writers after endpoint map changes. Set to 0 to keep stale writers draining indefinitely (no force-close). @@ -57,6 +61,7 @@ me_config_apply_cooldown_secs = 300 # Cooldown in seconds between applied proxy_secret_rotate_runtime = true # Enable runtime proxy-secret rotation from getProxySecret. proxy_secret_stable_snapshots = 2 # Number of identical getProxySecret snapshots required before runtime secret rotation. proxy_secret_len_max = 256 # Maximum allowed proxy-secret length in bytes for startup and runtime refresh. +default_me_reinit_every_secs = 900 # Periodic ME pool reinitialization interval in seconds. [general.modes] classic = false From 6efcbe9bbf1da8ce5da903aac15c85663d2c5bbf Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:05:32 +0300 Subject: [PATCH 16/36] Update README.md --- README.md | 45 ++++++++++++--------------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8d0c41a..cba2a9d 100644 --- a/README.md +++ b/README.md @@ -10,28 +10,18 @@ ### 🇷🇺 RU -#### Драфтинг LTS и текущие улучшения +#### Релиз 3.0.15 — 25 февраля -С 21 февраля мы начали подготовку LTS-версии. +25 февраля мы выпустили версию **3.0.15**. -Мы внимательно анализируем весь доступный фидбек. -Наша цель — сделать LTS-кандидаты максимально стабильными, тщательно отлаженными и готовыми к long-run и highload production-сценариям. +Мы предполагаем, что она станет завершающей версией поколения 3.0 и уже сейчас мы рассматриваем её как **LTS-кандидата** для версии **3.1.0**! ---- +После нескольких дней детального анализа особенностей работы Middle-End мы спроектировали и реализовали продуманный режим **ротации ME Writer**. Данный режим позволяет поддерживать стабильно высокую производительность в long-run сценариях без возникновения ошибок, связанных с некорректной конфигурацией прокси. -#### Улучшения от 23 февраля - -23 февраля были внесены улучшения производительности в режимах **DC** и **Middle-End (ME)**, с акцентом на обратный канал (путь клиент → DC / ME). - -Дополнительно реализован ряд изменений, направленных на повышение устойчивости системы: - -- Смягчение сетевой нестабильности -- Повышение устойчивости к десинхронизации криптографии -- Снижение дрейфа сессий при неблагоприятных условиях -- Улучшение обработки ошибок в edge-case транспортных сценариях +Будем рады вашему фидбеку и предложениям по улучшению — особенно в части **статистики** и **UX**. Релиз: -[3.0.12](https://github.com/telemt/telemt/releases/tag/3.0.12) +[3.0.15](https://github.com/telemt/telemt/releases/tag/3.0.15) --- @@ -48,28 +38,17 @@ ### 🇬🇧 EN -#### LTS Drafting and Ongoing Improvements +#### Release 3.0.15 — February 25 -Starting February 21, we began drafting the upcoming LTS version. +On February 25, we released version **3.0.15**. -We are carefully reviewing and analyzing all available feedback. -The goal is to ensure that LTS candidates are максимально stable, thoroughly debugged, and ready for long-run and high-load production scenarios. +We expect this to become the final release of the 3.0 generation and at this point, we already see it as a strong **LTS candidate** for the upcoming **3.1.0** release! ---- - -#### February 23 Improvements - -On February 23, we introduced performance improvements for both **DC** and **Middle-End (ME)** modes, specifically optimizing the reverse channel (client → DC / ME data path). - -Additionally, we implemented a set of robustness enhancements designed to: - -- Mitigate network-related instability -- Improve resilience against cryptographic desynchronization -- Reduce session drift under adverse conditions -- Improve error handling in edge-case transport scenarios +After several days of deep analysis of Middle-End behavior, we designed and implemented a well-engineered **ME Writer rotation mode**. This mode enables sustained high throughput in long-run scenarios while preventing proxy misconfiguration errors. +We are looking forward to your feedback and improvement proposals — especially regarding **statistics** and **UX**. Release: -[3.0.12](https://github.com/telemt/telemt/releases/tag/3.0.12) +[3.0.15](https://github.com/telemt/telemt/releases/tag/3.0.15) --- From 16f166cec8285df6552becadd20f24b6090b7b86 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:07:58 +0300 Subject: [PATCH 17/36] Update README.md --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cba2a9d..192cd00 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ #### Релиз 3.0.15 — 25 февраля -25 февраля мы выпустили версию **3.0.15**. +25 февраля мы выпустили версию **3.0.15** Мы предполагаем, что она станет завершающей версией поколения 3.0 и уже сейчас мы рассматриваем её как **LTS-кандидата** для версии **3.1.0**! -После нескольких дней детального анализа особенностей работы Middle-End мы спроектировали и реализовали продуманный режим **ротации ME Writer**. Данный режим позволяет поддерживать стабильно высокую производительность в long-run сценариях без возникновения ошибок, связанных с некорректной конфигурацией прокси. +После нескольких дней детального анализа особенностей работы Middle-End мы спроектировали и реализовали продуманный режим **ротации ME Writer**. Данный режим позволяет поддерживать стабильно высокую производительность в long-run сценариях без возникновения ошибок, связанных с некорректной конфигурацией прокси -Будем рады вашему фидбеку и предложениям по улучшению — особенно в части **статистики** и **UX**. +Будем рады вашему фидбеку и предложениям по улучшению — особенно в части **статистики** и **UX** Релиз: [3.0.15](https://github.com/telemt/telemt/releases/tag/3.0.15) @@ -40,13 +40,14 @@ #### Release 3.0.15 — February 25 -On February 25, we released version **3.0.15**. +On February 25, we released version **3.0.15** We expect this to become the final release of the 3.0 generation and at this point, we already see it as a strong **LTS candidate** for the upcoming **3.1.0** release! -After several days of deep analysis of Middle-End behavior, we designed and implemented a well-engineered **ME Writer rotation mode**. This mode enables sustained high throughput in long-run scenarios while preventing proxy misconfiguration errors. +After several days of deep analysis of Middle-End behavior, we designed and implemented a well-engineered **ME Writer rotation mode**. This mode enables sustained high throughput in long-run scenarios while preventing proxy misconfiguration errors + +We are looking forward to your feedback and improvement proposals — especially regarding **statistics** and **UX** -We are looking forward to your feedback and improvement proposals — especially regarding **statistics** and **UX**. Release: [3.0.15](https://github.com/telemt/telemt/releases/tag/3.0.15) From 618b7a183797978c555482f82bcb2e77be5be08a Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:10:14 +0300 Subject: [PATCH 18/36] ME Pool Beobachter --- src/config/defaults.rs | 12 ++++ src/config/load.rs | 18 +++++ src/config/types.rs | 20 ++++++ src/main.rs | 42 ++++++++++- src/metrics.rs | 84 ++++++++++++++++++---- src/proxy/client.rs | 154 ++++++++++++++++++++++++++++++++++++++--- src/proxy/masking.rs | 12 +++- src/stats/mod.rs | 2 + 8 files changed, 318 insertions(+), 26 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index d43ace9..80fcc07 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -121,6 +121,18 @@ pub(crate) fn default_desync_all_full() -> bool { false } +pub(crate) fn default_beobachten_minutes() -> u64 { + 10 +} + +pub(crate) fn default_beobachten_flush_secs() -> u64 { + 15 +} + +pub(crate) fn default_beobachten_file() -> String { + "cache/beobachten.txt".to_string() +} + pub(crate) fn default_tls_new_session_tickets() -> u8 { 0 } diff --git a/src/config/load.rs b/src/config/load.rs index 5698a71..aab553f 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -153,6 +153,24 @@ impl ProxyConfig { )); } + 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(), diff --git a/src/config/types.rs b/src/config/types.rs index 0cda9f4..cfa8d31 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -206,6 +206,22 @@ pub struct GeneralConfig { #[serde(default = "default_desync_all_full")] pub desync_all_full: bool, + /// Enable per-IP forensic observation buckets for scanners and handshake failures. + #[serde(default)] + 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")] @@ -383,6 +399,10 @@ impl Default for GeneralConfig { crypto_pending_buffer: default_crypto_pending_buffer(), max_client_frame: default_max_client_frame(), desync_all_full: default_desync_all_full(), + beobachten: false, + 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: Some(default_update_every_secs()), diff --git a/src/main.rs b/src/main.rs index 3bcbf3e..ab524a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ use crate::crypto::SecureRandom; use crate::ip_tracker::UserIpTracker; use crate::network::probe::{decide_network_capabilities, log_probe_result, run_probe}; use crate::proxy::ClientHandler; +use crate::stats::beobachten::BeobachtenStore; use crate::stats::{ReplayChecker, Stats}; use crate::stream::BufferPool; use crate::transport::middle_proxy::{ @@ -159,6 +160,15 @@ fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) { info!(target: "telemt::links", "------------------------"); } +async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> { + if let Some(parent) = std::path::Path::new(path).parent() + && !parent.as_os_str().is_empty() + { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, payload).await +} + #[tokio::main] async fn main() -> std::result::Result<(), Box> { let (config_path, cli_silent, cli_log_level) = parse_cli(); @@ -256,6 +266,7 @@ async fn main() -> std::result::Result<(), Box> { let prefer_ipv6 = decision.prefer_ipv6(); let mut use_middle_proxy = config.general.use_middle_proxy && (decision.ipv4_me || decision.ipv6_me); let stats = Arc::new(Stats::new()); + let beobachten = Arc::new(BeobachtenStore::new()); let rng = Arc::new(SecureRandom::new()); // IP Tracker initialization @@ -692,6 +703,26 @@ async fn main() -> std::result::Result<(), Box> { detected_ip_v6, ); + let beobachten_writer = beobachten.clone(); + let config_rx_beobachten = config_rx.clone(); + tokio::spawn(async move { + loop { + let cfg = config_rx_beobachten.borrow().clone(); + let sleep_secs = cfg.general.beobachten_flush_secs.max(1); + + if cfg.general.beobachten { + let ttl = Duration::from_secs(cfg.general.beobachten_minutes.saturating_mul(60)); + let path = cfg.general.beobachten_file.clone(); + let snapshot = beobachten_writer.snapshot_text(ttl); + if let Err(e) = write_beobachten_snapshot(&path, &snapshot).await { + warn!(error = %e, path = %path, "Failed to flush beobachten snapshot"); + } + } + + tokio::time::sleep(Duration::from_secs(sleep_secs)).await; + } + }); + if let Some(ref pool) = me_pool { let pool_clone = pool.clone(); let rng_clone = rng.clone(); @@ -860,6 +891,7 @@ async fn main() -> std::result::Result<(), Box> { let me_pool = me_pool.clone(); let tls_cache = tls_cache.clone(); let ip_tracker = ip_tracker.clone(); + let beobachten = beobachten.clone(); let max_connections_unix = max_connections.clone(); tokio::spawn(async move { @@ -887,6 +919,7 @@ async fn main() -> std::result::Result<(), Box> { let me_pool = me_pool.clone(); let tls_cache = tls_cache.clone(); let ip_tracker = ip_tracker.clone(); + let beobachten = beobachten.clone(); let proxy_protocol_enabled = config.server.proxy_protocol; tokio::spawn(async move { @@ -894,7 +927,7 @@ async fn main() -> std::result::Result<(), Box> { if let Err(e) = crate::proxy::client::handle_client_stream( stream, fake_peer, config, stats, upstream_manager, replay_checker, buffer_pool, rng, - me_pool, tls_cache, ip_tracker, proxy_protocol_enabled, + me_pool, tls_cache, ip_tracker, beobachten, proxy_protocol_enabled, ).await { debug!(error = %e, "Unix socket connection error"); } @@ -942,9 +975,11 @@ async fn main() -> std::result::Result<(), Box> { if let Some(port) = config.server.metrics_port { let stats = stats.clone(); + let beobachten = beobachten.clone(); + let config_rx_metrics = config_rx.clone(); let whitelist = config.server.metrics_whitelist.clone(); tokio::spawn(async move { - metrics::serve(port, stats, whitelist).await; + metrics::serve(port, stats, beobachten, config_rx_metrics, whitelist).await; }); } @@ -958,6 +993,7 @@ async fn main() -> std::result::Result<(), Box> { let me_pool = me_pool.clone(); let tls_cache = tls_cache.clone(); let ip_tracker = ip_tracker.clone(); + let beobachten = beobachten.clone(); let max_connections_tcp = max_connections.clone(); tokio::spawn(async move { @@ -980,6 +1016,7 @@ async fn main() -> std::result::Result<(), Box> { let me_pool = me_pool.clone(); let tls_cache = tls_cache.clone(); let ip_tracker = ip_tracker.clone(); + let beobachten = beobachten.clone(); let proxy_protocol_enabled = listener_proxy_protocol; tokio::spawn(async move { @@ -996,6 +1033,7 @@ async fn main() -> std::result::Result<(), Box> { me_pool, tls_cache, ip_tracker, + beobachten, proxy_protocol_enabled, ) .run() diff --git a/src/metrics.rs b/src/metrics.rs index 0051858..08abb2d 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,6 +1,7 @@ use std::convert::Infallible; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use http_body_util::Full; use hyper::body::Bytes; @@ -11,9 +12,17 @@ use ipnetwork::IpNetwork; use tokio::net::TcpListener; use tracing::{info, warn, debug}; +use crate::config::ProxyConfig; +use crate::stats::beobachten::BeobachtenStore; use crate::stats::Stats; -pub async fn serve(port: u16, stats: Arc, whitelist: Vec) { +pub async fn serve( + port: u16, + stats: Arc, + beobachten: Arc, + config_rx: tokio::sync::watch::Receiver>, + whitelist: Vec, +) { let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = match TcpListener::bind(addr).await { Ok(l) => l, @@ -22,7 +31,7 @@ pub async fn serve(port: u16, stats: Arc, whitelist: Vec) { return; } }; - info!("Metrics endpoint: http://{}/metrics", addr); + info!("Metrics endpoint: http://{}/metrics and /beobachten", addr); loop { let (stream, peer) = match listener.accept().await { @@ -39,10 +48,14 @@ pub async fn serve(port: u16, stats: Arc, whitelist: Vec) { } let stats = stats.clone(); + let beobachten = beobachten.clone(); + let config_rx_conn = config_rx.clone(); tokio::spawn(async move { let svc = service_fn(move |req| { let stats = stats.clone(); - async move { handle(req, &stats) } + let beobachten = beobachten.clone(); + let config = config_rx_conn.borrow().clone(); + async move { handle(req, &stats, &beobachten, &config) } }); if let Err(e) = http1::Builder::new() .serve_connection(hyper_util::rt::TokioIo::new(stream), svc) @@ -54,24 +67,48 @@ pub async fn serve(port: u16, stats: Arc, whitelist: Vec) { } } -fn handle(req: Request, stats: &Stats) -> Result>, Infallible> { - if req.uri().path() != "/metrics" { +fn handle( + req: Request, + stats: &Stats, + beobachten: &BeobachtenStore, + config: &ProxyConfig, +) -> Result>, Infallible> { + if req.uri().path() == "/metrics" { + let body = render_metrics(stats); let resp = Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("Not Found\n"))) + .status(StatusCode::OK) + .header("content-type", "text/plain; version=0.0.4; charset=utf-8") + .body(Full::new(Bytes::from(body))) + .unwrap(); + return Ok(resp); + } + + if req.uri().path() == "/beobachten" { + let body = render_beobachten(beobachten, config); + let resp = Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/plain; charset=utf-8") + .body(Full::new(Bytes::from(body))) .unwrap(); return Ok(resp); } - let body = render_metrics(stats); let resp = Response::builder() - .status(StatusCode::OK) - .header("content-type", "text/plain; version=0.0.4; charset=utf-8") - .body(Full::new(Bytes::from(body))) + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found\n"))) .unwrap(); Ok(resp) } +fn render_beobachten(beobachten: &BeobachtenStore, config: &ProxyConfig) -> String { + if !config.general.beobachten { + return "beobachten disabled\n".to_string(); + } + + let ttl = Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60)); + beobachten.snapshot_text(ttl) +} + fn render_metrics(stats: &Stats) -> String { use std::fmt::Write; let mut out = String::with_capacity(4096); @@ -318,6 +355,7 @@ fn render_metrics(stats: &Stats) -> String { #[cfg(test)] mod tests { use super::*; + use std::net::IpAddr; use http_body_util::BodyExt; #[test] @@ -375,6 +413,8 @@ mod tests { #[tokio::test] async fn test_endpoint_integration() { let stats = Arc::new(Stats::new()); + let beobachten = Arc::new(BeobachtenStore::new()); + let mut config = ProxyConfig::default(); stats.increment_connects_all(); stats.increment_connects_all(); stats.increment_connects_all(); @@ -383,16 +423,34 @@ mod tests { .uri("/metrics") .body(()) .unwrap(); - let resp = handle(req, &stats).unwrap(); + let resp = handle(req, &stats, &beobachten, &config).unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); assert!(std::str::from_utf8(body.as_ref()).unwrap().contains("telemt_connections_total 3")); + config.general.beobachten = true; + config.general.beobachten_minutes = 10; + beobachten.record( + "TLS-scanner", + "203.0.113.10".parse::().unwrap(), + Duration::from_secs(600), + ); + let req_beob = Request::builder() + .uri("/beobachten") + .body(()) + .unwrap(); + let resp_beob = handle(req_beob, &stats, &beobachten, &config).unwrap(); + assert_eq!(resp_beob.status(), StatusCode::OK); + let body_beob = resp_beob.into_body().collect().await.unwrap().to_bytes(); + let beob_text = std::str::from_utf8(body_beob.as_ref()).unwrap(); + assert!(beob_text.contains("[TLS-scanner]")); + assert!(beob_text.contains("203.0.113.10-1")); + let req404 = Request::builder() .uri("/other") .body(()) .unwrap(); - let resp404 = handle(req404, &stats).unwrap(); + let resp404 = handle(req404, &stats, &beobachten, &config).unwrap(); assert_eq!(resp404.status(), StatusCode::NOT_FOUND); } } diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 483f6e0..c598023 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -1,7 +1,7 @@ //! Client Handler use std::future::Future; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -27,6 +27,7 @@ use crate::error::{HandshakeResult, ProxyError, Result}; use crate::ip_tracker::UserIpTracker; use crate::protocol::constants::*; use crate::protocol::tls; +use crate::stats::beobachten::BeobachtenStore; use crate::stats::{ReplayChecker, Stats}; use crate::stream::{BufferPool, CryptoReader, CryptoWriter}; use crate::transport::middle_proxy::MePool; @@ -39,6 +40,36 @@ use crate::proxy::handshake::{HandshakeSuccess, handle_mtproto_handshake, handle use crate::proxy::masking::handle_bad_client; use crate::proxy::middle_relay::handle_via_middle_proxy; +fn beobachten_ttl(config: &ProxyConfig) -> Duration { + Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60)) +} + +fn record_beobachten_class( + beobachten: &BeobachtenStore, + config: &ProxyConfig, + peer_ip: IpAddr, + class: &str, +) { + if !config.general.beobachten { + return; + } + beobachten.record(class, peer_ip, beobachten_ttl(config)); +} + +fn record_handshake_failure_class( + beobachten: &BeobachtenStore, + config: &ProxyConfig, + peer_ip: IpAddr, + error: &ProxyError, +) { + let class = if error.to_string().contains("expected 64 bytes, got 0") { + "expected_64_got_0" + } else { + "other" + }; + record_beobachten_class(beobachten, config, peer_ip, class); +} + pub async fn handle_client_stream( mut stream: S, peer: SocketAddr, @@ -51,6 +82,7 @@ pub async fn handle_client_stream( me_pool: Option>, tls_cache: Option>, ip_tracker: Arc, + beobachten: Arc, proxy_protocol_enabled: bool, ) -> Result<()> where @@ -73,6 +105,7 @@ where Err(e) => { stats.increment_connects_bad(); warn!(peer = %peer, error = %e, "Invalid PROXY protocol header"); + record_beobachten_class(&beobachten, &config, peer.ip(), "other"); return Err(e); } } @@ -82,6 +115,9 @@ where let handshake_timeout = Duration::from_secs(config.timeouts.client_handshake); let stats_for_timeout = stats.clone(); + let config_for_timeout = config.clone(); + let beobachten_for_timeout = beobachten.clone(); + let peer_for_timeout = real_peer.ip(); // For non-TCP streams, use a synthetic local address let local_addr: SocketAddr = format!("0.0.0.0:{}", config.server.port) @@ -103,7 +139,15 @@ where debug!(peer = %real_peer, tls_len = tls_len, "TLS handshake too short"); stats.increment_connects_bad(); let (reader, writer) = tokio::io::split(stream); - handle_bad_client(reader, writer, &first_bytes, &config).await; + handle_bad_client( + reader, + writer, + &first_bytes, + real_peer.ip(), + &config, + &beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } @@ -120,7 +164,15 @@ where HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { stats.increment_connects_bad(); - handle_bad_client(reader, writer, &handshake, &config).await; + handle_bad_client( + reader, + writer, + &handshake, + real_peer.ip(), + &config, + &beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } HandshakeResult::Error(e) => return Err(e), @@ -156,7 +208,15 @@ where debug!(peer = %real_peer, "Non-TLS modes disabled"); stats.increment_connects_bad(); let (reader, writer) = tokio::io::split(stream); - handle_bad_client(reader, writer, &first_bytes, &config).await; + handle_bad_client( + reader, + writer, + &first_bytes, + real_peer.ip(), + &config, + &beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } @@ -173,7 +233,15 @@ where HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { stats.increment_connects_bad(); - handle_bad_client(reader, writer, &handshake, &config).await; + handle_bad_client( + reader, + writer, + &handshake, + real_peer.ip(), + &config, + &beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } HandshakeResult::Error(e) => return Err(e), @@ -200,11 +268,23 @@ where Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { debug!(peer = %peer, error = %e, "Handshake failed"); + record_handshake_failure_class( + &beobachten_for_timeout, + &config_for_timeout, + peer_for_timeout, + &e, + ); return Err(e); } Err(_) => { stats_for_timeout.increment_handshake_timeouts(); debug!(peer = %peer, "Handshake timeout"); + record_beobachten_class( + &beobachten_for_timeout, + &config_for_timeout, + peer_for_timeout, + "other", + ); return Err(ProxyError::TgHandshakeTimeout); } }; @@ -230,6 +310,7 @@ pub struct RunningClientHandler { me_pool: Option>, tls_cache: Option>, ip_tracker: Arc, + beobachten: Arc, proxy_protocol_enabled: bool, } @@ -246,6 +327,7 @@ impl ClientHandler { me_pool: Option>, tls_cache: Option>, ip_tracker: Arc, + beobachten: Arc, proxy_protocol_enabled: bool, ) -> RunningClientHandler { RunningClientHandler { @@ -260,6 +342,7 @@ impl ClientHandler { me_pool, tls_cache, ip_tracker, + beobachten, proxy_protocol_enabled, } } @@ -284,17 +367,32 @@ impl RunningClientHandler { let handshake_timeout = Duration::from_secs(self.config.timeouts.client_handshake); let stats = self.stats.clone(); + let config_for_timeout = self.config.clone(); + let beobachten_for_timeout = self.beobachten.clone(); + let peer_for_timeout = peer.ip(); // Phase 1: handshake (with timeout) let outcome = match timeout(handshake_timeout, self.do_handshake()).await { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { debug!(peer = %peer, error = %e, "Handshake failed"); + record_handshake_failure_class( + &beobachten_for_timeout, + &config_for_timeout, + peer_for_timeout, + &e, + ); return Err(e); } Err(_) => { stats.increment_handshake_timeouts(); debug!(peer = %peer, "Handshake timeout"); + record_beobachten_class( + &beobachten_for_timeout, + &config_for_timeout, + peer_for_timeout, + "other", + ); return Err(ProxyError::TgHandshakeTimeout); } }; @@ -321,6 +419,12 @@ impl RunningClientHandler { Err(e) => { self.stats.increment_connects_bad(); warn!(peer = %self.peer, error = %e, "Invalid PROXY protocol header"); + record_beobachten_class( + &self.beobachten, + &self.config, + self.peer.ip(), + "other", + ); return Err(e); } } @@ -354,7 +458,15 @@ impl RunningClientHandler { debug!(peer = %peer, tls_len = tls_len, "TLS handshake too short"); self.stats.increment_connects_bad(); let (reader, writer) = self.stream.into_split(); - handle_bad_client(reader, writer, &first_bytes, &self.config).await; + handle_bad_client( + reader, + writer, + &first_bytes, + peer.ip(), + &self.config, + &self.beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } @@ -385,7 +497,15 @@ impl RunningClientHandler { HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { stats.increment_connects_bad(); - handle_bad_client(reader, writer, &handshake, &config).await; + handle_bad_client( + reader, + writer, + &handshake, + peer.ip(), + &config, + &self.beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } HandshakeResult::Error(e) => return Err(e), @@ -446,7 +566,15 @@ impl RunningClientHandler { debug!(peer = %peer, "Non-TLS modes disabled"); self.stats.increment_connects_bad(); let (reader, writer) = self.stream.into_split(); - handle_bad_client(reader, writer, &first_bytes, &self.config).await; + handle_bad_client( + reader, + writer, + &first_bytes, + peer.ip(), + &self.config, + &self.beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } @@ -476,7 +604,15 @@ impl RunningClientHandler { HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { stats.increment_connects_bad(); - handle_bad_client(reader, writer, &handshake, &config).await; + handle_bad_client( + reader, + writer, + &handshake, + peer.ip(), + &config, + &self.beobachten, + ) + .await; return Ok(HandshakeOutcome::Handled); } HandshakeResult::Error(e) => return Err(e), diff --git a/src/proxy/masking.rs b/src/proxy/masking.rs index 72175fe..cdb6cf9 100644 --- a/src/proxy/masking.rs +++ b/src/proxy/masking.rs @@ -1,6 +1,7 @@ //! Masking - forward unrecognized traffic to mask host use std::str; +use std::net::IpAddr; use std::time::Duration; use tokio::net::TcpStream; #[cfg(unix)] @@ -9,6 +10,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt}; use tokio::time::timeout; use tracing::debug; use crate::config::ProxyConfig; +use crate::stats::beobachten::BeobachtenStore; const MASK_TIMEOUT: Duration = Duration::from_secs(5); /// Maximum duration for the entire masking relay. @@ -50,20 +52,26 @@ pub async fn handle_bad_client( reader: R, writer: W, initial_data: &[u8], + peer_ip: IpAddr, config: &ProxyConfig, + beobachten: &BeobachtenStore, ) where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { + let client_type = detect_client_type(initial_data); + if config.general.beobachten { + let ttl = Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60)); + beobachten.record(client_type, peer_ip, ttl); + } + if !config.censorship.mask { // Masking disabled, just consume data consume_client_data(reader).await; return; } - let client_type = detect_client_type(initial_data); - // Connect via Unix socket or TCP #[cfg(unix)] if let Some(ref sock_path) = config.censorship.mask_unix_sock { diff --git a/src/stats/mod.rs b/src/stats/mod.rs index 5f4c98e..1e32bb7 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -2,6 +2,8 @@ #![allow(dead_code)] +pub mod beobachten; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Instant, Duration}; use dashmap::DashMap; From 6b8619d3c91e309897032243d55da9e5120b0fef Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:17:48 +0300 Subject: [PATCH 19/36] Create beobachten.rs --- src/stats/beobachten.rs | 117 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/stats/beobachten.rs diff --git a/src/stats/beobachten.rs b/src/stats/beobachten.rs new file mode 100644 index 0000000..2e87fcc --- /dev/null +++ b/src/stats/beobachten.rs @@ -0,0 +1,117 @@ +//! Per-IP forensic buckets for scanner and handshake failure observation. + +use std::collections::{BTreeMap, HashMap}; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; + +const CLEANUP_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Default)] +struct BeobachtenInner { + entries: HashMap<(String, IpAddr), BeobachtenEntry>, + last_cleanup: Option, +} + +#[derive(Clone, Copy)] +struct BeobachtenEntry { + tries: u64, + last_seen: Instant, +} + +/// In-memory, TTL-scoped per-IP counters keyed by source class. +pub struct BeobachtenStore { + inner: Mutex, +} + +impl Default for BeobachtenStore { + fn default() -> Self { + Self::new() + } +} + +impl BeobachtenStore { + pub fn new() -> Self { + Self { + inner: Mutex::new(BeobachtenInner::default()), + } + } + + pub fn record(&self, class: &str, ip: IpAddr, ttl: Duration) { + if class.is_empty() || ttl.is_zero() { + return; + } + + let now = Instant::now(); + let mut guard = self.inner.lock(); + Self::cleanup_if_needed(&mut guard, now, ttl); + + let key = (class.to_string(), ip); + let entry = guard.entries.entry(key).or_insert(BeobachtenEntry { + tries: 0, + last_seen: now, + }); + entry.tries = entry.tries.saturating_add(1); + entry.last_seen = now; + } + + pub fn snapshot_text(&self, ttl: Duration) -> String { + if ttl.is_zero() { + return "beobachten disabled\n".to_string(); + } + + let now = Instant::now(); + let mut guard = self.inner.lock(); + Self::cleanup(&mut guard, now, ttl); + guard.last_cleanup = Some(now); + + let mut grouped = BTreeMap::>::new(); + for ((class, ip), entry) in &guard.entries { + grouped + .entry(class.clone()) + .or_default() + .push((*ip, entry.tries)); + } + + if grouped.is_empty() { + return "empty\n".to_string(); + } + + let mut out = String::with_capacity(grouped.len() * 64); + for (class, entries) in &mut grouped { + out.push('['); + out.push_str(class); + out.push_str("]\n"); + + entries.sort_by(|(ip_a, tries_a), (ip_b, tries_b)| { + tries_b + .cmp(tries_a) + .then_with(|| ip_a.to_string().cmp(&ip_b.to_string())) + }); + + for (ip, tries) in entries { + out.push_str(&format!("{ip}-{tries}\n")); + } + } + + out + } + + fn cleanup_if_needed(inner: &mut BeobachtenInner, now: Instant, ttl: Duration) { + let should_cleanup = match inner.last_cleanup { + Some(last) => now.saturating_duration_since(last) >= CLEANUP_INTERVAL, + None => true, + }; + if should_cleanup { + Self::cleanup(inner, now, ttl); + inner.last_cleanup = Some(now); + } + } + + fn cleanup(inner: &mut BeobachtenInner, now: Instant, ttl: Duration) { + inner.entries.retain(|_, entry| { + now.saturating_duration_since(entry.last_seen) <= ttl + }); + } +} From f83e23c521570f76c393fdaaec2dadcb778a34cd Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:08:34 +0300 Subject: [PATCH 20/36] Update defaults.rs --- src/config/defaults.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 80fcc07..2ce7bac 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -199,11 +199,11 @@ pub(crate) fn default_me_reinit_every_secs() -> u64 { } pub(crate) fn default_me_hardswap_warmup_delay_min_ms() -> u64 { - 1000 + 2000 } pub(crate) fn default_me_hardswap_warmup_delay_max_ms() -> u64 { - 2000 + 3500 } pub(crate) fn default_me_hardswap_warmup_extra_passes() -> u8 { From 5a09d30e1cd866f0a4a0681aaa94ca170c7a2501 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:09:02 +0300 Subject: [PATCH 21/36] Update Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b6ef28d..994e11f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.0.15" +version = "3.1.0" edition = "2024" [dependencies] From 206f87fe648dd123436f42bbdb5969c2fc680e28 Mon Sep 17 00:00:00 2001 From: D Date: Wed, 25 Feb 2026 09:22:26 +0300 Subject: [PATCH 22/36] fix: remove bracket in info --- src/main.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index ab524a4..fbb50aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,14 +203,14 @@ async fn main() -> std::result::Result<(), Box> { }; let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new("info")); - + // Configure color output based on config 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) @@ -272,7 +272,7 @@ async fn main() -> std::result::Result<(), Box> { // IP Tracker initialization let ip_tracker = Arc::new(UserIpTracker::new()); ip_tracker.load_limits(&config.access.user_max_unique_ips).await; - + if !config.access.user_max_unique_ips.is_empty() { info!("IP limits configured for {} users", config.access.user_max_unique_ips.len()); } @@ -598,7 +598,7 @@ async fn main() -> std::result::Result<(), Box> { .v4_results .iter() .any(|r| r.rtt_ms.is_some()); - + if upstream_result.both_available { if prefer_ipv6 { info!(" IPv6 in use / IPv4 is fallback"); @@ -606,9 +606,9 @@ async fn main() -> std::result::Result<(), Box> { info!(" IPv4 in use / IPv6 is fallback"); } } else if v6_works && !v4_works { - info!(" IPv6 only / IPv4 unavailable)"); + info!(" IPv6 only / IPv4 unavailable"); } else if v4_works && !v6_works { - info!(" IPv4 only / IPv6 unavailable)"); + info!(" IPv4 only / IPv6 unavailable"); } else if !v6_works && !v4_works { info!(" No DC connectivity"); } From 5558900c44235b026c723b68bf533c52512720e0 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:29:46 +0300 Subject: [PATCH 23/36] Update main.rs --- src/main.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index fbb50aa..c2b8c34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,7 +272,7 @@ async fn main() -> std::result::Result<(), Box> { // IP Tracker initialization let ip_tracker = Arc::new(UserIpTracker::new()); ip_tracker.load_limits(&config.access.user_max_unique_ips).await; - + if !config.access.user_max_unique_ips.is_empty() { info!("IP limits configured for {} users", config.access.user_max_unique_ips.len()); } @@ -598,7 +598,7 @@ async fn main() -> std::result::Result<(), Box> { .v4_results .iter() .any(|r| r.rtt_ms.is_some()); - + if upstream_result.both_available { if prefer_ipv6 { info!(" IPv6 in use / IPv4 is fallback"); @@ -677,14 +677,8 @@ async fn main() -> std::result::Result<(), Box> { rc_clone.run_periodic_cleanup().await; }); - let detected_ip_v4: Option = probe - .reflected_ipv4 - .map(|s| s.ip()) - .or_else(|| probe.detected_ipv4.map(std::net::IpAddr::V4)); - let detected_ip_v6: Option = probe - .reflected_ipv6 - .map(|s| s.ip()) - .or_else(|| probe.detected_ipv6.map(std::net::IpAddr::V6)); + let detected_ip_v4: Option = probe.detected_ipv4.map(std::net::IpAddr::V4); + let detected_ip_v6: Option = probe.detected_ipv6.map(std::net::IpAddr::V6); debug!( "Detected IPs: v4={:?} v6={:?}", detected_ip_v4, detected_ip_v6 From 1b1bdfe99a33eabf4ccdfafae611d9d10683a438 Mon Sep 17 00:00:00 2001 From: Vladislav Yaroslavlev Date: Wed, 25 Feb 2026 14:00:50 +0300 Subject: [PATCH 24/36] Add proxy-secret to .gitignore The proxy-secret file contains sensitive authentication data that should never be committed to version control. --- .gitignore | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 6b5f1d5..3a45e41 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,5 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -*.rs -target -Cargo.lock -src + +proxy-secret From f40b645c0530b3b870c22c556546f20b933ca627 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:28:06 +0300 Subject: [PATCH 25/36] Defaults in-place --- src/config/defaults.rs | 50 +++++++++++++++++++++++++++++++++++++++--- src/config/load.rs | 49 +++++++++++++++++++++++++++++++++++++++++ src/config/types.rs | 43 ++++++++++++++++-------------------- 3 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 2ce7bac..51abe65 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -3,6 +3,15 @@ use ipnetwork::IpNetwork; use serde::Deserialize; // Helper defaults kept private to the config module. +const DEFAULT_NETWORK_IPV6: Option = Some(false); +const DEFAULT_STUN_TCP_FALLBACK: bool = true; +const DEFAULT_MIDDLE_PROXY_WARM_STANDBY: usize = 16; +const DEFAULT_ME_RECONNECT_MAX_CONCURRENT_PER_DC: u32 = 8; +const DEFAULT_ME_RECONNECT_FAST_RETRY_COUNT: u32 = 12; +const DEFAULT_LISTEN_ADDR_IPV6: &str = "::"; +const DEFAULT_ACCESS_USER: &str = "default"; +const DEFAULT_ACCESS_SECRET: &str = "00000000000000000000000000000000"; + pub(crate) fn default_true() -> bool { true } @@ -77,6 +86,14 @@ pub(crate) fn default_prefer_4() -> u8 { 4 } +pub(crate) fn default_network_ipv6() -> Option { + DEFAULT_NETWORK_IPV6 +} + +pub(crate) fn default_stun_tcp_fallback() -> bool { + DEFAULT_STUN_TCP_FALLBACK +} + pub(crate) fn default_unknown_dc_log_path() -> Option { Some("unknown-dc.txt".to_string()) } @@ -85,6 +102,10 @@ pub(crate) fn default_pool_size() -> usize { 8 } +pub(crate) fn default_middle_proxy_warm_standby() -> usize { + DEFAULT_MIDDLE_PROXY_WARM_STANDBY +} + pub(crate) fn default_keepalive_interval() -> u64 { 25 } @@ -109,6 +130,14 @@ pub(crate) fn default_reconnect_backoff_cap_ms() -> u64 { 30_000 } +pub(crate) fn default_me_reconnect_max_concurrent_per_dc() -> u32 { + DEFAULT_ME_RECONNECT_MAX_CONCURRENT_PER_DC +} + +pub(crate) fn default_me_reconnect_fast_retry_count() -> u32 { + DEFAULT_ME_RECONNECT_FAST_RETRY_COUNT +} + pub(crate) fn default_crypto_pending_buffer() -> usize { 256 * 1024 } @@ -191,7 +220,11 @@ pub(crate) fn default_proxy_config_reload_secs() -> u64 { } pub(crate) fn default_update_every_secs() -> u64 { - 30 * 60 + 5 * 60 +} + +pub(crate) fn default_update_every() -> Option { + Some(default_update_every_secs()) } pub(crate) fn default_me_reinit_every_secs() -> u64 { @@ -199,11 +232,11 @@ pub(crate) fn default_me_reinit_every_secs() -> u64 { } pub(crate) fn default_me_hardswap_warmup_delay_min_ms() -> u64 { - 2000 + 1000 } pub(crate) fn default_me_hardswap_warmup_delay_max_ms() -> u64 { - 3500 + 2000 } pub(crate) fn default_me_hardswap_warmup_extra_passes() -> u8 { @@ -266,6 +299,17 @@ pub(crate) fn default_degradation_min_unavailable_dc_groups() -> u8 { 2 } +pub(crate) fn default_listen_addr_ipv6() -> String { + DEFAULT_LISTEN_ADDR_IPV6.to_string() +} + +pub(crate) fn default_access_users() -> HashMap { + HashMap::from([( + DEFAULT_ACCESS_USER.to_string(), + DEFAULT_ACCESS_SECRET.to_string(), + )]) +} + // Custom deserializer helpers #[derive(Deserialize)] diff --git a/src/config/load.rs b/src/config/load.rs index aab553f..be6759e 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -427,6 +427,55 @@ impl ProxyConfig { mod tests { 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.network.ipv6, None); + assert!(!cfg.network.stun_tcp_fallback); + assert_eq!(cfg.general.middle_proxy_warm_standby, 0); + assert_eq!(cfg.general.me_reconnect_max_concurrent_per_dc, 0); + assert_eq!(cfg.general.me_reconnect_fast_retry_count, 0); + assert_eq!(cfg.general.update_every, None); + assert_eq!(cfg.server.listen_addr_ipv4, None); + assert_eq!(cfg.server.listen_addr_ipv6, None); + assert!(cfg.access.users.is_empty()); + } + + #[test] + fn impl_defaults_are_sourced_from_default_helpers() { + let network = NetworkConfig::default(); + assert_eq!(network.ipv6, default_network_ipv6()); + 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.update_every, default_update_every()); + + let server = ServerConfig::default(); + assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6())); + + let access = AccessConfig::default(); + assert_eq!(access.users, default_access_users()); + } + #[test] fn dc_overrides_allow_string_and_array() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index cfa8d31..ad22c93 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -76,7 +76,7 @@ impl Default for ProxyModes { Self { classic: false, secure: false, - tls: true, + tls: default_true(), } } } @@ -117,12 +117,12 @@ pub struct NetworkConfig { impl Default for NetworkConfig { fn default() -> Self { Self { - ipv4: true, - ipv6: Some(false), - prefer: 4, + ipv4: default_true(), + ipv6: default_network_ipv6(), + prefer: default_prefer_4(), multipath: false, stun_servers: default_stun_servers(), - stun_tcp_fallback: true, + 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(), } @@ -370,27 +370,27 @@ impl Default for GeneralConfig { Self { modes: ProxyModes::default(), prefer_ipv6: false, - fast_mode: true, + fast_mode: default_true(), use_middle_proxy: false, ad_tag: None, proxy_secret_path: None, middle_proxy_nat_ip: None, - middle_proxy_nat_probe: false, + middle_proxy_nat_probe: true, middle_proxy_nat_stun: None, middle_proxy_nat_stun_servers: Vec::new(), middle_proxy_pool_size: default_pool_size(), - middle_proxy_warm_standby: 16, - me_keepalive_enabled: true, + middle_proxy_warm_standby: default_middle_proxy_warm_standby(), + me_keepalive_enabled: default_true(), me_keepalive_interval_secs: default_keepalive_interval(), me_keepalive_jitter_secs: default_keepalive_jitter(), - me_keepalive_payload_random: true, - me_warmup_stagger_enabled: true, + me_keepalive_payload_random: default_true(), + 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: 8, + 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: 8, + me_reconnect_fast_retry_count: default_me_reconnect_fast_retry_count(), stun_iface_mismatch_ignore: false, unknown_dc_log_path: default_unknown_dc_log_path(), log_level: LogLevel::Normal, @@ -399,13 +399,13 @@ impl Default for GeneralConfig { crypto_pending_buffer: default_crypto_pending_buffer(), max_client_frame: default_max_client_frame(), desync_all_full: default_desync_all_full(), - beobachten: false, + beobachten: 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: Some(default_update_every_secs()), + 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(), @@ -423,7 +423,7 @@ impl Default for GeneralConfig { proxy_config_auto_reload_secs: default_proxy_config_reload_secs(), ntp_check: default_ntp_check(), ntp_servers: default_ntp_servers(), - auto_degradation_enabled: true, + auto_degradation_enabled: default_true(), degradation_min_unavailable_dc_groups: default_degradation_min_unavailable_dc_groups(), } } @@ -510,7 +510,7 @@ impl Default for ServerConfig { Self { port: default_port(), listen_addr_ipv4: Some(default_listen_addr()), - listen_addr_ipv6: Some("::".to_string()), + listen_addr_ipv6: Some(default_listen_addr_ipv6()), listen_unix_sock: None, listen_unix_sock_perm: None, listen_tcp: None, @@ -618,7 +618,7 @@ impl Default for AntiCensorshipConfig { Self { tls_domain: default_tls_domain(), tls_domains: Vec::new(), - mask: true, + mask: default_true(), mask_host: None, mask_port: default_mask_port(), mask_unix_sock: None, @@ -663,13 +663,8 @@ pub struct AccessConfig { impl Default for AccessConfig { fn default() -> Self { - let mut users = HashMap::new(); - users.insert( - "default".to_string(), - "00000000000000000000000000000000".to_string(), - ); Self { - users, + users: default_access_users(), user_max_tcp_conns: HashMap::new(), user_expirations: HashMap::new(), user_data_quota: HashMap::new(), From fed93464445eb6b32af08b393bc828198522206c Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:49:54 +0300 Subject: [PATCH 26/36] New config.toml + tls_emulation enabled by default --- config.toml | 266 +++++++++++++++++++++++++------------------- src/config/types.rs | 2 +- 2 files changed, 152 insertions(+), 116 deletions(-) diff --git a/config.toml b/config.toml index f3df049..e82d97c 100644 --- a/config.toml +++ b/config.toml @@ -1,67 +1,66 @@ -# === General Settings === +# Telemt full config with default values. +# Examples are kept in comments after '#'. + +# Top-level legacy field. +show_link = [] # example: "*" or ["alice", "bob"] +# default_dc = 2 # example: default DC for unmapped non-standard DCs + [general] fast_mode = true -use_middle_proxy = true -# ad_tag = "00000000000000000000000000000000" -# Path to proxy-secret binary (auto-downloaded if missing). -proxy_secret_path = "proxy-secret" -# disable_colors = false # Disable colored output in logs (useful for files/systemd) - -# === Log Level === -# Log level: debug | verbose | normal | silent -# Can be overridden with --silent or --log-level CLI flags -# RUST_LOG env var takes absolute priority over all of these -log_level = "normal" - -# === Middle Proxy - ME === -# Public IP override for ME KDF when behind NAT; leave unset to auto-detect. -# middle_proxy_nat_ip = "203.0.113.10" -# Enable STUN probing to discover public IP:port for ME. +use_middle_proxy = false +# ad_tag = "0123456789abcdef0123456789abcdef" # example +# proxy_secret_path = "proxy-secret" # example custom path +# middle_proxy_nat_ip = "203.0.113.10" # example public NAT IP override middle_proxy_nat_probe = true -# Primary STUN server (host:port); defaults to Telegram STUN when empty. -middle_proxy_nat_stun = "stun.l.google.com:19302" -# Optional fallback STUN servers list. -middle_proxy_nat_stun_servers = ["stun1.l.google.com:19302", "stun2.l.google.com:19302"] -# Desired number of concurrent ME writers in pool. +# middle_proxy_nat_stun = "stun.l.google.com:19302" # example +middle_proxy_nat_stun_servers = [] # example: ["stun1.l.google.com:19302", "stun2.l.google.com:19302"] middle_proxy_pool_size = 8 -# Pre-initialized warm-standby ME connections kept idle. -middle_proxy_warm_standby = 8 -# Ignore STUN/interface mismatch and keep ME enabled even if IP differs. -stun_iface_mismatch_ignore = false -# Keepalive padding frames - fl==4 +middle_proxy_warm_standby = 16 me_keepalive_enabled = true -me_keepalive_interval_secs = 25 # Period between keepalives -me_keepalive_jitter_secs = 5 # Jitter added to interval -me_keepalive_payload_random = true # Randomize 4-byte payload (vs zeros) -# Stagger extra ME connections on warmup to de-phase lifecycles. +me_keepalive_interval_secs = 25 +me_keepalive_jitter_secs = 5 +me_keepalive_payload_random = true +crypto_pending_buffer = 262144 +max_client_frame = 16777216 +desync_all_full = false +beobachten = true +beobachten_minutes = 10 +beobachten_flush_secs = 15 +beobachten_file = "cache/beobachten.txt" +hardswap = true me_warmup_stagger_enabled = true -me_warmup_step_delay_ms = 500 # Base delay between extra connects -me_warmup_step_jitter_ms = 300 # Jitter for warmup delay -# Reconnect policy knobs. -me_reconnect_max_concurrent_per_dc = 4 # Parallel reconnects per DC - EXPERIMENTAL! UNSTABLE! -me_reconnect_backoff_base_ms = 500 # Backoff start -me_reconnect_backoff_cap_ms = 30000 # Backoff cap -me_reconnect_fast_retry_count = 11 # Quick retries before backoff -update_every = 7200 # Resolve the active updater interval for ME infrastructure refresh tasks. -crypto_pending_buffer = 262144 # Max pending ciphertext buffer per client writer (bytes). Controls FakeTLS backpressure vs throughput. -max_client_frame = 16777216 # Maximum allowed client MTProto frame size (bytes). -desync_all_full = false # Emit full crypto-desync forensic logs for every event. When false, full forensic details are emitted once per key window. -auto_degradation_enabled = true # Enable auto-degradation from ME to Direct-DC. -degradation_min_unavailable_dc_groups = 2 # Minimum unavailable ME DC groups before degrading. -hardswap = true # Enable C-like hard-swap for ME pool generations. When true, Telemt prewarms a new generation and switches once full coverage is reached. -default_me_hardswap_warmup_delay_min_ms = 1000 # Minimum delay in ms between hardswap warmup connect attempts. -default_me_hardswap_warmup_delay_max_ms = 2000 # Maximum delay in ms between hardswap warmup connect attempts. -default_me_hardswap_warmup_extra_passes = 3 # Additional warmup passes in the same hardswap cycle after the base pass. -default_me_hardswap_warmup_pass_backoff_base_ms = 500 # Base backoff in ms between hardswap warmup passes when floor is still incomplete. -me_pool_drain_ttl_secs = 90 # 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. -me_pool_min_fresh_ratio = 0.8 # Minimum desired-DC coverage ratio required before draining stale writers. Range: 0.0..=1.0. -me_reinit_drain_timeout_secs = 120 # Drain timeout in seconds for stale ME writers after endpoint map changes. Set to 0 to keep stale writers draining indefinitely (no force-close). -me_config_stable_snapshots = 2 # Number of identical getProxyConfig snapshots required before applying ME map updates. -me_config_apply_cooldown_secs = 300 # Cooldown in seconds between applied ME map updates. -proxy_secret_rotate_runtime = true # Enable runtime proxy-secret rotation from getProxySecret. -proxy_secret_stable_snapshots = 2 # Number of identical getProxySecret snapshots required before runtime secret rotation. -proxy_secret_len_max = 256 # Maximum allowed proxy-secret length in bytes for startup and runtime refresh. -default_me_reinit_every_secs = 900 # Periodic ME pool reinitialization interval in seconds. +me_warmup_step_delay_ms = 500 +me_warmup_step_jitter_ms = 300 +me_reconnect_max_concurrent_per_dc = 8 +me_reconnect_backoff_base_ms = 500 +me_reconnect_backoff_cap_ms = 30000 +me_reconnect_fast_retry_count = 12 +stun_iface_mismatch_ignore = false +unknown_dc_log_path = "unknown-dc.txt" # to disable: set to null +log_level = "normal" # debug | verbose | normal | silent +disable_colors = false +fast_mode_min_tls_record = 0 +update_every = 300 +me_reinit_every_secs = 900 +me_hardswap_warmup_delay_min_ms = 1000 +me_hardswap_warmup_delay_max_ms = 2000 +me_hardswap_warmup_extra_passes = 3 +me_hardswap_warmup_pass_backoff_base_ms = 500 +me_config_stable_snapshots = 2 +me_config_apply_cooldown_secs = 300 +proxy_secret_stable_snapshots = 2 +proxy_secret_rotate_runtime = true +proxy_secret_len_max = 256 +me_pool_drain_ttl_secs = 90 +me_pool_min_fresh_ratio = 0.8 +me_reinit_drain_timeout_secs = 120 +# Legacy compatibility fields used when update_every is omitted. +proxy_secret_auto_reload_secs = 3600 +proxy_config_auto_reload_secs = 3600 +ntp_check = true +ntp_servers = ["pool.ntp.org"] # example: ["pool.ntp.org", "time.cloudflare.com"] +auto_degradation_enabled = true +degradation_min_unavailable_dc_groups = 2 [general.modes] classic = false @@ -69,63 +68,82 @@ secure = false tls = true [general.links] -show = "*" -# show = ["alice", "bob"] # Only show links for alice and bob -# show = "*" # Show links for all users -# public_host = "proxy.example.com" # Host (IP or domain) for tg:// links -# public_port = 443 # Port for tg:// links (default: server.port) +show = [] # example: "*" or ["alice", "bob"] +# public_host = "proxy.example.com" # example explicit host/IP for tg:// links +# public_port = 443 # example explicit port for tg:// links -# === Network Parameters === [network] -# Enable/disable families: true/false/auto(None) ipv4 = true -ipv6 = false # UNSTABLE WITH ME -# prefer = 4 or 6 -prefer = 4 -multipath = false # EXPERIMENTAL! +ipv6 = false # set true to enable IPv6 +prefer = 4 # 4 or 6 +multipath = false +stun_servers = [ + "stun.l.google.com:5349", + "stun1.l.google.com:3478", + "stun.gmx.net:3478", + "stun.l.google.com:19302", + "stun.1und1.de:3478", + "stun1.l.google.com:19302", + "stun2.l.google.com:19302", + "stun3.l.google.com:19302", + "stun4.l.google.com:19302", + "stun.services.mozilla.com:3478", + "stun.stunprotocol.org:3478", + "stun.nextcloud.com:3478", + "stun.voip.eutelia.it:3478", +] +stun_tcp_fallback = true +http_ip_detect_urls = ["https://ifconfig.me/ip", "https://api.ipify.org"] +cache_public_ip_path = "cache/public_ip.txt" -# === Server Binding === [server] port = 443 listen_addr_ipv4 = "0.0.0.0" listen_addr_ipv6 = "::" -# listen_unix_sock = "/var/run/telemt.sock" # Unix socket -# listen_unix_sock_perm = "0666" # Socket file permissions -# proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol -# metrics_port = 9090 -# metrics_whitelist = ["127.0.0.1", "::1"] +# listen_unix_sock = "/var/run/telemt.sock" # example +# listen_unix_sock_perm = "0660" # example unix socket mode +# listen_tcp = true # example explicit override (auto-detected when omitted) +proxy_protocol = false +# metrics_port = 9090 # example +metrics_whitelist = ["127.0.0.1/32", "::1/128"] +# Example explicit listeners (default: omitted, auto-generated from listen_addr_*): +# [[server.listeners]] +# ip = "0.0.0.0" +# announce = "proxy-v4.example.com" +# # announce_ip = "203.0.113.10" # deprecated alias +# proxy_protocol = false +# reuse_allow = false +# +# [[server.listeners]] +# ip = "::" +# announce = "proxy-v6.example.com" +# proxy_protocol = false +# reuse_allow = false -# Listen on multiple interfaces/IPs - IPv4 -[[server.listeners]] -ip = "0.0.0.0" - -# Listen on multiple interfaces/IPs - IPv6 -[[server.listeners]] -ip = "::" - -# === Timeouts (in seconds) === [timeouts] -client_handshake = 30 +client_handshake = 15 tg_connect = 10 client_keepalive = 60 client_ack = 300 -# Quick ME reconnects for single-address DCs (count and per-attempt timeout, ms). -me_one_retry = 12 -me_one_timeout_ms = 1200 +me_one_retry = 3 +me_one_timeout_ms = 1500 -# === Anti-Censorship & Masking === [censorship] tls_domain = "petrovich.ru" # tls_domains = ["example.com", "cdn.example.net"] # Additional domains for EE links mask = true +# mask_host = "www.google.com" # example, defaults to tls_domain when both mask_host/mask_unix_sock are unset +# mask_unix_sock = "/var/run/nginx.sock" # example, mutually exclusive with mask_host mask_port = 443 -# mask_host = "petrovich.ru" # Defaults to tls_domain if not set -# mask_unix_sock = "/var/run/nginx.sock" # Unix socket (mutually exclusive with mask_host) -fake_cert_len = 2048 -# tls_emulation = false # Fetch real cert lengths and emulate TLS records -# tls_front_dir = "tlsfront" # Cache directory for TLS emulation +fake_cert_len = 2048 # if tls_emulation=false and default value is used, loader may randomize this value at runtime +tls_emulation = true +tls_front_dir = "tlsfront" +server_hello_delay_min_ms = 0 +server_hello_delay_max_ms = 0 +tls_new_session_tickets = 0 +tls_full_cert_ttl_secs = 90 +alpn_enforce = true -# === Access Control & Users === [access] replay_check_len = 65536 replay_window_secs = 1800 @@ -134,34 +152,52 @@ ignore_time_skew = false [access.users] # format: "username" = "32_hex_chars_secret" hello = "00000000000000000000000000000000" +default = "00000000000000000000000000000000" +# alice = "11111111111111111111111111111111" # example -# [access.user_max_tcp_conns] -# hello = 50 +[access.user_max_tcp_conns] +# alice = 100 # example -# [access.user_max_unique_ips] -# hello = 5 +[access.user_expirations] +# alice = "2027-01-01T00:00:00Z" # example -# [access.user_data_quota] -# hello = 1073741824 # 1 GB +[access.user_data_quota] +# alice = 10737418240 # example bytes -# [access.user_expirations] -# format: username = "[year]-[month]-[day]T[hour]:[minute]:[second]Z" UTC -# hello = "2027-01-01T00:00:00Z" - -# === Upstreams & Routing === -[[upstreams]] -type = "direct" -enabled = true -weight = 10 -# interface = "192.168.1.100" # Bind outgoing to specific IP or iface name -# bind_addresses = ["192.168.1.100"] # List for round-robin binding (family must match target) +[access.user_max_unique_ips] +# alice = 10 # example +# Default behavior if [[upstreams]] is omitted: loader injects one direct upstream. +# Example explicit upstreams: +# [[upstreams]] +# type = "direct" +# interface = "eth0" +# bind_addresses = ["192.0.2.10"] +# weight = 1 +# enabled = true +# scopes = "*" +# +# [[upstreams]] +# type = "socks4" +# address = "198.51.100.20:1080" +# interface = "eth0" +# user_id = "telemt" +# weight = 1 +# enabled = true +# scopes = "*" +# # [[upstreams]] # type = "socks5" -# address = "127.0.0.1:1080" -# enabled = false +# address = "198.51.100.30:1080" +# interface = "eth0" +# username = "proxy-user" +# password = "proxy-pass" # weight = 1 +# enabled = true +# scopes = "*" # === DC Address Overrides === # [dc_overrides] -# "203" = "91.105.192.100:443" +# "201" = "149.154.175.50:443" # example +# "202" = ["149.154.167.51:443", "149.154.175.100:443"] # example +# "203" = "91.105.192.100:443" # loader auto-adds this one when omitted diff --git a/src/config/types.rs b/src/config/types.rs index ad22c93..1302a97 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -623,7 +623,7 @@ impl Default for AntiCensorshipConfig { mask_port: default_mask_port(), mask_unix_sock: None, fake_cert_len: default_fake_cert_len(), - tls_emulation: false, + 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(), From 06292ff8335b67d606b2b1eb490af504b996dd77 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:33:06 +0300 Subject: [PATCH 27/36] Update config.toml --- config.toml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/config.toml b/config.toml index e82d97c..44db620 100644 --- a/config.toml +++ b/config.toml @@ -8,12 +8,12 @@ show_link = [] # example: "*" or ["alice", "bob"] [general] fast_mode = true use_middle_proxy = false -# ad_tag = "0123456789abcdef0123456789abcdef" # example +# ad_tag = "00000000000000000000000000000000" # example # proxy_secret_path = "proxy-secret" # example custom path # middle_proxy_nat_ip = "203.0.113.10" # example public NAT IP override middle_proxy_nat_probe = true # middle_proxy_nat_stun = "stun.l.google.com:19302" # example -middle_proxy_nat_stun_servers = [] # example: ["stun1.l.google.com:19302", "stun2.l.google.com:19302"] +# middle_proxy_nat_stun_servers = [] # example: ["stun1.l.google.com:19302", "stun2.l.google.com:19302"] middle_proxy_pool_size = 8 middle_proxy_warm_standby = 16 me_keepalive_enabled = true @@ -68,7 +68,7 @@ secure = false tls = true [general.links] -show = [] # example: "*" or ["alice", "bob"] +show ="*" # example: "*" or ["alice", "bob"] # public_host = "proxy.example.com" # example explicit host/IP for tg:// links # public_port = 443 # example explicit port for tg:// links @@ -152,20 +152,21 @@ ignore_time_skew = false [access.users] # format: "username" = "32_hex_chars_secret" hello = "00000000000000000000000000000000" -default = "00000000000000000000000000000000" # alice = "11111111111111111111111111111111" # example [access.user_max_tcp_conns] # alice = 100 # example [access.user_expirations] -# alice = "2027-01-01T00:00:00Z" # example +# alice = "2078-01-01T00:00:00Z" # example [access.user_data_quota] +# hello = 10737418240 # example bytes # alice = 10737418240 # example bytes [access.user_max_unique_ips] -# alice = 10 # example +# hello = 10 # example +# alice = 100 # example # Default behavior if [[upstreams]] is omitted: loader injects one direct upstream. # Example explicit upstreams: From 79a3720fd577e2da69518a15acc0107b5587f457 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:22:04 +0300 Subject: [PATCH 28/36] Rename config.toml to config.full.toml --- config.toml => config.full.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config.toml => config.full.toml (100%) diff --git a/config.toml b/config.full.toml similarity index 100% rename from config.toml rename to config.full.toml From a6bfa3309e2713d8ecc4d4dd10d58119094fe464 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:32:02 +0300 Subject: [PATCH 29/36] Create config.toml --- config.toml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 config.toml diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..d21b8f7 --- /dev/null +++ b/config.toml @@ -0,0 +1,37 @@ +# === General Settings === +[general] +use_middle_proxy = true +# ad_tag = "00000000000000000000000000000000" + +# === Log Level === +# Log level: debug | verbose | normal | silent +# Can be overridden with --silent or --log-level CLI flags +# RUST_LOG env var takes absolute priority over all of these +log_level = "normal" + +[general.modes] +classic = false +secure = false +tls = true + +# === Server Binding === +[server] +port = 9999 +# proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol +metrics_port = 9090 +metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"] + +# Listen on multiple interfaces/IPs - IPv4 +[[server.listeners]] +ip = "0.0.0.0" + +# === Anti-Censorship & Masking === +[censorship] +tls_domain = "petrovich.ru" +mask = true +tls_emulation = true # Fetch real cert lengths and emulate TLS records +tls_front_dir = "tlsfront" # Cache directory for TLS emulation + +[access.users] +# format: "username" = "32_hex_chars_secret" +hello = "00000000000000000000000000000000" From 03ce2678650f7f3ddf839286f1a77c8723904ad3 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:33:38 +0300 Subject: [PATCH 30/36] Update config.toml --- config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.toml b/config.toml index d21b8f7..28a0087 100644 --- a/config.toml +++ b/config.toml @@ -16,7 +16,7 @@ tls = true # === Server Binding === [server] -port = 9999 +port = 443 # proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol metrics_port = 9090 metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"] From 76f1b5101828b0cbdb25daa9b636596401342773 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:44:38 +0300 Subject: [PATCH 31/36] Update config.toml --- config.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config.toml b/config.toml index 28a0087..cc49bb3 100644 --- a/config.toml +++ b/config.toml @@ -1,3 +1,7 @@ +### Telemt Based Config.toml +# We believe that these settings are sufficient for most scenarios +# where cutting-egde methods and parameters or special solutions are not needed + # === General Settings === [general] use_middle_proxy = true From 1e4ba2eb56d0ff8dc9127f6a056e9945bbe8934c Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:45:47 +0300 Subject: [PATCH 32/36] Update config.toml --- config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.toml b/config.toml index cc49bb3..9da6a5d 100644 --- a/config.toml +++ b/config.toml @@ -4,7 +4,7 @@ # === General Settings === [general] -use_middle_proxy = true +use_middle_proxy = false # ad_tag = "00000000000000000000000000000000" # === Log Level === From 4af40f71215cb5e19c6f3ad1580b17db19d989ff Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:13:58 +0300 Subject: [PATCH 33/36] Update config.toml --- config.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config.toml b/config.toml index 9da6a5d..3460d37 100644 --- a/config.toml +++ b/config.toml @@ -18,6 +18,13 @@ classic = false secure = false tls = true +[general.links] +show = "*" +# show = ["alice", "bob"] # Only show links for alice and bob +# show = "*" # Show links for all users +# public_host = "proxy.example.com" # Host (IP or domain) for tg:// links +# public_port = 443 # Port for tg:// links (default: server.port) + # === Server Binding === [server] port = 443 From 4e30a4999ccd62b65bdebdfa933b8dc180693c41 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:14:52 +0300 Subject: [PATCH 34/36] Update config.toml --- config.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.toml b/config.toml index 3460d37..b280234 100644 --- a/config.toml +++ b/config.toml @@ -29,8 +29,8 @@ show = "*" [server] port = 443 # proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol -metrics_port = 9090 -metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"] +# metrics_port = 9090 +# metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"] # Listen on multiple interfaces/IPs - IPv4 [[server.listeners]] From 6cf9687dd6e6fdd5d7839ad06e9a98441332c8c3 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:43:27 +0300 Subject: [PATCH 35/36] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 192cd00..9bba0cb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **Telemt** is a fast, secure, and feature-rich server written in Rust: it fully implements the official Telegram proxy algo and adds many production-ready improvements such as connection pooling, replay protection, detailed statistics, masking from "prying" eyes +{**Telemt Chat in Telegram**](https://t.me/telemtrs) + ## NEWS and EMERGENCY ### ✈️ Telemt 3 is released! From 7ead0cd753a42bcfb179ad4dc758225a53bd107a Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:45:50 +0300 Subject: [PATCH 36/36] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bba0cb..e2a898f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Telemt** is a fast, secure, and feature-rich server written in Rust: it fully implements the official Telegram proxy algo and adds many production-ready improvements such as connection pooling, replay protection, detailed statistics, masking from "prying" eyes -{**Telemt Chat in Telegram**](https://t.me/telemtrs) +[**Telemt Chat in Telegram**](https://t.me/telemtrs) ## NEWS and EMERGENCY ### ✈️ Telemt 3 is released!