From 86be0d53fecf01a8ed471c39cad6123739c6070f Mon Sep 17 00:00:00 2001 From: miniusercoder Date: Mon, 6 Apr 2026 20:27:17 +0300 Subject: [PATCH 01/15] fix(me-pool): resolve 0-writer blackouts with zero-allocation constraints - Converts adaptive floor logic from proactive idle drops to reactive global capacity constraints, fixing sudden drops to 0 active writers. - Implements `base_req` override gateway via `can_open_writer_for_contour`, retaining critical connections for starved datacenters during bursts. - Applies zero-allocation performance optimization via direct inner lock iter, avoiding `HashSet` generation and deep `RwLock` checks in writer validation paths. - Scrubs now-dead variables/evaluations (`adaptive_idle_since`, `adaptive_recover_until`) to fulfill strict memory & hot-path constraints. --- src/transport/middle_proxy/health.rs | 110 +++++----------------- src/transport/middle_proxy/pool.rs | 44 +++++---- src/transport/middle_proxy/pool_writer.rs | 2 +- 3 files changed, 54 insertions(+), 102 deletions(-) diff --git a/src/transport/middle_proxy/health.rs b/src/transport/middle_proxy/health.rs index 257d8f3..9f6e1bb 100644 --- a/src/transport/middle_proxy/health.rs +++ b/src/transport/middle_proxy/health.rs @@ -82,8 +82,6 @@ pub async fn me_health_monitor(pool: Arc, rng: Arc, _min_c let mut single_endpoint_outage: HashSet<(i32, IpFamily)> = HashSet::new(); let mut shadow_rotate_deadline: HashMap<(i32, IpFamily), Instant> = HashMap::new(); let mut idle_refresh_next_attempt: HashMap<(i32, IpFamily), Instant> = HashMap::new(); - let mut adaptive_idle_since: HashMap<(i32, IpFamily), Instant> = HashMap::new(); - let mut adaptive_recover_until: HashMap<(i32, IpFamily), Instant> = HashMap::new(); let mut floor_warn_next_allowed: HashMap<(i32, IpFamily), Instant> = HashMap::new(); let mut drain_warn_next_allowed: HashMap = HashMap::new(); let mut degraded_interval = true; @@ -109,8 +107,6 @@ pub async fn me_health_monitor(pool: Arc, rng: Arc, _min_c &mut single_endpoint_outage, &mut shadow_rotate_deadline, &mut idle_refresh_next_attempt, - &mut adaptive_idle_since, - &mut adaptive_recover_until, &mut floor_warn_next_allowed, ) .await; @@ -126,8 +122,6 @@ pub async fn me_health_monitor(pool: Arc, rng: Arc, _min_c &mut single_endpoint_outage, &mut shadow_rotate_deadline, &mut idle_refresh_next_attempt, - &mut adaptive_idle_since, - &mut adaptive_recover_until, &mut floor_warn_next_allowed, ) .await; @@ -360,8 +354,6 @@ async fn check_family( single_endpoint_outage: &mut HashSet<(i32, IpFamily)>, shadow_rotate_deadline: &mut HashMap<(i32, IpFamily), Instant>, idle_refresh_next_attempt: &mut HashMap<(i32, IpFamily), Instant>, - adaptive_idle_since: &mut HashMap<(i32, IpFamily), Instant>, - adaptive_recover_until: &mut HashMap<(i32, IpFamily), Instant>, floor_warn_next_allowed: &mut HashMap<(i32, IpFamily), Instant>, ) -> bool { let enabled = match family { @@ -394,8 +386,6 @@ async fn check_family( let reconnect_sem = Arc::new(Semaphore::new(reconnect_budget)); if pool.floor_mode() == MeFloorMode::Static { - adaptive_idle_since.clear(); - adaptive_recover_until.clear(); } let mut live_addr_counts = HashMap::<(i32, SocketAddr), usize>::new(); @@ -435,8 +425,6 @@ async fn check_family( &live_addr_counts, &live_writer_ids_by_addr, &bound_clients_by_writer, - adaptive_idle_since, - adaptive_recover_until, ) .await; pool.set_adaptive_floor_runtime_caps( @@ -503,8 +491,6 @@ async fn check_family( outage_next_attempt.remove(&key); shadow_rotate_deadline.remove(&key); idle_refresh_next_attempt.remove(&key); - adaptive_idle_since.remove(&key); - adaptive_recover_until.remove(&key); info!( dc = %dc, ?family, @@ -632,21 +618,25 @@ async fn check_family( restored += 1; continue; } - pool_for_reconnect - .stats - .increment_me_floor_cap_block_total(); - pool_for_reconnect - .stats - .increment_me_floor_swap_idle_failed_total(); - debug!( - dc = %dc, - ?family, - alive, - required, - active_cap_effective_total, - "Adaptive floor cap reached, reconnect attempt blocked" - ); - break; + + let base_req = pool_for_reconnect.required_writers_for_dc_with_floor_mode(endpoints_for_dc.len(), false); + if alive + restored >= base_req { + pool_for_reconnect + .stats + .increment_me_floor_cap_block_total(); + pool_for_reconnect + .stats + .increment_me_floor_swap_idle_failed_total(); + debug!( + dc = %dc, + ?family, + alive, + required, + active_cap_effective_total, + "Adaptive floor cap reached, reconnect attempt blocked" + ); + break; + } } let res = tokio::time::timeout( pool_for_reconnect.reconnect_runtime.me_one_timeout, @@ -904,8 +894,6 @@ async fn build_family_floor_plan( live_addr_counts: &HashMap<(i32, SocketAddr), usize>, live_writer_ids_by_addr: &HashMap<(i32, SocketAddr), Vec>, bound_clients_by_writer: &HashMap, - adaptive_idle_since: &mut HashMap<(i32, IpFamily), Instant>, - adaptive_recover_until: &mut HashMap<(i32, IpFamily), Instant>, ) -> FamilyFloorPlan { let mut entries = Vec::::new(); let mut by_dc = HashMap::::new(); @@ -921,18 +909,7 @@ async fn build_family_floor_plan( if endpoints.is_empty() { continue; } - let key = (*dc, family); - let reduce_for_idle = should_reduce_floor_for_idle( - pool, - key, - *dc, - endpoints, - live_writer_ids_by_addr, - bound_clients_by_writer, - adaptive_idle_since, - adaptive_recover_until, - ) - .await; + let _key = (*dc, family); let base_required = pool.required_writers_for_dc(endpoints.len()).max(1); let min_required = if is_adaptive { adaptive_floor_class_min(pool, endpoints.len(), base_required) @@ -947,11 +924,11 @@ async fn build_family_floor_plan( if max_required < min_required { max_required = min_required; } - let desired_raw = if is_adaptive && reduce_for_idle { - min_required - } else { - base_required - }; + // We initialize target_required at base_required to prevent 0-writer blackouts + // caused by proactively dropping an idle DC to a single fragile connection. + // The Adaptive Floor constraint loop below will gracefully compress idle DCs + // (prioritized via has_bound_clients = false) to min_required only when global capacity is reached. + let desired_raw = base_required; let target_required = desired_raw.clamp(min_required, max_required); let alive = endpoints .iter() @@ -1278,43 +1255,6 @@ async fn maybe_refresh_idle_writer_for_dc( ); } -async fn should_reduce_floor_for_idle( - pool: &Arc, - key: (i32, IpFamily), - dc: i32, - endpoints: &[SocketAddr], - live_writer_ids_by_addr: &HashMap<(i32, SocketAddr), Vec>, - bound_clients_by_writer: &HashMap, - adaptive_idle_since: &mut HashMap<(i32, IpFamily), Instant>, - adaptive_recover_until: &mut HashMap<(i32, IpFamily), Instant>, -) -> bool { - if pool.floor_mode() != MeFloorMode::Adaptive { - adaptive_idle_since.remove(&key); - adaptive_recover_until.remove(&key); - return false; - } - - let now = Instant::now(); - let writer_ids = list_writer_ids_for_endpoints(dc, endpoints, live_writer_ids_by_addr); - let has_bound_clients = has_bound_clients_on_endpoint(&writer_ids, bound_clients_by_writer); - if has_bound_clients { - adaptive_idle_since.remove(&key); - adaptive_recover_until.insert(key, now + pool.adaptive_floor_recover_grace_duration()); - return false; - } - - if let Some(recover_until) = adaptive_recover_until.get(&key) - && now < *recover_until - { - adaptive_idle_since.remove(&key); - return false; - } - adaptive_recover_until.remove(&key); - - let idle_since = adaptive_idle_since.entry(key).or_insert(now); - now.saturating_duration_since(*idle_since) >= pool.adaptive_floor_idle_duration() -} - fn has_bound_clients_on_endpoint( writer_ids: &[u64], bound_clients_by_writer: &HashMap, diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index 249d387..7b1d3e8 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -1422,22 +1422,6 @@ impl MePool { MeFloorMode::from_u8(self.floor_runtime.me_floor_mode.load(Ordering::Relaxed)) } - pub(super) fn adaptive_floor_idle_duration(&self) -> Duration { - Duration::from_secs( - self.floor_runtime - .me_adaptive_floor_idle_secs - .load(Ordering::Relaxed), - ) - } - - pub(super) fn adaptive_floor_recover_grace_duration(&self) -> Duration { - Duration::from_secs( - self.floor_runtime - .me_adaptive_floor_recover_grace_secs - .load(Ordering::Relaxed), - ) - } - pub(super) fn adaptive_floor_min_writers_multi_endpoint(&self) -> usize { (self .floor_runtime @@ -1659,6 +1643,7 @@ impl MePool { &self, contour: WriterContour, allow_coverage_override: bool, + writer_dc: i32, ) -> bool { let (active_writers, warm_writers, _) = self.non_draining_writer_counts_by_contour().await; match contour { @@ -1670,6 +1655,33 @@ impl MePool { if !allow_coverage_override { return false; } + + let mut endpoints_len = 0; + let now_epoch = Self::now_epoch_secs(); + if self.family_enabled_for_drain_coverage(IpFamily::V4, now_epoch) { + if let Some(addrs) = self.proxy_map_v4.read().await.get(&writer_dc) { + endpoints_len += addrs.len(); + } + } + if self.family_enabled_for_drain_coverage(IpFamily::V6, now_epoch) { + if let Some(addrs) = self.proxy_map_v6.read().await.get(&writer_dc) { + endpoints_len += addrs.len(); + } + } + + if endpoints_len > 0 { + let base_req = self.required_writers_for_dc_with_floor_mode(endpoints_len, false); + let active_for_dc = { + let ws = self.writers.read().await; + ws.iter() + .filter(|w| !w.draining.load(std::sync::atomic::Ordering::Relaxed) && w.writer_dc == writer_dc) + .count() + }; + if active_for_dc < base_req { + return true; + } + } + let coverage_required = self.active_coverage_required_total().await; active_writers < coverage_required } diff --git a/src/transport/middle_proxy/pool_writer.rs b/src/transport/middle_proxy/pool_writer.rs index fae68b9..52c8fae 100644 --- a/src/transport/middle_proxy/pool_writer.rs +++ b/src/transport/middle_proxy/pool_writer.rs @@ -342,7 +342,7 @@ impl MePool { allow_coverage_override: bool, ) -> Result<()> { if !self - .can_open_writer_for_contour(contour, allow_coverage_override) + .can_open_writer_for_contour(contour, allow_coverage_override, writer_dc) .await { return Err(ProxyError::Proxy(format!( From 2b8159a65ea45e12808df3ac3f7a3e8bd0fe8d79 Mon Sep 17 00:00:00 2001 From: miniusercoder Date: Mon, 6 Apr 2026 21:06:53 +0300 Subject: [PATCH 02/15] fix(pool): enhance reconnect logic for single-endpoint data centers --- src/transport/middle_proxy/health.rs | 38 ++++++++++++++++++----- src/transport/middle_proxy/pool.rs | 11 ++++++- src/transport/middle_proxy/pool_refill.rs | 13 ++++++-- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/transport/middle_proxy/health.rs b/src/transport/middle_proxy/health.rs index 9f6e1bb..e56d001 100644 --- a/src/transport/middle_proxy/health.rs +++ b/src/transport/middle_proxy/health.rs @@ -67,10 +67,8 @@ struct FamilyReconnectOutcome { key: (i32, IpFamily), dc: i32, family: IpFamily, - alive: usize, required: usize, endpoint_count: usize, - restored: usize, } pub async fn me_health_monitor(pool: Arc, rng: Arc, _min_connections: usize) { @@ -638,6 +636,7 @@ async fn check_family( break; } } + pool_for_reconnect.stats.increment_me_reconnect_attempt(); let res = tokio::time::timeout( pool_for_reconnect.reconnect_runtime.me_one_timeout, pool_for_reconnect.connect_endpoints_round_robin( @@ -653,11 +652,9 @@ async fn check_family( pool_for_reconnect.stats.increment_me_reconnect_success(); } Ok(false) => { - pool_for_reconnect.stats.increment_me_reconnect_attempt(); debug!(dc = %dc, ?family, "ME round-robin reconnect failed") } Err(_) => { - pool_for_reconnect.stats.increment_me_reconnect_attempt(); debug!(dc = %dc, ?family, "ME reconnect timed out"); } } @@ -668,10 +665,8 @@ async fn check_family( key, dc, family, - alive, required, endpoint_count: endpoints_for_dc.len(), - restored, } }); } @@ -685,7 +680,7 @@ async fn check_family( } }; let now = Instant::now(); - let now_alive = outcome.alive + outcome.restored; + let now_alive = live_active_writers_for_dc_family(pool, outcome.dc, outcome.family).await; if now_alive >= outcome.required { info!( dc = %outcome.dc, @@ -841,6 +836,33 @@ fn should_emit_rate_limited_warn( false } +async fn live_active_writers_for_dc_family(pool: &Arc, dc: i32, family: IpFamily) -> usize { + let writers = pool.writers.read().await; + writers + .iter() + .filter(|writer| { + if writer.draining.load(std::sync::atomic::Ordering::Relaxed) { + return false; + } + if writer.writer_dc != dc { + return false; + } + if !matches!( + super::pool::WriterContour::from_u8( + writer.contour.load(std::sync::atomic::Ordering::Relaxed), + ), + super::pool::WriterContour::Active + ) { + return false; + } + match family { + IpFamily::V4 => writer.addr.is_ipv4(), + IpFamily::V6 => writer.addr.is_ipv6(), + } + }) + .count() +} + fn adaptive_floor_class_min( pool: &Arc, endpoint_count: usize, @@ -1304,6 +1326,7 @@ async fn recover_single_endpoint_outage( ); return; }; + pool.stats.increment_me_reconnect_attempt(); pool.stats .increment_me_single_endpoint_outage_reconnect_attempt_total(); @@ -1379,7 +1402,6 @@ async fn recover_single_endpoint_outage( return; } - pool.stats.increment_me_reconnect_attempt(); let current_ms = *outage_backoff.get(&key).unwrap_or(&min_backoff_ms); let next_ms = current_ms.saturating_mul(2).min(max_backoff_ms); outage_backoff.insert(key, next_ms); diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index 7b1d3e8..315b6fc 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -1674,7 +1674,16 @@ impl MePool { let active_for_dc = { let ws = self.writers.read().await; ws.iter() - .filter(|w| !w.draining.load(std::sync::atomic::Ordering::Relaxed) && w.writer_dc == writer_dc) + .filter(|w| { + !w.draining.load(std::sync::atomic::Ordering::Relaxed) + && w.writer_dc == writer_dc + && matches!( + WriterContour::from_u8( + w.contour.load(std::sync::atomic::Ordering::Relaxed), + ), + WriterContour::Active + ) + }) .count() }; if active_for_dc < base_req { diff --git a/src/transport/middle_proxy/pool_refill.rs b/src/transport/middle_proxy/pool_refill.rs index 69d8aa0..f43ec3e 100644 --- a/src/transport/middle_proxy/pool_refill.rs +++ b/src/transport/middle_proxy/pool_refill.rs @@ -236,8 +236,18 @@ impl MePool { let fast_retries = self.reconnect_runtime.me_reconnect_fast_retry_count.max(1); let mut total_attempts = 0u32; let same_endpoint_quarantined = self.is_endpoint_quarantined(addr).await; + let dc_endpoints = self.endpoints_for_dc(writer_dc).await; + let single_endpoint_dc = dc_endpoints.len() == 1 && dc_endpoints[0] == addr; + let bypass_quarantine_for_single_endpoint = + single_endpoint_dc && self.single_endpoint_outage_disable_quarantine(); - if !same_endpoint_quarantined { + if !same_endpoint_quarantined || bypass_quarantine_for_single_endpoint { + if same_endpoint_quarantined && bypass_quarantine_for_single_endpoint { + debug!( + %addr, + "Bypassing quarantine for immediate reconnect on single-endpoint DC" + ); + } for attempt in 0..fast_retries { if total_attempts >= ME_REFILL_TOTAL_ATTEMPT_CAP { break; @@ -276,7 +286,6 @@ impl MePool { ); } - let dc_endpoints = self.endpoints_for_dc(writer_dc).await; if dc_endpoints.is_empty() { self.stats.increment_me_refill_failed_total(); return false; From 19f9eb36ac3278e3fc572361b27e5cccf89f2868 Mon Sep 17 00:00:00 2001 From: miniusercoder Date: Mon, 6 Apr 2026 21:38:19 +0300 Subject: [PATCH 03/15] docs(api): update descriptions for outage mode parameters in API documentation --- docs/API.md | 2 +- docs/CONFIG_PARAMS.en.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/API.md b/docs/API.md index a1f0f4f..886d159 100644 --- a/docs/API.md +++ b/docs/API.md @@ -919,7 +919,7 @@ Note: the request contract is defined, but the corresponding route currently ret | `me_bind_stale_ttl_secs` | `u64` | Stale writer TTL. | | `me_single_endpoint_shadow_writers` | `u8` | Shadow writers for single-endpoint DCs. | | `me_single_endpoint_outage_mode_enabled` | `bool` | Outage mode toggle for single-endpoint DCs. | -| `me_single_endpoint_outage_disable_quarantine` | `bool` | Quarantine behavior in outage mode. | +| `me_single_endpoint_outage_disable_quarantine` | `bool` | Allows reconnect attempts to bypass endpoint quarantine for single-endpoint outage recovery paths. | | `me_single_endpoint_outage_backoff_min_ms` | `u64` | Outage mode min reconnect backoff. | | `me_single_endpoint_outage_backoff_max_ms` | `u64` | Outage mode max reconnect backoff. | | `me_single_endpoint_shadow_rotate_every_secs` | `u64` | Shadow rotation interval. | diff --git a/docs/CONFIG_PARAMS.en.md b/docs/CONFIG_PARAMS.en.md index 1222e89..ed3796d 100644 --- a/docs/CONFIG_PARAMS.en.md +++ b/docs/CONFIG_PARAMS.en.md @@ -738,7 +738,7 @@ This document lists all configuration keys accepted by `config.toml`. - `me_single_endpoint_outage_disable_quarantine` - **Constraints / validation**: `bool`. - - **Description**: Ignores endpoint quarantine while in single-endpoint outage mode. + - **Description**: Allows single-endpoint outage recovery reconnect paths to bypass endpoint quarantine. - **Example**: ```toml @@ -788,7 +788,7 @@ This document lists all configuration keys accepted by `config.toml`. - `me_adaptive_floor_idle_secs` - **Constraints / validation**: `u64` (seconds). - - **Description**: Idle time before adaptive floor may reduce the single-endpoint writer target. + - **Description**: Reserved adaptive-floor timing knob exposed in runtime config and API snapshots for compatibility. - **Example**: ```toml @@ -818,7 +818,7 @@ This document lists all configuration keys accepted by `config.toml`. - `me_adaptive_floor_recover_grace_secs` - **Constraints / validation**: `u64` (seconds). - - **Description**: Grace period to hold static floor after activity in adaptive mode. + - **Description**: Reserved adaptive-floor grace knob exposed in runtime config and API snapshots for compatibility. - **Example**: ```toml From b6a30c1b51da55568e999ac49e8983d5f6839583 Mon Sep 17 00:00:00 2001 From: miniusercoder Date: Tue, 7 Apr 2026 13:52:35 +0300 Subject: [PATCH 04/15] refactor: cargo fmt fixes --- src/transport/middle_proxy/health.rs | 6 +++--- src/transport/middle_proxy/pool.rs | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/transport/middle_proxy/health.rs b/src/transport/middle_proxy/health.rs index e56d001..000bca0 100644 --- a/src/transport/middle_proxy/health.rs +++ b/src/transport/middle_proxy/health.rs @@ -383,8 +383,7 @@ async fn check_family( let reconnect_budget = health_reconnect_budget(pool, dc_endpoints.len()); let reconnect_sem = Arc::new(Semaphore::new(reconnect_budget)); - if pool.floor_mode() == MeFloorMode::Static { - } + if pool.floor_mode() == MeFloorMode::Static {} let mut live_addr_counts = HashMap::<(i32, SocketAddr), usize>::new(); let mut live_writer_ids_by_addr = HashMap::<(i32, SocketAddr), Vec>::new(); @@ -617,7 +616,8 @@ async fn check_family( continue; } - let base_req = pool_for_reconnect.required_writers_for_dc_with_floor_mode(endpoints_for_dc.len(), false); + let base_req = pool_for_reconnect + .required_writers_for_dc_with_floor_mode(endpoints_for_dc.len(), false); if alive + restored >= base_req { pool_for_reconnect .stats diff --git a/src/transport/middle_proxy/pool.rs b/src/transport/middle_proxy/pool.rs index 315b6fc..b89a844 100644 --- a/src/transport/middle_proxy/pool.rs +++ b/src/transport/middle_proxy/pool.rs @@ -1670,7 +1670,8 @@ impl MePool { } if endpoints_len > 0 { - let base_req = self.required_writers_for_dc_with_floor_mode(endpoints_len, false); + let base_req = + self.required_writers_for_dc_with_floor_mode(endpoints_len, false); let active_for_dc = { let ws = self.writers.read().await; ws.iter() From 185e0081d7d7b801322ebb9ad06e313c58dc8e74 Mon Sep 17 00:00:00 2001 From: miniusercoder Date: Tue, 7 Apr 2026 18:57:22 +0300 Subject: [PATCH 05/15] fix(pool): improve endpoint handling during single endpoint outages --- src/transport/middle_proxy/pool_refill.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/transport/middle_proxy/pool_refill.rs b/src/transport/middle_proxy/pool_refill.rs index f43ec3e..bb62604 100644 --- a/src/transport/middle_proxy/pool_refill.rs +++ b/src/transport/middle_proxy/pool_refill.rs @@ -77,6 +77,12 @@ impl MePool { return Vec::new(); } + if endpoints.len() == 1 && self.single_endpoint_outage_disable_quarantine() { + let mut guard = self.endpoint_quarantine.lock().await; + guard.retain(|_, expiry| *expiry > Instant::now()); + return endpoints.to_vec(); + } + let mut guard = self.endpoint_quarantine.lock().await; let now = Instant::now(); guard.retain(|_, expiry| *expiry > now); From 7b1aa46753cc6efed2cb260b2cc3e316e8392543 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:19:06 +0300 Subject: [PATCH 06/15] Deleting API and CONFIG_PARAMS --- docs/API.md | 1137 ------------- docs/CONFIG_PARAMS.en.md | 3300 -------------------------------------- 2 files changed, 4437 deletions(-) delete mode 100644 docs/API.md delete mode 100644 docs/CONFIG_PARAMS.en.md diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index 886d159..0000000 --- a/docs/API.md +++ /dev/null @@ -1,1137 +0,0 @@ -# Telemt Control API - -## Purpose -Control-plane HTTP API for runtime visibility and user/config management. -Data-plane MTProto traffic is out of scope. - -## Runtime Configuration -API runtime is configured in `[server.api]`. - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `enabled` | `bool` | `false` | Enables REST API listener. | -| `listen` | `string` (`IP:PORT`) | `127.0.0.1:9091` | API bind address. | -| `whitelist` | `CIDR[]` | `127.0.0.1/32, ::1/128` | Source IP allowlist. Empty list means allow all. | -| `auth_header` | `string` | `""` | Exact value for `Authorization` header. Empty disables header auth. | -| `request_body_limit_bytes` | `usize` | `65536` | Maximum request body size. Must be `> 0`. | -| `minimal_runtime_enabled` | `bool` | `false` | Enables runtime snapshot endpoints requiring ME pool read-lock aggregation. | -| `minimal_runtime_cache_ttl_ms` | `u64` | `1000` | Cache TTL for minimal snapshots. `0` disables cache; valid range is `[0, 60000]`. | -| `runtime_edge_enabled` | `bool` | `false` | Enables runtime edge endpoints with cached aggregation payloads. | -| `runtime_edge_cache_ttl_ms` | `u64` | `1000` | Cache TTL for runtime edge summary payloads. `0` disables cache. | -| `runtime_edge_top_n` | `usize` | `10` | Top-N rows for runtime edge leaderboard payloads. | -| `runtime_edge_events_capacity` | `usize` | `256` | Ring-buffer size for `/v1/runtime/events/recent`. | -| `read_only` | `bool` | `false` | Disables mutating endpoints. | - -`server.admin_api` is accepted as an alias for backward compatibility. - -Runtime validation for API config: -- `server.api.listen` must be a valid `IP:PORT`. -- `server.api.request_body_limit_bytes` must be `> 0`. -- `server.api.minimal_runtime_cache_ttl_ms` must be within `[0, 60000]`. -- `server.api.runtime_edge_cache_ttl_ms` must be within `[0, 60000]`. -- `server.api.runtime_edge_top_n` must be within `[1, 1000]`. -- `server.api.runtime_edge_events_capacity` must be within `[16, 4096]`. - -## Protocol Contract - -| Item | Value | -| --- | --- | -| Transport | HTTP/1.1 | -| Content type | `application/json; charset=utf-8` | -| Prefix | `/v1` | -| Optimistic concurrency | `If-Match: ` on mutating requests (optional) | -| Revision format | SHA-256 hex of current `config.toml` content | - -### Success Envelope -```json -{ - "ok": true, - "data": {}, - "revision": "sha256-hex" -} -``` - -### Error Envelope -```json -{ - "ok": false, - "error": { - "code": "machine_code", - "message": "human-readable" - }, - "request_id": 1 -} -``` - -## Request Processing Order - -Requests are processed in this order: -1. `api_enabled` gate (`503 api_disabled` if disabled). -2. Source IP whitelist gate (`403 forbidden`). -3. `Authorization` header gate when configured (`401 unauthorized`). -4. Route and method matching (`404 not_found` or `405 method_not_allowed`). -5. `read_only` gate for mutating routes (`403 read_only`). -6. Request body read/limit/JSON decode (`413 payload_too_large`, `400 bad_request`). -7. Business validation and config write path. - -Notes: -- Whitelist is evaluated against the direct TCP peer IP (`SocketAddr::ip`), without `X-Forwarded-For` support. -- `Authorization` check is exact string equality against configured `auth_header`. - -## Endpoint Matrix - -| Method | Path | Body | Success | `data` contract | -| --- | --- | --- | --- | --- | -| `GET` | `/v1/health` | none | `200` | `HealthData` | -| `GET` | `/v1/system/info` | none | `200` | `SystemInfoData` | -| `GET` | `/v1/runtime/gates` | none | `200` | `RuntimeGatesData` | -| `GET` | `/v1/runtime/initialization` | none | `200` | `RuntimeInitializationData` | -| `GET` | `/v1/limits/effective` | none | `200` | `EffectiveLimitsData` | -| `GET` | `/v1/security/posture` | none | `200` | `SecurityPostureData` | -| `GET` | `/v1/security/whitelist` | none | `200` | `SecurityWhitelistData` | -| `GET` | `/v1/stats/summary` | none | `200` | `SummaryData` | -| `GET` | `/v1/stats/zero/all` | none | `200` | `ZeroAllData` | -| `GET` | `/v1/stats/upstreams` | none | `200` | `UpstreamsData` | -| `GET` | `/v1/stats/minimal/all` | none | `200` | `MinimalAllData` | -| `GET` | `/v1/stats/me-writers` | none | `200` | `MeWritersData` | -| `GET` | `/v1/stats/dcs` | none | `200` | `DcStatusData` | -| `GET` | `/v1/runtime/me_pool_state` | none | `200` | `RuntimeMePoolStateData` | -| `GET` | `/v1/runtime/me_quality` | none | `200` | `RuntimeMeQualityData` | -| `GET` | `/v1/runtime/upstream_quality` | none | `200` | `RuntimeUpstreamQualityData` | -| `GET` | `/v1/runtime/nat_stun` | none | `200` | `RuntimeNatStunData` | -| `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` | -| `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` | -| `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` | -| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` | -| `GET` | `/v1/users` | none | `200` | `UserInfo[]` | -| `POST` | `/v1/users` | `CreateUserRequest` | `201` | `CreateUserResponse` | -| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` | -| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` | `UserInfo` | -| `DELETE` | `/v1/users/{username}` | none | `200` | `string` (deleted username) | -| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `404` | `ErrorResponse` (`not_found`, current runtime behavior) | - -## Common Error Codes - -| HTTP | `error.code` | Trigger | -| --- | --- | --- | -| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. | -| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. | -| `403` | `forbidden` | Source IP is not allowed by whitelist. | -| `403` | `read_only` | Mutating endpoint called while `read_only=true`. | -| `404` | `not_found` | Unknown route, unknown user, or unsupported sub-route (including current `rotate-secret` route). | -| `405` | `method_not_allowed` | Unsupported method for `/v1/users/{username}` route shape. | -| `409` | `revision_conflict` | `If-Match` revision mismatch. | -| `409` | `user_exists` | User already exists on create. | -| `409` | `last_user_forbidden` | Attempt to delete last configured user. | -| `413` | `payload_too_large` | Body exceeds `request_body_limit_bytes`. | -| `500` | `internal_error` | Internal error (I/O, serialization, config load/save). | -| `503` | `api_disabled` | API disabled in config. | - -## Routing and Method Edge Cases - -| Case | Behavior | -| --- | --- | -| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. | -| Trailing slash | Not normalized. Example: `/v1/users/` is `404`. | -| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. | -| `PUT /v1/users/{username}` | `405 method_not_allowed`. | -| `POST /v1/users/{username}` | `404 not_found`. | -| `POST /v1/users/{username}/rotate-secret` | `404 not_found` in current release due route matcher limitation. | - -## Body and JSON Semantics - -- Request body is read only for mutating routes that define a body contract. -- Body size limit is enforced during streaming read (`413 payload_too_large`). -- Invalid transport body frame returns `400 bad_request` (`Invalid request body`). -- Invalid JSON returns `400 bad_request` (`Invalid JSON body`). -- `Content-Type` is not required for JSON parsing. -- Unknown JSON fields are ignored by deserialization. -- `PATCH` updates only provided fields and does not support explicit clearing of optional fields. -- `If-Match` supports both quoted and unquoted values; surrounding whitespace is trimmed. - -## Query Parameters - -| Endpoint | Query | Behavior | -| --- | --- | --- | -| `GET /v1/runtime/events/recent` | `limit=` | Optional. Invalid/missing value falls back to default `50`. Effective value is clamped to `[1, 1000]` and additionally bounded by ring-buffer capacity. | - -## Request Contracts - -### `CreateUserRequest` -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `username` | `string` | yes | `[A-Za-z0-9_.-]`, length `1..64`. | -| `secret` | `string` | no | Exactly 32 hex chars. If missing, generated automatically. | -| `user_ad_tag` | `string` | no | Exactly 32 hex chars. | -| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. | -| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. | -| `data_quota_bytes` | `u64` | no | Per-user traffic quota. | -| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. | - -### `PatchUserRequest` -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `secret` | `string` | no | Exactly 32 hex chars. | -| `user_ad_tag` | `string` | no | Exactly 32 hex chars. | -| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. | -| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. | -| `data_quota_bytes` | `u64` | no | Per-user traffic quota. | -| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. | - -### `RotateSecretRequest` -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `secret` | `string` | no | Exactly 32 hex chars. If missing, generated automatically. | - -Note: the request contract is defined, but the corresponding route currently returns `404` (see routing edge cases). - -## Response Data Contracts - -### `HealthData` -| Field | Type | Description | -| --- | --- | --- | -| `status` | `string` | Always `"ok"`. | -| `read_only` | `bool` | Mirrors current API `read_only` mode. | - -### `SummaryData` -| Field | Type | Description | -| --- | --- | --- | -| `uptime_seconds` | `f64` | Process uptime in seconds. | -| `connections_total` | `u64` | Total accepted client connections. | -| `connections_bad_total` | `u64` | Failed/invalid client connections. | -| `handshake_timeouts_total` | `u64` | Handshake timeout count. | -| `configured_users` | `usize` | Number of configured users in config. | - -### `SystemInfoData` -| Field | Type | Description | -| --- | --- | --- | -| `version` | `string` | Binary version (`CARGO_PKG_VERSION`). | -| `target_arch` | `string` | Target architecture (`std::env::consts::ARCH`). | -| `target_os` | `string` | Target OS (`std::env::consts::OS`). | -| `build_profile` | `string` | Build profile (`PROFILE` env when available). | -| `git_commit` | `string?` | Optional commit hash from build env metadata. | -| `build_time_utc` | `string?` | Optional build timestamp from build env metadata. | -| `rustc_version` | `string?` | Optional compiler version from build env metadata. | -| `process_started_at_epoch_secs` | `u64` | Process start time as Unix epoch seconds. | -| `uptime_seconds` | `f64` | Process uptime in seconds. | -| `config_path` | `string` | Active config file path used by runtime. | -| `config_hash` | `string` | SHA-256 hash of current config content (same value as envelope `revision`). | -| `config_reload_count` | `u64` | Number of successfully observed config updates since process start. | -| `last_config_reload_epoch_secs` | `u64?` | Unix epoch seconds of the latest observed config reload; null/absent before first reload. | - -### `RuntimeGatesData` -| Field | Type | Description | -| --- | --- | --- | -| `accepting_new_connections` | `bool` | Current admission-gate state for new listener accepts. | -| `conditional_cast_enabled` | `bool` | Whether conditional ME admission logic is enabled (`general.use_middle_proxy`). | -| `me_runtime_ready` | `bool` | Current ME runtime readiness status used for conditional gate decisions. | -| `me2dc_fallback_enabled` | `bool` | Whether ME -> direct fallback is enabled. | -| `use_middle_proxy` | `bool` | Current transport mode preference. | -| `startup_status` | `string` | Startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). | -| `startup_stage` | `string` | Current startup stage identifier. | -| `startup_progress_pct` | `f64` | Startup progress percentage (`0..100`). | - -### `RuntimeInitializationData` -| Field | Type | Description | -| --- | --- | --- | -| `status` | `string` | Startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). | -| `degraded` | `bool` | Whether runtime is currently in degraded mode. | -| `current_stage` | `string` | Current startup stage identifier. | -| `progress_pct` | `f64` | Overall startup progress percentage (`0..100`). | -| `started_at_epoch_secs` | `u64` | Process start timestamp (Unix seconds). | -| `ready_at_epoch_secs` | `u64?` | Timestamp when startup reached ready state; absent until ready. | -| `total_elapsed_ms` | `u64` | Elapsed startup duration in milliseconds. | -| `transport_mode` | `string` | Startup transport mode (`middle_proxy` or `direct`). | -| `me` | `RuntimeInitializationMeData` | ME startup substate snapshot. | -| `components` | `RuntimeInitializationComponentData[]` | Per-component startup timeline and status. | - -#### `RuntimeInitializationMeData` -| Field | Type | Description | -| --- | --- | --- | -| `status` | `string` | ME startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). | -| `current_stage` | `string` | Current ME startup stage identifier. | -| `progress_pct` | `f64` | ME startup progress percentage (`0..100`). | -| `init_attempt` | `u32` | Current ME init attempt counter. | -| `retry_limit` | `string` | Retry limit (`"unlimited"` or numeric string). | -| `last_error` | `string?` | Last ME initialization error text when present. | - -#### `RuntimeInitializationComponentData` -| Field | Type | Description | -| --- | --- | --- | -| `id` | `string` | Startup component identifier. | -| `title` | `string` | Human-readable component title. | -| `status` | `string` | Component status (`pending`, `running`, `ready`, `failed`, `skipped`). | -| `started_at_epoch_ms` | `u64?` | Component start timestamp in Unix milliseconds. | -| `finished_at_epoch_ms` | `u64?` | Component finish timestamp in Unix milliseconds. | -| `duration_ms` | `u64?` | Component duration in milliseconds. | -| `attempts` | `u32` | Attempt counter for this component. | -| `details` | `string?` | Optional short status details text. | - -### `EffectiveLimitsData` -| Field | Type | Description | -| --- | --- | --- | -| `update_every_secs` | `u64` | Effective unified updater interval. | -| `me_reinit_every_secs` | `u64` | Effective ME periodic reinit interval. | -| `me_pool_force_close_secs` | `u64` | Effective stale-writer force-close timeout. | -| `timeouts` | `EffectiveTimeoutLimits` | Effective timeout policy snapshot. | -| `upstream` | `EffectiveUpstreamLimits` | Effective upstream connect/retry limits. | -| `middle_proxy` | `EffectiveMiddleProxyLimits` | Effective ME pool/floor/reconnect limits. | -| `user_ip_policy` | `EffectiveUserIpPolicyLimits` | Effective unique-IP policy mode/window. | - -#### `EffectiveTimeoutLimits` -| Field | Type | Description | -| --- | --- | --- | -| `client_handshake_secs` | `u64` | Client handshake timeout. | -| `tg_connect_secs` | `u64` | Upstream Telegram connect timeout. | -| `client_keepalive_secs` | `u64` | Client keepalive interval. | -| `client_ack_secs` | `u64` | ACK timeout. | -| `me_one_retry` | `u8` | Fast retry count for single-endpoint ME DC. | -| `me_one_timeout_ms` | `u64` | Fast retry timeout per attempt for single-endpoint ME DC. | - -#### `EffectiveUpstreamLimits` -| Field | Type | Description | -| --- | --- | --- | -| `connect_retry_attempts` | `u32` | Upstream connect retry attempts. | -| `connect_retry_backoff_ms` | `u64` | Upstream retry backoff delay. | -| `connect_budget_ms` | `u64` | Total connect wall-clock budget across retries. | -| `unhealthy_fail_threshold` | `u32` | Consecutive fail threshold for unhealthy marking. | -| `connect_failfast_hard_errors` | `bool` | Whether hard errors skip additional retries. | - -#### `EffectiveMiddleProxyLimits` -| Field | Type | Description | -| --- | --- | --- | -| `floor_mode` | `string` | Effective floor mode (`static` or `adaptive`). | -| `adaptive_floor_idle_secs` | `u64` | Adaptive floor idle threshold. | -| `adaptive_floor_min_writers_single_endpoint` | `u8` | Adaptive floor minimum for single-endpoint DCs. | -| `adaptive_floor_min_writers_multi_endpoint` | `u8` | Adaptive floor minimum for multi-endpoint DCs. | -| `adaptive_floor_recover_grace_secs` | `u64` | Adaptive floor recovery grace period. | -| `adaptive_floor_writers_per_core_total` | `u16` | Target total writers-per-core budget in adaptive mode. | -| `adaptive_floor_cpu_cores_override` | `u16` | Manual CPU core override (`0` means auto-detect). | -| `adaptive_floor_max_extra_writers_single_per_core` | `u16` | Extra per-core adaptive headroom for single-endpoint DCs. | -| `adaptive_floor_max_extra_writers_multi_per_core` | `u16` | Extra per-core adaptive headroom for multi-endpoint DCs. | -| `adaptive_floor_max_active_writers_per_core` | `u16` | Active writer cap per CPU core. | -| `adaptive_floor_max_warm_writers_per_core` | `u16` | Warm writer cap per CPU core. | -| `adaptive_floor_max_active_writers_global` | `u32` | Global active writer cap. | -| `adaptive_floor_max_warm_writers_global` | `u32` | Global warm writer cap. | -| `reconnect_max_concurrent_per_dc` | `u32` | Max concurrent reconnects per DC. | -| `reconnect_backoff_base_ms` | `u64` | Reconnect base backoff. | -| `reconnect_backoff_cap_ms` | `u64` | Reconnect backoff cap. | -| `reconnect_fast_retry_count` | `u32` | Number of fast retries before standard backoff strategy. | -| `writer_pick_mode` | `string` | Writer picker mode (`sorted_rr`, `p2c`). | -| `writer_pick_sample_size` | `u8` | Candidate sample size for `p2c` picker mode. | -| `me2dc_fallback` | `bool` | Effective ME -> direct fallback flag. | - -#### `EffectiveUserIpPolicyLimits` -| Field | Type | Description | -| --- | --- | --- | -| `mode` | `string` | Unique-IP policy mode (`active_window`, `time_window`, `combined`). | -| `window_secs` | `u64` | Time window length used by unique-IP policy. | - -### `SecurityPostureData` -| Field | Type | Description | -| --- | --- | --- | -| `api_read_only` | `bool` | Current API read-only state. | -| `api_whitelist_enabled` | `bool` | Whether whitelist filtering is active. | -| `api_whitelist_entries` | `usize` | Number of configured whitelist CIDRs. | -| `api_auth_header_enabled` | `bool` | Whether `Authorization` header validation is active. | -| `proxy_protocol_enabled` | `bool` | Global PROXY protocol accept setting. | -| `log_level` | `string` | Effective log level (`debug`, `verbose`, `normal`, `silent`). | -| `telemetry_core_enabled` | `bool` | Core telemetry toggle. | -| `telemetry_user_enabled` | `bool` | Per-user telemetry toggle. | -| `telemetry_me_level` | `string` | ME telemetry level (`silent`, `normal`, `debug`). | - -### `SecurityWhitelistData` -| Field | Type | Description | -| --- | --- | --- | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `enabled` | `bool` | `true` when whitelist has at least one CIDR entry. | -| `entries_total` | `usize` | Number of whitelist CIDR entries. | -| `entries` | `string[]` | Whitelist CIDR entries as strings. | - -### `RuntimeMePoolStateData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Runtime payload availability. | -| `reason` | `string?` | `source_unavailable` when ME pool snapshot is unavailable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `data` | `RuntimeMePoolStatePayload?` | Null when unavailable. | - -#### `RuntimeMePoolStatePayload` -| Field | Type | Description | -| --- | --- | --- | -| `generations` | `RuntimeMePoolStateGenerationData` | Active/warm/pending/draining generation snapshot. | -| `hardswap` | `RuntimeMePoolStateHardswapData` | Hardswap state flags. | -| `writers` | `RuntimeMePoolStateWriterData` | Writer total/contour/health counters. | -| `refill` | `RuntimeMePoolStateRefillData` | In-flight refill counters by DC/family. | - -#### `RuntimeMePoolStateGenerationData` -| Field | Type | Description | -| --- | --- | --- | -| `active_generation` | `u64` | Active pool generation id. | -| `warm_generation` | `u64` | Warm pool generation id. | -| `pending_hardswap_generation` | `u64` | Pending hardswap generation id (`0` when none). | -| `pending_hardswap_age_secs` | `u64?` | Age of pending hardswap generation in seconds. | -| `draining_generations` | `u64[]` | Distinct generation ids currently draining. | - -#### `RuntimeMePoolStateHardswapData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Hardswap feature toggle. | -| `pending` | `bool` | `true` when pending generation is non-zero. | - -#### `RuntimeMePoolStateWriterData` -| Field | Type | Description | -| --- | --- | --- | -| `total` | `usize` | Total writer rows in snapshot. | -| `alive_non_draining` | `usize` | Alive writers excluding draining ones. | -| `draining` | `usize` | Writers marked draining. | -| `degraded` | `usize` | Non-draining degraded writers. | -| `contour` | `RuntimeMePoolStateWriterContourData` | Counts by contour state. | -| `health` | `RuntimeMePoolStateWriterHealthData` | Counts by health bucket. | - -#### `RuntimeMePoolStateWriterContourData` -| Field | Type | Description | -| --- | --- | --- | -| `warm` | `usize` | Writers in warm contour. | -| `active` | `usize` | Writers in active contour. | -| `draining` | `usize` | Writers in draining contour. | - -#### `RuntimeMePoolStateWriterHealthData` -| Field | Type | Description | -| --- | --- | --- | -| `healthy` | `usize` | Non-draining non-degraded writers. | -| `degraded` | `usize` | Non-draining degraded writers. | -| `draining` | `usize` | Draining writers. | - -#### `RuntimeMePoolStateRefillData` -| Field | Type | Description | -| --- | --- | --- | -| `inflight_endpoints_total` | `usize` | Total in-flight endpoint refill operations. | -| `inflight_dc_total` | `usize` | Number of distinct DC+family keys with refill in flight. | -| `by_dc` | `RuntimeMePoolStateRefillDcData[]` | Per-DC refill rows. | - -#### `RuntimeMePoolStateRefillDcData` -| Field | Type | Description | -| --- | --- | --- | -| `dc` | `i16` | Telegram DC id. | -| `family` | `string` | Address family label (`V4`, `V6`). | -| `inflight` | `usize` | In-flight refill operations for this row. | - -### `RuntimeMeQualityData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Runtime payload availability. | -| `reason` | `string?` | `source_unavailable` when ME pool snapshot is unavailable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `data` | `RuntimeMeQualityPayload?` | Null when unavailable. | - -#### `RuntimeMeQualityPayload` -| Field | Type | Description | -| --- | --- | --- | -| `counters` | `RuntimeMeQualityCountersData` | Key ME lifecycle/error counters. | -| `route_drops` | `RuntimeMeQualityRouteDropData` | Route drop counters by reason. | -| `dc_rtt` | `RuntimeMeQualityDcRttData[]` | Per-DC RTT and writer coverage rows. | - -#### `RuntimeMeQualityCountersData` -| Field | Type | Description | -| --- | --- | --- | -| `idle_close_by_peer_total` | `u64` | Peer-initiated idle closes. | -| `reader_eof_total` | `u64` | Reader EOF events. | -| `kdf_drift_total` | `u64` | KDF drift detections. | -| `kdf_port_only_drift_total` | `u64` | KDF port-only drift detections. | -| `reconnect_attempt_total` | `u64` | Reconnect attempts. | -| `reconnect_success_total` | `u64` | Successful reconnects. | - -#### `RuntimeMeQualityRouteDropData` -| Field | Type | Description | -| --- | --- | --- | -| `no_conn_total` | `u64` | Route drops with no connection mapping. | -| `channel_closed_total` | `u64` | Route drops because destination channel is closed. | -| `queue_full_total` | `u64` | Route drops due queue backpressure (aggregate). | -| `queue_full_base_total` | `u64` | Route drops in base-queue path. | -| `queue_full_high_total` | `u64` | Route drops in high-priority queue path. | - -#### `RuntimeMeQualityDcRttData` -| Field | Type | Description | -| --- | --- | --- | -| `dc` | `i16` | Telegram DC id. | -| `rtt_ema_ms` | `f64?` | RTT EMA for this DC. | -| `alive_writers` | `usize` | Alive writers currently mapped to this DC. | -| `required_writers` | `usize` | Target writer floor for this DC. | -| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. | - -### `RuntimeUpstreamQualityData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Runtime payload availability. | -| `reason` | `string?` | `source_unavailable` when upstream runtime snapshot is unavailable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `policy` | `RuntimeUpstreamQualityPolicyData` | Effective upstream policy values. | -| `counters` | `RuntimeUpstreamQualityCountersData` | Upstream connect counters. | -| `summary` | `RuntimeUpstreamQualitySummaryData?` | Aggregate runtime health summary. | -| `upstreams` | `RuntimeUpstreamQualityUpstreamData[]?` | Per-upstream runtime rows. | - -#### `RuntimeUpstreamQualityPolicyData` -| Field | Type | Description | -| --- | --- | --- | -| `connect_retry_attempts` | `u32` | Upstream connect retry attempts. | -| `connect_retry_backoff_ms` | `u64` | Upstream retry backoff delay. | -| `connect_budget_ms` | `u64` | Total connect wall-clock budget. | -| `unhealthy_fail_threshold` | `u32` | Consecutive fail threshold for unhealthy marking. | -| `connect_failfast_hard_errors` | `bool` | Whether hard errors skip retries. | - -#### `RuntimeUpstreamQualityCountersData` -| Field | Type | Description | -| --- | --- | --- | -| `connect_attempt_total` | `u64` | Total connect attempts. | -| `connect_success_total` | `u64` | Successful connects. | -| `connect_fail_total` | `u64` | Failed connects. | -| `connect_failfast_hard_error_total` | `u64` | Fail-fast hard errors. | - -#### `RuntimeUpstreamQualitySummaryData` -| Field | Type | Description | -| --- | --- | --- | -| `configured_total` | `usize` | Total configured upstream entries. | -| `healthy_total` | `usize` | Upstreams currently healthy. | -| `unhealthy_total` | `usize` | Upstreams currently unhealthy. | -| `direct_total` | `usize` | Direct-route upstream entries. | -| `socks4_total` | `usize` | SOCKS4 upstream entries. | -| `socks5_total` | `usize` | SOCKS5 upstream entries. | -| `shadowsocks_total` | `usize` | Shadowsocks upstream entries. | - -#### `RuntimeUpstreamQualityUpstreamData` -| Field | Type | Description | -| --- | --- | --- | -| `upstream_id` | `usize` | Runtime upstream index. | -| `route_kind` | `string` | `direct`, `socks4`, `socks5`, `shadowsocks`. | -| `address` | `string` | Upstream address (`direct` literal for direct route kind, `host:port` only for proxied upstreams). | -| `weight` | `u16` | Selection weight. | -| `scopes` | `string` | Configured scope selector. | -| `healthy` | `bool` | Current health flag. | -| `fails` | `u32` | Consecutive fail counter. | -| `last_check_age_secs` | `u64` | Seconds since last health update. | -| `effective_latency_ms` | `f64?` | Effective latency score used by selector. | -| `dc` | `RuntimeUpstreamQualityDcData[]` | Per-DC runtime rows. | - -#### `RuntimeUpstreamQualityDcData` -| Field | Type | Description | -| --- | --- | --- | -| `dc` | `i16` | Telegram DC id. | -| `latency_ema_ms` | `f64?` | Per-DC latency EMA. | -| `ip_preference` | `string` | `unknown`, `prefer_v4`, `prefer_v6`, `both_work`, `unavailable`. | - -### `RuntimeNatStunData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Runtime payload availability. | -| `reason` | `string?` | `source_unavailable` when shared STUN state is unavailable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `data` | `RuntimeNatStunPayload?` | Null when unavailable. | - -#### `RuntimeNatStunPayload` -| Field | Type | Description | -| --- | --- | --- | -| `flags` | `RuntimeNatStunFlagsData` | NAT probe runtime flags. | -| `servers` | `RuntimeNatStunServersData` | Configured/live STUN server lists. | -| `reflection` | `RuntimeNatStunReflectionBlockData` | Reflection cache data for v4/v6. | -| `stun_backoff_remaining_ms` | `u64?` | Remaining retry backoff (milliseconds). | - -#### `RuntimeNatStunFlagsData` -| Field | Type | Description | -| --- | --- | --- | -| `nat_probe_enabled` | `bool` | Current NAT probe enable state. | -| `nat_probe_disabled_runtime` | `bool` | Runtime disable flag due failures/conditions. | -| `nat_probe_attempts` | `u8` | Configured NAT probe attempt count. | - -#### `RuntimeNatStunServersData` -| Field | Type | Description | -| --- | --- | --- | -| `configured` | `string[]` | Configured STUN server entries. | -| `live` | `string[]` | Runtime live STUN server entries. | -| `live_total` | `usize` | Number of live STUN entries. | - -#### `RuntimeNatStunReflectionBlockData` -| Field | Type | Description | -| --- | --- | --- | -| `v4` | `RuntimeNatStunReflectionData?` | IPv4 reflection data. | -| `v6` | `RuntimeNatStunReflectionData?` | IPv6 reflection data. | - -#### `RuntimeNatStunReflectionData` -| Field | Type | Description | -| --- | --- | --- | -| `addr` | `string` | Reflected public endpoint (`ip:port`). | -| `age_secs` | `u64` | Reflection value age in seconds. | - -### `RuntimeMeSelftestData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Runtime payload availability. | -| `reason` | `string?` | `source_unavailable` when ME pool is unavailable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `data` | `RuntimeMeSelftestPayload?` | Null when unavailable. | - -#### `RuntimeMeSelftestPayload` -| Field | Type | Description | -| --- | --- | --- | -| `kdf` | `RuntimeMeSelftestKdfData` | KDF EWMA health state. | -| `timeskew` | `RuntimeMeSelftestTimeskewData` | Date-header skew health state. | -| `ip` | `RuntimeMeSelftestIpData` | Interface IP family classification. | -| `pid` | `RuntimeMeSelftestPidData` | Process PID marker (`one|non-one`). | -| `bnd` | `RuntimeMeSelftestBndData` | SOCKS BND.ADDR/BND.PORT health state. | - -#### `RuntimeMeSelftestKdfData` -| Field | Type | Description | -| --- | --- | --- | -| `state` | `string` | `ok` or `error` based on EWMA threshold. | -| `ewma_errors_per_min` | `f64` | EWMA KDF error rate per minute. | -| `threshold_errors_per_min` | `f64` | Threshold used for `error` decision. | -| `errors_total` | `u64` | Total source errors (`kdf_drift + socks_kdf_strict_reject`). | - -#### `RuntimeMeSelftestTimeskewData` -| Field | Type | Description | -| --- | --- | --- | -| `state` | `string` | `ok` or `error` (`max_skew_secs_15m > 60` => `error`). | -| `max_skew_secs_15m` | `u64?` | Maximum observed skew in the last 15 minutes. | -| `samples_15m` | `usize` | Number of skew samples in the last 15 minutes. | -| `last_skew_secs` | `u64?` | Latest observed skew value. | -| `last_source` | `string?` | Latest skew source marker. | -| `last_seen_age_secs` | `u64?` | Age of the latest skew sample. | - -#### `RuntimeMeSelftestIpData` -| Field | Type | Description | -| --- | --- | --- | -| `v4` | `RuntimeMeSelftestIpFamilyData?` | IPv4 interface probe result; absent when unknown. | -| `v6` | `RuntimeMeSelftestIpFamilyData?` | IPv6 interface probe result; absent when unknown. | - -#### `RuntimeMeSelftestIpFamilyData` -| Field | Type | Description | -| --- | --- | --- | -| `addr` | `string` | Detected interface IP. | -| `state` | `string` | `good`, `bogon`, or `loopback`. | - -#### `RuntimeMeSelftestPidData` -| Field | Type | Description | -| --- | --- | --- | -| `pid` | `u32` | Current process PID. | -| `state` | `string` | `one` when PID=1, otherwise `non-one`. | - -#### `RuntimeMeSelftestBndData` -| Field | Type | Description | -| --- | --- | --- | -| `addr_state` | `string` | `ok`, `bogon`, or `error`. | -| `port_state` | `string` | `ok`, `zero`, or `error`. | -| `last_addr` | `string?` | Latest observed SOCKS BND address. | -| `last_seen_age_secs` | `u64?` | Age of latest BND sample. | - -### `RuntimeEdgeConnectionsSummaryData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. | -| `reason` | `string?` | `feature_disabled` or `source_unavailable`. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `data` | `RuntimeEdgeConnectionsSummaryPayload?` | Null when unavailable. | - -#### `RuntimeEdgeConnectionsSummaryPayload` -| Field | Type | Description | -| --- | --- | --- | -| `cache` | `RuntimeEdgeConnectionCacheData` | Runtime edge cache metadata. | -| `totals` | `RuntimeEdgeConnectionTotalsData` | Connection totals block. | -| `top` | `RuntimeEdgeConnectionTopData` | Top-N leaderboard blocks. | -| `telemetry` | `RuntimeEdgeConnectionTelemetryData` | Telemetry-policy flags for counters. | - -#### `RuntimeEdgeConnectionCacheData` -| Field | Type | Description | -| --- | --- | --- | -| `ttl_ms` | `u64` | Configured cache TTL in milliseconds. | -| `served_from_cache` | `bool` | `true` when payload is served from cache. | -| `stale_cache_used` | `bool` | `true` when stale cache is used because recompute is busy. | - -#### `RuntimeEdgeConnectionTotalsData` -| Field | Type | Description | -| --- | --- | --- | -| `current_connections` | `u64` | Current global live connections. | -| `current_connections_me` | `u64` | Current live connections routed through ME. | -| `current_connections_direct` | `u64` | Current live connections routed through direct path. | -| `active_users` | `usize` | Users with `current_connections > 0`. | - -#### `RuntimeEdgeConnectionTopData` -| Field | Type | Description | -| --- | --- | --- | -| `limit` | `usize` | Effective Top-N row count. | -| `by_connections` | `RuntimeEdgeConnectionUserData[]` | Users sorted by current connections. | -| `by_throughput` | `RuntimeEdgeConnectionUserData[]` | Users sorted by cumulative octets. | - -#### `RuntimeEdgeConnectionUserData` -| Field | Type | Description | -| --- | --- | --- | -| `username` | `string` | Username. | -| `current_connections` | `u64` | Current live connections for user. | -| `total_octets` | `u64` | Cumulative (`client->proxy + proxy->client`) octets. | - -#### `RuntimeEdgeConnectionTelemetryData` -| Field | Type | Description | -| --- | --- | --- | -| `user_enabled` | `bool` | Per-user telemetry enable flag. | -| `throughput_is_cumulative` | `bool` | Always `true` in current implementation. | - -### `RuntimeEdgeEventsData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. | -| `reason` | `string?` | `feature_disabled` when endpoint is disabled. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | -| `data` | `RuntimeEdgeEventsPayload?` | Null when unavailable. | - -#### `RuntimeEdgeEventsPayload` -| Field | Type | Description | -| --- | --- | --- | -| `capacity` | `usize` | Effective ring-buffer capacity. | -| `dropped_total` | `u64` | Count of dropped oldest events due capacity pressure. | -| `events` | `ApiEventRecord[]` | Recent events in chronological order. | - -#### `ApiEventRecord` -| Field | Type | Description | -| --- | --- | --- | -| `seq` | `u64` | Monotonic sequence number. | -| `ts_epoch_secs` | `u64` | Event timestamp (Unix seconds). | -| `event_type` | `string` | Event kind identifier. | -| `context` | `string` | Context text (truncated to implementation-defined max length). | - -### `ZeroAllData` -| Field | Type | Description | -| --- | --- | --- | -| `generated_at_epoch_secs` | `u64` | Snapshot time (Unix epoch seconds). | -| `core` | `ZeroCoreData` | Core counters and telemetry policy snapshot. | -| `upstream` | `ZeroUpstreamData` | Upstream connect counters/histogram buckets. | -| `middle_proxy` | `ZeroMiddleProxyData` | ME protocol/health counters. | -| `pool` | `ZeroPoolData` | ME pool lifecycle counters. | -| `desync` | `ZeroDesyncData` | Frame desync counters. | - -#### `ZeroCoreData` -| Field | Type | Description | -| --- | --- | --- | -| `uptime_seconds` | `f64` | Process uptime. | -| `connections_total` | `u64` | Total accepted connections. | -| `connections_bad_total` | `u64` | Failed/invalid connections. | -| `handshake_timeouts_total` | `u64` | Handshake timeouts. | -| `configured_users` | `usize` | Configured user count. | -| `telemetry_core_enabled` | `bool` | Core telemetry toggle. | -| `telemetry_user_enabled` | `bool` | User telemetry toggle. | -| `telemetry_me_level` | `string` | ME telemetry level (`off|normal|verbose`). | - -#### `ZeroUpstreamData` -| Field | Type | Description | -| --- | --- | --- | -| `connect_attempt_total` | `u64` | Total upstream connect attempts. | -| `connect_success_total` | `u64` | Successful upstream connects. | -| `connect_fail_total` | `u64` | Failed upstream connects. | -| `connect_failfast_hard_error_total` | `u64` | Fail-fast hard errors. | -| `connect_attempts_bucket_1` | `u64` | Connect attempts resolved in 1 try. | -| `connect_attempts_bucket_2` | `u64` | Connect attempts resolved in 2 tries. | -| `connect_attempts_bucket_3_4` | `u64` | Connect attempts resolved in 3-4 tries. | -| `connect_attempts_bucket_gt_4` | `u64` | Connect attempts requiring more than 4 tries. | -| `connect_duration_success_bucket_le_100ms` | `u64` | Successful connects <=100 ms. | -| `connect_duration_success_bucket_101_500ms` | `u64` | Successful connects 101-500 ms. | -| `connect_duration_success_bucket_501_1000ms` | `u64` | Successful connects 501-1000 ms. | -| `connect_duration_success_bucket_gt_1000ms` | `u64` | Successful connects >1000 ms. | -| `connect_duration_fail_bucket_le_100ms` | `u64` | Failed connects <=100 ms. | -| `connect_duration_fail_bucket_101_500ms` | `u64` | Failed connects 101-500 ms. | -| `connect_duration_fail_bucket_501_1000ms` | `u64` | Failed connects 501-1000 ms. | -| `connect_duration_fail_bucket_gt_1000ms` | `u64` | Failed connects >1000 ms. | - -### `UpstreamsData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Runtime upstream snapshot availability according to API config. | -| `reason` | `string?` | `feature_disabled` or `source_unavailable` when runtime snapshot is unavailable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | -| `zero` | `ZeroUpstreamData` | Always available zero-cost upstream counters block. | -| `summary` | `UpstreamSummaryData?` | Runtime upstream aggregate view, null when unavailable. | -| `upstreams` | `UpstreamStatus[]?` | Per-upstream runtime status rows, null when unavailable. | - -#### `UpstreamSummaryData` -| Field | Type | Description | -| --- | --- | --- | -| `configured_total` | `usize` | Total configured upstream entries. | -| `healthy_total` | `usize` | Upstreams currently marked healthy. | -| `unhealthy_total` | `usize` | Upstreams currently marked unhealthy. | -| `direct_total` | `usize` | Number of direct upstream entries. | -| `socks4_total` | `usize` | Number of SOCKS4 upstream entries. | -| `socks5_total` | `usize` | Number of SOCKS5 upstream entries. | -| `shadowsocks_total` | `usize` | Number of Shadowsocks upstream entries. | - -#### `UpstreamStatus` -| Field | Type | Description | -| --- | --- | --- | -| `upstream_id` | `usize` | Runtime upstream index. | -| `route_kind` | `string` | Upstream route kind: `direct`, `socks4`, `socks5`, `shadowsocks`. | -| `address` | `string` | Upstream address (`direct` for direct route kind, `host:port` for Shadowsocks). Authentication fields are intentionally omitted. | -| `weight` | `u16` | Selection weight. | -| `scopes` | `string` | Configured scope selector string. | -| `healthy` | `bool` | Current health flag. | -| `fails` | `u32` | Consecutive fail counter. | -| `last_check_age_secs` | `u64` | Seconds since the last health-check update. | -| `effective_latency_ms` | `f64?` | Effective upstream latency used by selector. | -| `dc` | `UpstreamDcStatus[]` | Per-DC latency/IP preference snapshot. | - -#### `UpstreamDcStatus` -| Field | Type | Description | -| --- | --- | --- | -| `dc` | `i16` | Telegram DC id. | -| `latency_ema_ms` | `f64?` | Per-DC latency EMA value. | -| `ip_preference` | `string` | Per-DC IP family preference: `unknown`, `prefer_v4`, `prefer_v6`, `both_work`, `unavailable`. | - -#### `ZeroMiddleProxyData` -| Field | Type | Description | -| --- | --- | --- | -| `keepalive_sent_total` | `u64` | ME keepalive packets sent. | -| `keepalive_failed_total` | `u64` | ME keepalive send failures. | -| `keepalive_pong_total` | `u64` | Keepalive pong responses received. | -| `keepalive_timeout_total` | `u64` | Keepalive timeout events. | -| `rpc_proxy_req_signal_sent_total` | `u64` | RPC proxy activity signals sent. | -| `rpc_proxy_req_signal_failed_total` | `u64` | RPC proxy activity signal failures. | -| `rpc_proxy_req_signal_skipped_no_meta_total` | `u64` | Signals skipped due to missing metadata. | -| `rpc_proxy_req_signal_response_total` | `u64` | RPC proxy signal responses received. | -| `rpc_proxy_req_signal_close_sent_total` | `u64` | RPC proxy close signals sent. | -| `reconnect_attempt_total` | `u64` | ME reconnect attempts. | -| `reconnect_success_total` | `u64` | Successful reconnects. | -| `handshake_reject_total` | `u64` | ME handshake rejects. | -| `handshake_error_codes` | `ZeroCodeCount[]` | Handshake rejects grouped by code. | -| `reader_eof_total` | `u64` | ME reader EOF events. | -| `idle_close_by_peer_total` | `u64` | Idle closes initiated by peer. | -| `route_drop_no_conn_total` | `u64` | Route drops due to missing bound connection. | -| `route_drop_channel_closed_total` | `u64` | Route drops due to closed channel. | -| `route_drop_queue_full_total` | `u64` | Route drops due to full queue (total). | -| `route_drop_queue_full_base_total` | `u64` | Route drops in base queue mode. | -| `route_drop_queue_full_high_total` | `u64` | Route drops in high queue mode. | -| `socks_kdf_strict_reject_total` | `u64` | SOCKS KDF strict rejects. | -| `socks_kdf_compat_fallback_total` | `u64` | SOCKS KDF compat fallbacks. | -| `endpoint_quarantine_total` | `u64` | Endpoint quarantine activations. | -| `kdf_drift_total` | `u64` | KDF drift detections. | -| `kdf_port_only_drift_total` | `u64` | KDF port-only drift detections. | -| `hardswap_pending_reuse_total` | `u64` | Pending hardswap reused events. | -| `hardswap_pending_ttl_expired_total` | `u64` | Pending hardswap TTL expiry events. | -| `single_endpoint_outage_enter_total` | `u64` | Entered single-endpoint outage mode. | -| `single_endpoint_outage_exit_total` | `u64` | Exited single-endpoint outage mode. | -| `single_endpoint_outage_reconnect_attempt_total` | `u64` | Reconnect attempts in outage mode. | -| `single_endpoint_outage_reconnect_success_total` | `u64` | Reconnect successes in outage mode. | -| `single_endpoint_quarantine_bypass_total` | `u64` | Quarantine bypasses in outage mode. | -| `single_endpoint_shadow_rotate_total` | `u64` | Shadow writer rotations. | -| `single_endpoint_shadow_rotate_skipped_quarantine_total` | `u64` | Shadow rotations skipped because of quarantine. | -| `floor_mode_switch_total` | `u64` | Total floor mode switches. | -| `floor_mode_switch_static_to_adaptive_total` | `u64` | Static -> adaptive switches. | -| `floor_mode_switch_adaptive_to_static_total` | `u64` | Adaptive -> static switches. | - -#### `ZeroCodeCount` -| Field | Type | Description | -| --- | --- | --- | -| `code` | `i32` | Handshake error code. | -| `total` | `u64` | Events with this code. | - -#### `ZeroPoolData` -| Field | Type | Description | -| --- | --- | --- | -| `pool_swap_total` | `u64` | Pool swap count. | -| `pool_drain_active` | `u64` | Current active draining pools. | -| `pool_force_close_total` | `u64` | Forced pool closes by timeout. | -| `pool_stale_pick_total` | `u64` | Stale writer picks for binding. | -| `writer_removed_total` | `u64` | Writer removals total. | -| `writer_removed_unexpected_total` | `u64` | Unexpected writer removals. | -| `refill_triggered_total` | `u64` | Refill triggers. | -| `refill_skipped_inflight_total` | `u64` | Refill skipped because refill already in-flight. | -| `refill_failed_total` | `u64` | Refill failures. | -| `writer_restored_same_endpoint_total` | `u64` | Restores on same endpoint. | -| `writer_restored_fallback_total` | `u64` | Restores on fallback endpoint. | - -#### `ZeroDesyncData` -| Field | Type | Description | -| --- | --- | --- | -| `secure_padding_invalid_total` | `u64` | Invalid secure padding events. | -| `desync_total` | `u64` | Desync events total. | -| `desync_full_logged_total` | `u64` | Fully logged desync events. | -| `desync_suppressed_total` | `u64` | Suppressed desync logs. | -| `desync_frames_bucket_0` | `u64` | Desync frames bucket 0. | -| `desync_frames_bucket_1_2` | `u64` | Desync frames bucket 1-2. | -| `desync_frames_bucket_3_10` | `u64` | Desync frames bucket 3-10. | -| `desync_frames_bucket_gt_10` | `u64` | Desync frames bucket >10. | - -### `MinimalAllData` -| Field | Type | Description | -| --- | --- | --- | -| `enabled` | `bool` | Whether minimal runtime snapshots are enabled by config. | -| `reason` | `string?` | `feature_disabled` or `source_unavailable` when applicable. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | -| `data` | `MinimalAllPayload?` | Null when disabled; fallback payload when source unavailable. | - -#### `MinimalAllPayload` -| Field | Type | Description | -| --- | --- | --- | -| `me_writers` | `MeWritersData` | ME writer status block. | -| `dcs` | `DcStatusData` | DC aggregate status block. | -| `me_runtime` | `MinimalMeRuntimeData?` | Runtime ME control snapshot. | -| `network_path` | `MinimalDcPathData[]` | Active IP path selection per DC. | - -#### `MinimalMeRuntimeData` -| Field | Type | Description | -| --- | --- | --- | -| `active_generation` | `u64` | Active pool generation. | -| `warm_generation` | `u64` | Warm pool generation. | -| `pending_hardswap_generation` | `u64` | Pending hardswap generation. | -| `pending_hardswap_age_secs` | `u64?` | Pending hardswap age in seconds. | -| `hardswap_enabled` | `bool` | Hardswap mode toggle. | -| `floor_mode` | `string` | Writer floor mode. | -| `adaptive_floor_idle_secs` | `u64` | Idle threshold for adaptive floor. | -| `adaptive_floor_min_writers_single_endpoint` | `u8` | Minimum writers for single-endpoint DC in adaptive mode. | -| `adaptive_floor_min_writers_multi_endpoint` | `u8` | Minimum writers for multi-endpoint DC in adaptive mode. | -| `adaptive_floor_recover_grace_secs` | `u64` | Grace period for floor recovery. | -| `adaptive_floor_writers_per_core_total` | `u16` | Target total writers-per-core budget in adaptive mode. | -| `adaptive_floor_cpu_cores_override` | `u16` | CPU core override (`0` means auto-detect). | -| `adaptive_floor_max_extra_writers_single_per_core` | `u16` | Extra single-endpoint writers budget per core. | -| `adaptive_floor_max_extra_writers_multi_per_core` | `u16` | Extra multi-endpoint writers budget per core. | -| `adaptive_floor_max_active_writers_per_core` | `u16` | Active writer cap per core. | -| `adaptive_floor_max_warm_writers_per_core` | `u16` | Warm writer cap per core. | -| `adaptive_floor_max_active_writers_global` | `u32` | Global active writer cap. | -| `adaptive_floor_max_warm_writers_global` | `u32` | Global warm writer cap. | -| `adaptive_floor_cpu_cores_detected` | `u32` | Runtime-detected CPU cores. | -| `adaptive_floor_cpu_cores_effective` | `u32` | Effective core count used for adaptive caps. | -| `adaptive_floor_global_cap_raw` | `u64` | Raw global cap before clamping. | -| `adaptive_floor_global_cap_effective` | `u64` | Effective global cap after clamping. | -| `adaptive_floor_target_writers_total` | `u64` | Current adaptive total writer target. | -| `adaptive_floor_active_cap_configured` | `u64` | Configured global active cap. | -| `adaptive_floor_active_cap_effective` | `u64` | Effective global active cap. | -| `adaptive_floor_warm_cap_configured` | `u64` | Configured global warm cap. | -| `adaptive_floor_warm_cap_effective` | `u64` | Effective global warm cap. | -| `adaptive_floor_active_writers_current` | `u64` | Current active writers count. | -| `adaptive_floor_warm_writers_current` | `u64` | Current warm writers count. | -| `me_keepalive_enabled` | `bool` | ME keepalive toggle. | -| `me_keepalive_interval_secs` | `u64` | Keepalive period. | -| `me_keepalive_jitter_secs` | `u64` | Keepalive jitter. | -| `me_keepalive_payload_random` | `bool` | Randomized keepalive payload toggle. | -| `rpc_proxy_req_every_secs` | `u64` | Period for RPC proxy request signal. | -| `me_reconnect_max_concurrent_per_dc` | `u32` | Reconnect concurrency per DC. | -| `me_reconnect_backoff_base_ms` | `u64` | Base reconnect backoff. | -| `me_reconnect_backoff_cap_ms` | `u64` | Max reconnect backoff. | -| `me_reconnect_fast_retry_count` | `u32` | Fast retry attempts before normal backoff. | -| `me_pool_drain_ttl_secs` | `u64` | Pool drain TTL. | -| `me_pool_force_close_secs` | `u64` | Hard close timeout for draining writers. | -| `me_pool_min_fresh_ratio` | `f32` | Minimum fresh ratio before swap. | -| `me_bind_stale_mode` | `string` | Stale writer bind policy. | -| `me_bind_stale_ttl_secs` | `u64` | Stale writer TTL. | -| `me_single_endpoint_shadow_writers` | `u8` | Shadow writers for single-endpoint DCs. | -| `me_single_endpoint_outage_mode_enabled` | `bool` | Outage mode toggle for single-endpoint DCs. | -| `me_single_endpoint_outage_disable_quarantine` | `bool` | Allows reconnect attempts to bypass endpoint quarantine for single-endpoint outage recovery paths. | -| `me_single_endpoint_outage_backoff_min_ms` | `u64` | Outage mode min reconnect backoff. | -| `me_single_endpoint_outage_backoff_max_ms` | `u64` | Outage mode max reconnect backoff. | -| `me_single_endpoint_shadow_rotate_every_secs` | `u64` | Shadow rotation interval. | -| `me_deterministic_writer_sort` | `bool` | Deterministic writer ordering toggle. | -| `me_writer_pick_mode` | `string` | Writer picker mode (`sorted_rr`, `p2c`). | -| `me_writer_pick_sample_size` | `u8` | Candidate sample size for `p2c` picker mode. | -| `me_socks_kdf_policy` | `string` | Current SOCKS KDF policy mode. | -| `quarantined_endpoints_total` | `usize` | Total quarantined endpoints. | -| `quarantined_endpoints` | `MinimalQuarantineData[]` | Quarantine details. | - -#### `MinimalQuarantineData` -| Field | Type | Description | -| --- | --- | --- | -| `endpoint` | `string` | Endpoint (`ip:port`). | -| `remaining_ms` | `u64` | Remaining quarantine duration. | - -#### `MinimalDcPathData` -| Field | Type | Description | -| --- | --- | --- | -| `dc` | `i16` | Telegram DC identifier. | -| `ip_preference` | `string?` | Runtime IP family preference. | -| `selected_addr_v4` | `string?` | Selected IPv4 endpoint for this DC. | -| `selected_addr_v6` | `string?` | Selected IPv6 endpoint for this DC. | - -### `MeWritersData` -| Field | Type | Description | -| --- | --- | --- | -| `middle_proxy_enabled` | `bool` | `false` when minimal runtime is disabled or source unavailable. | -| `reason` | `string?` | `feature_disabled` or `source_unavailable` when not fully available. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | -| `summary` | `MeWritersSummary` | Coverage/availability summary. | -| `writers` | `MeWriterStatus[]` | Per-writer statuses. | - -#### `MeWritersSummary` -| Field | Type | Description | -| --- | --- | --- | -| `configured_dc_groups` | `usize` | Number of configured DC groups. | -| `configured_endpoints` | `usize` | Total configured ME endpoints. | -| `available_endpoints` | `usize` | Endpoints currently available. | -| `available_pct` | `f64` | `available_endpoints / configured_endpoints * 100`. | -| `required_writers` | `usize` | Required writers based on current floor policy. | -| `alive_writers` | `usize` | Writers currently alive. | -| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. | - -#### `MeWriterStatus` -| Field | Type | Description | -| --- | --- | --- | -| `writer_id` | `u64` | Runtime writer identifier. | -| `dc` | `i16?` | DC id if mapped. | -| `endpoint` | `string` | Endpoint (`ip:port`). | -| `generation` | `u64` | Pool generation owning this writer. | -| `state` | `string` | Writer state (`warm`, `active`, `draining`). | -| `draining` | `bool` | Draining flag. | -| `degraded` | `bool` | Degraded flag. | -| `bound_clients` | `usize` | Number of currently bound clients. | -| `idle_for_secs` | `u64?` | Idle age in seconds if idle. | -| `rtt_ema_ms` | `f64?` | RTT exponential moving average. | - -### `DcStatusData` -| Field | Type | Description | -| --- | --- | --- | -| `middle_proxy_enabled` | `bool` | `false` when minimal runtime is disabled or source unavailable. | -| `reason` | `string?` | `feature_disabled` or `source_unavailable` when not fully available. | -| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | -| `dcs` | `DcStatus[]` | Per-DC status rows. | - -#### `DcStatus` -| Field | Type | Description | -| --- | --- | --- | -| `dc` | `i16` | Telegram DC id. | -| `endpoints` | `string[]` | Endpoints in this DC (`ip:port`). | -| `endpoint_writers` | `DcEndpointWriters[]` | Active writer counts grouped by endpoint. | -| `available_endpoints` | `usize` | Endpoints currently available in this DC. | -| `available_pct` | `f64` | `available_endpoints / endpoints_total * 100`. | -| `required_writers` | `usize` | Required writer count for this DC. | -| `floor_min` | `usize` | Floor lower bound for this DC. | -| `floor_target` | `usize` | Floor target writer count for this DC. | -| `floor_max` | `usize` | Floor upper bound for this DC. | -| `floor_capped` | `bool` | `true` when computed floor target was capped by active limits. | -| `alive_writers` | `usize` | Alive writers in this DC. | -| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. | -| `rtt_ms` | `f64?` | Aggregated RTT for DC. | -| `load` | `usize` | Active client sessions bound to this DC. | - -#### `DcEndpointWriters` -| Field | Type | Description | -| --- | --- | --- | -| `endpoint` | `string` | Endpoint (`ip:port`). | -| `active_writers` | `usize` | Active writers currently mapped to endpoint. | - -### `UserInfo` -| Field | Type | Description | -| --- | --- | --- | -| `username` | `string` | Username. | -| `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). | -| `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. | -| `expiration_rfc3339` | `string?` | Optional expiration timestamp. | -| `data_quota_bytes` | `u64?` | Optional data quota. | -| `max_unique_ips` | `usize?` | Optional unique IP limit. | -| `current_connections` | `u64` | Current live connections. | -| `active_unique_ips` | `usize` | Current active unique source IPs. | -| `active_unique_ips_list` | `ip[]` | Current active unique source IP list. | -| `recent_unique_ips` | `usize` | Unique source IP count inside the configured recent window. | -| `recent_unique_ips_list` | `ip[]` | Recent-window unique source IP list. | -| `total_octets` | `u64` | Total traffic octets for this user. | -| `links` | `UserLinks` | Active connection links derived from current config. | - -#### `UserLinks` -| Field | Type | Description | -| --- | --- | --- | -| `classic` | `string[]` | Active `tg://proxy` links for classic mode. | -| `secure` | `string[]` | Active `tg://proxy` links for secure/DD mode. | -| `tls` | `string[]` | Active `tg://proxy` links for EE-TLS mode (for each host+TLS domain). | - -Link generation uses active config and enabled modes: -- Link port is `general.links.public_port` when configured; otherwise `server.port`. -- If `general.links.public_host` is non-empty, it is used as the single link host override. -- If `public_host` is not set, hosts are resolved from `server.listeners` in order: - `announce` -> `announce_ip` -> listener bind `ip`. -- For wildcard listener IPs (`0.0.0.0` / `::`), startup-detected external IP of the same family is used when available. -- Listener-derived hosts are de-duplicated while preserving first-seen order. -- If multiple hosts are resolved, API returns links for all resolved hosts in every enabled mode. -- If no host can be resolved from listeners, fallback is startup-detected `IPv4 -> IPv6`. -- Final compatibility fallback uses `listen_addr_ipv4`/`listen_addr_ipv6` when routable, otherwise `"UNKNOWN"`. -- User rows are sorted by `username` in ascending lexical order. - -### `CreateUserResponse` -| Field | Type | Description | -| --- | --- | --- | -| `user` | `UserInfo` | Created or updated user view. | -| `secret` | `string` | Effective user secret. | - -## Mutation Semantics - -| Endpoint | Notes | -| --- | --- | -| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). | -| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged. Current implementation persists full config document on success. | -| `POST /v1/users/{username}/rotate-secret` | Currently returns `404` in runtime route matcher; request schema is reserved for intended behavior. | -| `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. | - -All mutating endpoints: -- Respect `read_only` mode. -- Accept optional `If-Match` for optimistic concurrency. -- Return new `revision` after successful write. -- Use process-local mutation lock + atomic write (`tmp + rename`) for config persistence. - -Delete path cleanup guarantees: -- Config cleanup removes only the requested username keys. -- Runtime unique-IP cleanup removes only this user's limiter and tracked IP state. - -## Runtime State Matrix - -| Endpoint | `minimal_runtime_enabled=false` | `minimal_runtime_enabled=true` + source unavailable | `minimal_runtime_enabled=true` + source available | -| --- | --- | --- | --- | -| `/v1/stats/minimal/all` | `enabled=false`, `reason=feature_disabled`, `data=null` | `enabled=true`, `reason=source_unavailable`, fallback `data` with disabled ME blocks | `enabled=true`, `reason` omitted, full payload | -| `/v1/stats/me-writers` | `middle_proxy_enabled=false`, `reason=feature_disabled` | `middle_proxy_enabled=false`, `reason=source_unavailable` | `middle_proxy_enabled=true`, runtime snapshot | -| `/v1/stats/dcs` | `middle_proxy_enabled=false`, `reason=feature_disabled` | `middle_proxy_enabled=false`, `reason=source_unavailable` | `middle_proxy_enabled=true`, runtime snapshot | -| `/v1/stats/upstreams` | `enabled=false`, `reason=feature_disabled`, `summary/upstreams` omitted, `zero` still present | `enabled=true`, `reason=source_unavailable`, `summary/upstreams` omitted, `zero` present | `enabled=true`, `reason` omitted, `summary/upstreams` present, `zero` present | - -`source_unavailable` conditions: -- ME endpoints: ME pool is absent (for example direct-only mode or failed ME initialization). -- Upstreams endpoint: non-blocking upstream snapshot lock is unavailable at request time. - -Additional runtime endpoint behavior: - -| Endpoint | Disabled by feature flag | `source_unavailable` condition | Normal mode | -| --- | --- | --- | --- | -| `/v1/runtime/me_pool_state` | No | ME pool snapshot unavailable | `enabled=true`, full payload | -| `/v1/runtime/me_quality` | No | ME pool snapshot unavailable | `enabled=true`, full payload | -| `/v1/runtime/upstream_quality` | No | Upstream runtime snapshot unavailable | `enabled=true`, full payload | -| `/v1/runtime/nat_stun` | No | STUN shared state unavailable | `enabled=true`, full payload | -| `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload | -| `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload | -| `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload | - -## ME Fallback Behavior Exposed Via API - -When `general.use_middle_proxy=true` and `general.me2dc_fallback=true`: -- Startup does not block on full ME pool readiness; initialization can continue in background. -- Runtime initialization payload can expose ME stage `background_init` until pool becomes ready. -- Admission/routing decision uses two readiness grace windows for "ME not ready" periods: - `80s` before first-ever readiness is observed (startup grace), - `6s` after readiness has been observed at least once (runtime failover timeout). -- While in fallback window breach, new sessions are routed via Direct-DC; when ME becomes ready, routing returns to Middle mode for new sessions. - -## Serialization Rules - -- Success responses always include `revision`. -- Error responses never include `revision`; they include `request_id`. -- Optional fields with `skip_serializing_if` are omitted when absent. -- Nullable payload fields may still be `null` where contract uses `?` (for example `UserInfo` option fields). -- For `/v1/stats/upstreams`, authentication details of SOCKS upstreams are intentionally omitted. -- `ip[]` fields are serialized as JSON string arrays (for example `"1.2.3.4"`, `"2001:db8::1"`). - -## Operational Notes - -| Topic | Details | -| --- | --- | -| API startup | API listener is spawned only when `[server.api].enabled=true`. | -| `listen` port `0` | API spawn is skipped when parsed listen port is `0` (treated as disabled bind target). | -| Bind failure | Failed API bind logs warning and API task exits (no auto-retry loop). | -| ME runtime status endpoints | `/v1/stats/me-writers`, `/v1/stats/dcs`, `/v1/stats/minimal/all` require `[server.api].minimal_runtime_enabled=true`; otherwise they return disabled payload with `reason=feature_disabled`. | -| Upstream runtime endpoint | `/v1/stats/upstreams` always returns `zero`, but runtime fields (`summary`, `upstreams`) require `[server.api].minimal_runtime_enabled=true`. | -| Restart requirements | `server.api` changes are restart-required for predictable behavior. | -| Hot-reload nuance | A pure `server.api`-only config change may not propagate through watcher broadcast; a mixed change (with hot fields) may propagate API flags while still warning that restart is required. | -| Runtime apply path | Successful writes are picked up by existing config watcher/hot-reload path. | -| Exposure | Built-in TLS/mTLS is not provided. Use loopback bind + reverse proxy if needed. | -| Pagination | User list currently has no pagination/filtering. | -| Serialization side effect | Updated TOML table bodies are re-serialized on write. Endpoints that persist full config can still rewrite broader formatting/comments. | - -## Known Limitations (Current Release) - -- `POST /v1/users/{username}/rotate-secret` is currently unreachable in route matcher and returns `404`. -- API runtime controls under `server.api` are documented as restart-required; hot-reload behavior for these fields is not strictly uniform in all change combinations. diff --git a/docs/CONFIG_PARAMS.en.md b/docs/CONFIG_PARAMS.en.md deleted file mode 100644 index ed3796d..0000000 --- a/docs/CONFIG_PARAMS.en.md +++ /dev/null @@ -1,3300 +0,0 @@ -# Telemt Config Parameters Reference - -This document lists all configuration keys accepted by `config.toml`. - -> [!NOTE] -> -> This reference was drafted with the help of AI and cross-checked against the codebase (config schema, defaults, and validation logic). - -> [!WARNING] -> -> The configuration parameters detailed in this document are intended for advanced users and fine-tuning purposes. Modifying these settings without a clear understanding of their function may lead to application instability or other unexpected behavior. Please proceed with caution and at your own risk. - -## Top-level keys - -| Key | Type | Default | -| --- | ---- | ------- | -| [`include`](#cfg-top-include) | `String` (special directive) | — | -| [`show_link`](#cfg-top-show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | -| [`dc_overrides`](#cfg-top-dc_overrides) | `Map` | `{}` | -| [`default_dc`](#cfg-top-default_dc) | `u8` | — (effective fallback: `2` in ME routing) | - - -- `include` - - **Constraints / validation**: Must be a single-line directive in the form `include = "path/to/file.toml"`. Includes are expanded before TOML parsing. Maximum include depth is 10. - - **Description**: Includes another TOML file with `include = "relative/or/absolute/path.toml"`; includes are processed recursively before parsing. - - **Example**: - - ```toml - include = "secrets.toml" - ``` - -- `show_link` - - **Constraints / validation**: Accepts `"*"` or an array of usernames. Empty array means "show none". - - **Description**: Legacy top-level link visibility selector (`"*"` for all users or explicit usernames list). - - **Example**: - - ```toml - # show links for all configured users - show_link = "*" - - # or: show links only for selected users - # show_link = ["alice", "bob"] - ``` - -- `dc_overrides` - - **Constraints / validation**: Key must be a positive integer DC index encoded as string (e.g. `"203"`). Values must parse as `SocketAddr` (`ip:port`). Empty strings are ignored. - - **Description**: Overrides DC endpoints for non-standard DCs; key is DC index string, value is one or more `ip:port` addresses. - - **Example**: - - ```toml - [dc_overrides] - "201" = "149.154.175.50:443" - "203" = ["149.154.175.100:443", "91.105.192.100:443"] - ``` - -- `default_dc` - - **Constraints / validation**: Intended range is `1..=5`. If set out of range, runtime falls back to DC1 behavior in direct relay; Middle-End routing falls back to `2` when not set. - - **Description**: Default DC index used for unmapped non-standard DCs. - - **Example**: - - ```toml - # When a client requests an unknown/non-standard DC with no override, - # route it to this default cluster (1..=5). - default_dc = 2 - ``` - -## [general] - -| Key | Type | Default | -| --- | ---- | ------- | -| [`data_path`](#cfg-general-data_path) | `String` | — | -| [`prefer_ipv6`](#cfg-general-prefer_ipv6) | `bool` | `false` | -| [`fast_mode`](#cfg-general-fast_mode) | `bool` | `true` | -| [`use_middle_proxy`](#cfg-general-use_middle_proxy) | `bool` | `true` | -| [`proxy_secret_path`](#cfg-general-proxy_secret_path) | `String` | `"proxy-secret"` | -| [`proxy_config_v4_cache_path`](#cfg-general-proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` | -| [`proxy_config_v6_cache_path`](#cfg-general-proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` | -| [`ad_tag`](#cfg-general-ad_tag) | `String` | — | -| [`middle_proxy_nat_ip`](#cfg-general-middle_proxy_nat_ip) | `IpAddr` | — | -| [`middle_proxy_nat_probe`](#cfg-general-middle_proxy_nat_probe) | `bool` | `true` | -| [`middle_proxy_nat_stun`](#cfg-general-middle_proxy_nat_stun) | `String` | — | -| [`middle_proxy_nat_stun_servers`](#cfg-general-middle_proxy_nat_stun_servers) | `String[]` | `[]` | -| [`stun_nat_probe_concurrency`](#cfg-general-stun_nat_probe_concurrency) | `usize` | `8` | -| [`middle_proxy_pool_size`](#cfg-general-middle_proxy_pool_size) | `usize` | `8` | -| [`middle_proxy_warm_standby`](#cfg-general-middle_proxy_warm_standby) | `usize` | `16` | -| [`me_init_retry_attempts`](#cfg-general-me_init_retry_attempts) | `u32` | `0` | -| [`me2dc_fallback`](#cfg-general-me2dc_fallback) | `bool` | `true` | -| [`me2dc_fast`](#cfg-general-me2dc_fast) | `bool` | `false` | -| [`me_keepalive_enabled`](#cfg-general-me_keepalive_enabled) | `bool` | `true` | -| [`me_keepalive_interval_secs`](#cfg-general-me_keepalive_interval_secs) | `u64` | `8` | -| [`me_keepalive_jitter_secs`](#cfg-general-me_keepalive_jitter_secs) | `u64` | `2` | -| [`me_keepalive_payload_random`](#cfg-general-me_keepalive_payload_random) | `bool` | `true` | -| [`rpc_proxy_req_every`](#cfg-general-rpc_proxy_req_every) | `u64` | `0` | -| [`me_writer_cmd_channel_capacity`](#cfg-general-me_writer_cmd_channel_capacity) | `usize` | `4096` | -| [`me_route_channel_capacity`](#cfg-general-me_route_channel_capacity) | `usize` | `768` | -| [`me_c2me_channel_capacity`](#cfg-general-me_c2me_channel_capacity) | `usize` | `1024` | -| [`me_c2me_send_timeout_ms`](#cfg-general-me_c2me_send_timeout_ms) | `u64` | `4000` | -| [`me_reader_route_data_wait_ms`](#cfg-general-me_reader_route_data_wait_ms) | `u64` | `2` | -| [`me_d2c_flush_batch_max_frames`](#cfg-general-me_d2c_flush_batch_max_frames) | `usize` | `32` | -| [`me_d2c_flush_batch_max_bytes`](#cfg-general-me_d2c_flush_batch_max_bytes) | `usize` | `131072` | -| [`me_d2c_flush_batch_max_delay_us`](#cfg-general-me_d2c_flush_batch_max_delay_us) | `u64` | `500` | -| [`me_d2c_ack_flush_immediate`](#cfg-general-me_d2c_ack_flush_immediate) | `bool` | `true` | -| [`me_quota_soft_overshoot_bytes`](#cfg-general-me_quota_soft_overshoot_bytes) | `u64` | `65536` | -| [`me_d2c_frame_buf_shrink_threshold_bytes`](#cfg-general-me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` | -| [`direct_relay_copy_buf_c2s_bytes`](#cfg-general-direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` | -| [`direct_relay_copy_buf_s2c_bytes`](#cfg-general-direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` | -| [`crypto_pending_buffer`](#cfg-general-crypto_pending_buffer) | `usize` | `262144` | -| [`max_client_frame`](#cfg-general-max_client_frame) | `usize` | `16777216` | -| [`desync_all_full`](#cfg-general-desync_all_full) | `bool` | `false` | -| [`beobachten`](#cfg-general-beobachten) | `bool` | `true` | -| [`beobachten_minutes`](#cfg-general-beobachten_minutes) | `u64` | `10` | -| [`beobachten_flush_secs`](#cfg-general-beobachten_flush_secs) | `u64` | `15` | -| [`beobachten_file`](#cfg-general-beobachten_file) | `String` | `"cache/beobachten.txt"` | -| [`hardswap`](#cfg-general-hardswap) | `bool` | `true` | -| [`me_warmup_stagger_enabled`](#cfg-general-me_warmup_stagger_enabled) | `bool` | `true` | -| [`me_warmup_step_delay_ms`](#cfg-general-me_warmup_step_delay_ms) | `u64` | `500` | -| [`me_warmup_step_jitter_ms`](#cfg-general-me_warmup_step_jitter_ms) | `u64` | `300` | -| [`me_reconnect_max_concurrent_per_dc`](#cfg-general-me_reconnect_max_concurrent_per_dc) | `u32` | `8` | -| [`me_reconnect_backoff_base_ms`](#cfg-general-me_reconnect_backoff_base_ms) | `u64` | `500` | -| [`me_reconnect_backoff_cap_ms`](#cfg-general-me_reconnect_backoff_cap_ms) | `u64` | `30000` | -| [`me_reconnect_fast_retry_count`](#cfg-general-me_reconnect_fast_retry_count) | `u32` | `16` | -| [`me_single_endpoint_shadow_writers`](#cfg-general-me_single_endpoint_shadow_writers) | `u8` | `2` | -| [`me_single_endpoint_outage_mode_enabled`](#cfg-general-me_single_endpoint_outage_mode_enabled) | `bool` | `true` | -| [`me_single_endpoint_outage_disable_quarantine`](#cfg-general-me_single_endpoint_outage_disable_quarantine) | `bool` | `true` | -| [`me_single_endpoint_outage_backoff_min_ms`](#cfg-general-me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` | -| [`me_single_endpoint_outage_backoff_max_ms`](#cfg-general-me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` | -| [`me_single_endpoint_shadow_rotate_every_secs`](#cfg-general-me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` | -| [`me_floor_mode`](#cfg-general-me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` | -| [`me_adaptive_floor_idle_secs`](#cfg-general-me_adaptive_floor_idle_secs) | `u64` | `90` | -| [`me_adaptive_floor_min_writers_single_endpoint`](#cfg-general-me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` | -| [`me_adaptive_floor_min_writers_multi_endpoint`](#cfg-general-me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` | -| [`me_adaptive_floor_recover_grace_secs`](#cfg-general-me_adaptive_floor_recover_grace_secs) | `u64` | `180` | -| [`me_adaptive_floor_writers_per_core_total`](#cfg-general-me_adaptive_floor_writers_per_core_total) | `u16` | `48` | -| [`me_adaptive_floor_cpu_cores_override`](#cfg-general-me_adaptive_floor_cpu_cores_override) | `u16` | `0` | -| [`me_adaptive_floor_max_extra_writers_single_per_core`](#cfg-general-me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` | -| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#cfg-general-me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` | -| [`me_adaptive_floor_max_active_writers_per_core`](#cfg-general-me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` | -| [`me_adaptive_floor_max_warm_writers_per_core`](#cfg-general-me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` | -| [`me_adaptive_floor_max_active_writers_global`](#cfg-general-me_adaptive_floor_max_active_writers_global) | `u32` | `256` | -| [`me_adaptive_floor_max_warm_writers_global`](#cfg-general-me_adaptive_floor_max_warm_writers_global) | `u32` | `256` | -| [`upstream_connect_retry_attempts`](#cfg-general-upstream_connect_retry_attempts) | `u32` | `2` | -| [`upstream_connect_retry_backoff_ms`](#cfg-general-upstream_connect_retry_backoff_ms) | `u64` | `100` | -| [`upstream_connect_budget_ms`](#cfg-general-upstream_connect_budget_ms) | `u64` | `3000` | -| [`upstream_unhealthy_fail_threshold`](#cfg-general-upstream_unhealthy_fail_threshold) | `u32` | `5` | -| [`upstream_connect_failfast_hard_errors`](#cfg-general-upstream_connect_failfast_hard_errors) | `bool` | `false` | -| [`stun_iface_mismatch_ignore`](#cfg-general-stun_iface_mismatch_ignore) | `bool` | `false` | -| [`unknown_dc_log_path`](#cfg-general-unknown_dc_log_path) | `String` | `"unknown-dc.txt"` | -| [`unknown_dc_file_log_enabled`](#cfg-general-unknown_dc_file_log_enabled) | `bool` | `false` | -| [`log_level`](#cfg-general-log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` | -| [`disable_colors`](#cfg-general-disable_colors) | `bool` | `false` | -| [`me_socks_kdf_policy`](#cfg-general-me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` | -| [`me_route_backpressure_base_timeout_ms`](#cfg-general-me_route_backpressure_base_timeout_ms) | `u64` | `25` | -| [`me_route_backpressure_high_timeout_ms`](#cfg-general-me_route_backpressure_high_timeout_ms) | `u64` | `120` | -| [`me_route_backpressure_high_watermark_pct`](#cfg-general-me_route_backpressure_high_watermark_pct) | `u8` | `80` | -| [`me_health_interval_ms_unhealthy`](#cfg-general-me_health_interval_ms_unhealthy) | `u64` | `1000` | -| [`me_health_interval_ms_healthy`](#cfg-general-me_health_interval_ms_healthy) | `u64` | `3000` | -| [`me_admission_poll_ms`](#cfg-general-me_admission_poll_ms) | `u64` | `1000` | -| [`me_warn_rate_limit_ms`](#cfg-general-me_warn_rate_limit_ms) | `u64` | `5000` | -| [`me_route_no_writer_mode`](#cfg-general-me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` | -| [`me_route_no_writer_wait_ms`](#cfg-general-me_route_no_writer_wait_ms) | `u64` | `250` | -| [`me_route_hybrid_max_wait_ms`](#cfg-general-me_route_hybrid_max_wait_ms) | `u64` | `3000` | -| [`me_route_blocking_send_timeout_ms`](#cfg-general-me_route_blocking_send_timeout_ms) | `u64` | `250` | -| [`me_route_inline_recovery_attempts`](#cfg-general-me_route_inline_recovery_attempts) | `u32` | `3` | -| [`me_route_inline_recovery_wait_ms`](#cfg-general-me_route_inline_recovery_wait_ms) | `u64` | `3000` | -| [`fast_mode_min_tls_record`](#cfg-general-fast_mode_min_tls_record) | `usize` | `0` | -| [`update_every`](#cfg-general-update_every) | `u64` | `300` | -| [`me_reinit_every_secs`](#cfg-general-me_reinit_every_secs) | `u64` | `900` | -| [`me_hardswap_warmup_delay_min_ms`](#cfg-general-me_hardswap_warmup_delay_min_ms) | `u64` | `1000` | -| [`me_hardswap_warmup_delay_max_ms`](#cfg-general-me_hardswap_warmup_delay_max_ms) | `u64` | `2000` | -| [`me_hardswap_warmup_extra_passes`](#cfg-general-me_hardswap_warmup_extra_passes) | `u8` | `3` | -| [`me_hardswap_warmup_pass_backoff_base_ms`](#cfg-general-me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` | -| [`me_config_stable_snapshots`](#cfg-general-me_config_stable_snapshots) | `u8` | `2` | -| [`me_config_apply_cooldown_secs`](#cfg-general-me_config_apply_cooldown_secs) | `u64` | `300` | -| [`me_snapshot_require_http_2xx`](#cfg-general-me_snapshot_require_http_2xx) | `bool` | `true` | -| [`me_snapshot_reject_empty_map`](#cfg-general-me_snapshot_reject_empty_map) | `bool` | `true` | -| [`me_snapshot_min_proxy_for_lines`](#cfg-general-me_snapshot_min_proxy_for_lines) | `u32` | `1` | -| [`proxy_secret_stable_snapshots`](#cfg-general-proxy_secret_stable_snapshots) | `u8` | `2` | -| [`proxy_secret_rotate_runtime`](#cfg-general-proxy_secret_rotate_runtime) | `bool` | `true` | -| [`me_secret_atomic_snapshot`](#cfg-general-me_secret_atomic_snapshot) | `bool` | `true` | -| [`proxy_secret_len_max`](#cfg-general-proxy_secret_len_max) | `usize` | `256` | -| [`me_pool_drain_ttl_secs`](#cfg-general-me_pool_drain_ttl_secs) | `u64` | `90` | -| [`me_instadrain`](#cfg-general-me_instadrain) | `bool` | `false` | -| [`me_pool_drain_threshold`](#cfg-general-me_pool_drain_threshold) | `u64` | `32` | -| [`me_pool_drain_soft_evict_enabled`](#cfg-general-me_pool_drain_soft_evict_enabled) | `bool` | `true` | -| [`me_pool_drain_soft_evict_grace_secs`](#cfg-general-me_pool_drain_soft_evict_grace_secs) | `u64` | `10` | -| [`me_pool_drain_soft_evict_per_writer`](#cfg-general-me_pool_drain_soft_evict_per_writer) | `u8` | `2` | -| [`me_pool_drain_soft_evict_budget_per_core`](#cfg-general-me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` | -| [`me_pool_drain_soft_evict_cooldown_ms`](#cfg-general-me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` | -| [`me_bind_stale_mode`](#cfg-general-me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` | -| [`me_bind_stale_ttl_secs`](#cfg-general-me_bind_stale_ttl_secs) | `u64` | `90` | -| [`me_pool_min_fresh_ratio`](#cfg-general-me_pool_min_fresh_ratio) | `f32` | `0.8` | -| [`me_reinit_drain_timeout_secs`](#cfg-general-me_reinit_drain_timeout_secs) | `u64` | `90` | -| [`proxy_secret_auto_reload_secs`](#cfg-general-proxy_secret_auto_reload_secs) | `u64` | `3600` | -| [`proxy_config_auto_reload_secs`](#cfg-general-proxy_config_auto_reload_secs) | `u64` | `3600` | -| [`me_reinit_singleflight`](#cfg-general-me_reinit_singleflight) | `bool` | `true` | -| [`me_reinit_trigger_channel`](#cfg-general-me_reinit_trigger_channel) | `usize` | `64` | -| [`me_reinit_coalesce_window_ms`](#cfg-general-me_reinit_coalesce_window_ms) | `u64` | `200` | -| [`me_deterministic_writer_sort`](#cfg-general-me_deterministic_writer_sort) | `bool` | `true` | -| [`me_writer_pick_mode`](#cfg-general-me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` | -| [`me_writer_pick_sample_size`](#cfg-general-me_writer_pick_sample_size) | `u8` | `3` | -| [`ntp_check`](#cfg-general-ntp_check) | `bool` | `true` | -| [`ntp_servers`](#cfg-general-ntp_servers) | `String[]` | `["pool.ntp.org"]` | -| [`auto_degradation_enabled`](#cfg-general-auto_degradation_enabled) | `bool` | `true` | -| [`degradation_min_unavailable_dc_groups`](#cfg-general-degradation_min_unavailable_dc_groups) | `u8` | `2` | - - -- `data_path` - - **Constraints / validation**: `String` (optional). - - **Description**: Optional runtime data directory path. - - **Example**: - - ```toml - [general] - data_path = "/var/lib/telemt" - ``` - -- `prefer_ipv6` - - **Constraints / validation**: Deprecated. Use `network.prefer`. - - **Description**: Deprecated legacy IPv6 preference flag migrated to `network.prefer`. - - **Example**: - - ```toml - [network] - prefer = 6 - ``` - -- `fast_mode` - - **Constraints / validation**: `bool`. - - **Description**: Enables fast-path optimizations for traffic processing. - - **Example**: - - ```toml - [general] - fast_mode = true - ``` - -- `use_middle_proxy` - - **Constraints / validation**: `bool`. - - **Description**: Enables ME transport mode; if `false`, runtime falls back to direct DC routing. - - **Example**: - - ```toml - [general] - use_middle_proxy = true - ``` - -- `proxy_secret_path` - - **Constraints / validation**: `String`. When omitted, the default path is `"proxy-secret"`. Empty values are accepted by TOML/serde but will likely fail at runtime (invalid file path). - - **Description**: Path to Telegram infrastructure `proxy-secret` cache file used by ME handshake/RPC auth. Telemt always tries a fresh download from `https://core.telegram.org/getProxySecret` first, caches it to this path on success, and falls back to reading the cached file (any age) on download failure. - - **Example**: - - ```toml - [general] - proxy_secret_path = "proxy-secret" - ``` - -- `proxy_config_v4_cache_path` - - **Constraints / validation**: `String`. When set, must not be empty/whitespace-only. - - **Description**: Optional disk cache path for raw `getProxyConfig` (IPv4) snapshot. At startup Telemt tries to fetch a fresh snapshot first; on fetch failure or empty snapshot it falls back to this cache file when present and non-empty. - - **Example**: - - ```toml - [general] - proxy_config_v4_cache_path = "cache/proxy-config-v4.txt" - ``` - -- `proxy_config_v6_cache_path` - - **Constraints / validation**: `String`. When set, must not be empty/whitespace-only. - - **Description**: Optional disk cache path for raw `getProxyConfigV6` (IPv6) snapshot. At startup Telemt tries to fetch a fresh snapshot first; on fetch failure or empty snapshot it falls back to this cache file when present and non-empty. - - **Example**: - - ```toml - [general] - proxy_config_v6_cache_path = "cache/proxy-config-v6.txt" - ``` - -- `ad_tag` - - **Constraints / validation**: `String` (optional). When set, must be exactly 32 hex characters; invalid values are disabled during config load. - - **Description**: Global fallback sponsored-channel `ad_tag` (used when user has no override in `access.user_ad_tags`). An all-zero tag is accepted but has no effect (and is warned about) until replaced with a real tag from `@MTProxybot`. - - **Example**: - - ```toml - [general] - ad_tag = "00112233445566778899aabbccddeeff" - ``` - -- `middle_proxy_nat_ip` - - **Constraints / validation**: `IpAddr` (optional). - - **Description**: Manual public NAT IP override used as ME address material when set. - - **Example**: - - ```toml - [general] - middle_proxy_nat_ip = "203.0.113.10" - ``` - -- `middle_proxy_nat_probe` - - **Constraints / validation**: `bool`. Effective probing is gated by `network.stun_use` (when `network.stun_use = false`, STUN probing is disabled even if this flag is `true`). - - **Description**: Enables STUN-based NAT probing to discover public IP:port used by ME key derivation in NAT environments. - - **Example**: - - ```toml - [general] - middle_proxy_nat_probe = true - ``` - -- `middle_proxy_nat_stun` - - **Constraints / validation**: Deprecated. Use `network.stun_servers`. - - **Description**: Deprecated legacy single STUN server for NAT probing. During config load it is merged into `network.stun_servers` unless `network.stun_servers` is explicitly set. - - **Example**: - - ```toml - [network] - stun_servers = ["stun.l.google.com:19302"] - ``` - -- `middle_proxy_nat_stun_servers` - - **Constraints / validation**: Deprecated. Use `network.stun_servers`. - - **Description**: Deprecated legacy STUN list for NAT probing fallback. During config load it is merged into `network.stun_servers` unless `network.stun_servers` is explicitly set. - - **Example**: - - ```toml - [network] - stun_servers = ["stun.l.google.com:19302"] - ``` - -- `stun_nat_probe_concurrency` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Maximum number of parallel STUN probes during NAT/public endpoint discovery. - - **Example**: - - ```toml - [general] - stun_nat_probe_concurrency = 8 - ``` - -- `middle_proxy_pool_size` - - **Constraints / validation**: `usize`. Effective value is `max(value, 1)` at runtime (so `0` behaves as `1`). - - **Description**: Target size of active ME writer pool. - - **Example**: - - ```toml - [general] - middle_proxy_pool_size = 8 - ``` - -- `middle_proxy_warm_standby` - - **Constraints / validation**: `usize`. - - **Description**: Number of warm standby ME connections kept pre-initialized. - - **Example**: - - ```toml - [general] - middle_proxy_warm_standby = 16 - ``` - -- `me_init_retry_attempts` - - **Constraints / validation**: `0..=1_000_000` (`0` means unlimited retries). - - **Description**: Startup retries for ME pool initialization. - - **Example**: - - ```toml - [general] - me_init_retry_attempts = 0 - ``` - -- `me2dc_fallback` - - **Constraints / validation**: `bool`. - - **Description**: Allows fallback from ME mode to direct DC when ME startup fails. - - **Example**: - - ```toml - [general] - me2dc_fallback = true - ``` - -- `me2dc_fast` - - **Constraints / validation**: `bool`. Active only when `use_middle_proxy = true` and `me2dc_fallback = true`. - - **Description**: Fast ME->Direct fallback mode for new sessions. - - **Example**: - - ```toml - [general] - use_middle_proxy = true - me2dc_fallback = true - me2dc_fast = false - ``` - -- `me_keepalive_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables periodic ME keepalive padding frames. - - **Example**: - - ```toml - [general] - me_keepalive_enabled = true - ``` - -- `me_keepalive_interval_secs` - - **Constraints / validation**: `u64` (seconds). - - **Description**: Base ME keepalive interval in seconds. - - **Example**: - - ```toml - [general] - me_keepalive_interval_secs = 8 - ``` - -- `me_keepalive_jitter_secs` - - **Constraints / validation**: `u64` (seconds). - - **Description**: Keepalive jitter in seconds to reduce synchronized bursts. - - **Example**: - - ```toml - [general] - me_keepalive_jitter_secs = 2 - ``` - -- `me_keepalive_payload_random` - - **Constraints / validation**: `bool`. - - **Description**: Randomizes keepalive payload bytes instead of fixed zero payload. - - **Example**: - - ```toml - [general] - me_keepalive_payload_random = true - ``` - -- `rpc_proxy_req_every` - - **Constraints / validation**: `0` or within `10..=300` (seconds). - - **Description**: Interval for service `RPC_PROXY_REQ` activity signals to ME (`0` disables). - - **Example**: - - ```toml - [general] - rpc_proxy_req_every = 0 - ``` - -- `me_writer_cmd_channel_capacity` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Capacity of per-writer command channel. - - **Example**: - - ```toml - [general] - me_writer_cmd_channel_capacity = 4096 - ``` - -- `me_route_channel_capacity` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Capacity of per-connection ME response route channel. - - **Example**: - - ```toml - [general] - me_route_channel_capacity = 768 - ``` - -- `me_c2me_channel_capacity` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Capacity of per-client command queue (client reader -> ME sender). - - **Example**: - - ```toml - [general] - me_c2me_channel_capacity = 1024 - ``` - -- `me_c2me_send_timeout_ms` - - **Constraints / validation**: `0..=60000` (milliseconds). - - **Description**: Maximum wait for enqueueing client->ME commands when the per-client queue is full (`0` keeps legacy unbounded wait). - - **Example**: - - ```toml - [general] - me_c2me_send_timeout_ms = 4000 - ``` - -- `me_reader_route_data_wait_ms` - - **Constraints / validation**: `0..=20` (milliseconds). - - **Description**: Bounded wait for routing ME DATA to per-connection queue (`0` = no wait). - - **Example**: - - ```toml - [general] - me_reader_route_data_wait_ms = 2 - ``` - -- `me_d2c_flush_batch_max_frames` - - **Constraints / validation**: Must be within `1..=512`. - - **Description**: Max ME->client frames coalesced before flush. - - **Example**: - - ```toml - [general] - me_d2c_flush_batch_max_frames = 32 - ``` - -- `me_d2c_flush_batch_max_bytes` - - **Constraints / validation**: Must be within `4096..=2097152` (bytes). - - **Description**: Max ME->client payload bytes coalesced before flush. - - **Example**: - - ```toml - [general] - me_d2c_flush_batch_max_bytes = 131072 - ``` - -- `me_d2c_flush_batch_max_delay_us` - - **Constraints / validation**: `0..=5000` (microseconds). - - **Description**: Max microsecond wait for coalescing more ME->client frames (`0` disables timed coalescing). - - **Example**: - - ```toml - [general] - me_d2c_flush_batch_max_delay_us = 500 - ``` - -- `me_d2c_ack_flush_immediate` - - **Constraints / validation**: `bool`. - - **Description**: Flushes client writer immediately after quick-ack write. - - **Example**: - - ```toml - [general] - me_d2c_ack_flush_immediate = true - ``` - -- `me_quota_soft_overshoot_bytes` - - **Constraints / validation**: `0..=16777216` (bytes). - - **Description**: Extra per-route quota allowance (bytes) tolerated before writer-side quota enforcement drops route data. - - **Example**: - - ```toml - [general] - me_quota_soft_overshoot_bytes = 65536 - ``` - -- `me_d2c_frame_buf_shrink_threshold_bytes` - - **Constraints / validation**: Must be within `4096..=16777216` (bytes). - - **Description**: Threshold for shrinking oversized ME->client frame-aggregation buffers after flush. - - **Example**: - - ```toml - [general] - me_d2c_frame_buf_shrink_threshold_bytes = 262144 - ``` - -- `direct_relay_copy_buf_c2s_bytes` - - **Constraints / validation**: Must be within `4096..=1048576` (bytes). - - **Description**: Copy buffer size for client->DC direction in direct relay. - - **Example**: - - ```toml - [general] - direct_relay_copy_buf_c2s_bytes = 65536 - ``` - -- `direct_relay_copy_buf_s2c_bytes` - - **Constraints / validation**: Must be within `8192..=2097152` (bytes). - - **Description**: Copy buffer size for DC->client direction in direct relay. - - **Example**: - - ```toml - [general] - direct_relay_copy_buf_s2c_bytes = 262144 - ``` - -- `crypto_pending_buffer` - - **Constraints / validation**: `usize` (bytes). - - **Description**: Max pending ciphertext buffer per client writer (bytes). - - **Example**: - - ```toml - [general] - crypto_pending_buffer = 262144 - ``` - -- `max_client_frame` - - **Constraints / validation**: `usize` (bytes). - - **Description**: Maximum allowed client MTProto frame size (bytes). - - **Example**: - - ```toml - [general] - max_client_frame = 16777216 - ``` - -- `desync_all_full` - - **Constraints / validation**: `bool`. - - **Description**: Emits full crypto-desync forensic logs for every event. - - **Example**: - - ```toml - [general] - desync_all_full = false - ``` - -- `beobachten` - - **Constraints / validation**: `bool`. - - **Description**: Enables per-IP forensic observation buckets. - - **Example**: - - ```toml - [general] - beobachten = true - ``` - -- `beobachten_minutes` - - **Constraints / validation**: Must be `> 0` (minutes). - - **Description**: Retention window (minutes) for per-IP observation buckets. - - **Example**: - - ```toml - [general] - beobachten_minutes = 10 - ``` - -- `beobachten_flush_secs` - - **Constraints / validation**: Must be `> 0` (seconds). - - **Description**: Snapshot flush interval (seconds) for observation output file. - - **Example**: - - ```toml - [general] - beobachten_flush_secs = 15 - ``` - -- `beobachten_file` - - **Constraints / validation**: Must not be empty/whitespace-only. - - **Description**: Observation snapshot output file path. - - **Example**: - - ```toml - [general] - beobachten_file = "cache/beobachten.txt" - ``` - -- `hardswap` - - **Constraints / validation**: `bool`. - - **Description**: Enables generation-based ME hardswap strategy. - - **Example**: - - ```toml - [general] - hardswap = true - ``` - -- `me_warmup_stagger_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Staggers extra ME warmup dials to avoid connection spikes. - - **Example**: - - ```toml - [general] - me_warmup_stagger_enabled = true - ``` - -- `me_warmup_step_delay_ms` - - **Constraints / validation**: `u64` (milliseconds). - - **Description**: Base delay in milliseconds between warmup dial steps. - - **Example**: - - ```toml - [general] - me_warmup_step_delay_ms = 500 - ``` - -- `me_warmup_step_jitter_ms` - - **Constraints / validation**: `u64` (milliseconds). - - **Description**: Additional random delay in milliseconds for warmup steps. - - **Example**: - - ```toml - [general] - me_warmup_step_jitter_ms = 300 - ``` - -- `me_reconnect_max_concurrent_per_dc` - - **Constraints / validation**: `u32`. Effective value is `max(value, 1)` at runtime (so `0` behaves as `1`). - - **Description**: Limits concurrent reconnect workers per DC during health recovery. - - **Example**: - - ```toml - [general] - me_reconnect_max_concurrent_per_dc = 8 - ``` - -- `me_reconnect_backoff_base_ms` - - **Constraints / validation**: `u64` (milliseconds). - - **Description**: Initial reconnect backoff in milliseconds. - - **Example**: - - ```toml - [general] - me_reconnect_backoff_base_ms = 500 - ``` - -- `me_reconnect_backoff_cap_ms` - - **Constraints / validation**: `u64` (milliseconds). - - **Description**: Maximum reconnect backoff cap in milliseconds. - - **Example**: - - ```toml - [general] - me_reconnect_backoff_cap_ms = 30000 - ``` - -- `me_reconnect_fast_retry_count` - - **Constraints / validation**: `u32`. Effective value is `max(value, 1)` at runtime (so `0` behaves as `1`). - - **Description**: Immediate retry budget before long backoff behavior applies. - - **Example**: - - ```toml - [general] - me_reconnect_fast_retry_count = 16 - ``` - -- `me_single_endpoint_shadow_writers` - - **Constraints / validation**: Must be within `0..=32`. - - **Description**: Additional reserve writers for DC groups with exactly one endpoint. - - **Example**: - - ```toml - [general] - me_single_endpoint_shadow_writers = 2 - ``` - -- `me_single_endpoint_outage_mode_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables aggressive outage recovery mode for DC groups with exactly one endpoint. - - **Example**: - - ```toml - [general] - me_single_endpoint_outage_mode_enabled = true - ``` - -- `me_single_endpoint_outage_disable_quarantine` - - **Constraints / validation**: `bool`. - - **Description**: Allows single-endpoint outage recovery reconnect paths to bypass endpoint quarantine. - - **Example**: - - ```toml - [general] - me_single_endpoint_outage_disable_quarantine = true - ``` - -- `me_single_endpoint_outage_backoff_min_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds) and `<= me_single_endpoint_outage_backoff_max_ms`. - - **Description**: Minimum reconnect backoff in single-endpoint outage mode. - - **Example**: - - ```toml - [general] - me_single_endpoint_outage_backoff_min_ms = 250 - ``` - -- `me_single_endpoint_outage_backoff_max_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds) and `>= me_single_endpoint_outage_backoff_min_ms`. - - **Description**: Maximum reconnect backoff in single-endpoint outage mode. - - **Example**: - - ```toml - [general] - me_single_endpoint_outage_backoff_max_ms = 3000 - ``` - -- `me_single_endpoint_shadow_rotate_every_secs` - - **Constraints / validation**: `u64` (seconds). `0` disables periodic shadow rotation. - - **Description**: Periodic shadow writer rotation interval for single-endpoint DC groups. - - **Example**: - - ```toml - [general] - me_single_endpoint_shadow_rotate_every_secs = 900 - ``` - -- `me_floor_mode` - - **Constraints / validation**: `"static"` or `"adaptive"`. - - **Description**: Floor policy mode for ME writer targets. - - **Example**: - - ```toml - [general] - me_floor_mode = "adaptive" - ``` - -- `me_adaptive_floor_idle_secs` - - **Constraints / validation**: `u64` (seconds). - - **Description**: Reserved adaptive-floor timing knob exposed in runtime config and API snapshots for compatibility. - - **Example**: - - ```toml - [general] - me_adaptive_floor_idle_secs = 90 - ``` - -- `me_adaptive_floor_min_writers_single_endpoint` - - **Constraints / validation**: Must be within `1..=32`. - - **Description**: Minimum writer target for single-endpoint DC groups in adaptive floor mode. - - **Example**: - - ```toml - [general] - me_adaptive_floor_min_writers_single_endpoint = 1 - ``` - -- `me_adaptive_floor_min_writers_multi_endpoint` - - **Constraints / validation**: Must be within `1..=32`. - - **Description**: Minimum writer target for multi-endpoint DC groups in adaptive floor mode. - - **Example**: - - ```toml - [general] - me_adaptive_floor_min_writers_multi_endpoint = 1 - ``` - -- `me_adaptive_floor_recover_grace_secs` - - **Constraints / validation**: `u64` (seconds). - - **Description**: Reserved adaptive-floor grace knob exposed in runtime config and API snapshots for compatibility. - - **Example**: - - ```toml - [general] - me_adaptive_floor_recover_grace_secs = 180 - ``` - -- `me_adaptive_floor_writers_per_core_total` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Global ME writer budget per logical CPU core in adaptive mode. - - **Example**: - - ```toml - [general] - me_adaptive_floor_writers_per_core_total = 48 - ``` - -- `me_adaptive_floor_cpu_cores_override` - - **Constraints / validation**: `u16`. `0` uses runtime auto-detection. - - **Description**: Override logical CPU core count used for adaptive floor calculations. - - **Example**: - - ```toml - [general] - me_adaptive_floor_cpu_cores_override = 0 - ``` - -- `me_adaptive_floor_max_extra_writers_single_per_core` - - **Constraints / validation**: `u16`. - - **Description**: Per-core max extra writers above base required floor for single-endpoint DC groups. - - **Example**: - - ```toml - [general] - me_adaptive_floor_max_extra_writers_single_per_core = 1 - ``` - -- `me_adaptive_floor_max_extra_writers_multi_per_core` - - **Constraints / validation**: `u16`. - - **Description**: Per-core max extra writers above base required floor for multi-endpoint DC groups. - - **Example**: - - ```toml - [general] - me_adaptive_floor_max_extra_writers_multi_per_core = 2 - ``` - -- `me_adaptive_floor_max_active_writers_per_core` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Hard cap for active ME writers per logical CPU core. - - **Example**: - - ```toml - [general] - me_adaptive_floor_max_active_writers_per_core = 64 - ``` - -- `me_adaptive_floor_max_warm_writers_per_core` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Hard cap for warm ME writers per logical CPU core. - - **Example**: - - ```toml - [general] - me_adaptive_floor_max_warm_writers_per_core = 64 - ``` - -- `me_adaptive_floor_max_active_writers_global` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Hard global cap for active ME writers. - - **Example**: - - ```toml - [general] - me_adaptive_floor_max_active_writers_global = 256 - ``` - -- `me_adaptive_floor_max_warm_writers_global` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Hard global cap for warm ME writers. - - **Example**: - - ```toml - [general] - me_adaptive_floor_max_warm_writers_global = 256 - ``` - -- `upstream_connect_retry_attempts` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Connect attempts for the selected upstream before returning error/fallback. - - **Example**: - - ```toml - [general] - upstream_connect_retry_attempts = 2 - ``` - -- `upstream_connect_retry_backoff_ms` - - **Constraints / validation**: `u64` (milliseconds). `0` disables backoff delay (retries become immediate). - - **Description**: Delay in milliseconds between upstream connect attempts. - - **Example**: - - ```toml - [general] - upstream_connect_retry_backoff_ms = 100 - ``` - -- `upstream_connect_budget_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Total wall-clock budget in milliseconds for one upstream connect request across retries. - - **Example**: - - ```toml - [general] - upstream_connect_budget_ms = 3000 - ``` - -- `upstream_unhealthy_fail_threshold` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Consecutive failed requests before upstream is marked unhealthy. - - **Example**: - - ```toml - [general] - upstream_unhealthy_fail_threshold = 5 - ``` - -- `upstream_connect_failfast_hard_errors` - - **Constraints / validation**: `bool`. - - **Description**: When true, skips additional retries for hard non-transient upstream connect errors. - - **Example**: - - ```toml - [general] - upstream_connect_failfast_hard_errors = false - ``` - -- `stun_iface_mismatch_ignore` - - **Constraints / validation**: `bool`. - - **Description**: Compatibility flag reserved for future use. Currently this key is parsed but not used by the runtime. - - **Example**: - - ```toml - [general] - stun_iface_mismatch_ignore = false - ``` - -- `unknown_dc_log_path` - - **Constraints / validation**: `String` (optional). Must be a safe path (no `..` components, parent directory must exist); unsafe paths are rejected at runtime. - - **Description**: Log file path for unknown (non-standard) DC requests when `unknown_dc_file_log_enabled = true`. Omit this key to disable file logging. - - **Example**: - - ```toml - [general] - unknown_dc_log_path = "unknown-dc.txt" - ``` - -- `unknown_dc_file_log_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables unknown-DC file logging (writes `dc_idx=` lines). Requires `unknown_dc_log_path` to be set and, on non-Unix platforms, may be unsupported. Logging is deduplicated and capped (only the first ~1024 distinct unknown DC indices are recorded). - - **Example**: - - ```toml - [general] - unknown_dc_file_log_enabled = false - ``` - -- `log_level` - - **Constraints / validation**: `"debug"`, `"verbose"`, `"normal"`, or `"silent"`. - - **Description**: Runtime logging verbosity level (used when `RUST_LOG` is not set). If `RUST_LOG` is set in the environment, it takes precedence over this setting. - - **Example**: - - ```toml - [general] - log_level = "normal" - ``` - -- `disable_colors` - - **Constraints / validation**: `bool`. - - **Description**: Disables ANSI colors in logs (useful for files/systemd). This affects log formatting only and does not change the log level/filtering. - - **Example**: - - ```toml - [general] - disable_colors = false - ``` - -- `me_socks_kdf_policy` - - **Constraints / validation**: `"strict"` or `"compat"`. - - **Description**: SOCKS-bound KDF fallback policy for Middle-End handshake. - - **Example**: - - ```toml - [general] - me_socks_kdf_policy = "strict" - ``` - -- `me_route_backpressure_base_timeout_ms` - - **Constraints / validation**: Must be within `1..=5000` (milliseconds). - - **Description**: Base backpressure timeout in milliseconds for ME route-channel send. - - **Example**: - - ```toml - [general] - me_route_backpressure_base_timeout_ms = 25 - ``` - -- `me_route_backpressure_high_timeout_ms` - - **Constraints / validation**: Must be within `1..=5000` (milliseconds) and `>= me_route_backpressure_base_timeout_ms`. - - **Description**: High backpressure timeout in milliseconds when queue occupancy is above watermark. - - **Example**: - - ```toml - [general] - me_route_backpressure_high_timeout_ms = 120 - ``` - -- `me_route_backpressure_high_watermark_pct` - - **Constraints / validation**: Must be within `1..=100` (percent). - - **Description**: Queue occupancy percent threshold for switching to high backpressure timeout. - - **Example**: - - ```toml - [general] - me_route_backpressure_high_watermark_pct = 80 - ``` - -- `me_health_interval_ms_unhealthy` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Health monitor interval while ME writer coverage is degraded. - - **Example**: - - ```toml - [general] - me_health_interval_ms_unhealthy = 1000 - ``` - -- `me_health_interval_ms_healthy` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Health monitor interval while ME writer coverage is stable/healthy. - - **Example**: - - ```toml - [general] - me_health_interval_ms_healthy = 3000 - ``` - -- `me_admission_poll_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Poll interval for conditional-admission state checks. - - **Example**: - - ```toml - [general] - me_admission_poll_ms = 1000 - ``` - -- `me_warn_rate_limit_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Cooldown for repetitive ME warning logs. - - **Example**: - - ```toml - [general] - me_warn_rate_limit_ms = 5000 - ``` - -- `me_route_no_writer_mode` - - **Constraints / validation**: `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"`. - - **Description**: ME route behavior when no writer is immediately available. - - **Example**: - - ```toml - [general] - me_route_no_writer_mode = "hybrid_async_persistent" - ``` - -- `me_route_no_writer_wait_ms` - - **Constraints / validation**: Must be within `10..=5000` (milliseconds). - - **Description**: Max wait time used by async-recovery failfast mode before falling back. - - **Example**: - - ```toml - [general] - me_route_no_writer_wait_ms = 250 - ``` - -- `me_route_hybrid_max_wait_ms` - - **Constraints / validation**: Must be within `50..=60000` (milliseconds). - - **Description**: Maximum cumulative wait in hybrid no-writer mode before failfast fallback. - - **Example**: - - ```toml - [general] - me_route_hybrid_max_wait_ms = 3000 - ``` - -- `me_route_blocking_send_timeout_ms` - - **Constraints / validation**: Must be within `0..=5000` (milliseconds). `0` keeps legacy unbounded wait behavior. - - **Description**: Maximum wait for blocking route-channel send fallback. - - **Example**: - - ```toml - [general] - me_route_blocking_send_timeout_ms = 250 - ``` - -- `me_route_inline_recovery_attempts` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Number of inline recovery attempts in legacy mode. - - **Example**: - - ```toml - [general] - me_route_inline_recovery_attempts = 3 - ``` - -- `me_route_inline_recovery_wait_ms` - - **Constraints / validation**: Must be within `10..=30000` (milliseconds). - - **Description**: Max inline recovery wait in legacy mode. - - **Example**: - - ```toml - [general] - me_route_inline_recovery_wait_ms = 3000 - ``` - -- `fast_mode_min_tls_record` - - **Constraints / validation**: `usize` (bytes). `0` disables the limit. - - **Description**: Minimum TLS record size when fast-mode coalescing is enabled. - - **Example**: - - ```toml - [general] - fast_mode_min_tls_record = 0 - ``` - -- `update_every` - - **Constraints / validation**: `u64` (seconds). If set, must be `> 0`. If this key is not explicitly set, legacy `proxy_secret_auto_reload_secs` and `proxy_config_auto_reload_secs` may be used (their effective minimum must be `> 0`). - - **Description**: Unified refresh interval for ME updater tasks (`getProxyConfig`, `getProxyConfigV6`, `getProxySecret`). When set, it overrides legacy proxy reload intervals. - - **Example**: - - ```toml - [general] - update_every = 300 - ``` - -- `me_reinit_every_secs` - - **Constraints / validation**: Must be `> 0` (seconds). - - **Description**: Periodic interval for zero-downtime ME reinit cycle. - - **Example**: - - ```toml - [general] - me_reinit_every_secs = 900 - ``` - -- `me_hardswap_warmup_delay_min_ms` - - **Constraints / validation**: `u64` (milliseconds). Must be `<= me_hardswap_warmup_delay_max_ms`. - - **Description**: Lower bound for hardswap warmup dial spacing. - - **Example**: - - ```toml - [general] - me_hardswap_warmup_delay_min_ms = 1000 - ``` - -- `me_hardswap_warmup_delay_max_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Upper bound for hardswap warmup dial spacing. - - **Example**: - - ```toml - [general] - me_hardswap_warmup_delay_max_ms = 2000 - ``` - -- `me_hardswap_warmup_extra_passes` - - **Constraints / validation**: Must be within `[0, 10]`. - - **Description**: Additional warmup passes after the base pass in one hardswap cycle. - - **Example**: - - ```toml - [general] - # default: 3 (allowed range: 0..=10) - me_hardswap_warmup_extra_passes = 3 - ``` - -- `me_hardswap_warmup_pass_backoff_base_ms` - - **Constraints / validation**: `u64` (milliseconds). Must be `> 0`. - - **Description**: Base backoff between extra hardswap warmup passes when the floor is still incomplete. - - **Example**: - - ```toml - [general] - # default: 500 - me_hardswap_warmup_pass_backoff_base_ms = 500 - ``` - -- `me_config_stable_snapshots` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Number of identical ME config snapshots required before apply. - - **Example**: - - ```toml - [general] - # require 3 identical snapshots before applying ME endpoint map updates - me_config_stable_snapshots = 3 - ``` - -- `me_config_apply_cooldown_secs` - - **Constraints / validation**: `u64`. - - **Description**: Cooldown between applied ME endpoint-map updates. `0` disables the cooldown. - - **Example**: - - ```toml - [general] - # allow applying stable snapshots immediately (no cooldown) - me_config_apply_cooldown_secs = 0 - ``` - -- `me_snapshot_require_http_2xx` - - **Constraints / validation**: `bool`. - - **Description**: Requires 2xx HTTP responses for applying ME config snapshots. When `false`, non-2xx responses may still be parsed/considered by the updater. - - **Example**: - - ```toml - [general] - # allow applying snapshots even when the HTTP status is non-2xx - me_snapshot_require_http_2xx = false - ``` - -- `me_snapshot_reject_empty_map` - - **Constraints / validation**: `bool`. - - **Description**: Rejects empty ME config snapshots (no endpoints). When `false`, an empty snapshot can be applied (subject to other gates), which may temporarily reduce/clear the ME map. - - **Example**: - - ```toml - [general] - # allow applying empty snapshots (use with care) - me_snapshot_reject_empty_map = false - ``` - -- `me_snapshot_min_proxy_for_lines` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Minimum parsed `proxy_for` rows required to accept snapshot. - - **Example**: - - ```toml - [general] - # require at least 10 proxy_for rows before accepting a snapshot - me_snapshot_min_proxy_for_lines = 10 - ``` - -- `proxy_secret_stable_snapshots` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Number of identical proxy-secret snapshots required before rotation. - - **Example**: - - ```toml - [general] - # require 2 identical getProxySecret snapshots before rotating at runtime - proxy_secret_stable_snapshots = 2 - ``` - -- `proxy_secret_rotate_runtime` - - **Constraints / validation**: `bool`. - - **Description**: Enables runtime proxy-secret rotation from updater snapshots. - - **Example**: - - ```toml - [general] - # disable runtime proxy-secret rotation (startup still uses proxy_secret_path/proxy_secret_len_max) - proxy_secret_rotate_runtime = false - ``` - -- `me_secret_atomic_snapshot` - - **Constraints / validation**: `bool`. - - **Description**: Keeps selector and secret bytes from the same snapshot atomically. When `general.use_middle_proxy = true`, this is auto-enabled during config load to keep ME KDF material coherent. - - **Example**: - - ```toml - [general] - # NOTE: when use_middle_proxy=true, Telemt will auto-enable this during load - me_secret_atomic_snapshot = false - ``` - -- `proxy_secret_len_max` - - **Constraints / validation**: Must be within `[32, 4096]`. - - **Description**: Upper length limit (bytes) for accepted proxy-secret during startup and runtime refresh. - - **Example**: - - ```toml - [general] - # default: 256 (bytes) - proxy_secret_len_max = 256 - ``` - -- `me_pool_drain_ttl_secs` - - **Constraints / validation**: `u64` (seconds). `0` disables the drain-TTL window (and suppresses drain-TTL warnings for non-empty draining writers). - - **Description**: Drain-TTL time window for stale ME writers after endpoint map changes. During the TTL, stale writers may be used only as fallback for new bindings (depending on bind policy). - - **Example**: - - ```toml - [general] - # disable drain TTL (draining writers won't emit "past drain TTL" warnings) - me_pool_drain_ttl_secs = 0 - ``` - -- `me_instadrain` - - **Constraints / validation**: `bool`. - - **Description**: Forces draining stale writers to be removed on the next cleanup tick, bypassing TTL/deadline waiting. - - **Example**: - - ```toml - [general] - # default: false - me_instadrain = false - ``` - -- `me_pool_drain_threshold` - - **Constraints / validation**: `u64`. Set to `0` to disable threshold-based cleanup. - - **Description**: Maximum number of draining stale writers before oldest ones are force-closed in batches. - - **Example**: - - ```toml - [general] - # default: 32 - me_pool_drain_threshold = 32 - ``` - -- `me_pool_drain_soft_evict_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables gradual soft-eviction of stale writers during drain/reinit instead of immediate hard close. - - **Example**: - - ```toml - [general] - # default: true - me_pool_drain_soft_evict_enabled = true - ``` - -- `me_pool_drain_soft_evict_grace_secs` - - **Constraints / validation**: `u64` (seconds). Must be within `[0, 3600]`. - - **Description**: Extra grace (after drain TTL) before soft-eviction stage starts. - - **Example**: - - ```toml - [general] - # default: 10 - me_pool_drain_soft_evict_grace_secs = 10 - ``` - -- `me_pool_drain_soft_evict_per_writer` - - **Constraints / validation**: `1..=16`. - - **Description**: Maximum stale routes soft-evicted per writer in one eviction pass. - - **Example**: - - ```toml - [general] - # default: 2 - me_pool_drain_soft_evict_per_writer = 2 - ``` - -- `me_pool_drain_soft_evict_budget_per_core` - - **Constraints / validation**: `1..=64`. - - **Description**: Per-core budget limiting aggregate soft-eviction work per pass. - - **Example**: - - ```toml - [general] - # default: 16 - me_pool_drain_soft_evict_budget_per_core = 16 - ``` - -- `me_pool_drain_soft_evict_cooldown_ms` - - **Constraints / validation**: `u64` (milliseconds). Must be `> 0`. - - **Description**: Cooldown between repetitive soft-eviction on the same writer. - - **Example**: - - ```toml - [general] - # default: 1000 - me_pool_drain_soft_evict_cooldown_ms = 1000 - ``` - -- `me_bind_stale_mode` - - **Constraints / validation**: `"never"`, `"ttl"`, or `"always"`. - - **Description**: Policy for new binds on stale draining writers. - - **Example**: - - ```toml - [general] - # allow stale binds only for a limited time window - me_bind_stale_mode = "ttl" - ``` - -- `me_bind_stale_ttl_secs` - - **Constraints / validation**: `u64`. - - **Description**: TTL for stale bind allowance when stale mode is `ttl`. - - **Example**: - - ```toml - [general] - me_bind_stale_mode = "ttl" - me_bind_stale_ttl_secs = 90 - ``` - -- `me_pool_min_fresh_ratio` - - **Constraints / validation**: Must be within `[0.0, 1.0]`. - - **Description**: Minimum fresh desired-DC coverage ratio before stale writers are drained. - - **Example**: - - ```toml - [general] - # require >=90% desired-DC coverage before draining stale writers - me_pool_min_fresh_ratio = 0.9 - ``` - -- `me_reinit_drain_timeout_secs` - - **Constraints / validation**: `u64`. `0` uses the runtime safety fallback force-close timeout. If `> 0` and `< me_pool_drain_ttl_secs`, runtime bumps it to TTL. - - **Description**: Force-close timeout for draining stale writers. When set to `0`, the effective timeout is the runtime safety fallback (300 seconds). - - **Example**: - - ```toml - [general] - # use runtime safety fallback force-close timeout (300s) - me_reinit_drain_timeout_secs = 0 - ``` - -- `proxy_secret_auto_reload_secs` - - **Constraints / validation**: Deprecated. Use `general.update_every`. When `general.update_every` is not explicitly set, the effective legacy refresh interval is `min(proxy_secret_auto_reload_secs, proxy_config_auto_reload_secs)` and must be `> 0`. - - **Description**: Deprecated legacy proxy-secret refresh interval. Used only when `general.update_every` is not set. - - **Example**: - - ```toml - [general] - # legacy mode: omit update_every to use proxy_*_auto_reload_secs - proxy_secret_auto_reload_secs = 600 - proxy_config_auto_reload_secs = 120 - # effective updater interval = min(600, 120) = 120 seconds - ``` - -- `proxy_config_auto_reload_secs` - - **Constraints / validation**: Deprecated. Use `general.update_every`. When `general.update_every` is not explicitly set, the effective legacy refresh interval is `min(proxy_secret_auto_reload_secs, proxy_config_auto_reload_secs)` and must be `> 0`. - - **Description**: Deprecated legacy ME config refresh interval. Used only when `general.update_every` is not set. - - **Example**: - - ```toml - [general] - # legacy mode: omit update_every to use proxy_*_auto_reload_secs - proxy_secret_auto_reload_secs = 600 - proxy_config_auto_reload_secs = 120 - # effective updater interval = min(600, 120) = 120 seconds - ``` - -- `me_reinit_singleflight` - - **Constraints / validation**: `bool`. - - **Description**: Serializes ME reinit cycles across trigger sources. - - **Example**: - - ```toml - [general] - me_reinit_singleflight = true - ``` - -- `me_reinit_trigger_channel` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Trigger queue capacity for reinit scheduler. - - **Example**: - - ```toml - [general] - me_reinit_trigger_channel = 64 - ``` - -- `me_reinit_coalesce_window_ms` - - **Constraints / validation**: `u64`. - - **Description**: Trigger coalescing window before starting reinit (ms). - - **Example**: - - ```toml - [general] - me_reinit_coalesce_window_ms = 200 - ``` - -- `me_deterministic_writer_sort` - - **Constraints / validation**: `bool`. - - **Description**: Enables deterministic candidate sort for writer binding path. - - **Example**: - - ```toml - [general] - me_deterministic_writer_sort = true - ``` - -- `me_writer_pick_mode` - - **Constraints / validation**: `"sorted_rr"` or `"p2c"`. - - **Description**: Writer selection mode for route bind path. - - **Example**: - - ```toml - [general] - me_writer_pick_mode = "p2c" - ``` - -- `me_writer_pick_sample_size` - - **Constraints / validation**: `2..=4`. - - **Description**: Number of candidates sampled by picker in `p2c` mode. - - **Example**: - - ```toml - [general] - me_writer_pick_mode = "p2c" - me_writer_pick_sample_size = 3 - ``` - -- `ntp_check` - - **Constraints / validation**: `bool`. - - **Description**: Reserved for future use. Currently this key is parsed but not used by the runtime. - - **Example**: - - ```toml - [general] - ntp_check = true - ``` - -- `ntp_servers` - - **Constraints / validation**: `String[]`. - - **Description**: Reserved for future use. Currently this key is parsed but not used by the runtime. - - **Example**: - - ```toml - [general] - ntp_servers = ["pool.ntp.org"] - ``` - -- `auto_degradation_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Reserved for future use. Currently this key is parsed but not used by the runtime. - - **Example**: - - ```toml - [general] - auto_degradation_enabled = true - ``` - -- `degradation_min_unavailable_dc_groups` - - **Constraints / validation**: `u8`. - - **Description**: Reserved for future use. Currently this key is parsed but not used by the runtime. - - **Example**: - - ```toml - [general] - degradation_min_unavailable_dc_groups = 2 - ``` - - -## [general.modes] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`classic`](#cfg-general-modes-classic) | `bool` | `false` | -| [`secure`](#cfg-general-modes-secure) | `bool` | `false` | -| [`tls`](#cfg-general-modes-tls) | `bool` | `true` | - - -- `classic` - - **Constraints / validation**: `bool`. - - **Description**: Enables classic MTProxy mode. - - **Example**: - - ```toml - [general.modes] - classic = true - ``` - -- `secure` - - **Constraints / validation**: `bool`. - - **Description**: Enables secure mode. - - **Example**: - - ```toml - [general.modes] - secure = true - ``` - -- `tls` - - **Constraints / validation**: `bool`. - - **Description**: Enables TLS mode. - - **Example**: - - ```toml - [general.modes] - tls = true - ``` - - -## [general.links] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`show`](#cfg-general-links-show) | `"*"` or `String[]` | `"*"` | -| [`public_host`](#cfg-general-links-public_host) | `String` | — | -| [`public_port`](#cfg-general-links-public_port) | `u16` | — | - - -- `show` - - **Constraints / validation**: `"*"` or `String[]`. An empty array means "show none". - - **Description**: Selects users whose `tg://` proxy links are shown at startup. - - **Example**: - - ```toml - [general.links] - show = "*" - # or: - # show = ["alice", "bob"] - ``` - -- `public_host` - - **Constraints / validation**: `String` (optional). - - **Description**: Public hostname/IP override used for generated `tg://` links (overrides detected IP). - - **Example**: - - ```toml - [general.links] - public_host = "proxy.example.com" - ``` - -- `public_port` - - **Constraints / validation**: `u16` (optional). - - **Description**: Public port override used for generated `tg://` links (overrides `server.port`). - - **Example**: - - ```toml - [general.links] - public_port = 443 - ``` - - -## [general.telemetry] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`core_enabled`](#cfg-general-telemetry-core_enabled) | `bool` | `true` | -| [`user_enabled`](#cfg-general-telemetry-user_enabled) | `bool` | `true` | -| [`me_level`](#cfg-general-telemetry-me_level) | `"silent"`, `"normal"`, or `"debug"` | `"normal"` | - - -- `core_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables core hot-path telemetry counters. - - **Example**: - - ```toml - [general.telemetry] - core_enabled = true - ``` - -- `user_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables per-user telemetry counters. - - **Example**: - - ```toml - [general.telemetry] - user_enabled = true - ``` - -- `me_level` - - **Constraints / validation**: `"silent"`, `"normal"`, or `"debug"`. - - **Description**: Middle-End telemetry verbosity level. - - **Example**: - - ```toml - [general.telemetry] - me_level = "normal" - ``` - - -## [network] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`ipv4`](#cfg-network-ipv4) | `bool` | `true` | -| [`ipv6`](#cfg-network-ipv6) | `bool` | `false` | -| [`prefer`](#cfg-network-prefer) | `u8` | `4` | -| [`multipath`](#cfg-network-multipath) | `bool` | `false` | -| [`stun_use`](#cfg-network-stun_use) | `bool` | `true` | -| [`stun_servers`](#cfg-network-stun_servers) | `String[]` | Built-in STUN list (13 hosts) | -| [`stun_tcp_fallback`](#cfg-network-stun_tcp_fallback) | `bool` | `true` | -| [`http_ip_detect_urls`](#cfg-network-http_ip_detect_urls) | `String[]` | `["https://ifconfig.me/ip", "https://api.ipify.org"]` | -| [`cache_public_ip_path`](#cfg-network-cache_public_ip_path) | `String` | `"cache/public_ip.txt"` | -| [`dns_overrides`](#cfg-network-dns_overrides) | `String[]` | `[]` | - - -- `ipv4` - - **Constraints / validation**: `bool`. - - **Description**: Enables IPv4 networking. - - **Example**: - - ```toml - [network] - ipv4 = true - ``` - -- `ipv6` - - **Constraints / validation**: `bool`. - - **Description**: Enables/disables IPv6 networking. When omitted, defaults to `false`. - - **Example**: - - ```toml - [network] - # enable IPv6 explicitly - ipv6 = true - - # or: disable IPv6 explicitly - # ipv6 = false - ``` - -- `prefer` - - **Constraints / validation**: Must be `4` or `6`. If `prefer = 4` while `ipv4 = false`, Telemt forces `prefer = 6`. If `prefer = 6` while `ipv6 = false`, Telemt forces `prefer = 4`. - - **Description**: Preferred IP family for selection when both families are available. - - **Example**: - - ```toml - [network] - prefer = 6 - ``` - -- `multipath` - - **Constraints / validation**: `bool`. - - **Description**: Enables multipath behavior where supported by the platform and runtime. - - **Example**: - - ```toml - [network] - multipath = true - ``` - -- `stun_use` - - **Constraints / validation**: `bool`. - - **Description**: Global STUN switch; when `false`, STUN probing is disabled and only non-STUN detection remains. - - **Example**: - - ```toml - [network] - stun_use = false - ``` - -- `stun_servers` - - **Constraints / validation**: `String[]`. Values are trimmed; empty values are removed; list is deduplicated. If this key is **not** explicitly set, Telemt keeps the built-in default STUN list. - - **Description**: STUN servers list for public IP discovery. - - **Example**: - - ```toml - [network] - stun_servers = [ - "stun.l.google.com:19302", - "stun.stunprotocol.org:3478", - ] - ``` - -- `stun_tcp_fallback` - - **Constraints / validation**: `bool`. - - **Description**: Enables TCP fallback for STUN when the UDP path is blocked/unavailable. - - **Example**: - - ```toml - [network] - stun_tcp_fallback = true - ``` - -- `http_ip_detect_urls` - - **Constraints / validation**: `String[]`. - - **Description**: HTTP endpoints used for public IP detection (fallback after STUN). - - **Example**: - - ```toml - [network] - http_ip_detect_urls = ["https://ifconfig.me/ip", "https://api.ipify.org"] - ``` - -- `cache_public_ip_path` - - **Constraints / validation**: `String`. - - **Description**: File path used to cache the detected public IP. - - **Example**: - - ```toml - [network] - cache_public_ip_path = "cache/public_ip.txt" - ``` - -- `dns_overrides` - - **Constraints / validation**: `String[]`. Each entry must use `host:port:ip` format. - - `host`: domain name (must be non-empty and must not contain `:`) - - `port`: `u16` - - `ip`: IPv4 (`1.2.3.4`) or bracketed IPv6 (`[2001:db8::1]`). **Unbracketed IPv6 is rejected**. - - **Description**: Runtime DNS overrides for `host:port` targets. Useful for forcing specific IPs for given upstream domains without touching system DNS. - - **Example**: - - ```toml - [network] - dns_overrides = [ - "example.com:443:127.0.0.1", - "example.net:8443:[2001:db8::10]", - ] - ``` - - -## [server] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`port`](#cfg-server-port) | `u16` | `443` | -| [`listen_addr_ipv4`](#cfg-server-listen_addr_ipv4) | `String` | `"0.0.0.0"` | -| [`listen_addr_ipv6`](#cfg-server-listen_addr_ipv6) | `String` | `"::"` | -| [`listen_unix_sock`](#cfg-server-listen_unix_sock) | `String` | — | -| [`listen_unix_sock_perm`](#cfg-server-listen_unix_sock_perm) | `String` | — | -| [`listen_tcp`](#cfg-server-listen_tcp) | `bool` | — (auto) | -| [`proxy_protocol`](#cfg-server-proxy_protocol) | `bool` | `false` | -| [`proxy_protocol_header_timeout_ms`](#cfg-server-proxy_protocol_header_timeout_ms) | `u64` | `500` | -| [`proxy_protocol_trusted_cidrs`](#cfg-server-proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | -| [`metrics_port`](#cfg-server-metrics_port) | `u16` | — | -| [`metrics_listen`](#cfg-server-metrics_listen) | `String` | — | -| [`metrics_whitelist`](#cfg-server-metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` | -| [`max_connections`](#cfg-server-max_connections) | `u32` | `10000` | -| [`accept_permit_timeout_ms`](#cfg-server-accept_permit_timeout_ms) | `u64` | `250` | - - -- `port` - - **Constraints / validation**: `u16`. - - **Description**: Main proxy listen port (TCP). - - **Example**: - - ```toml - [server] - port = 443 - ``` - -- `listen_addr_ipv4` - - **Constraints / validation**: `String` (optional). When set, must be a valid IPv4 address string. - - **Description**: IPv4 bind address for TCP listener (omit this key to disable IPv4 bind). - - **Example**: - - ```toml - [server] - listen_addr_ipv4 = "0.0.0.0" - ``` - -- `listen_addr_ipv6` - - **Constraints / validation**: `String` (optional). When set, must be a valid IPv6 address string. - - **Description**: IPv6 bind address for TCP listener (omit this key to disable IPv6 bind). - - **Example**: - - ```toml - [server] - listen_addr_ipv6 = "::" - ``` - -- `listen_unix_sock` - - **Constraints / validation**: `String` (optional). Must not be empty when set. Unix only. - - **Description**: Unix socket path for listener. When set, `server.listen_tcp` defaults to `false` (unless explicitly overridden). - - **Example**: - - ```toml - [server] - listen_unix_sock = "/run/telemt.sock" - ``` - -- `listen_unix_sock_perm` - - **Constraints / validation**: `String` (optional). When set, should be an octal permission string like `"0666"` or `"0777"`. - - **Description**: Optional Unix socket file permissions applied after bind (chmod). When omitted, permissions are not changed (inherits umask). - - **Example**: - - ```toml - [server] - listen_unix_sock = "/run/telemt.sock" - listen_unix_sock_perm = "0666" - ``` - -- `listen_tcp` - - **Constraints / validation**: `bool` (optional). When omitted, Telemt auto-detects: - - `true` when `listen_unix_sock` is not set - - `false` when `listen_unix_sock` is set - - **Description**: Explicit TCP listener enable/disable override. - - **Example**: - - ```toml - [server] - # force-enable TCP even when also binding a unix socket - listen_unix_sock = "/run/telemt.sock" - listen_tcp = true - ``` - -- `proxy_protocol` - - **Constraints / validation**: `bool`. - - **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header. - - **Example**: - - ```toml - [server] - proxy_protocol = true - ``` - -- `proxy_protocol_header_timeout_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Timeout for reading and parsing PROXY protocol headers (ms). - - **Example**: - - ```toml - [server] - proxy_protocol = true - proxy_protocol_header_timeout_ms = 500 - ``` - -- `proxy_protocol_trusted_cidrs` - - **Constraints / validation**: `IpNetwork[]`. - - If omitted, defaults to trust-all CIDRs (`0.0.0.0/0` and `::/0`). - - If explicitly set to an empty array, all PROXY headers are rejected. - - **Description**: Trusted source CIDRs allowed to provide PROXY protocol headers (security control). - - **Example**: - - ```toml - [server] - proxy_protocol = true - proxy_protocol_trusted_cidrs = ["127.0.0.1/32", "10.0.0.0/8"] - ``` - -- `metrics_port` - - **Constraints / validation**: `u16` (optional). - - **Description**: Prometheus-compatible metrics endpoint port. When set, enables the metrics listener (bind behavior can be overridden by `metrics_listen`). - - **Example**: - - ```toml - [server] - metrics_port = 9090 - ``` - -- `metrics_listen` - - **Constraints / validation**: `String` (optional). When set, must be in `IP:PORT` format. - - **Description**: Full metrics bind address (`IP:PORT`), overrides `metrics_port` and binds on the specified address only. - - **Example**: - - ```toml - [server] - metrics_listen = "127.0.0.1:9090" - ``` - -- `metrics_whitelist` - - **Constraints / validation**: `IpNetwork[]`. - - **Description**: CIDR whitelist for metrics endpoint access. - - **Example**: - - ```toml - [server] - metrics_port = 9090 - metrics_whitelist = ["127.0.0.1/32", "::1/128"] - ``` - -- `max_connections` - - **Constraints / validation**: `u32`. `0` means unlimited. - - **Description**: Maximum number of concurrent client connections. - - **Example**: - - ```toml - [server] - max_connections = 10000 - ``` - -- `accept_permit_timeout_ms` - - **Constraints / validation**: `0..=60000` (milliseconds). `0` keeps legacy unbounded wait behavior. - - **Description**: Maximum wait for acquiring a connection-slot permit before the accepted connection is dropped. - - **Example**: - - ```toml - [server] - accept_permit_timeout_ms = 250 - ``` - - -Note: When `server.proxy_protocol` is enabled, incoming PROXY protocol headers are parsed from the first bytes of the connection and the client source address is replaced with `src_addr` from the header. For security, the peer source IP (the direct connection address) is verified against `server.proxy_protocol_trusted_cidrs`; if this list is empty, PROXY headers are rejected and the connection is considered untrusted. - -## [server.conntrack_control] - -Note: The conntrack-control worker runs **only on Linux**. On other operating systems it is not started; if `inline_conntrack_control` is `true`, a warning is logged. Effective operation also requires **CAP_NET_ADMIN** and a usable backend (`nft` or `iptables` / `ip6tables` on `PATH`). The `conntrack` utility is used for optional table entry deletes under pressure. - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`inline_conntrack_control`](#cfg-server-conntrack_control-inline_conntrack_control) | `bool` | `true` | -| [`mode`](#cfg-server-conntrack_control-mode) | `String` | `"tracked"` | -| [`backend`](#cfg-server-conntrack_control-backend) | `String` | `"auto"` | -| [`profile`](#cfg-server-conntrack_control-profile) | `String` | `"balanced"` | -| [`hybrid_listener_ips`](#cfg-server-conntrack_control-hybrid_listener_ips) | `IpAddr[]` | `[]` | -| [`pressure_high_watermark_pct`](#cfg-server-conntrack_control-pressure_high_watermark_pct) | `u8` | `85` | -| [`pressure_low_watermark_pct`](#cfg-server-conntrack_control-pressure_low_watermark_pct) | `u8` | `70` | -| [`delete_budget_per_sec`](#cfg-server-conntrack_control-delete_budget_per_sec) | `u64` | `4096` | - - -- `inline_conntrack_control` - - **Constraints / validation**: `bool`. - - **Description**: Master switch for the runtime conntrack-control task: reconciles **raw/notrack** netfilter rules for listener ingress (see `mode`), samples load every second, and may run **`conntrack -D`** deletes for qualifying close events while **pressure mode** is active (see `delete_budget_per_sec`). When `false`, notrack rules are cleared and pressure-driven deletes are disabled. - - **Example**: - - ```toml - [server.conntrack_control] - inline_conntrack_control = true - ``` - -- `mode` - - **Constraints / validation**: One of `tracked`, `notrack`, `hybrid` (case-insensitive; serialized lowercase). - - **Description**: **`tracked`**: do not install telemt notrack rules (connections stay in conntrack). **`notrack`**: mark matching ingress TCP to `server.port` as notrack — targets are derived from `[[server.listeners]]` if any, otherwise from `server.listen_addr_ipv4` / `server.listen_addr_ipv6` (unspecified addresses mean “any” for that family). **`hybrid`**: notrack only for addresses listed in `hybrid_listener_ips` (must be non-empty; validated at load). - - **Example**: - - ```toml - [server.conntrack_control] - mode = "notrack" - ``` - -- `backend` - - **Constraints / validation**: One of `auto`, `nftables`, `iptables` (case-insensitive; serialized lowercase). - - **Description**: Which command set applies notrack rules. **`auto`**: use `nft` if present on `PATH`, else `iptables`/`ip6tables` if present. **`nftables`** / **`iptables`**: force that backend; missing binary means rules cannot be applied. The nft path uses table `inet telemt_conntrack` and a prerouting raw hook; iptables uses chain `TELEMT_NOTRACK` in the `raw` table. - - **Example**: - - ```toml - [server.conntrack_control] - backend = "auto" - ``` - -- `profile` - - **Constraints / validation**: One of `conservative`, `balanced`, `aggressive` (case-insensitive; serialized lowercase). - - **Description**: When **conntrack pressure mode** is active (`pressure_*` watermarks), caps idle and activity timeouts to reduce conntrack churn: e.g. **client first-byte idle** (`client.rs`), **direct relay activity timeout** (`direct_relay.rs`), and **middle-relay idle policy** caps (`middle_relay.rs` via `ConntrackPressureProfile::*_cap_secs` / `direct_activity_timeout_secs`). More aggressive profiles use shorter caps. - - **Example**: - - ```toml - [server.conntrack_control] - profile = "balanced" - ``` - -- `hybrid_listener_ips` - - **Constraints / validation**: `IpAddr[]`. Required to be **non-empty** when `mode = "hybrid"`. Ignored for `tracked` / `notrack`. - - **Description**: Explicit listener addresses that receive notrack rules in hybrid mode (split into IPv4 vs IPv6 rules by the implementation). - - **Example**: - - ```toml - [server.conntrack_control] - mode = "hybrid" - hybrid_listener_ips = ["203.0.113.10", "2001:db8::1"] - ``` - -- `pressure_high_watermark_pct` - - **Constraints / validation**: Must be within `[1, 100]`. - - **Description**: Pressure mode **enters** when any of: connection fill vs `server.max_connections` (percentage, if `max_connections > 0`), **file-descriptor** usage vs process soft `RLIMIT_NOFILE`, **non-zero** `accept_permit_timeout` events in the last sample window, or **ME c2me send-full** counter delta. Entry compares relevant percentages against this high watermark (see `update_pressure_state` in `conntrack_control.rs`). - - **Example**: - - ```toml - [server.conntrack_control] - pressure_high_watermark_pct = 85 - ``` - -- `pressure_low_watermark_pct` - - **Constraints / validation**: Must be **strictly less than** `pressure_high_watermark_pct`. - - **Description**: Pressure mode **clears** only after **three** consecutive one-second samples where all signals are at or below this low watermark and the accept-timeout / ME-queue deltas are zero (hysteresis). - - **Example**: - - ```toml - [server.conntrack_control] - pressure_low_watermark_pct = 70 - ``` - -- `delete_budget_per_sec` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Maximum number of **`conntrack -D`** attempts **per second** while pressure mode is active (token bucket refilled each second). Deletes run only for close events with reasons **timeout**, **pressure**, or **reset**; each attempt consumes a token regardless of outcome. - - **Example**: - - ```toml - [server.conntrack_control] - delete_budget_per_sec = 4096 - ``` - - -## [server.api] - -Note: This section also accepts the legacy alias `[server.admin_api]` (same schema as `[server.api]`). - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`enabled`](#cfg-server-api-enabled) | `bool` | `true` | -| [`listen`](#cfg-server-api-listen) | `String` | `"0.0.0.0:9091"` | -| [`whitelist`](#cfg-server-api-whitelist) | `IpNetwork[]` | `["127.0.0.0/8"]` | -| [`auth_header`](#cfg-server-api-auth_header) | `String` | `""` | -| [`request_body_limit_bytes`](#cfg-server-api-request_body_limit_bytes) | `usize` | `65536` | -| [`minimal_runtime_enabled`](#cfg-server-api-minimal_runtime_enabled) | `bool` | `true` | -| [`minimal_runtime_cache_ttl_ms`](#cfg-server-api-minimal_runtime_cache_ttl_ms) | `u64` | `1000` | -| [`runtime_edge_enabled`](#cfg-server-api-runtime_edge_enabled) | `bool` | `false` | -| [`runtime_edge_cache_ttl_ms`](#cfg-server-api-runtime_edge_cache_ttl_ms) | `u64` | `1000` | -| [`runtime_edge_top_n`](#cfg-server-api-runtime_edge_top_n) | `usize` | `10` | -| [`runtime_edge_events_capacity`](#cfg-server-api-runtime_edge_events_capacity) | `usize` | `256` | -| [`read_only`](#cfg-server-api-read_only) | `bool` | `false` | - - -- `enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables control-plane REST API. - - **Example**: - - ```toml - [server.api] - enabled = true - ``` - -- `listen` - - **Constraints / validation**: `String`. Must be in `IP:PORT` format. - - **Description**: API bind address in `IP:PORT` format. - - **Example**: - - ```toml - [server.api] - listen = "0.0.0.0:9091" - ``` - -- `whitelist` - - **Constraints / validation**: `IpNetwork[]`. - - **Description**: CIDR whitelist allowed to access API. - - **Example**: - - ```toml - [server.api] - whitelist = ["127.0.0.0/8"] - ``` - -- `auth_header` - - **Constraints / validation**: `String`. Empty string disables auth-header validation. - - **Description**: Exact expected `Authorization` header value (static shared secret). - - **Example**: - - ```toml - [server.api] - auth_header = "Bearer MY_TOKEN" - ``` - -- `request_body_limit_bytes` - - **Constraints / validation**: Must be `> 0` (bytes). - - **Description**: Maximum accepted HTTP request body size (bytes). - - **Example**: - - ```toml - [server.api] - request_body_limit_bytes = 65536 - ``` - -- `minimal_runtime_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables minimal runtime snapshots endpoint logic. - - **Example**: - - ```toml - [server.api] - minimal_runtime_enabled = true - ``` - -- `minimal_runtime_cache_ttl_ms` - - **Constraints / validation**: `0..=60000` (milliseconds). `0` disables cache. - - **Description**: Cache TTL for minimal runtime snapshots (ms). - - **Example**: - - ```toml - [server.api] - minimal_runtime_cache_ttl_ms = 1000 - ``` - -- `runtime_edge_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables runtime edge endpoints. - - **Example**: - - ```toml - [server.api] - runtime_edge_enabled = false - ``` - -- `runtime_edge_cache_ttl_ms` - - **Constraints / validation**: `0..=60000` (milliseconds). - - **Description**: Cache TTL for runtime edge aggregation payloads (ms). - - **Example**: - - ```toml - [server.api] - runtime_edge_cache_ttl_ms = 1000 - ``` - -- `runtime_edge_top_n` - - **Constraints / validation**: `1..=1000`. - - **Description**: Top-N size for edge connection leaderboard. - - **Example**: - - ```toml - [server.api] - runtime_edge_top_n = 10 - ``` - -- `runtime_edge_events_capacity` - - **Constraints / validation**: `16..=4096`. - - **Description**: Ring-buffer capacity for runtime edge events. - - **Example**: - - ```toml - [server.api] - runtime_edge_events_capacity = 256 - ``` - -- `read_only` - - **Constraints / validation**: `bool`. - - **Description**: Rejects mutating API endpoints when enabled. - - **Example**: - - ```toml - [server.api] - read_only = false - ``` - - -## [[server.listeners]] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`ip`](#cfg-server-listeners-ip) | `IpAddr` | — | -| [`announce`](#cfg-server-listeners-announce) | `String` | — | -| [`announce_ip`](#cfg-server-listeners-announce_ip) | `IpAddr` | — | -| [`proxy_protocol`](#cfg-server-listeners-proxy_protocol) | `bool` | — | -| [`reuse_allow`](#cfg-server-listeners-reuse_allow) | `bool` | `false` | - - -- `ip` - - **Constraints / validation**: Required field. Must be an `IpAddr`. - - **Description**: Listener bind IP. - - **Example**: - - ```toml - [[server.listeners]] - ip = "0.0.0.0" - ``` - -- `announce` - - **Constraints / validation**: `String` (optional). Must not be empty when set. - - **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`. - - **Example**: - - ```toml - [[server.listeners]] - ip = "0.0.0.0" - announce = "proxy.example.com" - ``` - -- `announce_ip` - - **Constraints / validation**: `IpAddr` (optional). Deprecated. Use `announce`. - - **Description**: Deprecated legacy announce IP. During config load it is migrated to `announce` when `announce` is not set. - - **Example**: - - ```toml - [[server.listeners]] - ip = "0.0.0.0" - announce_ip = "203.0.113.10" - ``` - -- `proxy_protocol` - - **Constraints / validation**: `bool` (optional). When set, overrides `server.proxy_protocol` for this listener. - - **Description**: Per-listener PROXY protocol override. - - **Example**: - - ```toml - [server] - proxy_protocol = false - - [[server.listeners]] - ip = "0.0.0.0" - proxy_protocol = true - ``` - -- `reuse_allow` - - **Constraints / validation**: `bool`. - - **Description**: Enables `SO_REUSEPORT` for multi-instance bind sharing (allows multiple telemt instances to listen on the same `ip:port`). - - **Example**: - - ```toml - [[server.listeners]] - ip = "0.0.0.0" - reuse_allow = false - ``` - - -## [timeouts] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`client_handshake`](#cfg-timeouts-client_handshake) | `u64` | `30` | -| [`relay_idle_policy_v2_enabled`](#cfg-timeouts-relay_idle_policy_v2_enabled) | `bool` | `true` | -| [`relay_client_idle_soft_secs`](#cfg-timeouts-relay_client_idle_soft_secs) | `u64` | `120` | -| [`relay_client_idle_hard_secs`](#cfg-timeouts-relay_client_idle_hard_secs) | `u64` | `360` | -| [`relay_idle_grace_after_downstream_activity_secs`](#cfg-timeouts-relay_idle_grace_after_downstream_activity_secs) | `u64` | `30` | -| [`tg_connect`](#cfg-timeouts-tg_connect) | `u64` | `10` | -| [`client_keepalive`](#cfg-timeouts-client_keepalive) | `u64` | `15` | -| [`client_ack`](#cfg-timeouts-client_ack) | `u64` | `90` | -| [`me_one_retry`](#cfg-timeouts-me_one_retry) | `u8` | `12` | -| [`me_one_timeout_ms`](#cfg-timeouts-me_one_timeout_ms) | `u64` | `1200` | - - -- `client_handshake` - - **Constraints / validation**: Must be `> 0`. Value is in seconds. Also used as an upper bound for some TLS emulation delays (see `censorship.server_hello_delay_max_ms`). - - **Description**: Client handshake timeout (seconds). - - **Example**: - - ```toml - [timeouts] - client_handshake = 30 - ``` - -- `relay_idle_policy_v2_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables soft/hard middle-relay client idle policy. - - **Example**: - - ```toml - [timeouts] - relay_idle_policy_v2_enabled = true - ``` - -- `relay_client_idle_soft_secs` - - **Constraints / validation**: Must be `> 0`; must be `<= relay_client_idle_hard_secs`. - - **Description**: Soft idle threshold (seconds) for middle-relay client uplink inactivity. Hitting this threshold marks the session as an idle-candidate (it may be eligible for cleanup depending on policy). - - **Example**: - - ```toml - [timeouts] - relay_client_idle_soft_secs = 120 - ``` - -- `relay_client_idle_hard_secs` - - **Constraints / validation**: Must be `> 0`; must be `>= relay_client_idle_soft_secs`. - - **Description**: Hard idle threshold (seconds) for middle-relay client uplink inactivity. Hitting this threshold closes the session. - - **Example**: - - ```toml - [timeouts] - relay_client_idle_hard_secs = 360 - ``` - -- `relay_idle_grace_after_downstream_activity_secs` - - **Constraints / validation**: Must be `<= relay_client_idle_hard_secs`. - - **Description**: Extra hard-idle grace period (seconds) added after recent downstream activity. - - **Example**: - - ```toml - [timeouts] - relay_idle_grace_after_downstream_activity_secs = 30 - ``` - -- `tg_connect` - - **Constraints / validation**: `u64`. Value is in seconds. - - **Description**: Upstream Telegram connect timeout (seconds). - - **Example**: - - ```toml - [timeouts] - tg_connect = 10 - ``` - -- `client_keepalive` - - **Constraints / validation**: `u64`. Value is in seconds. - - **Description**: Client keepalive timeout (seconds). - - **Example**: - - ```toml - [timeouts] - client_keepalive = 15 - ``` - -- `client_ack` - - **Constraints / validation**: `u64`. Value is in seconds. - - **Description**: Client ACK timeout (seconds). - - **Example**: - - ```toml - [timeouts] - client_ack = 90 - ``` - -- `me_one_retry` - - **Constraints / validation**: `u8`. - - **Description**: Fast reconnect attempts budget for single-endpoint DC scenarios. - - **Example**: - - ```toml - [timeouts] - me_one_retry = 12 - ``` - -- `me_one_timeout_ms` - - **Constraints / validation**: `u64`. Value is in milliseconds. - - **Description**: Timeout per quick attempt (ms) for single-endpoint DC reconnect logic. - - **Example**: - - ```toml - [timeouts] - me_one_timeout_ms = 1200 - ``` - - -## [censorship] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`tls_domain`](#cfg-censorship-tls_domain) | `String` | `"petrovich.ru"` | -| [`tls_domains`](#cfg-censorship-tls_domains) | `String[]` | `[]` | -| [`unknown_sni_action`](#cfg-censorship-unknown_sni_action) | `"drop"` or `"mask"` | `"drop"` | -| [`tls_fetch_scope`](#cfg-censorship-tls_fetch_scope) | `String` | `""` | -| [`tls_fetch`](#cfg-censorship-tls_fetch) | `Table` | built-in defaults | -| [`mask`](#cfg-censorship-mask) | `bool` | `true` | -| [`mask_host`](#cfg-censorship-mask_host) | `String` | — | -| [`mask_port`](#cfg-censorship-mask_port) | `u16` | `443` | -| [`mask_unix_sock`](#cfg-censorship-mask_unix_sock) | `String` | — | -| [`fake_cert_len`](#cfg-censorship-fake_cert_len) | `usize` | `2048` | -| [`tls_emulation`](#cfg-censorship-tls_emulation) | `bool` | `true` | -| [`tls_front_dir`](#cfg-censorship-tls_front_dir) | `String` | `"tlsfront"` | -| [`server_hello_delay_min_ms`](#cfg-censorship-server_hello_delay_min_ms) | `u64` | `0` | -| [`server_hello_delay_max_ms`](#cfg-censorship-server_hello_delay_max_ms) | `u64` | `0` | -| [`tls_new_session_tickets`](#cfg-censorship-tls_new_session_tickets) | `u8` | `0` | -| [`tls_full_cert_ttl_secs`](#cfg-censorship-tls_full_cert_ttl_secs) | `u64` | `90` | -| [`alpn_enforce`](#cfg-censorship-alpn_enforce) | `bool` | `true` | -| [`mask_proxy_protocol`](#cfg-censorship-mask_proxy_protocol) | `u8` | `0` | -| [`mask_shape_hardening`](#cfg-censorship-mask_shape_hardening) | `bool` | `true` | -| [`mask_shape_hardening_aggressive_mode`](#cfg-censorship-mask_shape_hardening_aggressive_mode) | `bool` | `false` | -| [`mask_shape_bucket_floor_bytes`](#cfg-censorship-mask_shape_bucket_floor_bytes) | `usize` | `512` | -| [`mask_shape_bucket_cap_bytes`](#cfg-censorship-mask_shape_bucket_cap_bytes) | `usize` | `4096` | -| [`mask_shape_above_cap_blur`](#cfg-censorship-mask_shape_above_cap_blur) | `bool` | `false` | -| [`mask_shape_above_cap_blur_max_bytes`](#cfg-censorship-mask_shape_above_cap_blur_max_bytes) | `usize` | `512` | -| [`mask_relay_max_bytes`](#cfg-censorship-mask_relay_max_bytes) | `usize` | `5242880` | -| [`mask_classifier_prefetch_timeout_ms`](#cfg-censorship-mask_classifier_prefetch_timeout_ms) | `u64` | `5` | -| [`mask_timing_normalization_enabled`](#cfg-censorship-mask_timing_normalization_enabled) | `bool` | `false` | -| [`mask_timing_normalization_floor_ms`](#cfg-censorship-mask_timing_normalization_floor_ms) | `u64` | `0` | -| [`mask_timing_normalization_ceiling_ms`](#cfg-censorship-mask_timing_normalization_ceiling_ms) | `u64` | `0` | - - -- `tls_domain` - - **Constraints / validation**: Must be a non-empty domain name. Must not contain spaces or `/`. - - **Description**: Primary TLS domain used in FakeTLS handshake profile and as the default SNI domain. - - **Example**: - - ```toml - [censorship] - tls_domain = "example.com" - ``` - -- `tls_domains` - - **Constraints / validation**: `String[]`. When set, values are merged with `tls_domain` and deduplicated (primary `tls_domain` always stays first). - - **Description**: Additional TLS domains for generating multiple proxy links. - - **Example**: - - ```toml - [censorship] - tls_domain = "example.com" - tls_domains = ["example.net", "example.org"] - ``` - -- `unknown_sni_action` - - **Constraints / validation**: `"drop"` or `"mask"`. - - **Description**: Action for TLS ClientHello with unknown / non-configured SNI. - - **Example**: - - ```toml - [censorship] - unknown_sni_action = "drop" - ``` - -- `tls_fetch_scope` - - **Constraints / validation**: `String`. Value is trimmed during load; whitespace-only becomes empty. - - **Description**: Upstream scope tag used for TLS-front metadata fetches. Empty value keeps default upstream routing behavior. - - **Example**: - - ```toml - [censorship] - tls_fetch_scope = "fetch" - ``` - -- `tls_fetch` - - **Constraints / validation**: Table. See `[censorship.tls_fetch]` section below. - - **Description**: TLS-front metadata fetch strategy settings (bootstrap + refresh behavior for TLS emulation data). - - **Example**: - - ```toml - [censorship.tls_fetch] - strict_route = true - attempt_timeout_ms = 5000 - total_budget_ms = 15000 - ``` - -- `mask` - - **Constraints / validation**: `bool`. - - **Description**: Enables masking / fronting relay mode. - - **Example**: - - ```toml - [censorship] - mask = true - ``` - -- `mask_host` - - **Constraints / validation**: `String` (optional). - - If `mask_unix_sock` is set, `mask_host` must be omitted (mutually exclusive). - - If `mask_host` is not set and `mask_unix_sock` is not set, Telemt defaults `mask_host` to `tls_domain`. - - **Description**: Upstream mask host for TLS fronting relay. - - **Example**: - - ```toml - [censorship] - mask_host = "www.cloudflare.com" - ``` - -- `mask_port` - - **Constraints / validation**: `u16`. - - **Description**: Upstream mask port for TLS fronting relay. - - **Example**: - - ```toml - [censorship] - mask_port = 443 - ``` - -- `mask_unix_sock` - - **Constraints / validation**: `String` (optional). - - Must not be empty when set. - - Unix only; rejected on non-Unix platforms. - - On Unix, must be \(\le 107\) bytes (path length limit). - - Mutually exclusive with `mask_host`. - - **Description**: Unix socket path for mask backend instead of TCP `mask_host`/`mask_port`. - - **Example**: - - ```toml - [censorship] - mask_unix_sock = "/run/telemt/mask.sock" - ``` - -- `fake_cert_len` - - **Constraints / validation**: `usize`. When `tls_emulation = false` and the default value is in use, Telemt may randomize this at startup for variability. - - **Description**: Length of synthetic certificate payload when emulation data is unavailable. - - **Example**: - - ```toml - [censorship] - fake_cert_len = 2048 - ``` - -- `tls_emulation` - - **Constraints / validation**: `bool`. - - **Description**: Enables certificate/TLS behavior emulation from cached real fronts. - - **Example**: - - ```toml - [censorship] - tls_emulation = true - ``` - -- `tls_front_dir` - - **Constraints / validation**: `String`. - - **Description**: Directory path for TLS front cache storage. - - **Example**: - - ```toml - [censorship] - tls_front_dir = "tlsfront" - ``` - -- `server_hello_delay_min_ms` - - **Constraints / validation**: `u64` (milliseconds). - - **Description**: Minimum `server_hello` delay for anti-fingerprint behavior (ms). - - **Example**: - - ```toml - [censorship] - server_hello_delay_min_ms = 0 - ``` - -- `server_hello_delay_max_ms` - - **Constraints / validation**: `u64` (milliseconds). Must be \(<\) `timeouts.client_handshake * 1000`. - - **Description**: Maximum `server_hello` delay for anti-fingerprint behavior (ms). - - **Example**: - - ```toml - [timeouts] - client_handshake = 30 - - [censorship] - server_hello_delay_max_ms = 0 - ``` - -- `tls_new_session_tickets` - - **Constraints / validation**: `u8`. - - **Description**: Number of `NewSessionTicket` messages to emit after handshake. - - **Example**: - - ```toml - [censorship] - tls_new_session_tickets = 0 - ``` - -- `tls_full_cert_ttl_secs` - - **Constraints / validation**: `u64` (seconds). - - **Description**: TTL for sending full cert payload per (domain, client IP) tuple. - - **Example**: - - ```toml - [censorship] - tls_full_cert_ttl_secs = 90 - ``` - -- `alpn_enforce` - - **Constraints / validation**: `bool`. - - **Description**: Enforces ALPN echo behavior based on client preference. - - **Example**: - - ```toml - [censorship] - alpn_enforce = true - ``` - -- `mask_proxy_protocol` - - **Constraints / validation**: `u8`. `0` = disabled, `1` = v1 (text), `2` = v2 (binary). - - **Description**: Sends PROXY protocol header when connecting to mask backend, allowing the backend to see the real client IP. - - **Example**: - - ```toml - [censorship] - mask_proxy_protocol = 0 - ``` - -- `mask_shape_hardening` - - **Constraints / validation**: `bool`. - - **Description**: Enables client->mask shape-channel hardening by applying controlled tail padding to bucket boundaries on mask relay shutdown. - - **Example**: - - ```toml - [censorship] - mask_shape_hardening = true - ``` - -- `mask_shape_hardening_aggressive_mode` - - **Constraints / validation**: Requires `mask_shape_hardening = true`. - - **Description**: Opt-in aggressive shaping profile (stronger anti-classifier behavior with different shaping semantics). - - **Example**: - - ```toml - [censorship] - mask_shape_hardening = true - mask_shape_hardening_aggressive_mode = false - ``` - -- `mask_shape_bucket_floor_bytes` - - **Constraints / validation**: Must be `> 0`; must be `<= mask_shape_bucket_cap_bytes`. - - **Description**: Minimum bucket size used by shape-channel hardening. - - **Example**: - - ```toml - [censorship] - mask_shape_bucket_floor_bytes = 512 - ``` - -- `mask_shape_bucket_cap_bytes` - - **Constraints / validation**: Must be `>= mask_shape_bucket_floor_bytes`. - - **Description**: Maximum bucket size used by shape-channel hardening; traffic above cap is not bucket-padded further. - - **Example**: - - ```toml - [censorship] - mask_shape_bucket_cap_bytes = 4096 - ``` - -- `mask_shape_above_cap_blur` - - **Constraints / validation**: Requires `mask_shape_hardening = true`. - - **Description**: Adds bounded randomized tail bytes even when forwarded size already exceeds cap. - - **Example**: - - ```toml - [censorship] - mask_shape_hardening = true - mask_shape_above_cap_blur = false - ``` - -- `mask_shape_above_cap_blur_max_bytes` - - **Constraints / validation**: Must be `<= 1048576`. Must be `> 0` when `mask_shape_above_cap_blur = true`. - - **Description**: Maximum randomized extra bytes appended above cap when above-cap blur is enabled. - - **Example**: - - ```toml - [censorship] - mask_shape_above_cap_blur = true - mask_shape_above_cap_blur_max_bytes = 64 - ``` - -- `mask_relay_max_bytes` - - **Constraints / validation**: Must be `> 0`; must be `<= 67108864`. - - **Description**: Maximum relayed bytes per direction on unauthenticated masking fallback path. - - **Example**: - - ```toml - [censorship] - mask_relay_max_bytes = 5242880 - ``` - -- `mask_classifier_prefetch_timeout_ms` - - **Constraints / validation**: Must be within `[5, 50]` (milliseconds). - - **Description**: Timeout budget (ms) for extending fragmented initial classifier window on masking fallback. - - **Example**: - - ```toml - [censorship] - mask_classifier_prefetch_timeout_ms = 5 - ``` - -- `mask_timing_normalization_enabled` - - **Constraints / validation**: When `true`, requires `mask_timing_normalization_floor_ms > 0` and `mask_timing_normalization_ceiling_ms >= mask_timing_normalization_floor_ms`. Ceiling must be `<= 60000`. - - **Description**: Enables timing envelope normalization on masking outcomes. - - **Example**: - - ```toml - [censorship] - mask_timing_normalization_enabled = false - ``` - -- `mask_timing_normalization_floor_ms` - - **Constraints / validation**: Must be `> 0` when timing normalization is enabled; must be `<= mask_timing_normalization_ceiling_ms`. - - **Description**: Lower bound (ms) for masking outcome normalization target. - - **Example**: - - ```toml - [censorship] - mask_timing_normalization_floor_ms = 0 - ``` - -- `mask_timing_normalization_ceiling_ms` - - **Constraints / validation**: Must be `>= mask_timing_normalization_floor_ms`; must be `<= 60000`. - - **Description**: Upper bound (ms) for masking outcome normalization target. - - **Example**: - - ```toml - [censorship] - mask_timing_normalization_ceiling_ms = 0 - ``` - - -## [censorship.tls_fetch] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`profiles`](#cfg-censorship-tls_fetch-profiles) | `String[]` | `["modern_chrome_like", "modern_firefox_like", "compat_tls12", "legacy_minimal"]` | -| [`strict_route`](#cfg-censorship-tls_fetch-strict_route) | `bool` | `true` | -| [`attempt_timeout_ms`](#cfg-censorship-tls_fetch-attempt_timeout_ms) | `u64` | `5000` | -| [`total_budget_ms`](#cfg-censorship-tls_fetch-total_budget_ms) | `u64` | `15000` | -| [`grease_enabled`](#cfg-censorship-tls_fetch-grease_enabled) | `bool` | `false` | -| [`deterministic`](#cfg-censorship-tls_fetch-deterministic) | `bool` | `false` | -| [`profile_cache_ttl_secs`](#cfg-censorship-tls_fetch-profile_cache_ttl_secs) | `u64` | `600` | - - -- `profiles` - - **Constraints / validation**: `String[]`. Empty list falls back to defaults; values are deduplicated preserving order. - - **Description**: Ordered ClientHello profile fallback chain for TLS-front metadata fetch. - - **Example**: - - ```toml - [censorship.tls_fetch] - profiles = ["modern_chrome_like", "compat_tls12"] - ``` - -- `strict_route` - - **Constraints / validation**: `bool`. - - **Description**: When `true` and an upstream route is configured, TLS fetch fails closed on upstream connect errors instead of falling back to direct TCP. - - **Example**: - - ```toml - [censorship.tls_fetch] - strict_route = true - ``` - -- `attempt_timeout_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Timeout budget per one TLS-fetch profile attempt (ms). - - **Example**: - - ```toml - [censorship.tls_fetch] - attempt_timeout_ms = 5000 - ``` - -- `total_budget_ms` - - **Constraints / validation**: Must be `> 0` (milliseconds). - - **Description**: Total wall-clock budget across all TLS-fetch attempts (ms). - - **Example**: - - ```toml - [censorship.tls_fetch] - total_budget_ms = 15000 - ``` - -- `grease_enabled` - - **Constraints / validation**: `bool`. - - **Description**: Enables GREASE-style random values in selected ClientHello extensions for fetch traffic. - - **Example**: - - ```toml - [censorship.tls_fetch] - grease_enabled = false - ``` - -- `deterministic` - - **Constraints / validation**: `bool`. - - **Description**: Enables deterministic ClientHello randomness for debugging/tests. - - **Example**: - - ```toml - [censorship.tls_fetch] - deterministic = false - ``` - -- `profile_cache_ttl_secs` - - **Constraints / validation**: `u64` (seconds). `0` disables cache. - - **Description**: TTL for winner-profile cache entries used by TLS fetch path. - - **Example**: - - ```toml - [censorship.tls_fetch] - profile_cache_ttl_secs = 600 - ``` - - -### Shape-channel hardening notes (`[censorship]`) - -These parameters are designed to reduce one specific fingerprint source during masking: the exact number of bytes sent from proxy to `mask_host` for invalid or probing traffic. - -Without hardening, a censor can often correlate probe input length with backend-observed length very precisely (for example: `5 + body_sent` on early TLS reject paths). That creates a length-based classifier signal. - -When `mask_shape_hardening = true`, Telemt pads the **client->mask** stream tail to a bucket boundary at relay shutdown: - -- Total bytes sent to mask are first measured. -- A bucket is selected using powers of two starting from `mask_shape_bucket_floor_bytes`. -- Padding is added only if total bytes are below `mask_shape_bucket_cap_bytes`. -- If bytes already exceed cap, no extra padding is added. - -This means multiple nearby probe sizes collapse into the same backend-observed size class, making active classification harder. - -What each parameter changes in practice: - -- `mask_shape_hardening` - Enables or disables this entire length-shaping stage on the fallback path. - When `false`, backend-observed length stays close to the real forwarded probe length. - When `true`, clean relay shutdown can append random padding bytes to move the total into a bucket. -- `mask_shape_bucket_floor_bytes` - Sets the first bucket boundary used for small probes. - Example: with floor `512`, a malformed probe that would otherwise forward `37` bytes can be expanded to `512` bytes on clean EOF. - Larger floor values hide very small probes better, but increase egress cost. -- `mask_shape_bucket_cap_bytes` - Sets the largest bucket Telemt will pad up to with bucket logic. - Example: with cap `4096`, a forwarded total of `1800` bytes may be padded to `2048` or `4096` depending on the bucket ladder, but a total already above `4096` will not be bucket-padded further. - Larger cap values increase the range over which size classes are collapsed, but also increase worst-case overhead. -- Clean EOF matters in conservative mode - In the default profile, shape padding is intentionally conservative: it is applied on clean relay shutdown, not on every timeout/drip path. - This avoids introducing new timeout-tail artifacts that some backends or tests interpret as a separate fingerprint. - -Practical trade-offs: - -- Better anti-fingerprinting on size/shape channel. -- Slightly higher egress overhead for small probes due to padding. -- Behavior is intentionally conservative and enabled by default. - -Recommended starting profile: - -- `mask_shape_hardening = true` (default) -- `mask_shape_bucket_floor_bytes = 512` -- `mask_shape_bucket_cap_bytes = 4096` - -### Aggressive mode notes (`[censorship]`) - -`mask_shape_hardening_aggressive_mode` is an opt-in profile for higher anti-classifier pressure. - -- Default is `false` to preserve conservative timeout/no-tail behavior. -- Requires `mask_shape_hardening = true`. -- When enabled, backend-silent non-EOF masking paths may be shaped. -- When enabled together with above-cap blur, the random extra tail uses `[1, max]` instead of `[0, max]`. - -What changes when aggressive mode is enabled: - -- Backend-silent timeout paths can be shaped - In default mode, a client that keeps the socket half-open and times out will usually not receive shape padding on that path. - In aggressive mode, Telemt may still shape that backend-silent session if no backend bytes were returned. - This is specifically aimed at active probes that try to avoid EOF in order to preserve an exact backend-observed length. -- Above-cap blur always adds at least one byte - In default mode, above-cap blur may choose `0`, so some oversized probes still land on their exact base forwarded length. - In aggressive mode, that exact-base sample is removed by construction. -- Tradeoff - Aggressive mode improves resistance to active length classifiers, but it is more opinionated and less conservative. - If your deployment prioritizes strict compatibility with timeout/no-tail semantics, leave it disabled. - If your threat model includes repeated active probing by a censor, this mode is the stronger profile. - -Use this mode only when your threat model prioritizes classifier resistance over strict compatibility with conservative masking semantics. - -### Above-cap blur notes (`[censorship]`) - -`mask_shape_above_cap_blur` adds a second-stage blur for very large probes that are already above `mask_shape_bucket_cap_bytes`. - -- A random tail in `[0, mask_shape_above_cap_blur_max_bytes]` is appended in default mode. -- In aggressive mode, the random tail becomes strictly positive: `[1, mask_shape_above_cap_blur_max_bytes]`. -- This reduces exact-size leakage above cap at bounded overhead. -- Keep `mask_shape_above_cap_blur_max_bytes` conservative to avoid unnecessary egress growth. - -Operational meaning: - -- Without above-cap blur - A probe that forwards `5005` bytes will still look like `5005` bytes to the backend if it is already above cap. -- With above-cap blur enabled - That same probe may look like any value in a bounded window above its base length. - Example with `mask_shape_above_cap_blur_max_bytes = 64`: - backend-observed size becomes `5005..5069` in default mode, or `5006..5069` in aggressive mode. -- Choosing `mask_shape_above_cap_blur_max_bytes` - Small values reduce cost but preserve more separability between far-apart oversized classes. - Larger values blur oversized classes more aggressively, but add more egress overhead and more output variance. - -### Timing normalization envelope notes (`[censorship]`) - -`mask_timing_normalization_enabled` smooths timing differences between masking outcomes by applying a target duration envelope. - -- A random target is selected in `[mask_timing_normalization_floor_ms, mask_timing_normalization_ceiling_ms]`. -- Fast paths are delayed up to the selected target. -- Slow paths are not forced to finish by the ceiling (the envelope is best-effort shaping, not truncation). - -Recommended starting profile for timing shaping: - -- `mask_timing_normalization_enabled = true` -- `mask_timing_normalization_floor_ms = 180` -- `mask_timing_normalization_ceiling_ms = 320` - -If your backend or network is very bandwidth-constrained, reduce cap first. If probes are still too distinguishable in your environment, increase floor gradually. - -## [access] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`users`](#cfg-access-users) | `Map` | `{"default": "000…000"}` | -| [`user_ad_tags`](#cfg-access-user_ad_tags) | `Map` | `{}` | -| [`user_max_tcp_conns`](#cfg-access-user_max_tcp_conns) | `Map` | `{}` | -| [`user_max_tcp_conns_global_each`](#cfg-access-user_max_tcp_conns_global_each) | `usize` | `0` | -| [`user_expirations`](#cfg-access-user_expirations) | `Map>` | `{}` | -| [`user_data_quota`](#cfg-access-user_data_quota) | `Map` | `{}` | -| [`user_max_unique_ips`](#cfg-access-user_max_unique_ips) | `Map` | `{}` | -| [`user_max_unique_ips_global_each`](#cfg-access-user_max_unique_ips_global_each) | `usize` | `0` | -| [`user_max_unique_ips_mode`](#cfg-access-user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` | -| [`user_max_unique_ips_window_secs`](#cfg-access-user_max_unique_ips_window_secs) | `u64` | `30` | -| [`replay_check_len`](#cfg-access-replay_check_len) | `usize` | `65536` | -| [`replay_window_secs`](#cfg-access-replay_window_secs) | `u64` | `120` | -| [`ignore_time_skew`](#cfg-access-ignore_time_skew) | `bool` | `false` | - - -- `users` - - **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**. - - **Description**: User credentials map used for client authentication. Keys are user names; values are MTProxy secrets. - - **Example**: - - ```toml - [access.users] - alice = "00112233445566778899aabbccddeeff" - bob = "0123456789abcdef0123456789abcdef" - ``` - -- `user_ad_tags` - - **Constraints / validation**: Each value must be **exactly 32 hex characters** (same format as `general.ad_tag`). An all-zero tag is allowed but logs a warning. - - **Description**: Per-user sponsored-channel ad tag override. When a user has an entry here, it takes precedence over `general.ad_tag`. - - **Example**: - - ```toml - [general] - ad_tag = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - - [access.user_ad_tags] - alice = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ``` - -- `user_max_tcp_conns` - - **Constraints / validation**: `Map`. - - **Description**: Per-user maximum concurrent TCP connections. - - **Example**: - - ```toml - [access.user_max_tcp_conns] - alice = 500 - ``` - -- `user_max_tcp_conns_global_each` - - **Constraints / validation**: `usize`. `0` disables the inherited limit. - - **Description**: Global per-user maximum concurrent TCP connections, applied when a user has **no positive** entry in `[access.user_max_tcp_conns]` (a missing key, or a value of `0`, both fall through to this setting). Per-user limits greater than `0` in `user_max_tcp_conns` take precedence. - - **Example**: - - ```toml - [access] - user_max_tcp_conns_global_each = 200 - - [access.user_max_tcp_conns] - alice = 500 # uses 500, not the global cap - # bob has no entry → uses 200 - ``` - -- `user_expirations` - - **Constraints / validation**: `Map>`. Each value must be a valid RFC3339 / ISO-8601 datetime. - - **Description**: Per-user account expiration timestamps (UTC). - - **Example**: - - ```toml - [access.user_expirations] - alice = "2026-12-31T23:59:59Z" - ``` - -- `user_data_quota` - - **Constraints / validation**: `Map`. - - **Description**: Per-user traffic quota in bytes. - - **Example**: - - ```toml - [access.user_data_quota] - alice = 1073741824 # 1 GiB - ``` - -- `user_max_unique_ips` - - **Constraints / validation**: `Map`. - - **Description**: Per-user unique source IP limits. - - **Example**: - - ```toml - [access.user_max_unique_ips] - alice = 16 - ``` - -- `user_max_unique_ips_global_each` - - **Constraints / validation**: `usize`. `0` disables the inherited limit. - - **Description**: Global per-user unique IP limit applied when a user has no individual override in `[access.user_max_unique_ips]`. - - **Example**: - - ```toml - [access] - user_max_unique_ips_global_each = 8 - ``` - -- `user_max_unique_ips_mode` - - **Constraints / validation**: Must be one of `"active_window"`, `"time_window"`, `"combined"`. - - **Description**: Unique source IP limit accounting mode. - - **Example**: - - ```toml - [access] - user_max_unique_ips_mode = "active_window" - ``` - -- `user_max_unique_ips_window_secs` - - **Constraints / validation**: Must be `> 0`. - - **Description**: Window size (seconds) used by unique-IP accounting modes that include a time window (`"time_window"` and `"combined"`). - - **Example**: - - ```toml - [access] - user_max_unique_ips_window_secs = 30 - ``` - -- `replay_check_len` - - **Constraints / validation**: `usize`. - - **Description**: Replay-protection storage length (number of entries tracked for duplicate detection). - - **Example**: - - ```toml - [access] - replay_check_len = 65536 - ``` - -- `replay_window_secs` - - **Constraints / validation**: `u64`. - - **Description**: Replay-protection time window in seconds. - - **Example**: - - ```toml - [access] - replay_window_secs = 120 - ``` - -- `ignore_time_skew` - - **Constraints / validation**: `bool`. - - **Description**: Disables client/server timestamp skew checks in replay validation when enabled. - - **Example**: - - ```toml - [access] - ignore_time_skew = false - ``` - - -## [[upstreams]] - - -| Key | Type | Default | -| --- | ---- | ------- | -| [`type`](#cfg-upstreams-type) | `"direct"`, `"socks4"`, `"socks5"`, or `"shadowsocks"` | — | -| [`weight`](#cfg-upstreams-weight) | `u16` | `1` | -| [`enabled`](#cfg-upstreams-enabled) | `bool` | `true` | -| [`scopes`](#cfg-upstreams-scopes) | `String` | `""` | -| [`interface`](#cfg-upstreams-interface) | `String` | — | -| [`bind_addresses`](#cfg-upstreams-bind_addresses) | `String[]` | — | -| [`url`](#cfg-upstreams-url) | `String` | — | -| [`address`](#cfg-upstreams-address) | `String` | — | -| [`user_id`](#cfg-upstreams-user_id) | `String` | — | -| [`username`](#cfg-upstreams-username) | `String` | — | -| [`password`](#cfg-upstreams-password) | `String` | — | - - -- `type` - - **Constraints / validation**: Required field. Must be one of: `"direct"`, `"socks4"`, `"socks5"`, `"shadowsocks"`. - - **Description**: Selects the upstream transport implementation for this `[[upstreams]]` entry. - - **Example**: - - ```toml - [[upstreams]] - type = "direct" - - [[upstreams]] - type = "socks5" - address = "127.0.0.1:9050" - - [[upstreams]] - type = "shadowsocks" - url = "ss://2022-blake3-aes-256-gcm:BASE64PASSWORD@127.0.0.1:8388" - ``` - -- `weight` - - **Constraints / validation**: `u16` (0..=65535). - - **Description**: Base weight used by weighted-random upstream selection (higher = chosen more often). - - **Example**: - - ```toml - [[upstreams]] - type = "direct" - weight = 10 - ``` - -- `enabled` - - **Constraints / validation**: `bool`. - - **Description**: When `false`, this entry is ignored and not used for any upstream selection. - - **Example**: - - ```toml - [[upstreams]] - type = "socks5" - address = "127.0.0.1:9050" - enabled = false - ``` - -- `scopes` - - **Constraints / validation**: `String`. Comma-separated list; whitespace is trimmed during matching. - - **Description**: Scope tags used for request-level upstream filtering. If a request specifies a scope, only upstreams whose `scopes` contains that tag can be selected. If a request does not specify a scope, only upstreams with empty `scopes` are eligible. - - **Example**: - - ```toml - [[upstreams]] - type = "socks4" - address = "10.0.0.10:1080" - scopes = "me, fetch, dc2" - ``` - -- `interface` - - **Constraints / validation**: `String` (optional). - - For `"direct"`: may be an IP address (used as explicit local bind) or an OS interface name (resolved to an IP at runtime; Unix only). - - For `"socks4"`/`"socks5"`: supported only when `address` is an `IP:port` literal; when `address` is a hostname, interface binding is ignored. - - For `"shadowsocks"`: passed to the shadowsocks connector as an optional outbound bind hint. - - **Description**: Optional outbound interface / local bind hint for the upstream connect socket. - - **Example**: - - ```toml - [[upstreams]] - type = "direct" - interface = "eth0" - - [[upstreams]] - type = "socks5" - address = "203.0.113.10:1080" - interface = "192.0.2.10" # explicit local bind IP - ``` - -- `bind_addresses` - - **Constraints / validation**: `String[]` (optional). Applies only to `type = "direct"`. - - Each entry should be an IP address string. - - At runtime, Telemt selects an address that matches the target family (IPv4 vs IPv6). If `bind_addresses` is set and none match the target family, the connect attempt fails. - - **Description**: Explicit local source addresses for outgoing direct TCP connects. When multiple addresses are provided, selection is round-robin. - - **Example**: - - ```toml - [[upstreams]] - type = "direct" - bind_addresses = ["192.0.2.10", "192.0.2.11"] - ``` - -- `url` - - **Constraints / validation**: Applies only to `type = "shadowsocks"`. - - Must be a valid Shadowsocks URL accepted by the `shadowsocks` crate. - - Shadowsocks plugins are not supported. - - Requires `general.use_middle_proxy = false` (shadowsocks upstreams are rejected in ME mode). - - **Description**: Shadowsocks server URL used for connecting to Telegram via a Shadowsocks relay. - - **Example**: - - ```toml - [general] - use_middle_proxy = false - - [[upstreams]] - type = "shadowsocks" - url = "ss://2022-blake3-aes-256-gcm:BASE64PASSWORD@127.0.0.1:8388" - ``` - -- `address` - - **Constraints / validation**: Required for `type = "socks4"` and `type = "socks5"`. Must be `host:port` or `ip:port`. - - **Description**: SOCKS proxy server endpoint used for upstream connects. - - **Example**: - - ```toml - [[upstreams]] - type = "socks5" - address = "127.0.0.1:9050" - ``` - -- `user_id` - - **Constraints / validation**: `String` (optional). Only for `type = "socks4"`. - - **Description**: SOCKS4 CONNECT user ID. Note: when a request scope is selected, Telemt may override this with the selected scope value. - - **Example**: - - ```toml - [[upstreams]] - type = "socks4" - address = "127.0.0.1:1080" - user_id = "telemt" - ``` - -- `username` - - **Constraints / validation**: `String` (optional). Only for `type = "socks5"`. - - **Description**: SOCKS5 username (for username/password authentication). Note: when a request scope is selected, Telemt may override this with the selected scope value. - - **Example**: - - ```toml - [[upstreams]] - type = "socks5" - address = "127.0.0.1:9050" - username = "alice" - ``` - -- `password` - - **Constraints / validation**: `String` (optional). Only for `type = "socks5"`. - - **Description**: SOCKS5 password (for username/password authentication). Note: when a request scope is selected, Telemt may override this with the selected scope value. - - **Example**: - - ```toml - [[upstreams]] - type = "socks5" - address = "127.0.0.1:9050" - username = "alice" - password = "secret" - ``` - - From 16c7a63fbcd95801b14014d24648e15efe6f2495 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:38:22 +0300 Subject: [PATCH 07/15] Fix test for single-endpoint DC --- .../tests/pool_refill_security_tests.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/transport/middle_proxy/tests/pool_refill_security_tests.rs b/src/transport/middle_proxy/tests/pool_refill_security_tests.rs index 90c8382..4463444 100644 --- a/src/transport/middle_proxy/tests/pool_refill_security_tests.rs +++ b/src/transport/middle_proxy/tests/pool_refill_security_tests.rs @@ -109,18 +109,16 @@ async fn connectable_endpoints_waits_until_quarantine_expires() { { let mut guard = pool.endpoint_quarantine.lock().await; - guard.insert(addr, Instant::now() + Duration::from_millis(80)); + guard.insert(addr, Instant::now() + Duration::from_millis(500)); } - let started = Instant::now(); - let endpoints = pool.connectable_endpoints_for_test(&[addr]).await; - let elapsed = started.elapsed(); - + let endpoints = tokio::time::timeout( + Duration::from_millis(120), + pool.connectable_endpoints_for_test(&[addr]), + ) + .await + .expect("single-endpoint outage mode should bypass quarantine delay"); assert_eq!(endpoints, vec![addr]); - assert!( - elapsed >= Duration::from_millis(50), - "single-endpoint DC should honor quarantine before retry" - ); } #[tokio::test] From 26c40092f32a7f5aab6c1611dfcb569960acbb18 Mon Sep 17 00:00:00 2001 From: Batmaev Date: Sun, 12 Apr 2026 10:44:16 +0300 Subject: [PATCH 08/15] rm hardcoded mask timeouts --- docs/Config_params/CONFIG_PARAMS.en.md | 22 +++++ src/config/defaults.rs | 20 +++++ src/config/hot_reload.rs | 2 + src/config/types.rs | 15 ++++ src/proxy/masking.rs | 86 ++++++++++++++----- ...ing_additional_hardening_security_tests.rs | 2 +- ...ing_consume_idle_timeout_security_tests.rs | 13 +-- ...asking_consume_stress_adversarial_tests.rs | 8 +- ...roduction_cap_regression_security_tests.rs | 62 +++++++++++-- ...masking_relay_guardrails_security_tests.rs | 2 + src/proxy/tests/masking_security_tests.rs | 2 + ...masking_self_target_loop_security_tests.rs | 1 + .../masking_shape_guard_adversarial_tests.rs | 1 + ...sking_shape_hardening_adversarial_tests.rs | 1 + 14 files changed, 198 insertions(+), 39 deletions(-) diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 82c98f5..4c9872b 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2498,6 +2498,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche | [`mask_shape_above_cap_blur`](#cfg-censorship-mask_shape_above_cap_blur) | `bool` | `false` | | [`mask_shape_above_cap_blur_max_bytes`](#cfg-censorship-mask_shape_above_cap_blur_max_bytes) | `usize` | `512` | | [`mask_relay_max_bytes`](#cfg-censorship-mask_relay_max_bytes) | `usize` | `5242880` | +| [`mask_relay_timeout_ms`](#cfg-censorship-mask_relay_timeout_ms) | `u64` | `60_000` | +| [`mask_relay_idle_timeout_ms`](#cfg-censorship-mask_relay_idle_timeout_ms) | `u64` | `5_000` | | [`mask_classifier_prefetch_timeout_ms`](#cfg-censorship-mask_classifier_prefetch_timeout_ms) | `u64` | `5` | | [`mask_timing_normalization_enabled`](#cfg-censorship-mask_timing_normalization_enabled) | `bool` | `false` | | [`mask_timing_normalization_floor_ms`](#cfg-censorship-mask_timing_normalization_floor_ms) | `u64` | `0` | @@ -2768,6 +2770,26 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche [censorship] mask_relay_max_bytes = 5242880 ``` +## "cfg-censorship-mask_relay_timeout_ms" +- `mask_relay_timeout_ms` + - **Constraints / validation**: Should be `>= mask_relay_idle_timeout_ms`. + - **Description**: Wall-clock cap for the full masking relay on non-MTProto fallback paths. Raise when the mask target is a long-lived service (e.g. WebSocket). Default: 60 000 ms (1 minute). + - **Example**: + + ```toml + [censorship] + mask_relay_timeout_ms = 60000 + ``` +## "cfg-censorship-mask_relay_idle_timeout_ms" +- `mask_relay_idle_timeout_ms` + - **Constraints / validation**: Should be `<= mask_relay_timeout_ms`. + - **Description**: Per-read idle timeout on masking relay and drain paths. Limits resource consumption by slow-loris attacks and port scanners. A read call stalling beyond this value is treated as an abandoned connection. Default: 5 000 ms (5 s). + - **Example**: + + ```toml + [censorship] + mask_relay_idle_timeout_ms = 5000 + ``` ## "cfg-censorship-mask_classifier_prefetch_timeout_ms" - `mask_classifier_prefetch_timeout_ms` - **Constraints / validation**: Must be within `[5, 50]` (milliseconds). diff --git a/src/config/defaults.rs b/src/config/defaults.rs index beedd10..8eebe6c 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -615,6 +615,26 @@ pub(crate) fn default_mask_relay_max_bytes() -> usize { 32 * 1024 } +#[cfg(not(test))] +pub(crate) fn default_mask_relay_timeout_ms() -> u64 { + 60_000 +} + +#[cfg(test)] +pub(crate) fn default_mask_relay_timeout_ms() -> u64 { + 200 +} + +#[cfg(not(test))] +pub(crate) fn default_mask_relay_idle_timeout_ms() -> u64 { + 5_000 +} + +#[cfg(test)] +pub(crate) fn default_mask_relay_idle_timeout_ms() -> u64 { + 100 +} + pub(crate) fn default_mask_classifier_prefetch_timeout_ms() -> u64 { 5 } diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 5582e9b..61c36eb 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -611,6 +611,8 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b || old.censorship.mask_shape_above_cap_blur_max_bytes != new.censorship.mask_shape_above_cap_blur_max_bytes || old.censorship.mask_relay_max_bytes != new.censorship.mask_relay_max_bytes + || old.censorship.mask_relay_timeout_ms != new.censorship.mask_relay_timeout_ms + || old.censorship.mask_relay_idle_timeout_ms != new.censorship.mask_relay_idle_timeout_ms || old.censorship.mask_classifier_prefetch_timeout_ms != new.censorship.mask_classifier_prefetch_timeout_ms || old.censorship.mask_timing_normalization_enabled diff --git a/src/config/types.rs b/src/config/types.rs index 98c22a6..7eb7702 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1710,6 +1710,19 @@ pub struct AntiCensorshipConfig { #[serde(default = "default_mask_relay_max_bytes")] pub mask_relay_max_bytes: usize, + /// Wall-clock cap for the full masking relay on non-MTProto fallback paths. + /// Raise when the mask target is a long-lived service (e.g. WebSocket). + /// Default: 60 000 ms (60 s). + #[serde(default = "default_mask_relay_timeout_ms")] + pub mask_relay_timeout_ms: u64, + + /// Per-read idle timeout on masking relay and drain paths. + /// Limits resource consumption by slow-loris attacks and port scanners. + /// A read call stalling beyond this is treated as an abandoned connection. + /// Default: 5 000 ms (5 s). + #[serde(default = "default_mask_relay_idle_timeout_ms")] + pub mask_relay_idle_timeout_ms: u64, + /// Prefetch timeout (ms) for extending fragmented masking classifier window. #[serde(default = "default_mask_classifier_prefetch_timeout_ms")] pub mask_classifier_prefetch_timeout_ms: u64, @@ -1755,6 +1768,8 @@ impl Default for AntiCensorshipConfig { mask_shape_above_cap_blur: default_mask_shape_above_cap_blur(), mask_shape_above_cap_blur_max_bytes: default_mask_shape_above_cap_blur_max_bytes(), mask_relay_max_bytes: default_mask_relay_max_bytes(), + mask_relay_timeout_ms: default_mask_relay_timeout_ms(), + mask_relay_idle_timeout_ms: default_mask_relay_idle_timeout_ms(), mask_classifier_prefetch_timeout_ms: default_mask_classifier_prefetch_timeout_ms(), mask_timing_normalization_enabled: default_mask_timing_normalization_enabled(), mask_timing_normalization_floor_ms: default_mask_timing_normalization_floor_ms(), diff --git a/src/proxy/masking.rs b/src/proxy/masking.rs index 70e72a0..d49e4c3 100644 --- a/src/proxy/masking.rs +++ b/src/proxy/masking.rs @@ -28,14 +28,10 @@ use tracing::debug; const MASK_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(test)] const MASK_TIMEOUT: Duration = Duration::from_millis(50); -/// Maximum duration for the entire masking relay. -/// Limits resource consumption from slow-loris attacks and port scanners. -#[cfg(not(test))] -const MASK_RELAY_TIMEOUT: Duration = Duration::from_secs(60); +/// Maximum duration for the entire masking relay under test (replaced by config at runtime). #[cfg(test)] const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200); -#[cfg(not(test))] -const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(5); +/// Per-read idle timeout for masking relay and drain paths under test (replaced by config at runtime). #[cfg(test)] const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100); const MASK_BUFFER_SIZE: usize = 8192; @@ -55,6 +51,7 @@ async fn copy_with_idle_timeout( writer: &mut W, byte_cap: usize, shutdown_on_eof: bool, + idle_timeout: Duration, ) -> CopyOutcome where R: AsyncRead + Unpin, @@ -78,7 +75,7 @@ where } let read_len = remaining_budget.min(MASK_BUFFER_SIZE); - let read_res = timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf[..read_len])).await; + let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await; let n = match read_res { Ok(Ok(n)) => n, Ok(Err(_)) | Err(_) => break, @@ -86,13 +83,13 @@ where if n == 0 { ended_by_eof = true; if shutdown_on_eof { - let _ = timeout(MASK_RELAY_IDLE_TIMEOUT, writer.shutdown()).await; + let _ = timeout(idle_timeout, writer.shutdown()).await; } break; } total = total.saturating_add(n); - let write_res = timeout(MASK_RELAY_IDLE_TIMEOUT, writer.write_all(&buf[..n])).await; + let write_res = timeout(idle_timeout, writer.write_all(&buf[..n])).await; match write_res { Ok(Ok(())) => {} Ok(Err(_)) | Err(_) => break, @@ -230,13 +227,20 @@ where } } -async fn consume_client_data_with_timeout_and_cap(reader: R, byte_cap: usize) -where +async fn consume_client_data_with_timeout_and_cap( + reader: R, + byte_cap: usize, + relay_timeout: Duration, + idle_timeout: Duration, +) where R: AsyncRead + Unpin, { - if timeout(MASK_RELAY_TIMEOUT, consume_client_data(reader, byte_cap)) - .await - .is_err() + if timeout( + relay_timeout, + consume_client_data(reader, byte_cap, idle_timeout), + ) + .await + .is_err() { debug!("Timed out while consuming client data on masking fallback path"); } @@ -639,10 +643,18 @@ pub async fn handle_bad_client( beobachten.record(client_type, peer.ip(), ttl); } + let relay_timeout = Duration::from_millis(config.censorship.mask_relay_timeout_ms); + let idle_timeout = Duration::from_millis(config.censorship.mask_relay_idle_timeout_ms); + if !config.censorship.mask { // Masking disabled, just consume data - consume_client_data_with_timeout_and_cap(reader, config.censorship.mask_relay_max_bytes) - .await; + consume_client_data_with_timeout_and_cap( + reader, + config.censorship.mask_relay_max_bytes, + relay_timeout, + idle_timeout, + ) + .await; return; } @@ -674,7 +686,7 @@ pub async fn handle_bad_client( return; } if timeout( - MASK_RELAY_TIMEOUT, + relay_timeout, relay_to_mask( reader, writer, @@ -688,6 +700,7 @@ pub async fn handle_bad_client( config.censorship.mask_shape_above_cap_blur_max_bytes, config.censorship.mask_shape_hardening_aggressive_mode, config.censorship.mask_relay_max_bytes, + idle_timeout, ), ) .await @@ -703,6 +716,8 @@ pub async fn handle_bad_client( consume_client_data_with_timeout_and_cap( reader, config.censorship.mask_relay_max_bytes, + relay_timeout, + idle_timeout, ) .await; wait_mask_outcome_budget(outcome_started, config).await; @@ -712,6 +727,8 @@ pub async fn handle_bad_client( consume_client_data_with_timeout_and_cap( reader, config.censorship.mask_relay_max_bytes, + relay_timeout, + idle_timeout, ) .await; wait_mask_outcome_budget(outcome_started, config).await; @@ -742,8 +759,13 @@ pub async fn handle_bad_client( local = %local_addr, "Mask target resolves to local listener; refusing self-referential masking fallback" ); - consume_client_data_with_timeout_and_cap(reader, config.censorship.mask_relay_max_bytes) - .await; + consume_client_data_with_timeout_and_cap( + reader, + config.censorship.mask_relay_max_bytes, + relay_timeout, + idle_timeout, + ) + .await; wait_mask_outcome_budget(outcome_started, config).await; return; } @@ -777,7 +799,7 @@ pub async fn handle_bad_client( return; } if timeout( - MASK_RELAY_TIMEOUT, + relay_timeout, relay_to_mask( reader, writer, @@ -791,6 +813,7 @@ pub async fn handle_bad_client( config.censorship.mask_shape_above_cap_blur_max_bytes, config.censorship.mask_shape_hardening_aggressive_mode, config.censorship.mask_relay_max_bytes, + idle_timeout, ), ) .await @@ -806,6 +829,8 @@ pub async fn handle_bad_client( consume_client_data_with_timeout_and_cap( reader, config.censorship.mask_relay_max_bytes, + relay_timeout, + idle_timeout, ) .await; wait_mask_outcome_budget(outcome_started, config).await; @@ -815,6 +840,8 @@ pub async fn handle_bad_client( consume_client_data_with_timeout_and_cap( reader, config.censorship.mask_relay_max_bytes, + relay_timeout, + idle_timeout, ) .await; wait_mask_outcome_budget(outcome_started, config).await; @@ -836,6 +863,7 @@ async fn relay_to_mask( shape_above_cap_blur_max_bytes: usize, shape_hardening_aggressive_mode: bool, mask_relay_max_bytes: usize, + idle_timeout: Duration, ) where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, @@ -857,11 +885,19 @@ async fn relay_to_mask( &mut mask_write, mask_relay_max_bytes, !shape_hardening_enabled, + idle_timeout, ) .await }, async { - copy_with_idle_timeout(&mut mask_read, &mut writer, mask_relay_max_bytes, true).await + copy_with_idle_timeout( + &mut mask_read, + &mut writer, + mask_relay_max_bytes, + true, + idle_timeout, + ) + .await } ); @@ -889,7 +925,11 @@ async fn relay_to_mask( } /// Just consume all data from client without responding. -async fn consume_client_data(mut reader: R, byte_cap: usize) { +async fn consume_client_data( + mut reader: R, + byte_cap: usize, + idle_timeout: Duration, +) { if byte_cap == 0 { return; } @@ -905,7 +945,7 @@ async fn consume_client_data(mut reader: R, byte_cap: usiz } let read_len = remaining_budget.min(MASK_BUFFER_SIZE); - let n = match timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf[..read_len])).await { + let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await { Ok(Ok(n)) => n, Ok(Err(_)) | Err(_) => break, }; diff --git a/src/proxy/tests/masking_additional_hardening_security_tests.rs b/src/proxy/tests/masking_additional_hardening_security_tests.rs index a6f6386..1b8ca2e 100644 --- a/src/proxy/tests/masking_additional_hardening_security_tests.rs +++ b/src/proxy/tests/masking_additional_hardening_security_tests.rs @@ -47,7 +47,7 @@ async fn consume_client_data_stops_after_byte_cap_without_eof() { }; let cap = 10_000usize; - consume_client_data(reader, cap).await; + consume_client_data(reader, cap, MASK_RELAY_IDLE_TIMEOUT).await; let total = produced.load(Ordering::Relaxed); assert!( diff --git a/src/proxy/tests/masking_consume_idle_timeout_security_tests.rs b/src/proxy/tests/masking_consume_idle_timeout_security_tests.rs index f2c39a2..fcd2e79 100644 --- a/src/proxy/tests/masking_consume_idle_timeout_security_tests.rs +++ b/src/proxy/tests/masking_consume_idle_timeout_security_tests.rs @@ -31,7 +31,7 @@ async fn stalling_client_terminates_at_idle_not_relay_timeout() { let result = tokio::time::timeout( MASK_RELAY_TIMEOUT, - consume_client_data(reader, MASK_BUFFER_SIZE * 4), + consume_client_data(reader, MASK_BUFFER_SIZE * 4, MASK_RELAY_IDLE_TIMEOUT), ) .await; @@ -57,9 +57,12 @@ async fn fast_reader_drains_to_eof() { let data = vec![0xAAu8; 32 * 1024]; let reader = std::io::Cursor::new(data); - tokio::time::timeout(MASK_RELAY_TIMEOUT, consume_client_data(reader, usize::MAX)) - .await - .expect("consume_client_data did not complete for fast EOF reader"); + tokio::time::timeout( + MASK_RELAY_TIMEOUT, + consume_client_data(reader, usize::MAX, MASK_RELAY_IDLE_TIMEOUT), + ) + .await + .expect("consume_client_data did not complete for fast EOF reader"); } #[tokio::test] @@ -81,7 +84,7 @@ async fn io_error_terminates_cleanly() { tokio::time::timeout( MASK_RELAY_TIMEOUT, - consume_client_data(ErrReader, usize::MAX), + consume_client_data(ErrReader, usize::MAX, MASK_RELAY_IDLE_TIMEOUT), ) .await .expect("consume_client_data did not return on I/O error"); diff --git a/src/proxy/tests/masking_consume_stress_adversarial_tests.rs b/src/proxy/tests/masking_consume_stress_adversarial_tests.rs index 12287b5..7579a9c 100644 --- a/src/proxy/tests/masking_consume_stress_adversarial_tests.rs +++ b/src/proxy/tests/masking_consume_stress_adversarial_tests.rs @@ -34,7 +34,11 @@ async fn consume_stall_stress_finishes_within_idle_budget() { set.spawn(async { tokio::time::timeout( MASK_RELAY_TIMEOUT, - consume_client_data(OneByteThenStall { sent: false }, usize::MAX), + consume_client_data( + OneByteThenStall { sent: false }, + usize::MAX, + MASK_RELAY_IDLE_TIMEOUT, + ), ) .await .expect("consume_client_data exceeded relay timeout under stall load"); @@ -56,7 +60,7 @@ async fn consume_stall_stress_finishes_within_idle_budget() { #[tokio::test] async fn consume_zero_cap_returns_immediately() { let started = Instant::now(); - consume_client_data(tokio::io::empty(), 0).await; + consume_client_data(tokio::io::empty(), 0, MASK_RELAY_IDLE_TIMEOUT).await; assert!( started.elapsed() < MASK_RELAY_IDLE_TIMEOUT, "zero byte cap must return immediately" diff --git a/src/proxy/tests/masking_production_cap_regression_security_tests.rs b/src/proxy/tests/masking_production_cap_regression_security_tests.rs index 9ff51ba..c5d542e 100644 --- a/src/proxy/tests/masking_production_cap_regression_security_tests.rs +++ b/src/proxy/tests/masking_production_cap_regression_security_tests.rs @@ -127,7 +127,14 @@ async fn positive_copy_with_production_cap_stops_exactly_at_budget() { let mut reader = FinitePatternReader::new(PROD_CAP_BYTES + (256 * 1024), 4096, read_calls); let mut writer = CountingWriter::default(); - let outcome = copy_with_idle_timeout(&mut reader, &mut writer, PROD_CAP_BYTES, true).await; + let outcome = copy_with_idle_timeout( + &mut reader, + &mut writer, + PROD_CAP_BYTES, + true, + MASK_RELAY_IDLE_TIMEOUT, + ) + .await; assert_eq!( outcome.total, PROD_CAP_BYTES, @@ -145,7 +152,13 @@ async fn negative_consume_with_zero_cap_performs_no_reads() { let read_calls = Arc::new(AtomicUsize::new(0)); let reader = FinitePatternReader::new(1024, 64, Arc::clone(&read_calls)); - consume_client_data_with_timeout_and_cap(reader, 0).await; + consume_client_data_with_timeout_and_cap( + reader, + 0, + MASK_RELAY_TIMEOUT, + MASK_RELAY_IDLE_TIMEOUT, + ) + .await; assert_eq!( read_calls.load(Ordering::Relaxed), @@ -161,7 +174,14 @@ async fn edge_copy_below_cap_reports_eof_without_overread() { let mut reader = FinitePatternReader::new(payload, 3072, read_calls); let mut writer = CountingWriter::default(); - let outcome = copy_with_idle_timeout(&mut reader, &mut writer, PROD_CAP_BYTES, true).await; + let outcome = copy_with_idle_timeout( + &mut reader, + &mut writer, + PROD_CAP_BYTES, + true, + MASK_RELAY_IDLE_TIMEOUT, + ) + .await; assert_eq!(outcome.total, payload); assert_eq!(writer.written, payload); @@ -175,7 +195,13 @@ async fn edge_copy_below_cap_reports_eof_without_overread() { async fn adversarial_blackhat_never_ready_reader_is_bounded_by_timeout_guards() { let started = Instant::now(); - consume_client_data_with_timeout_and_cap(NeverReadyReader, PROD_CAP_BYTES).await; + consume_client_data_with_timeout_and_cap( + NeverReadyReader, + PROD_CAP_BYTES, + MASK_RELAY_TIMEOUT, + MASK_RELAY_IDLE_TIMEOUT, + ) + .await; assert!( started.elapsed() < Duration::from_millis(350), @@ -190,7 +216,12 @@ async fn integration_consume_path_honors_production_cap_for_large_payload() { let bounded = timeout( Duration::from_millis(350), - consume_client_data_with_timeout_and_cap(reader, PROD_CAP_BYTES), + consume_client_data_with_timeout_and_cap( + reader, + PROD_CAP_BYTES, + MASK_RELAY_TIMEOUT, + MASK_RELAY_IDLE_TIMEOUT, + ), ) .await; @@ -206,7 +237,13 @@ async fn adversarial_consume_path_never_reads_beyond_declared_byte_cap() { let total_read = Arc::new(AtomicUsize::new(0)); let reader = BudgetProbeReader::new(256 * 1024, Arc::clone(&total_read)); - consume_client_data_with_timeout_and_cap(reader, byte_cap).await; + consume_client_data_with_timeout_and_cap( + reader, + byte_cap, + MASK_RELAY_TIMEOUT, + MASK_RELAY_IDLE_TIMEOUT, + ) + .await; assert!( total_read.load(Ordering::Relaxed) <= byte_cap, @@ -231,7 +268,9 @@ async fn light_fuzz_cap_and_payload_matrix_preserves_min_budget_invariant() { let mut reader = FinitePatternReader::new(payload, chunk, read_calls); let mut writer = CountingWriter::default(); - let outcome = copy_with_idle_timeout(&mut reader, &mut writer, cap, true).await; + let outcome = + copy_with_idle_timeout(&mut reader, &mut writer, cap, true, MASK_RELAY_IDLE_TIMEOUT) + .await; let expected = payload.min(cap); assert_eq!( @@ -261,7 +300,14 @@ async fn stress_parallel_copy_tasks_with_production_cap_complete_without_leaks() read_calls, ); let mut writer = CountingWriter::default(); - copy_with_idle_timeout(&mut reader, &mut writer, PROD_CAP_BYTES, true).await + copy_with_idle_timeout( + &mut reader, + &mut writer, + PROD_CAP_BYTES, + true, + MASK_RELAY_IDLE_TIMEOUT, + ) + .await })); } diff --git a/src/proxy/tests/masking_relay_guardrails_security_tests.rs b/src/proxy/tests/masking_relay_guardrails_security_tests.rs index 257c0f8..3613c91 100644 --- a/src/proxy/tests/masking_relay_guardrails_security_tests.rs +++ b/src/proxy/tests/masking_relay_guardrails_security_tests.rs @@ -26,6 +26,7 @@ async fn relay_to_mask_enforces_masking_session_byte_cap() { 0, false, 32 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ) .await; }); @@ -81,6 +82,7 @@ async fn relay_to_mask_propagates_client_half_close_without_waiting_for_other_di 0, false, 32 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ) .await; }); diff --git a/src/proxy/tests/masking_security_tests.rs b/src/proxy/tests/masking_security_tests.rs index c698b55..84a0e6f 100644 --- a/src/proxy/tests/masking_security_tests.rs +++ b/src/proxy/tests/masking_security_tests.rs @@ -1377,6 +1377,7 @@ async fn relay_to_mask_keeps_backend_to_client_flow_when_client_to_backend_stall 0, false, 5 * 1024 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ) .await; }); @@ -1508,6 +1509,7 @@ async fn relay_to_mask_timeout_cancels_and_drops_all_io_endpoints() { 0, false, 5 * 1024 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ), ) .await; diff --git a/src/proxy/tests/masking_self_target_loop_security_tests.rs b/src/proxy/tests/masking_self_target_loop_security_tests.rs index 7f6cb29..975b4fc 100644 --- a/src/proxy/tests/masking_self_target_loop_security_tests.rs +++ b/src/proxy/tests/masking_self_target_loop_security_tests.rs @@ -228,6 +228,7 @@ async fn relay_path_idle_timeout_eviction_remains_effective() { 0, false, 5 * 1024 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ) .await; diff --git a/src/proxy/tests/masking_shape_guard_adversarial_tests.rs b/src/proxy/tests/masking_shape_guard_adversarial_tests.rs index 4fa8da7..6c3c4bf 100644 --- a/src/proxy/tests/masking_shape_guard_adversarial_tests.rs +++ b/src/proxy/tests/masking_shape_guard_adversarial_tests.rs @@ -44,6 +44,7 @@ async fn run_relay_case( above_cap_blur_max_bytes, false, 5 * 1024 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ) .await; }); diff --git a/src/proxy/tests/masking_shape_hardening_adversarial_tests.rs b/src/proxy/tests/masking_shape_hardening_adversarial_tests.rs index 9abf3c0..4e0aa18 100644 --- a/src/proxy/tests/masking_shape_hardening_adversarial_tests.rs +++ b/src/proxy/tests/masking_shape_hardening_adversarial_tests.rs @@ -89,6 +89,7 @@ async fn relay_to_mask_applies_cap_clamped_padding_for_non_power_of_two_cap() { 0, false, 5 * 1024 * 1024, + MASK_RELAY_IDLE_TIMEOUT, ) .await; }); From abff2fd7feb8eb7f5f97714b8413d99547aca309 Mon Sep 17 00:00:00 2001 From: Vladislav Yaroslavlev Date: Mon, 13 Apr 2026 00:21:19 +0300 Subject: [PATCH 09/15] fix(maestro): restore Windows build (missing run_inner) The full runtime entry was gated with #[cfg(unix)] while run() still called run_inner() on non-Unix targets, causing E0425 on Windows (issue #690). Extract shared pipeline into run_telemt_core with a post-bind hook for Unix privilege dropping; provide cfg-split run_inner wrappers. Fixes https://github.com/telemt/telemt/issues/690 Made-with: Cursor --- src/maestro/mod.rs | 73 ++++++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 00b3b2d..5f3cb69 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -81,23 +81,9 @@ pub async fn run() -> std::result::Result<(), Box> { } } -#[cfg(unix)] -async fn run_inner( - daemon_opts: DaemonOptions, -) -> std::result::Result<(), Box> { - // Acquire PID file if daemonizing or if explicitly requested - // Keep it alive until shutdown (underscore prefix = intentionally kept for RAII cleanup) - let _pid_file = if daemon_opts.daemonize || daemon_opts.pid_file.is_some() { - let mut pf = PidFile::new(daemon_opts.pid_file_path()); - if let Err(e) = pf.acquire() { - eprintln!("[telemt] {}", e); - std::process::exit(1); - } - Some(pf) - } else { - None - }; - +// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound +// (for privilege drop); it is a no-op on other platforms. +async fn run_telemt_core(drop_after_bind: impl FnOnce()) -> std::result::Result<(), Box> { let process_started_at = Instant::now(); let process_started_at_epoch_secs = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -761,17 +747,8 @@ async fn run_inner( std::process::exit(1); } - // Drop privileges after binding sockets (which may require root for port < 1024) - if daemon_opts.user.is_some() || daemon_opts.group.is_some() { - if let Err(e) = drop_privileges( - daemon_opts.user.as_deref(), - daemon_opts.group.as_deref(), - _pid_file.as_ref(), - ) { - error!(error = %e, "Failed to drop privileges"); - std::process::exit(1); - } - } + // On Unix, caller supplies privilege drop after bind (may require root for port < 1024). + drop_after_bind(); runtime_tasks::apply_runtime_log_filter( has_rust_log, @@ -819,3 +796,43 @@ async fn run_inner( Ok(()) } + +#[cfg(unix)] +async fn run_inner( + daemon_opts: DaemonOptions, +) -> std::result::Result<(), Box> { + // Acquire PID file if daemonizing or if explicitly requested + // Keep it alive until shutdown (underscore prefix = intentionally kept for RAII cleanup) + let _pid_file = if daemon_opts.daemonize || daemon_opts.pid_file.is_some() { + let mut pf = PidFile::new(daemon_opts.pid_file_path()); + if let Err(e) = pf.acquire() { + eprintln!("[telemt] {}", e); + std::process::exit(1); + } + Some(pf) + } else { + None + }; + + let user = daemon_opts.user.clone(); + let group = daemon_opts.group.clone(); + + run_telemt_core(|| { + if user.is_some() || group.is_some() { + if let Err(e) = drop_privileges( + user.as_deref(), + group.as_deref(), + _pid_file.as_ref(), + ) { + error!(error = %e, "Failed to drop privileges"); + std::process::exit(1); + } + } + }) + .await +} + +#[cfg(not(unix))] +async fn run_inner() -> std::result::Result<(), Box> { + run_telemt_core(|| {}).await +} From 13f86062f4c3e3dd220d3549a24d73a74ceb10b3 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:32:06 +0300 Subject: [PATCH 10/15] BINDTODEVICE for Direct Upstreams by #683 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/load.rs | 1 + src/config/types.rs | 4 ++ src/network/probe.rs | 1 + .../client_masking_blackhat_campaign_tests.rs | 1 + .../client_masking_budget_security_tests.rs | 1 + ...ient_masking_diagnostics_security_tests.rs | 1 + ...ng_fragmented_classifier_security_tests.rs | 1 + .../client_masking_hard_adversarial_tests.rs | 1 + ...http2_fragmented_preface_security_tests.rs | 1 + ...fig_pipeline_integration_security_tests.rs | 1 + ...sking_prefetch_invariant_security_tests.rs | 1 + ...nt_masking_probe_evasion_blackhat_tests.rs | 1 + ...ent_masking_redteam_expected_fail_tests.rs | 4 ++ ...nt_masking_replay_timing_security_tests.rs | 1 + ...sifier_fuzz_redteam_expected_fail_tests.rs | 1 + ...sking_shape_hardening_adversarial_tests.rs | 1 + ...e_hardening_redteam_expected_fail_tests.rs | 1 + ..._masking_shape_hardening_security_tests.rs | 1 + ...client_masking_stress_adversarial_tests.rs | 1 + src/proxy/tests/client_security_tests.rs | 27 ++++++++++ ...client_timing_profile_adversarial_tests.rs | 1 + ...ent_tls_clienthello_size_security_tests.rs | 1 + ...lienthello_truncation_adversarial_tests.rs | 1 + ...ent_tls_mtproto_fallback_security_tests.rs | 1 + .../tests/direct_relay_security_tests.rs | 5 ++ .../proxy_shared_state_isolation_tests.rs | 1 + src/transport/middle_proxy/ping.rs | 9 +++- src/transport/socket.rs | 50 +++++++++++++++++++ src/transport/upstream.rs | 13 ++++- 29 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/config/load.rs b/src/config/load.rs index f9e230c..b7bc9fa 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1289,6 +1289,7 @@ impl ProxyConfig { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/config/types.rs b/src/config/types.rs index 7eb7702..e287246 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1856,6 +1856,10 @@ pub enum UpstreamType { interface: Option, #[serde(default)] bind_addresses: Option>, + /// Linux-only hard interface pinning via `SO_BINDTODEVICE`. + /// Optional alias: `force_bind`. + #[serde(default, alias = "force_bind")] + bindtodevice: Option, }, Socks4 { address: String, diff --git a/src/network/probe.rs b/src/network/probe.rs index 1787b92..90484b3 100644 --- a/src/network/probe.rs +++ b/src/network/probe.rs @@ -97,6 +97,7 @@ pub async fn run_probe( let UpstreamType::Direct { interface, bind_addresses, + .. } = &upstream.upstream_type else { continue; diff --git a/src/proxy/tests/client_masking_blackhat_campaign_tests.rs b/src/proxy/tests/client_masking_blackhat_campaign_tests.rs index 917e799..962387a 100644 --- a/src/proxy/tests/client_masking_blackhat_campaign_tests.rs +++ b/src/proxy/tests/client_masking_blackhat_campaign_tests.rs @@ -31,6 +31,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_budget_security_tests.rs b/src/proxy/tests/client_masking_budget_security_tests.rs index 332451c..d356869 100644 --- a/src/proxy/tests/client_masking_budget_security_tests.rs +++ b/src/proxy/tests/client_masking_budget_security_tests.rs @@ -27,6 +27,7 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_diagnostics_security_tests.rs b/src/proxy/tests/client_masking_diagnostics_security_tests.rs index 67b797b..a2f44ce 100644 --- a/src/proxy/tests/client_masking_diagnostics_security_tests.rs +++ b/src/proxy/tests/client_masking_diagnostics_security_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs b/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs index 8fa2689..ae04c6a 100644 --- a/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs +++ b/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_hard_adversarial_tests.rs b/src/proxy/tests/client_masking_hard_adversarial_tests.rs index c6b0e98..7e0c683 100644 --- a/src/proxy/tests/client_masking_hard_adversarial_tests.rs +++ b/src/proxy/tests/client_masking_hard_adversarial_tests.rs @@ -25,6 +25,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs b/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs index b5a8b4d..8aa6fb2 100644 --- a/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs +++ b/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs b/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs index b3fd5cb..b992402 100644 --- a/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs +++ b/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs b/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs index b57ad51..e9af94c 100644 --- a/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs +++ b/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs @@ -38,6 +38,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs b/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs index 9ab5f78..282465a 100644 --- a/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs +++ b/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs @@ -16,6 +16,7 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs index 2b6f600..b4a79fe 100644 --- a/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs @@ -39,6 +39,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -229,6 +230,7 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -470,6 +472,7 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -544,6 +547,7 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_replay_timing_security_tests.rs b/src/proxy/tests/client_masking_replay_timing_security_tests.rs index 97ed52a..8c829be 100644 --- a/src/proxy/tests/client_masking_replay_timing_security_tests.rs +++ b/src/proxy/tests/client_masking_replay_timing_security_tests.rs @@ -13,6 +13,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs index c4dd4db..14837bf 100644 --- a/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs b/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs index 2cf98c4..014180d 100644 --- a/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs +++ b/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs index b0bf73e..49378f6 100644 --- a/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_shape_hardening_security_tests.rs b/src/proxy/tests/client_masking_shape_hardening_security_tests.rs index 7d2380b..6a64c6e 100644 --- a/src/proxy/tests/client_masking_shape_hardening_security_tests.rs +++ b/src/proxy/tests/client_masking_shape_hardening_security_tests.rs @@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_masking_stress_adversarial_tests.rs b/src/proxy/tests/client_masking_stress_adversarial_tests.rs index 1c8b599..9ccd033 100644 --- a/src/proxy/tests/client_masking_stress_adversarial_tests.rs +++ b/src/proxy/tests/client_masking_stress_adversarial_tests.rs @@ -25,6 +25,7 @@ fn new_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_security_tests.rs b/src/proxy/tests/client_security_tests.rs index d585326..40d99ec 100644 --- a/src/proxy/tests/client_security_tests.rs +++ b/src/proxy/tests/client_security_tests.rs @@ -332,6 +332,7 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -446,6 +447,7 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -570,6 +572,7 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -740,6 +743,7 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() { upstream_type: crate::config::UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -817,6 +821,7 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load( upstream_type: crate::config::UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -977,6 +982,7 @@ async fn short_tls_probe_is_masked_through_client_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1065,6 +1071,7 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1151,6 +1158,7 @@ async fn handle_client_stream_increments_connects_all_exactly_once() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1244,6 +1252,7 @@ async fn running_client_handler_increments_connects_all_exactly_once() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1334,6 +1343,7 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1405,6 +1415,7 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1491,6 +1502,7 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1816,6 +1828,7 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1925,6 +1938,7 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -2032,6 +2046,7 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -2154,6 +2169,7 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -2247,6 +2263,7 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -2346,6 +2363,7 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -3251,6 +3269,7 @@ async fn relay_connect_error_releases_user_and_ip_before_return() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -3812,6 +3831,7 @@ async fn untrusted_proxy_header_source_is_rejected() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -3882,6 +3902,7 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -3979,6 +4000,7 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -4082,6 +4104,7 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -4199,6 +4222,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -4302,6 +4326,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -4408,6 +4433,7 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -4509,6 +4535,7 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_timing_profile_adversarial_tests.rs b/src/proxy/tests/client_timing_profile_adversarial_tests.rs index d8df19f..54ccbe2 100644 --- a/src/proxy/tests/client_timing_profile_adversarial_tests.rs +++ b/src/proxy/tests/client_timing_profile_adversarial_tests.rs @@ -24,6 +24,7 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_tls_clienthello_size_security_tests.rs b/src/proxy/tests/client_tls_clienthello_size_security_tests.rs index 14c24b7..442412d 100644 --- a/src/proxy/tests/client_tls_clienthello_size_security_tests.rs +++ b/src/proxy/tests/client_tls_clienthello_size_security_tests.rs @@ -26,6 +26,7 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs b/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs index c757999..1243b83 100644 --- a/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs +++ b/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs @@ -27,6 +27,7 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs b/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs index a4d5df8..74ab347 100644 --- a/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs +++ b/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs @@ -41,6 +41,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/proxy/tests/direct_relay_security_tests.rs b/src/proxy/tests/direct_relay_security_tests.rs index e139923..5972204 100644 --- a/src/proxy/tests/direct_relay_security_tests.rs +++ b/src/proxy/tests/direct_relay_security_tests.rs @@ -1293,6 +1293,7 @@ async fn direct_relay_abort_midflight_releases_route_gauge() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1400,6 +1401,7 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1522,6 +1524,7 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, @@ -1758,6 +1761,7 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, selected_scope: String::new(), }], @@ -1849,6 +1853,7 @@ async fn adversarial_direct_relay_cutover_integrity() { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, selected_scope: String::new(), }], diff --git a/src/proxy/tests/proxy_shared_state_isolation_tests.rs b/src/proxy/tests/proxy_shared_state_isolation_tests.rs index 7887ef8..b174ee3 100644 --- a/src/proxy/tests/proxy_shared_state_isolation_tests.rs +++ b/src/proxy/tests/proxy_shared_state_isolation_tests.rs @@ -53,6 +53,7 @@ fn new_client_harness() -> ClientHarness { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, + bindtodevice: None, }, weight: 1, enabled: true, diff --git a/src/transport/middle_proxy/ping.rs b/src/transport/middle_proxy/ping.rs index bff088b..85888bd 100644 --- a/src/transport/middle_proxy/ping.rs +++ b/src/transport/middle_proxy/ping.rs @@ -67,6 +67,7 @@ pub fn format_sample_line(sample: &MePingSample) -> String { fn format_direct_with_config( interface: &Option, bind_addresses: &Option>, + bindtodevice: &Option, ) -> Option { let mut direct_parts: Vec = Vec::new(); if let Some(dev) = interface.as_deref().filter(|v| !v.is_empty()) { @@ -75,6 +76,9 @@ fn format_direct_with_config( if let Some(src) = bind_addresses.as_ref().filter(|v| !v.is_empty()) { direct_parts.push(format!("src={}", src.join(","))); } + if let Some(device) = bindtodevice.as_deref().filter(|v| !v.is_empty()) { + direct_parts.push(format!("bindtodevice={device}")); + } if direct_parts.is_empty() { None } else { @@ -231,8 +235,11 @@ pub async fn format_me_route( UpstreamType::Direct { interface, bind_addresses, + bindtodevice, } => { - if let Some(route) = format_direct_with_config(interface, bind_addresses) { + if let Some(route) = + format_direct_with_config(interface, bind_addresses, bindtodevice) + { route } else { detect_direct_route_details(reports, prefer_ipv6, v4_ok, v6_ok) diff --git a/src/transport/socket.rs b/src/transport/socket.rs index b751a30..58d3b97 100644 --- a/src/transport/socket.rs +++ b/src/transport/socket.rs @@ -158,6 +158,56 @@ pub fn create_outgoing_socket_bound(addr: SocketAddr, bind_addr: Option) Ok(socket) } +/// Pin an outgoing socket to a specific Linux network interface via SO_BINDTODEVICE. +#[cfg(target_os = "linux")] +pub fn bind_outgoing_socket_to_device(socket: &Socket, device: &str) -> Result<()> { + use std::io::{Error, ErrorKind}; + use std::os::fd::AsRawFd; + + let name = device.trim(); + if name.is_empty() { + return Err(Error::new( + ErrorKind::InvalidInput, + "bindtodevice must not be empty", + )); + } + + // The kernel expects an interface name buffer with a trailing NUL. + if name.len() >= libc::IFNAMSIZ { + return Err(Error::new( + ErrorKind::InvalidInput, + "bindtodevice exceeds IFNAMSIZ", + )); + } + let mut ifname = [0u8; libc::IFNAMSIZ]; + ifname[..name.len()].copy_from_slice(name.as_bytes()); + + let rc = unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + ifname.as_ptr().cast::(), + (name.len() + 1) as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(Error::last_os_error()); + } + debug!("Pinned outgoing socket to interface {}", name); + Ok(()) +} + +/// Stub for non-Linux targets where SO_BINDTODEVICE is unavailable. +#[cfg(not(target_os = "linux"))] +pub fn bind_outgoing_socket_to_device(_socket: &Socket, _device: &str) -> Result<()> { + use std::io::{Error, ErrorKind}; + Err(Error::new( + ErrorKind::Unsupported, + "bindtodevice is supported only on Linux", + )) +} + /// Get local address of a socket #[allow(dead_code)] pub fn get_local_addr(stream: &TcpStream) -> Option { diff --git a/src/transport/upstream.rs b/src/transport/upstream.rs index 674f0f0..2486b13 100644 --- a/src/transport/upstream.rs +++ b/src/transport/upstream.rs @@ -26,7 +26,9 @@ use crate::stats::Stats; use crate::transport::shadowsocks::{ ShadowsocksStream, connect_shadowsocks, sanitize_shadowsocks_url, }; -use crate::transport::socket::{create_outgoing_socket_bound, resolve_interface_ip}; +use crate::transport::socket::{ + bind_outgoing_socket_to_device, create_outgoing_socket_bound, resolve_interface_ip, +}; use crate::transport::socks::{connect_socks4, connect_socks5}; /// Number of Telegram datacenters @@ -928,6 +930,7 @@ impl UpstreamManager { UpstreamType::Direct { interface, bind_addresses, + bindtodevice, } => { let bind_ip = Self::resolve_bind_address( interface, @@ -943,6 +946,10 @@ impl UpstreamManager { } let socket = create_outgoing_socket_bound(target, bind_ip)?; + if let Some(device) = bindtodevice.as_deref().filter(|value| !value.is_empty()) { + bind_outgoing_socket_to_device(&socket, device).map_err(ProxyError::Io)?; + debug!(bindtodevice = %device, target = %target, "Pinned socket to interface"); + } if let Some(ip) = bind_ip { debug!(bind = %ip, target = %target, "Bound outgoing socket"); } else if interface.is_some() || bind_addresses.is_some() { @@ -1209,6 +1216,7 @@ impl UpstreamManager { UpstreamType::Direct { interface, bind_addresses, + bindtodevice, } => { let mut direct_parts = Vec::new(); if let Some(dev) = interface.as_deref().filter(|v| !v.is_empty()) { @@ -1217,6 +1225,9 @@ impl UpstreamManager { if let Some(src) = bind_addresses.as_ref().filter(|v| !v.is_empty()) { direct_parts.push(format!("src={}", src.join(","))); } + if let Some(device) = bindtodevice.as_deref().filter(|v| !v.is_empty()) { + direct_parts.push(format!("bindtodevice={device}")); + } if direct_parts.is_empty() { "direct".to_string() } else { From 57dca639f0ce9ee44ce8ddfee06364f2993ce4af Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:19:06 +0300 Subject: [PATCH 11/15] Gray Action for API by #630 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/api/mod.rs | 37 +++++++++++++++++++-------- src/config/hot_reload.rs | 1 + src/config/load.rs | 55 ++++++++++++++++++++++++++++++++++++++++ src/config/types.rs | 21 +++++++++++++++ 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index e60a375..5f4861e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,6 @@ #![allow(clippy::too_many_arguments)] -use std::convert::Infallible; +use std::io::{Error as IoError, ErrorKind}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -16,7 +16,7 @@ use tokio::net::TcpListener; use tokio::sync::{Mutex, RwLock, watch}; use tracing::{debug, info, warn}; -use crate::config::ProxyConfig; +use crate::config::{ApiGrayAction, ProxyConfig}; use crate::ip_tracker::UserIpTracker; use crate::proxy::route_mode::RouteRuntimeController; use crate::startup::StartupTracker; @@ -184,7 +184,9 @@ pub async fn serve( .serve_connection(hyper_util::rt::TokioIo::new(stream), svc) .await { - debug!(error = %error, "API connection error"); + if !error.is_user() { + debug!(error = %error, "API connection error"); + } } }); } @@ -195,7 +197,7 @@ async fn handle( peer: SocketAddr, shared: Arc, config_rx: watch::Receiver>, -) -> Result>, Infallible> { +) -> Result>, IoError> { let request_id = shared.next_request_id(); let cfg = config_rx.borrow().clone(); let api_cfg = &cfg.server.api; @@ -213,14 +215,27 @@ async fn handle( if !api_cfg.whitelist.is_empty() && !api_cfg.whitelist.iter().any(|net| net.contains(peer.ip())) { - return Ok(error_response( - request_id, - ApiFailure::new( - StatusCode::FORBIDDEN, - "forbidden", - "Source IP is not allowed", + return match api_cfg.gray_action { + ApiGrayAction::Api => Ok(error_response( + request_id, + ApiFailure::new( + StatusCode::FORBIDDEN, + "forbidden", + "Source IP is not allowed", + ), + )), + ApiGrayAction::Ok200 => Ok( + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::new())) + .unwrap(), ), - )); + ApiGrayAction::Drop => Err(IoError::new( + ErrorKind::ConnectionAborted, + "api request dropped by gray_action=drop", + )), + }; } if !api_cfg.auth_header.is_empty() { diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 61c36eb..f481798 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -560,6 +560,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b if old.server.api.enabled != new.server.api.enabled || old.server.api.listen != new.server.api.listen || old.server.api.whitelist != new.server.api.whitelist + || old.server.api.gray_action != new.server.api.gray_action || old.server.api.auth_header != new.server.api.auth_header || old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes || old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled diff --git a/src/config/load.rs b/src/config/load.rs index b7bc9fa..481cf9d 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1492,6 +1492,7 @@ mod tests { assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); assert_eq!(cfg.server.api.listen, default_api_listen()); assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); + assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop); assert_eq!( cfg.server.api.request_body_limit_bytes, default_api_request_body_limit_bytes() @@ -1662,6 +1663,7 @@ mod tests { ); assert_eq!(server.api.listen, default_api_listen()); assert_eq!(server.api.whitelist, default_api_whitelist()); + assert_eq!(server.api.gray_action, ApiGrayAction::Drop); assert_eq!( server.api.request_body_limit_bytes, default_api_request_body_limit_bytes() @@ -1809,6 +1811,59 @@ mod tests { ); } + #[test] + fn api_gray_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop); + + let cfg_api: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "api" + "#, + ) + .unwrap(); + assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api); + + let cfg_200: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "200" + "#, + ) + .unwrap(); + assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200); + + let cfg_drop: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "drop" + "#, + ) + .unwrap(); + assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); + } + #[test] fn dc_overrides_allow_string_and_array() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index e287246..ee52cb7 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1183,6 +1183,13 @@ pub struct ApiConfig { #[serde(default = "default_api_whitelist")] pub whitelist: Vec, + /// Behavior for requests from source IPs outside `whitelist`. + /// - `api`: return structured API forbidden response. + /// - `200`: return `200 OK` with an empty body. + /// - `drop`: close the connection without HTTP response. + #[serde(default)] + pub gray_action: ApiGrayAction, + /// Optional static value for `Authorization` header validation. /// Empty string disables header auth. #[serde(default)] @@ -1227,6 +1234,7 @@ impl Default for ApiConfig { enabled: default_true(), listen: default_api_listen(), whitelist: default_api_whitelist(), + gray_action: ApiGrayAction::default(), auth_header: String::new(), request_body_limit_bytes: default_api_request_body_limit_bytes(), minimal_runtime_enabled: default_api_minimal_runtime_enabled(), @@ -1240,6 +1248,19 @@ impl Default for ApiConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ApiGrayAction { + /// Preserve current API behavior for denied source IPs. + Api, + /// Mimic a plain web endpoint by returning `200 OK` with an empty body. + #[serde(rename = "200")] + Ok200, + /// Drop connection without HTTP response for denied source IPs. + #[default] + Drop, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ConntrackMode { From 3fefcdd11f4035c0c4a5a79f9f14557586c4f437 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:09:31 +0300 Subject: [PATCH 12/15] Fix for beobachten path by #664 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/load.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/config/load.rs b/src/config/load.rs index 481cf9d..ffecc30 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -340,12 +340,29 @@ impl ProxyConfig { let update_every_is_explicit = general_table .map(|table| table.contains_key("update_every")) .unwrap_or(false); + let beobachten_is_explicit = general_table + .map(|table| table.contains_key("beobachten")) + .unwrap_or(false); + let beobachten_minutes_is_explicit = general_table + .map(|table| table.contains_key("beobachten_minutes")) + .unwrap_or(false); + let beobachten_flush_secs_is_explicit = general_table + .map(|table| table.contains_key("beobachten_flush_secs")) + .unwrap_or(false); + let beobachten_file_is_explicit = general_table + .map(|table| table.contains_key("beobachten_file")) + .unwrap_or(false); let legacy_secret_is_explicit = general_table .map(|table| table.contains_key("proxy_secret_auto_reload_secs")) .unwrap_or(false); let legacy_config_is_explicit = general_table .map(|table| table.contains_key("proxy_config_auto_reload_secs")) .unwrap_or(false); + let legacy_top_level_beobachten = parsed_toml.get("beobachten").cloned(); + let legacy_top_level_beobachten_minutes = parsed_toml.get("beobachten_minutes").cloned(); + let legacy_top_level_beobachten_flush_secs = + parsed_toml.get("beobachten_flush_secs").cloned(); + let legacy_top_level_beobachten_file = parsed_toml.get("beobachten_file").cloned(); let stun_servers_is_explicit = network_table .map(|table| table.contains_key("stun_servers")) .unwrap_or(false); @@ -358,6 +375,63 @@ impl ProxyConfig { config.general.update_every = None; } + // Backward compatibility: legacy top-level beobachten* keys. + // Prefer `[general].*` when both are present. + let mut legacy_beobachten_applied = false; + if !beobachten_is_explicit + && let Some(value) = legacy_top_level_beobachten.as_ref() + { + let parsed = value.as_bool().ok_or_else(|| { + ProxyError::Config("beobachten (top-level) must be a boolean".to_string()) + })?; + config.general.beobachten = parsed; + legacy_beobachten_applied = true; + } + if !beobachten_minutes_is_explicit + && let Some(value) = legacy_top_level_beobachten_minutes.as_ref() + { + let raw = value.as_integer().ok_or_else(|| { + ProxyError::Config("beobachten_minutes (top-level) must be an integer".to_string()) + })?; + let parsed = u64::try_from(raw).map_err(|_| { + ProxyError::Config( + "beobachten_minutes (top-level) must be within u64 range".to_string(), + ) + })?; + config.general.beobachten_minutes = parsed; + legacy_beobachten_applied = true; + } + if !beobachten_flush_secs_is_explicit + && let Some(value) = legacy_top_level_beobachten_flush_secs.as_ref() + { + let raw = value.as_integer().ok_or_else(|| { + ProxyError::Config( + "beobachten_flush_secs (top-level) must be an integer".to_string(), + ) + })?; + let parsed = u64::try_from(raw).map_err(|_| { + ProxyError::Config( + "beobachten_flush_secs (top-level) must be within u64 range".to_string(), + ) + })?; + config.general.beobachten_flush_secs = parsed; + legacy_beobachten_applied = true; + } + if !beobachten_file_is_explicit + && let Some(value) = legacy_top_level_beobachten_file.as_ref() + { + let parsed = value.as_str().ok_or_else(|| { + ProxyError::Config("beobachten_file (top-level) must be a string".to_string()) + })?; + config.general.beobachten_file = parsed.to_string(); + legacy_beobachten_applied = true; + } + if legacy_beobachten_applied { + warn!( + "top-level beobachten* keys are deprecated; use general.beobachten* instead" + ); + } + let legacy_nat_stun = config.general.middle_proxy_nat_stun.take(); let legacy_nat_stun_servers = std::mem::take(&mut config.general.middle_proxy_nat_stun_servers); @@ -1386,6 +1460,21 @@ mod tests { const TEST_SHADOWSOCKS_URL: &str = "ss://2022-blake3-aes-256-gcm:MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=@127.0.0.1:8388"; + fn load_config_from_temp_toml(toml: &str) -> ProxyConfig { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("telemt_load_cfg_{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(dir); + cfg + } + #[test] fn serde_defaults_remain_unchanged_for_present_sections() { let toml = r#" @@ -1482,6 +1571,7 @@ mod tests { cfg.general.rpc_proxy_req_every, default_rpc_proxy_req_every() ); + assert_eq!(cfg.general.beobachten_file, default_beobachten_file()); assert_eq!(cfg.general.update_every, default_update_every()); assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); @@ -1649,6 +1739,7 @@ mod tests { default_upstream_connect_failfast_hard_errors() ); assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every()); + assert_eq!(general.beobachten_file, default_beobachten_file()); assert_eq!(general.update_every, default_update_every()); let server = ServerConfig::default(); @@ -1864,6 +1955,54 @@ mod tests { assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); } + #[test] + fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() { + let cfg = load_config_from_temp_toml( + r#" + beobachten = false + beobachten_minutes = 7 + beobachten_flush_secs = 3 + beobachten_file = "tmp/legacy-beob.txt" + + [server] + [general] + [network] + [access] + "#, + ); + + assert!(!cfg.general.beobachten); + assert_eq!(cfg.general.beobachten_minutes, 7); + assert_eq!(cfg.general.beobachten_flush_secs, 3); + assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt"); + } + + #[test] + fn general_beobachten_keys_have_priority_over_legacy_top_level() { + let cfg = load_config_from_temp_toml( + r#" + beobachten = true + beobachten_minutes = 30 + beobachten_flush_secs = 30 + beobachten_file = "tmp/legacy-beob.txt" + + [server] + [general] + beobachten = false + beobachten_minutes = 5 + beobachten_flush_secs = 2 + beobachten_file = "tmp/general-beob.txt" + [network] + [access] + "#, + ); + + assert!(!cfg.general.beobachten); + assert_eq!(cfg.general.beobachten_minutes, 5); + assert_eq!(cfg.general.beobachten_flush_secs, 2); + assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt"); + } + #[test] fn dc_overrides_allow_string_and_array() { let toml = r#" From d7a03196968c96f3c643230ca615a2833cbf9598 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:32:49 +0300 Subject: [PATCH 13/15] Server.Listeners + Upstream V4/V6 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/api/users.rs | 14 +- src/cli.rs | 3 +- src/config/hot_reload.rs | 16 +- src/config/load.rs | 17 ++ src/config/types.rs | 16 +- src/conntrack_control.rs | 61 ++++--- src/maestro/listeners.rs | 22 ++- .../client_masking_blackhat_campaign_tests.rs | 2 + .../client_masking_budget_security_tests.rs | 2 + ...ient_masking_diagnostics_security_tests.rs | 2 + ...ng_fragmented_classifier_security_tests.rs | 2 + .../client_masking_hard_adversarial_tests.rs | 2 + ...http2_fragmented_preface_security_tests.rs | 2 + ...fig_pipeline_integration_security_tests.rs | 2 + ...sking_prefetch_invariant_security_tests.rs | 2 + ...nt_masking_probe_evasion_blackhat_tests.rs | 2 + ...ent_masking_redteam_expected_fail_tests.rs | 8 + ...nt_masking_replay_timing_security_tests.rs | 2 + ...sifier_fuzz_redteam_expected_fail_tests.rs | 2 + ...sking_shape_hardening_adversarial_tests.rs | 2 + ...e_hardening_redteam_expected_fail_tests.rs | 2 + ..._masking_shape_hardening_security_tests.rs | 2 + ...client_masking_stress_adversarial_tests.rs | 2 + src/proxy/tests/client_security_tests.rs | 54 ++++++ ...client_timing_profile_adversarial_tests.rs | 2 + ...ent_tls_clienthello_size_security_tests.rs | 2 + ...lienthello_truncation_adversarial_tests.rs | 2 + ...ent_tls_mtproto_fallback_security_tests.rs | 2 + .../tests/direct_relay_security_tests.rs | 10 ++ .../proxy_shared_state_isolation_tests.rs | 2 + src/transport/upstream.rs | 168 +++++++++++++++--- 31 files changed, 377 insertions(+), 52 deletions(-) diff --git a/src/api/users.rs b/src/api/users.rs index 5a09714..6b20b85 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -452,7 +452,11 @@ fn build_user_links( startup_detected_ip_v6: Option, ) -> UserLinks { let hosts = resolve_link_hosts(cfg, startup_detected_ip_v4, startup_detected_ip_v6); - let port = cfg.general.links.public_port.unwrap_or(cfg.server.port); + let port = cfg + .general + .links + .public_port + .unwrap_or(resolve_default_link_port(cfg)); let tls_domains = resolve_tls_domains(cfg); let mut classic = Vec::new(); @@ -490,6 +494,14 @@ fn build_user_links( } } +fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 { + cfg.server + .listeners + .first() + .and_then(|listener| listener.port) + .unwrap_or(cfg.server.port) +} + fn resolve_link_hosts( cfg: &ProxyConfig, startup_detected_ip_v4: Option, diff --git a/src/cli.rs b/src/cli.rs index 5a79bae..47a10d5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -598,16 +598,17 @@ secure = false tls = true [server] -port = {port} listen_addr_ipv4 = "0.0.0.0" listen_addr_ipv6 = "::" [[server.listeners]] ip = "0.0.0.0" +port = {port} # reuse_allow = false # Set true only when intentionally running multiple telemt instances on same port [[server.listeners]] ip = "::" +port = {port} [timeouts] client_first_byte_idle_secs = 300 diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index f481798..f42638c 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -17,8 +17,9 @@ //! | `network` | `dns_overrides` | Applied immediately | //! | `access` | All user/quota fields | Effective immediately | //! -//! Fields that require re-binding sockets (`server.port`, `censorship.*`, -//! `network.*`, `use_middle_proxy`) are **not** applied; a warning is emitted. +//! Fields that require re-binding sockets (`server.listeners`, legacy +//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** +//! applied; a warning is emitted. //! Non-hot changes are never mixed into the runtime config snapshot. use std::collections::BTreeSet; @@ -299,6 +300,7 @@ fn listeners_equal( } lhs.iter().zip(rhs.iter()).all(|(a, b)| { a.ip == b.ip + && a.port == b.port && a.announce == b.announce && a.announce_ip == b.announce_ip && a.proxy_protocol == b.proxy_protocol @@ -306,6 +308,14 @@ fn listeners_equal( }) } +fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 { + cfg.server + .listeners + .first() + .and_then(|listener| listener.port) + .unwrap_or(cfg.server.port) +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] struct WatchManifest { files: BTreeSet, @@ -1120,7 +1130,7 @@ fn log_changes( .general .links .public_port - .unwrap_or(new_cfg.server.port); + .unwrap_or(resolve_default_link_port(new_cfg)); for user in &added { if let Some(secret) = new_hot.users.get(*user) { print_user_links(user, secret, &host, port, new_cfg); diff --git a/src/config/load.rs b/src/config/load.rs index ffecc30..abe4072 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -253,6 +253,12 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> { } for upstream in &config.upstreams { + if matches!(upstream.ipv4, Some(false)) && matches!(upstream.ipv6, Some(false)) { + return Err(ProxyError::Config( + "upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(), + )); + } + if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type { let parsed = ShadowsocksServerConfig::from_url(url) .map_err(|error| ProxyError::Config(format!("invalid shadowsocks url: {error}")))?; @@ -1324,6 +1330,7 @@ impl ProxyConfig { if let Ok(ipv4) = ipv4_str.parse::() { config.server.listeners.push(ListenerConfig { ip: ipv4, + port: Some(config.server.port), announce: None, announce_ip: None, proxy_protocol: None, @@ -1335,6 +1342,7 @@ impl ProxyConfig { { config.server.listeners.push(ListenerConfig { ip: ipv6, + port: Some(config.server.port), announce: None, announce_ip: None, proxy_protocol: None, @@ -1343,6 +1351,13 @@ impl ProxyConfig { } } + // Migration: listeners[].port fallback to legacy server.port. + for listener in &mut config.server.listeners { + if listener.port.is_none() { + listener.port = Some(config.server.port); + } + } + // Migration: announce_ip → announce for each listener. for listener in &mut config.server.listeners { if listener.announce.is_none() @@ -1369,6 +1384,8 @@ impl ProxyConfig { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }); } diff --git a/src/config/types.rs b/src/config/types.rs index ee52cb7..35b8d46 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1153,7 +1153,8 @@ pub struct LinksConfig { #[serde(default)] pub public_host: Option, - /// Public port for tg:// link generation (overrides server.port). + /// Public port for tg:// link generation. + /// Overrides listener ports and legacy `server.port`. #[serde(default)] pub public_port: Option, } @@ -1375,6 +1376,8 @@ impl Default for ConntrackControlConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { + /// Legacy listener port used for backward compatibility. + /// For new configs prefer `[[server.listeners]].port`. #[serde(default = "default_port")] pub port: u16, @@ -1917,11 +1920,22 @@ pub struct UpstreamConfig { pub scopes: String, #[serde(skip)] pub selected_scope: String, + /// Allow IPv4 DC targets for this upstream. + /// `None` means auto-detect from runtime connectivity state. + #[serde(default)] + pub ipv4: Option, + /// Allow IPv6 DC targets for this upstream. + /// `None` means auto-detect from runtime connectivity state. + #[serde(default)] + pub ipv6: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListenerConfig { pub ip: IpAddr, + /// Per-listener TCP port. If omitted, falls back to legacy `server.port`. + #[serde(default)] + pub port: Option, /// IP address or hostname to announce in proxy links. /// Takes precedence over `announce_ip` if both are set. #[serde(default)] diff --git a/src/conntrack_control.rs b/src/conntrack_control.rs index 306697e..12069c3 100644 --- a/src/conntrack_control.rs +++ b/src/conntrack_control.rs @@ -343,15 +343,28 @@ fn command_exists(binary: &str) -> bool { }) } -fn notrack_targets(cfg: &ProxyConfig) -> (Vec>, Vec>) { +fn listener_port_set(cfg: &ProxyConfig) -> Vec { + let mut ports: BTreeSet = BTreeSet::new(); + if cfg.server.listeners.is_empty() { + ports.insert(cfg.server.port); + } else { + for listener in &cfg.server.listeners { + ports.insert(listener.port.unwrap_or(cfg.server.port)); + } + } + ports.into_iter().collect() +} + +fn notrack_targets(cfg: &ProxyConfig) -> (Vec<(Option, u16)>, Vec<(Option, u16)>) { let mode = cfg.server.conntrack_control.mode; - let mut v4_targets: BTreeSet> = BTreeSet::new(); - let mut v6_targets: BTreeSet> = BTreeSet::new(); + let mut v4_targets: BTreeSet<(Option, u16)> = BTreeSet::new(); + let mut v6_targets: BTreeSet<(Option, u16)> = BTreeSet::new(); match mode { ConntrackMode::Tracked => {} ConntrackMode::Notrack => { if cfg.server.listeners.is_empty() { + let port = cfg.server.port; if let Some(ipv4) = cfg .server .listen_addr_ipv4 @@ -359,9 +372,9 @@ fn notrack_targets(cfg: &ProxyConfig) -> (Vec>, Vec().ok()) { if ipv4.is_unspecified() { - v4_targets.insert(None); + v4_targets.insert((None, port)); } else { - v4_targets.insert(Some(ipv4)); + v4_targets.insert((Some(ipv4), port)); } } if let Some(ipv6) = cfg @@ -371,33 +384,39 @@ fn notrack_targets(cfg: &ProxyConfig) -> (Vec>, Vec().ok()) { if ipv6.is_unspecified() { - v6_targets.insert(None); + v6_targets.insert((None, port)); } else { - v6_targets.insert(Some(ipv6)); + v6_targets.insert((Some(ipv6), port)); } } } else { for listener in &cfg.server.listeners { + let port = listener.port.unwrap_or(cfg.server.port); if listener.ip.is_ipv4() { if listener.ip.is_unspecified() { - v4_targets.insert(None); + v4_targets.insert((None, port)); } else { - v4_targets.insert(Some(listener.ip)); + v4_targets.insert((Some(listener.ip), port)); } } else if listener.ip.is_unspecified() { - v6_targets.insert(None); + v6_targets.insert((None, port)); } else { - v6_targets.insert(Some(listener.ip)); + v6_targets.insert((Some(listener.ip), port)); } } } } ConntrackMode::Hybrid => { + let ports = listener_port_set(cfg); for ip in &cfg.server.conntrack_control.hybrid_listener_ips { if ip.is_ipv4() { - v4_targets.insert(Some(*ip)); + for port in &ports { + v4_targets.insert((Some(*ip), *port)); + } } else { - v6_targets.insert(Some(*ip)); + for port in &ports { + v6_targets.insert((Some(*ip), *port)); + } } } } @@ -422,19 +441,19 @@ async fn apply_nft_rules(cfg: &ProxyConfig) -> Result<(), String> { let (v4_targets, v6_targets) = notrack_targets(cfg); let mut rules = Vec::new(); - for ip in v4_targets { + for (ip, port) in v4_targets { let rule = if let Some(ip) = ip { - format!("tcp dport {} ip daddr {} notrack", cfg.server.port, ip) + format!("tcp dport {} ip daddr {} notrack", port, ip) } else { - format!("tcp dport {} notrack", cfg.server.port) + format!("tcp dport {} notrack", port) }; rules.push(rule); } - for ip in v6_targets { + for (ip, port) in v6_targets { let rule = if let Some(ip) = ip { - format!("tcp dport {} ip6 daddr {} notrack", cfg.server.port, ip) + format!("tcp dport {} ip6 daddr {} notrack", port, ip) } else { - format!("tcp dport {} notrack", cfg.server.port) + format!("tcp dport {} notrack", port) }; rules.push(rule); } @@ -498,7 +517,7 @@ async fn apply_iptables_rules_for_binary( let (v4_targets, v6_targets) = notrack_targets(cfg); let selected = if ipv4 { v4_targets } else { v6_targets }; - for ip in selected { + for (ip, port) in selected { let mut args = vec![ "-t".to_string(), "raw".to_string(), @@ -507,7 +526,7 @@ async fn apply_iptables_rules_for_binary( "-p".to_string(), "tcp".to_string(), "--dport".to_string(), - cfg.server.port.to_string(), + port.to_string(), ]; if let Some(ip) = ip { args.push("-d".to_string()); diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index f032d77..034fbef 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -31,6 +31,19 @@ pub(crate) struct BoundListeners { pub(crate) has_unix_listener: bool, } +fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, config: &ProxyConfig) -> u16 { + listener.port.unwrap_or(config.server.port) +} + +fn default_link_port(config: &ProxyConfig) -> u16 { + config + .server + .listeners + .first() + .and_then(|listener| listener.port) + .unwrap_or(config.server.port) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn bind_listeners( config: &Arc, @@ -63,7 +76,8 @@ pub(crate) async fn bind_listeners( let mut listeners = Vec::new(); for listener_conf in &config.server.listeners { - let addr = SocketAddr::new(listener_conf.ip, config.server.port); + let listener_port = listener_port_or_legacy(listener_conf, config); + let addr = SocketAddr::new(listener_conf.ip, listener_port); if addr.is_ipv4() && !decision_ipv4_dc { warn!(%addr, "Skipping IPv4 listener: IPv4 disabled by [network]"); continue; @@ -110,7 +124,7 @@ pub(crate) async fn bind_listeners( .general .links .public_port - .unwrap_or(config.server.port); + .unwrap_or(listener_port); print_proxy_links(&public_host, link_port, config); } @@ -158,7 +172,7 @@ pub(crate) async fn bind_listeners( .general .links .public_port - .unwrap_or(config.server.port), + .unwrap_or(default_link_port(config)), ) } else { let ip = detected_ip_v4.or(detected_ip_v6).map(|ip| ip.to_string()); @@ -173,7 +187,7 @@ pub(crate) async fn bind_listeners( .general .links .public_port - .unwrap_or(config.server.port), + .unwrap_or(default_link_port(config)), ) }; diff --git a/src/proxy/tests/client_masking_blackhat_campaign_tests.rs b/src/proxy/tests/client_masking_blackhat_campaign_tests.rs index 962387a..c48caa0 100644 --- a/src/proxy/tests/client_masking_blackhat_campaign_tests.rs +++ b/src/proxy/tests/client_masking_blackhat_campaign_tests.rs @@ -37,6 +37,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_budget_security_tests.rs b/src/proxy/tests/client_masking_budget_security_tests.rs index d356869..11a72a0 100644 --- a/src/proxy/tests/client_masking_budget_security_tests.rs +++ b/src/proxy/tests/client_masking_budget_security_tests.rs @@ -33,6 +33,8 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_diagnostics_security_tests.rs b/src/proxy/tests/client_masking_diagnostics_security_tests.rs index a2f44ce..a55bb79 100644 --- a/src/proxy/tests/client_masking_diagnostics_security_tests.rs +++ b/src/proxy/tests/client_masking_diagnostics_security_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs b/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs index ae04c6a..5817f24 100644 --- a/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs +++ b/src/proxy/tests/client_masking_fragmented_classifier_security_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_hard_adversarial_tests.rs b/src/proxy/tests/client_masking_hard_adversarial_tests.rs index 7e0c683..709ff49 100644 --- a/src/proxy/tests/client_masking_hard_adversarial_tests.rs +++ b/src/proxy/tests/client_masking_hard_adversarial_tests.rs @@ -31,6 +31,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs b/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs index 8aa6fb2..49c9aa6 100644 --- a/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs +++ b/src/proxy/tests/client_masking_http2_fragmented_preface_security_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs b/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs index b992402..6ebaa5a 100644 --- a/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs +++ b/src/proxy/tests/client_masking_prefetch_config_pipeline_integration_security_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs b/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs index e9af94c..9491e3f 100644 --- a/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs +++ b/src/proxy/tests/client_masking_prefetch_invariant_security_tests.rs @@ -44,6 +44,8 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs b/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs index 282465a..62a2ef8 100644 --- a/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs +++ b/src/proxy/tests/client_masking_probe_evasion_blackhat_tests.rs @@ -22,6 +22,8 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs index b4a79fe..2781102 100644 --- a/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs @@ -45,6 +45,8 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -236,6 +238,8 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -478,6 +482,8 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -553,6 +559,8 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_replay_timing_security_tests.rs b/src/proxy/tests/client_masking_replay_timing_security_tests.rs index 8c829be..788ce80 100644 --- a/src/proxy/tests/client_masking_replay_timing_security_tests.rs +++ b/src/proxy/tests/client_masking_replay_timing_security_tests.rs @@ -19,6 +19,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs index 14837bf..ed1ac8d 100644 --- a/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_shape_classifier_fuzz_redteam_expected_fail_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs b/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs index 014180d..45ce014 100644 --- a/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs +++ b/src/proxy/tests/client_masking_shape_hardening_adversarial_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs index 49378f6..f160b01 100644 --- a/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_shape_hardening_redteam_expected_fail_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_shape_hardening_security_tests.rs b/src/proxy/tests/client_masking_shape_hardening_security_tests.rs index 6a64c6e..9948e60 100644 --- a/src/proxy/tests/client_masking_shape_hardening_security_tests.rs +++ b/src/proxy/tests/client_masking_shape_hardening_security_tests.rs @@ -17,6 +17,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_masking_stress_adversarial_tests.rs b/src/proxy/tests/client_masking_stress_adversarial_tests.rs index 9ccd033..575bfb5 100644 --- a/src/proxy/tests/client_masking_stress_adversarial_tests.rs +++ b/src/proxy/tests/client_masking_stress_adversarial_tests.rs @@ -31,6 +31,8 @@ fn new_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_security_tests.rs b/src/proxy/tests/client_security_tests.rs index 40d99ec..c5b971b 100644 --- a/src/proxy/tests/client_security_tests.rs +++ b/src/proxy/tests/client_security_tests.rs @@ -338,6 +338,8 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -453,6 +455,8 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -578,6 +582,8 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -749,6 +755,8 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -827,6 +835,8 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load( enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -988,6 +998,8 @@ async fn short_tls_probe_is_masked_through_client_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1077,6 +1089,8 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1164,6 +1178,8 @@ async fn handle_client_stream_increments_connects_all_exactly_once() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1258,6 +1274,8 @@ async fn running_client_handler_increments_connects_all_exactly_once() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1349,6 +1367,8 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1421,6 +1441,8 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1508,6 +1530,8 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1834,6 +1858,8 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1944,6 +1970,8 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -2052,6 +2080,8 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -2175,6 +2205,8 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -2269,6 +2301,8 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -2369,6 +2403,8 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -3275,6 +3311,8 @@ async fn relay_connect_error_releases_user_and_ip_before_return() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -3837,6 +3875,8 @@ async fn untrusted_proxy_header_source_is_rejected() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -3908,6 +3948,8 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -4006,6 +4048,8 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -4110,6 +4154,8 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -4228,6 +4274,8 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -4332,6 +4380,8 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -4439,6 +4489,8 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -4541,6 +4593,8 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_timing_profile_adversarial_tests.rs b/src/proxy/tests/client_timing_profile_adversarial_tests.rs index 54ccbe2..bc452a8 100644 --- a/src/proxy/tests/client_timing_profile_adversarial_tests.rs +++ b/src/proxy/tests/client_timing_profile_adversarial_tests.rs @@ -30,6 +30,8 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_tls_clienthello_size_security_tests.rs b/src/proxy/tests/client_tls_clienthello_size_security_tests.rs index 442412d..a779c92 100644 --- a/src/proxy/tests/client_tls_clienthello_size_security_tests.rs +++ b/src/proxy/tests/client_tls_clienthello_size_security_tests.rs @@ -32,6 +32,8 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs b/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs index 1243b83..aa0b925 100644 --- a/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs +++ b/src/proxy/tests/client_tls_clienthello_truncation_adversarial_tests.rs @@ -33,6 +33,8 @@ fn make_test_upstream_manager(stats: Arc) -> Arc { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs b/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs index 74ab347..edea451 100644 --- a/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs +++ b/src/proxy/tests/client_tls_mtproto_fallback_security_tests.rs @@ -47,6 +47,8 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/direct_relay_security_tests.rs b/src/proxy/tests/direct_relay_security_tests.rs index 5972204..193ff7b 100644 --- a/src/proxy/tests/direct_relay_security_tests.rs +++ b/src/proxy/tests/direct_relay_security_tests.rs @@ -1299,6 +1299,8 @@ async fn direct_relay_abort_midflight_releases_route_gauge() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1407,6 +1409,8 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1530,6 +1534,8 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, @@ -1764,6 +1770,8 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() { bindtodevice: None, }, selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 100, @@ -1856,6 +1864,8 @@ async fn adversarial_direct_relay_cutover_integrity() { bindtodevice: None, }, selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 100, diff --git a/src/proxy/tests/proxy_shared_state_isolation_tests.rs b/src/proxy/tests/proxy_shared_state_isolation_tests.rs index b174ee3..faa045f 100644 --- a/src/proxy/tests/proxy_shared_state_isolation_tests.rs +++ b/src/proxy/tests/proxy_shared_state_isolation_tests.rs @@ -59,6 +59,8 @@ fn new_client_harness() -> ClientHarness { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/transport/upstream.rs b/src/transport/upstream.rs index 2486b13..9aaca76 100644 --- a/src/transport/upstream.rs +++ b/src/transport/upstream.rs @@ -455,6 +455,87 @@ impl UpstreamManager { } } + fn resolve_probe_dc_families( + upstream: &UpstreamConfig, + ipv4_available: bool, + ipv6_available: bool, + ) -> (bool, bool) { + ( + upstream.ipv4.unwrap_or(ipv4_available), + upstream.ipv6.unwrap_or(ipv6_available), + ) + } + + fn resolve_runtime_dc_families( + upstream: &UpstreamConfig, + dc_preference: IpPreference, + ) -> (bool, bool) { + let (auto_ipv4, auto_ipv6) = match dc_preference { + IpPreference::PreferV4 => (true, false), + IpPreference::PreferV6 => (false, true), + IpPreference::BothWork | IpPreference::Unknown | IpPreference::Unavailable => { + (true, true) + } + }; + + ( + upstream.ipv4.unwrap_or(auto_ipv4), + upstream.ipv6.unwrap_or(auto_ipv6), + ) + } + + fn dc_table_addr(dc_idx: i16, ipv6: bool, port: u16) -> Option { + let arr_idx = UpstreamState::dc_array_idx(dc_idx)?; + let ip = if ipv6 { + TG_DATACENTERS_V6[arr_idx] + } else { + TG_DATACENTERS_V4[arr_idx] + }; + Some(SocketAddr::new(ip, port)) + } + + fn resolve_runtime_dc_target( + target: SocketAddr, + dc_idx: Option, + upstream: &UpstreamConfig, + dc_preference: IpPreference, + ) -> Result { + let (allow_ipv4, allow_ipv6) = Self::resolve_runtime_dc_families(upstream, dc_preference); + if (target.is_ipv4() && allow_ipv4) || (target.is_ipv6() && allow_ipv6) { + return Ok(target); + } + + if !allow_ipv4 && !allow_ipv6 { + return Err(ProxyError::Config(format!( + "Upstream DC family policy blocks all families for target {target}" + ))); + } + + let Some(dc_idx) = dc_idx else { + return Err(ProxyError::Config(format!( + "Upstream DC family policy cannot remap target {target} without dc_idx" + ))); + }; + + let remapped = if target.is_ipv4() { + if allow_ipv6 { + Self::dc_table_addr(dc_idx, true, target.port()) + } else { + None + } + } else if allow_ipv4 { + Self::dc_table_addr(dc_idx, false, target.port()) + } else { + None + }; + + remapped.ok_or_else(|| { + ProxyError::Config(format!( + "Upstream DC family policy rejected target {target} (dc_idx={dc_idx})" + )) + }) + } + #[cfg(unix)] fn resolve_interface_addrs(name: &str, want_ipv6: bool) -> Vec { use nix::ifaddrs::getifaddrs; @@ -728,18 +809,24 @@ impl UpstreamManager { .await .ok_or_else(|| ProxyError::Config("No upstreams available".to_string()))?; - let mut upstream = { + let (mut upstream, bind_rr, dc_preference) = { let guard = self.upstreams.read().await; - guard[idx].config.clone() + let state = &guard[idx]; + let dc_preference = dc_idx + .and_then(UpstreamState::dc_array_idx) + .map(|dc_array_idx| state.dc_ip_pref[dc_array_idx]) + .unwrap_or(IpPreference::Unknown); + (state.config.clone(), Some(state.bind_rr.clone()), dc_preference) }; if let Some(s) = scope { upstream.selected_scope = s.to_string(); } - let bind_rr = { - let guard = self.upstreams.read().await; - guard.get(idx).map(|u| u.bind_rr.clone()) + let target = if dc_idx.is_some() { + Self::resolve_runtime_dc_target(target, dc_idx, &upstream, dc_preference)? + } else { + target }; let (stream, _) = self @@ -760,9 +847,14 @@ impl UpstreamManager { .await .ok_or_else(|| ProxyError::Config("No upstreams available".to_string()))?; - let mut upstream = { + let (mut upstream, bind_rr, dc_preference) = { let guard = self.upstreams.read().await; - guard[idx].config.clone() + let state = &guard[idx]; + let dc_preference = dc_idx + .and_then(UpstreamState::dc_array_idx) + .map(|dc_array_idx| state.dc_ip_pref[dc_array_idx]) + .unwrap_or(IpPreference::Unknown); + (state.config.clone(), Some(state.bind_rr.clone()), dc_preference) }; // Set scope for configuration copy @@ -770,9 +862,10 @@ impl UpstreamManager { upstream.selected_scope = s.to_string(); } - let bind_rr = { - let guard = self.upstreams.read().await; - guard.get(idx).map(|u| u.bind_rr.clone()) + let target = if dc_idx.is_some() { + Self::resolve_runtime_dc_target(target, dc_idx, &upstream, dc_preference)? + } else { + target }; let (stream, egress) = self @@ -1212,6 +1305,8 @@ impl UpstreamManager { let mut all_results = Vec::new(); for (upstream_idx, upstream_config, bind_rr) in &upstreams { + let (upstream_ipv4_enabled, upstream_ipv6_enabled) = + Self::resolve_probe_dc_families(upstream_config, ipv4_enabled, ipv6_enabled); let upstream_name = match &upstream_config.upstream_type { UpstreamType::Direct { interface, @@ -1244,7 +1339,7 @@ impl UpstreamManager { }; let mut v6_results = Vec::with_capacity(NUM_DCS); - if ipv6_enabled { + if upstream_ipv6_enabled { for dc_zero_idx in 0..NUM_DCS { let dc_v6 = TG_DATACENTERS_V6[dc_zero_idx]; let addr_v6 = SocketAddr::new(dc_v6, TG_DATACENTER_PORT); @@ -1295,13 +1390,17 @@ impl UpstreamManager { dc_idx: dc_zero_idx + 1, dc_addr: SocketAddr::new(dc_v6, TG_DATACENTER_PORT), rtt_ms: None, - error: Some("ipv6 disabled".to_string()), + error: Some(if ipv6_enabled { + "ipv6 disabled by upstream policy".to_string() + } else { + "ipv6 disabled".to_string() + }), }); } } let mut v4_results = Vec::with_capacity(NUM_DCS); - if ipv4_enabled { + if upstream_ipv4_enabled { for dc_zero_idx in 0..NUM_DCS { let dc_v4 = TG_DATACENTERS_V4[dc_zero_idx]; let addr_v4 = SocketAddr::new(dc_v4, TG_DATACENTER_PORT); @@ -1352,7 +1451,11 @@ impl UpstreamManager { dc_idx: dc_zero_idx + 1, dc_addr: SocketAddr::new(dc_v4, TG_DATACENTER_PORT), rtt_ms: None, - error: Some("ipv4 disabled".to_string()), + error: Some(if ipv4_enabled { + "ipv4 disabled by upstream policy".to_string() + } else { + "ipv4 disabled".to_string() + }), }); } } @@ -1372,7 +1475,9 @@ impl UpstreamManager { match addr_str.parse::() { Ok(addr) => { let is_v6 = addr.is_ipv6(); - if (is_v6 && !ipv6_enabled) || (!is_v6 && !ipv4_enabled) { + if (is_v6 && !upstream_ipv6_enabled) + || (!is_v6 && !upstream_ipv4_enabled) + { continue; } let result = tokio::time::timeout( @@ -1614,6 +1719,8 @@ impl UpstreamManager { let u = &guard[i]; (u.config.clone(), u.bind_rr.clone()) }; + let (upstream_ipv4_enabled, upstream_ipv6_enabled) = + Self::resolve_probe_dc_families(&config, ipv4_enabled, ipv6_enabled); let mut healthy_groups = 0usize; let mut latency_updates: Vec<(usize, f64)> = Vec::new(); @@ -1629,14 +1736,31 @@ impl UpstreamManager { continue; } + let filtered_endpoints: Vec = endpoints + .iter() + .copied() + .filter(|endpoint| { + if endpoint.is_ipv4() { + upstream_ipv4_enabled + } else { + upstream_ipv6_enabled + } + }) + .collect(); + + if filtered_endpoints.is_empty() { + continue; + } + let rotation_key = (i, group.dc_idx, is_primary); let start_idx = - *endpoint_rotation.entry(rotation_key).or_insert(0) % endpoints.len(); - let mut next_idx = (start_idx + 1) % endpoints.len(); + *endpoint_rotation.entry(rotation_key).or_insert(0) + % filtered_endpoints.len(); + let mut next_idx = (start_idx + 1) % filtered_endpoints.len(); - for step in 0..endpoints.len() { - let endpoint_idx = (start_idx + step) % endpoints.len(); - let endpoint = endpoints[endpoint_idx]; + for step in 0..filtered_endpoints.len() { + let endpoint_idx = (start_idx + step) % filtered_endpoints.len(); + let endpoint = filtered_endpoints[endpoint_idx]; let start = Instant::now(); let result = tokio::time::timeout( @@ -1655,7 +1779,7 @@ impl UpstreamManager { Ok(Ok(_stream)) => { group_ok = true; group_rtt_ms = Some(start.elapsed().as_secs_f64() * 1000.0); - next_idx = (endpoint_idx + 1) % endpoints.len(); + next_idx = (endpoint_idx + 1) % filtered_endpoints.len(); break; } Ok(Err(e)) => { @@ -1910,6 +2034,8 @@ mod tests { enabled: true, scopes: String::new(), selected_scope: String::new(), + ipv4: None, + ipv6: None, }], 1, 100, From 696316f919c3d795bafbc883f12e18abe3f77176 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:39:47 +0300 Subject: [PATCH 14/15] Rustfmt --- src/api/mod.rs | 27 ++++++++++++------- src/config/load.rs | 8 ++---- src/maestro/listeners.rs | 6 +---- src/maestro/mod.rs | 10 +++---- ...ent_masking_redteam_expected_fail_tests.rs | 12 ++++----- src/proxy/tests/client_security_tests.rs | 8 +++--- src/transport/upstream.rs | 21 ++++++++++----- 7 files changed, 49 insertions(+), 43 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 5f4861e..850fb0e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -224,13 +224,11 @@ async fn handle( "Source IP is not allowed", ), )), - ApiGrayAction::Ok200 => Ok( - Response::builder() - .status(StatusCode::OK) - .header("content-type", "text/html; charset=utf-8") - .body(Full::new(Bytes::new())) - .unwrap(), - ), + ApiGrayAction::Ok200 => Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::new())) + .unwrap()), ApiGrayAction::Drop => Err(IoError::new( ErrorKind::ConnectionAborted, "api request dropped by gray_action=drop", @@ -259,11 +257,16 @@ async fn handle( let method = req.method().clone(); let path = req.uri().path().to_string(); + let normalized_path = if path.len() > 1 { + path.trim_end_matches('/') + } else { + path.as_str() + }; let query = req.uri().query().map(str::to_string); let body_limit = api_cfg.request_body_limit_bytes; let result: Result>, ApiFailure> = async { - match (method.as_str(), path.as_str()) { + match (method.as_str(), normalized_path) { ("GET", "/v1/health") => { let revision = current_revision(&shared.config_path).await?; let data = HealthData { @@ -446,7 +449,7 @@ async fn handle( Ok(success_response(status, data, revision)) } _ => { - if let Some(user) = path.strip_prefix("/v1/users/") + if let Some(user) = normalized_path.strip_prefix("/v1/users/") && !user.is_empty() && !user.contains('/') { @@ -615,6 +618,12 @@ async fn handle( ), )); } + debug!( + method = method.as_str(), + path = %path, + normalized_path = %normalized_path, + "API route not found" + ); Ok(error_response( request_id, ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "Route not found"), diff --git a/src/config/load.rs b/src/config/load.rs index abe4072..e5c8202 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -384,9 +384,7 @@ impl ProxyConfig { // Backward compatibility: legacy top-level beobachten* keys. // Prefer `[general].*` when both are present. let mut legacy_beobachten_applied = false; - if !beobachten_is_explicit - && let Some(value) = legacy_top_level_beobachten.as_ref() - { + if !beobachten_is_explicit && let Some(value) = legacy_top_level_beobachten.as_ref() { let parsed = value.as_bool().ok_or_else(|| { ProxyError::Config("beobachten (top-level) must be a boolean".to_string()) })?; @@ -433,9 +431,7 @@ impl ProxyConfig { legacy_beobachten_applied = true; } if legacy_beobachten_applied { - warn!( - "top-level beobachten* keys are deprecated; use general.beobachten* instead" - ); + warn!("top-level beobachten* keys are deprecated; use general.beobachten* instead"); } let legacy_nat_stun = config.general.middle_proxy_nat_stun.take(); diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index 034fbef..796eb9e 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -120,11 +120,7 @@ pub(crate) async fn bind_listeners( if config.general.links.public_host.is_none() && !config.general.links.show.is_empty() { - let link_port = config - .general - .links - .public_port - .unwrap_or(listener_port); + let link_port = config.general.links.public_port.unwrap_or(listener_port); print_proxy_links(&public_host, link_port, config); } diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 5f3cb69..f141331 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -83,7 +83,9 @@ pub async fn run() -> std::result::Result<(), Box> { // Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound // (for privilege drop); it is a no-op on other platforms. -async fn run_telemt_core(drop_after_bind: impl FnOnce()) -> std::result::Result<(), Box> { +async fn run_telemt_core( + drop_after_bind: impl FnOnce(), +) -> std::result::Result<(), Box> { let process_started_at = Instant::now(); let process_started_at_epoch_secs = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -819,11 +821,7 @@ async fn run_inner( run_telemt_core(|| { if user.is_some() || group.is_some() { - if let Err(e) = drop_privileges( - user.as_deref(), - group.as_deref(), - _pid_file.as_ref(), - ) { + if let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) { error!(error = %e, "Failed to drop privileges"); std::process::exit(1); } diff --git a/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs b/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs index 2781102..09ec626 100644 --- a/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs +++ b/src/proxy/tests/client_masking_redteam_expected_fail_tests.rs @@ -238,8 +238,8 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() { enabled: true, scopes: String::new(), selected_scope: String::new(), - ipv4: None, - ipv6: None, + ipv4: None, + ipv6: None, }], 1, 1, @@ -482,8 +482,8 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen enabled: true, scopes: String::new(), selected_scope: String::new(), - ipv4: None, - ipv6: None, + ipv4: None, + ipv6: None, }], 1, 1, @@ -559,8 +559,8 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize { enabled: true, scopes: String::new(), selected_scope: String::new(), - ipv4: None, - ipv6: None, + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/proxy/tests/client_security_tests.rs b/src/proxy/tests/client_security_tests.rs index c5b971b..85af766 100644 --- a/src/proxy/tests/client_security_tests.rs +++ b/src/proxy/tests/client_security_tests.rs @@ -835,8 +835,8 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load( enabled: true, scopes: String::new(), selected_scope: String::new(), - ipv4: None, - ipv6: None, + ipv4: None, + ipv6: None, }], 1, 1, @@ -2403,8 +2403,8 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() { enabled: true, scopes: String::new(), selected_scope: String::new(), - ipv4: None, - ipv6: None, + ipv4: None, + ipv6: None, }], 1, 1, diff --git a/src/transport/upstream.rs b/src/transport/upstream.rs index 9aaca76..6e4f196 100644 --- a/src/transport/upstream.rs +++ b/src/transport/upstream.rs @@ -816,7 +816,11 @@ impl UpstreamManager { .and_then(UpstreamState::dc_array_idx) .map(|dc_array_idx| state.dc_ip_pref[dc_array_idx]) .unwrap_or(IpPreference::Unknown); - (state.config.clone(), Some(state.bind_rr.clone()), dc_preference) + ( + state.config.clone(), + Some(state.bind_rr.clone()), + dc_preference, + ) }; if let Some(s) = scope { @@ -854,7 +858,11 @@ impl UpstreamManager { .and_then(UpstreamState::dc_array_idx) .map(|dc_array_idx| state.dc_ip_pref[dc_array_idx]) .unwrap_or(IpPreference::Unknown); - (state.config.clone(), Some(state.bind_rr.clone()), dc_preference) + ( + state.config.clone(), + Some(state.bind_rr.clone()), + dc_preference, + ) }; // Set scope for configuration copy @@ -1753,9 +1761,8 @@ impl UpstreamManager { } let rotation_key = (i, group.dc_idx, is_primary); - let start_idx = - *endpoint_rotation.entry(rotation_key).or_insert(0) - % filtered_endpoints.len(); + let start_idx = *endpoint_rotation.entry(rotation_key).or_insert(0) + % filtered_endpoints.len(); let mut next_idx = (start_idx + 1) % filtered_endpoints.len(); for step in 0..filtered_endpoints.len() { @@ -2034,8 +2041,8 @@ mod tests { enabled: true, scopes: String::new(), selected_scope: String::new(), - ipv4: None, - ipv6: None, + ipv4: None, + ipv6: None, }], 1, 100, From 902a4e83cfbac0cd4e8aa85697b01e8683239a46 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:56:49 +0300 Subject: [PATCH 15/15] Specific scopes for Connectivity by #699 and #700 --- src/transport/upstream.rs | 68 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/src/transport/upstream.rs b/src/transport/upstream.rs index 6e4f196..791fc00 100644 --- a/src/transport/upstream.rs +++ b/src/transport/upstream.rs @@ -329,6 +329,17 @@ pub struct UpstreamManager { } impl UpstreamManager { + fn is_unscoped_upstream(upstream: &UpstreamConfig) -> bool { + upstream.scopes.is_empty() + } + + fn should_check_in_default_dc_connectivity( + has_unscoped: bool, + upstream: &UpstreamConfig, + ) -> bool { + !has_unscoped || Self::is_unscoped_upstream(upstream) + } + pub fn new( configs: Vec, connect_retry_attempts: u32, @@ -1309,10 +1320,19 @@ impl UpstreamManager { .map(|(i, u)| (i, u.config.clone(), u.bind_rr.clone())) .collect() }; + let has_unscoped = upstreams + .iter() + .any(|(_, cfg, _)| Self::is_unscoped_upstream(cfg)); let mut all_results = Vec::new(); for (upstream_idx, upstream_config, bind_rr) in &upstreams { + // DC connectivity checks should follow the default routing path. + // Scoped upstreams are included only when no unscoped upstream exists. + if !Self::should_check_in_default_dc_connectivity(has_unscoped, upstream_config) { + continue; + } + let (upstream_ipv4_enabled, upstream_ipv6_enabled) = Self::resolve_probe_dc_families(upstream_config, ipv4_enabled, ipv6_enabled); let upstream_name = match &upstream_config.upstream_type { @@ -1720,8 +1740,25 @@ impl UpstreamManager { continue; } - let count = self.upstreams.read().await.len(); - for i in 0..count { + let target_upstreams: Vec = { + let guard = self.upstreams.read().await; + let has_unscoped = guard + .iter() + .any(|upstream| Self::is_unscoped_upstream(&upstream.config)); + guard + .iter() + .enumerate() + .filter(|(_, upstream)| { + Self::should_check_in_default_dc_connectivity( + has_unscoped, + &upstream.config, + ) + }) + .map(|(idx, _)| idx) + .collect() + }; + + for i in target_upstreams { let (config, bind_rr) = { let guard = self.upstreams.read().await; let u = &guard[i]; @@ -2001,6 +2038,33 @@ mod tests { assert!(!UpstreamManager::is_hard_connect_error(&error)); } + #[test] + fn unscoped_selection_detects_default_route_upstream() { + let mut upstream = UpstreamConfig { + upstream_type: UpstreamType::Direct { + interface: None, + bind_addresses: None, + bindtodevice: None, + }, + weight: 1, + enabled: true, + scopes: String::new(), + selected_scope: String::new(), + ipv4: None, + ipv6: None, + }; + + assert!(UpstreamManager::is_unscoped_upstream(&upstream)); + upstream.scopes = "local".to_string(); + assert!(!UpstreamManager::is_unscoped_upstream(&upstream)); + assert!(!UpstreamManager::should_check_in_default_dc_connectivity( + true, &upstream + )); + assert!(UpstreamManager::should_check_in_default_dc_connectivity( + false, &upstream + )); + } + #[test] fn resolve_bind_address_prefers_explicit_bind_ip() { let target = "203.0.113.10:443".parse::().unwrap();