diff --git a/benches/crypto_bench.rs b/benches/crypto_bench.rs index 940791c..182d713 100644 --- a/benches/crypto_bench.rs +++ b/benches/crypto_bench.rs @@ -1,12 +1,24 @@ -// Cryptobench -use criterion::{Criterion, black_box, criterion_group}; +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; + +#[allow(unused_imports)] +#[path = "../src/crypto/aes.rs"] +mod aes_impl; +#[allow(unused_imports)] +#[path = "../src/error.rs"] +mod error; + +use aes_impl::AesCtr; fn bench_aes_ctr(c: &mut Criterion) { c.bench_function("aes_ctr_encrypt_64kb", |b| { let data = vec![0u8; 65536]; b.iter(|| { let mut enc = AesCtr::new(&[0u8; 32], 0); - black_box(enc.encrypt(&data)) + black_box(enc.encrypt(black_box(data.as_slice()))) }) }); } + +criterion_group!(benches, bench_aes_ctr); +criterion_main!(benches); diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index d1fe111..189bdc2 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -1972,6 +1972,7 @@ This document lists all configuration keys accepted by `config.toml`. ## client_mss - **Constraints / validation**: `String`. Empty or omitted means do not change kernel MSS. Presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Custom decimal strings must be within `88..=4096`. - **Description**: Client-facing TCP MSS applied to TCP listener sockets before `listen(2)`, so Linux can announce it in SYN/ACK. This affects only proxy client TCP listeners, not API, metrics, Unix sockets, Telegram upstreams, ME sockets, or mask backend connections. Changes require listener restart/rebind. + - **Operator note**: The two-tier `synlimit` profile does not require Telemt to disable MSS automatically. Operators that follow external host-tuning recipes should decide explicitly whether to leave MSS shaping enabled for handshake fragmentation or disable it for higher media throughput. - **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`. - **Example**: @@ -2311,9 +2312,14 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche | [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` | | [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✔` | -| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` | -| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` | -| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` | +| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` | +| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` | +| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✔` | +| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✔` | +| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✔` | +| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✔` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2351,7 +2357,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ``` ## synlimit (server.listeners) - **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener. - - **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind. + - **Description**: Installs per-listener Linux netfilter two-tier SYN-fix rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit`, `length`, and TTL/hop-limit matches. `"nftables"` uses Telemt-owned tables with per-source `meter` rules and equivalent IPv4/IPv6 classifiers. Rules are inserted early in `INPUT`, accept under-limit SYN packets, and reject over-limit SYN packets with TCP RST so clients retry promptly instead of waiting for a silent DROP timeout. The generic bucket is controlled by `synlimit_seconds`, `synlimit_hitcount`, and `synlimit_burst`; the iOS-like TTL/length bucket is controlled by `synlimit_ios_*`. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind. + - **Operator note**: Telemt does not persist rules with `iptables-persistent`, write `/etc/sysctl.d`, edit systemd limits, or modify `client_mss`. Apply host-level tuning manually if your deployment policy requires it. - **Example**: ```toml @@ -2366,8 +2373,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche synlimit = "nftables" ``` ## synlimit_seconds (server.listeners) - - **Constraints / validation**: `u32`, must be `> 0`. Default is `1`. - - **Description**: Token-bucket interval for both SYN limiter backends. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). + - **Constraints / validation**: `u32`, must be `> 0`. Default is `60`. + - **Description**: Generic SYN-fix token-bucket interval. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). This bucket handles SYN packets that do not match the iOS-like TTL/length classifier. - **Example**: ```toml @@ -2375,11 +2382,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_seconds = 1 + synlimit_seconds = 60 ``` ## synlimit_hitcount (server.listeners) - - **Constraints / validation**: `u32`, must be `> 0`. Default is `1`. - - **Description**: Token-bucket rate amount for both SYN limiter backends. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets are dropped. + - **Constraints / validation**: `u32`, must be `> 0`. Default is `48`. + - **Description**: Generic SYN-fix token-bucket rate amount. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets receive TCP RST. - **Example**: ```toml @@ -2387,11 +2394,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_hitcount = 1 + synlimit_hitcount = 48 ``` ## synlimit_burst (server.listeners) - - **Constraints / validation**: `u32`, must be `> 0`. Default is `2`. - - **Description**: Token-bucket burst size for both SYN limiter backends. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced. + - **Constraints / validation**: `u32`, must be `> 0`. Default is `1`. + - **Description**: Generic SYN-fix token-bucket burst size. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced. - **Example**: ```toml @@ -2399,7 +2406,67 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_burst = 2 + synlimit_burst = 1 + ``` +## synlimit_ios_seconds (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `1`. + - **Description**: Token-bucket interval for SYN packets matching the iOS-like classifier. IPv4 matches packet length `64` and TTL `< 65`; IPv6 matches packet length `84` and hop limit `< 65`. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_ios_seconds = 1 + ``` +## synlimit_ios_hitcount (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `12`. + - **Description**: Token-bucket rate amount for the iOS-like SYN classifier. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_ios_hitcount = 12 + ``` +## synlimit_ios_burst (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `24`. + - **Description**: Token-bucket burst size for the iOS-like SYN classifier. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_ios_burst = 24 + ``` +## synlimit_hashlimit_expire_ms (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `60000`. + - **Description**: Entry expiration in milliseconds for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_hashlimit_expire_ms = 60000 + ``` +## synlimit_hashlimit_size (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `32768`. + - **Description**: Hash table size for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_hashlimit_size = 32768 ``` ## announce - **Constraints / validation**: `String` (optional). Must not be empty when set. diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index 755e964..4944528 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -1894,6 +1894,7 @@ ## client_mss - **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`. - **Описание**: MSS для входящих TCP-соединений клиентов. Значение применяется к TCP listener-сокетам до `listen(2)`, чтобы Linux мог объявить его в SYN/ACK. Параметр влияет только на proxy client TCP listeners и не применяется к API, metrics, Unix sockets, Telegram upstreams, ME sockets или mask backend connections. Изменение требует restart/rebind listener’ов. + - **Operator note**: Two-tier `synlimit` profile больше не требует автоматического отключения MSS внутри Telemt. Оператор должен сам решить, оставлять MSS shaping для handshake fragmentation или отключать его ради более высокой скорости media. - **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`. - **Пример**: @@ -2237,9 +2238,14 @@ | [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` | | [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✔` | -| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` | -| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` | -| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` | +| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` | +| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` | +| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✔` | +| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✔` | +| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✔` | +| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✔` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2277,7 +2283,8 @@ ``` ## synlimit (server.listeners) - **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен. - - **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind. + - **Описание**: Устанавливает per-listener Linux netfilter two-tier SYN-fix rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit`, `length` и TTL/hop-limit matches. `"nftables"` использует Telemt-owned tables с per-source `meter` rules и эквивалентными IPv4/IPv6 classifiers. Rules вставляются рано в `INPUT`, принимают under-limit SYN packets и отвечают TCP RST на over-limit SYN packets, чтобы клиент быстро переподключался вместо ожидания silent DROP timeout. Generic bucket управляется `synlimit_seconds`, `synlimit_hitcount` и `synlimit_burst`; iOS-like TTL/length bucket управляется `synlimit_ios_*`. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind. + - **Operator note**: Telemt не сохраняет rules через `iptables-persistent`, не пишет `/etc/sysctl.d`, не меняет systemd limits и не модифицирует `client_mss`. Host-level tuning применяется оператором вручную. - **Пример**: ```toml @@ -2292,8 +2299,8 @@ synlimit = "nftables" ``` ## synlimit_seconds (server.listeners) - - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`. - - **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`). + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60`. + - **Описание**: Generic SYN-fix token-bucket interval. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`). Этот bucket обрабатывает SYN packets, которые не совпали с iOS-like TTL/length classifier. - **Пример**: ```toml @@ -2301,11 +2308,11 @@ ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_seconds = 1 + synlimit_seconds = 60 ``` ## synlimit_hitcount (server.listeners) - - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`. - - **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться. + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `48`. + - **Описание**: Generic SYN-fix token-bucket rate amount. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets получат TCP RST. - **Пример**: ```toml @@ -2313,11 +2320,11 @@ ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_hitcount = 1 + synlimit_hitcount = 48 ``` ## synlimit_burst (server.listeners) - - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`. - - **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`. + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`. + - **Описание**: Generic SYN-fix token-bucket burst size. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`. - **Пример**: ```toml @@ -2325,7 +2332,67 @@ ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_burst = 2 + synlimit_burst = 1 + ``` +## synlimit_ios_seconds (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`. + - **Описание**: Token-bucket interval для SYN packets, совпавших с iOS-like classifier. IPv4 match: packet length `64` и TTL `< 65`; IPv6 match: packet length `84` и hop limit `< 65`. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_ios_seconds = 1 + ``` +## synlimit_ios_hitcount (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `12`. + - **Описание**: Token-bucket rate amount для iOS-like SYN classifier. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_ios_hitcount = 12 + ``` +## synlimit_ios_burst (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `24`. + - **Описание**: Token-bucket burst size для iOS-like SYN classifier. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_ios_burst = 24 + ``` +## synlimit_hashlimit_expire_ms (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60000`. + - **Описание**: Entry expiration в миллисекундах для iptables/ip6tables hashlimit buckets. nftables meters используют kernel-managed state и не имеют точного аналога этого knob. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_hashlimit_expire_ms = 60000 + ``` +## synlimit_hashlimit_size (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `32768`. + - **Описание**: Hash table size для iptables/ip6tables hashlimit buckets. nftables meters используют kernel-managed state и не имеют точного аналога этого knob. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_hashlimit_size = 32768 ``` ## announce - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. diff --git a/src/config/defaults.rs b/src/config/defaults.rs index b1af97c..fca0425 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -54,9 +54,14 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true; const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85; const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70; const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096; -const DEFAULT_SYNLIMIT_SECONDS: u32 = 1; -const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1; -const DEFAULT_SYNLIMIT_BURST: u32 = 2; +const DEFAULT_SYNLIMIT_SECONDS: u32 = 60; +const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 48; +const DEFAULT_SYNLIMIT_BURST: u32 = 1; +const DEFAULT_SYNLIMIT_IOS_SECONDS: u32 = 1; +const DEFAULT_SYNLIMIT_IOS_HITCOUNT: u32 = 12; +const DEFAULT_SYNLIMIT_IOS_BURST: u32 = 24; +const DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS: u32 = 60_000; +const DEFAULT_SYNLIMIT_HASHLIMIT_SIZE: u32 = 32_768; const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; @@ -258,6 +263,26 @@ pub(crate) fn default_synlimit_burst() -> u32 { DEFAULT_SYNLIMIT_BURST } +pub(crate) fn default_synlimit_ios_seconds() -> u32 { + DEFAULT_SYNLIMIT_IOS_SECONDS +} + +pub(crate) fn default_synlimit_ios_hitcount() -> u32 { + DEFAULT_SYNLIMIT_IOS_HITCOUNT +} + +pub(crate) fn default_synlimit_ios_burst() -> u32 { + DEFAULT_SYNLIMIT_IOS_BURST +} + +pub(crate) fn default_synlimit_hashlimit_expire_ms() -> u32 { + DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS +} + +pub(crate) fn default_synlimit_hashlimit_size() -> u32 { + DEFAULT_SYNLIMIT_HASHLIMIT_SIZE +} + pub(crate) fn default_prefer_4() -> u8 { 4 } diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index c911f8a..80f1b5c 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -145,6 +145,11 @@ pub struct ListenerSynLimitHotFields { pub synlimit_seconds: u32, pub synlimit_hitcount: u32, pub synlimit_burst: u32, + pub synlimit_ios_seconds: u32, + pub synlimit_ios_hitcount: u32, + pub synlimit_ios_burst: u32, + pub synlimit_hashlimit_expire_ms: u32, + pub synlimit_hashlimit_size: u32, } impl HotFields { @@ -293,6 +298,11 @@ impl ListenerSynLimitHotFields { synlimit_seconds: listener.synlimit_seconds, synlimit_hitcount: listener.synlimit_hitcount, synlimit_burst: listener.synlimit_burst, + synlimit_ios_seconds: listener.synlimit_ios_seconds, + synlimit_ios_hitcount: listener.synlimit_ios_hitcount, + synlimit_ios_burst: listener.synlimit_ios_burst, + synlimit_hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms, + synlimit_hashlimit_size: listener.synlimit_hashlimit_size, } } } @@ -620,6 +630,11 @@ fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerC old_listener.synlimit_seconds = new_listener.synlimit_seconds; old_listener.synlimit_hitcount = new_listener.synlimit_hitcount; old_listener.synlimit_burst = new_listener.synlimit_burst; + old_listener.synlimit_ios_seconds = new_listener.synlimit_ios_seconds; + old_listener.synlimit_ios_hitcount = new_listener.synlimit_ios_hitcount; + old_listener.synlimit_ios_burst = new_listener.synlimit_ios_burst; + old_listener.synlimit_hashlimit_expire_ms = new_listener.synlimit_hashlimit_expire_ms; + old_listener.synlimit_hashlimit_size = new_listener.synlimit_hashlimit_size; } } @@ -1710,6 +1725,51 @@ mod tests { assert!(!config_equal(&applied, &new)); } + #[test] + fn listener_synlimit_extended_fields_are_hot() { + let mut old = sample_config(); + old.server.listeners.push(ListenerConfig { + ip: "0.0.0.0".parse().unwrap(), + port: Some(443), + client_mss: None, + synlimit: SynLimitMode::Iptables, + synlimit_seconds: 60, + synlimit_hitcount: 48, + synlimit_burst: 1, + synlimit_ios_seconds: 1, + synlimit_ios_hitcount: 12, + synlimit_ios_burst: 24, + synlimit_hashlimit_expire_ms: 60_000, + synlimit_hashlimit_size: 32_768, + announce: None, + announce_ip: None, + proxy_protocol: None, + reuse_allow: false, + }); + let mut new = old.clone(); + new.server.port = 8443; + new.server.listeners[0].synlimit_seconds = 120; + new.server.listeners[0].synlimit_hitcount = 96; + new.server.listeners[0].synlimit_burst = 2; + new.server.listeners[0].synlimit_ios_seconds = 2; + new.server.listeners[0].synlimit_ios_hitcount = 18; + new.server.listeners[0].synlimit_ios_burst = 36; + new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000; + new.server.listeners[0].synlimit_hashlimit_size = 65_536; + + let applied = overlay_hot_fields(&old, &new); + let listener = &applied.server.listeners[0]; + assert_eq!(applied.server.port, old.server.port); + assert_eq!(listener.synlimit_seconds, 120); + assert_eq!(listener.synlimit_hitcount, 96); + assert_eq!(listener.synlimit_burst, 2); + assert_eq!(listener.synlimit_ios_seconds, 2); + assert_eq!(listener.synlimit_ios_hitcount, 18); + assert_eq!(listener.synlimit_ios_burst, 36); + assert_eq!(listener.synlimit_hashlimit_expire_ms, 90_000); + assert_eq!(listener.synlimit_hashlimit_size, 65_536); + } + #[test] fn reload_applies_hot_change_on_first_observed_snapshot() { let initial_tag = "11111111111111111111111111111111"; diff --git a/src/config/load.rs b/src/config/load.rs index 016acda..21932ec 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1,14 +1,12 @@ #![allow(deprecated)] use std::collections::{BTreeSet, HashMap, HashSet}; -use std::hash::{DefaultHasher, Hash, Hasher}; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; use rand::RngExt; use serde::{Deserialize, Serialize}; -use shadowsocks::config::ServerConfig as ShadowsocksServerConfig; use tracing::warn; use crate::error::{ProxyError, Result}; @@ -16,7 +14,30 @@ use crate::error::{ProxyError, Result}; use super::defaults::*; use super::types::*; -const ACCESS_SECRET_BYTES: usize = 16; +// Domain names, mask targets, and legacy scalar normalization helpers. +mod normalize; +// Include preprocessing and rendered config metadata helpers. +mod includes; +// Strict-config unknown key detection and suggestions. +mod strict_keys; +// Precomputed user authentication data for handshake hot paths. +mod runtime_auth; +// Post-deserialization validation helpers. +mod validation; + +use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes}; +use self::normalize::{ + is_valid_ad_tag, is_valid_tls_domain_name, normalize_domain_to_ascii, + normalize_exclusive_mask_target, normalize_mask_host_to_ascii, parse_exclusive_mask_target, + push_unique_nonempty, sanitize_ad_tag, +}; +pub(crate) use self::runtime_auth::UserAuthSnapshot; +use self::strict_keys::handle_unknown_config_keys; +use self::validation::{ + normalize_upstream_family_policy, validate_logging_config, validate_network_cfg, + validate_upstreams, +}; + const MAX_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 16_384; const MAX_ME_ROUTE_CHANNEL_CAPACITY: usize = 8_192; const MAX_ME_C2ME_CHANNEL_CAPACITY: usize = 8_192; @@ -24,779 +45,6 @@ const MIN_MAX_CLIENT_FRAME_BYTES: usize = 4 * 1024; const MAX_MAX_CLIENT_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAX_API_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024; -fn is_valid_tls_domain_name(domain: &str) -> bool { - !domain.is_empty() - && !domain - .chars() - .any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\')) -} - -fn normalize_domain_to_ascii(domain: &str, field: &str) -> Result { - let domain = domain.trim(); - if !is_valid_tls_domain_name(domain) { - return Err(ProxyError::Config(format!( - "Invalid {field}: '{}'. Must be a valid domain name", - domain - ))); - } - - let parsed = url::Url::parse(&format!("https://{domain}/")).map_err(|error| { - ProxyError::Config(format!( - "Invalid {field}: '{}'. IDNA conversion failed: {error}", - domain - )) - })?; - let host = parsed.host_str().ok_or_else(|| { - ProxyError::Config(format!("Invalid {field}: '{}'. Host is empty", domain)) - })?; - Ok(host.to_ascii_lowercase()) -} - -fn normalize_mask_host_to_ascii(host: &str, field: &str) -> Result { - let host = host.trim(); - if host.starts_with('[') && host.ends_with(']') { - let inner = &host[1..host.len() - 1]; - let ip = inner.parse::().map_err(|_| { - ProxyError::Config(format!( - "Invalid {field}: '{}'. IPv6 literal is invalid", - host - )) - })?; - return match ip { - std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")), - std::net::IpAddr::V4(v4) => Ok(v4.to_string()), - }; - } - if let Ok(ip) = host.parse::() { - return match ip { - std::net::IpAddr::V4(v4) => Ok(v4.to_string()), - std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")), - }; - } - - normalize_domain_to_ascii(host, field) -} - -fn parse_exclusive_mask_target(target: &str) -> Option<(&str, u16)> { - let target = target.trim(); - if target.is_empty() { - return None; - } - - if target.starts_with('[') { - let end = target.find(']')?; - if target.get(end + 1..end + 2)? != ":" { - return None; - } - let host = &target[..=end]; - let port = target[end + 2..].parse::().ok()?; - return (port > 0).then_some((host, port)); - } - - let (host, port) = target.rsplit_once(':')?; - if host.is_empty() || host.contains(':') { - return None; - } - let port = port.parse::().ok()?; - (port > 0).then_some((host, port)) -} - -fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result { - let (host, port) = parse_exclusive_mask_target(target).ok_or_else(|| { - ProxyError::Config(format!( - "Invalid {field}: '{}'. Expected host:port with port > 0", - target - )) - })?; - let host = normalize_mask_host_to_ascii(host, field)?; - Ok(format!("{host}:{port}")) -} - -const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ - "general", - "logging", - "network", - "server", - "timeouts", - "censorship", - "access", - "upstreams", - "show_link", - "dc_overrides", - "default_dc", - "beobachten", - "beobachten_minutes", - "beobachten_flush_secs", - "beobachten_file", - "include", -]; - -const GENERAL_CONFIG_KEYS: &[&str] = &[ - "data_path", - "quota_state_path", - "config_strict", - "modes", - "prefer_ipv6", - "fast_mode", - "use_middle_proxy", - "proxy_secret_path", - "proxy_secret_url", - "proxy_config_v4_cache_path", - "proxy_config_v4_url", - "proxy_config_v6_cache_path", - "proxy_config_v6_url", - "ad_tag", - "middle_proxy_nat_ip", - "middle_proxy_nat_probe", - "middle_proxy_nat_stun", - "middle_proxy_nat_stun_servers", - "stun_nat_probe_concurrency", - "middle_proxy_pool_size", - "middle_proxy_warm_standby", - "me_init_retry_attempts", - "me2dc_fallback", - "me2dc_fast", - "me_keepalive_enabled", - "me_keepalive_interval_secs", - "me_keepalive_jitter_secs", - "me_keepalive_payload_random", - "rpc_proxy_req_every", - "me_writer_cmd_channel_capacity", - "me_route_channel_capacity", - "me_c2me_channel_capacity", - "me_c2me_send_timeout_ms", - "me_reader_route_data_wait_ms", - "me_d2c_flush_batch_max_frames", - "me_d2c_flush_batch_max_bytes", - "me_d2c_flush_batch_max_delay_us", - "me_d2c_ack_flush_immediate", - "me_quota_soft_overshoot_bytes", - "me_d2c_frame_buf_shrink_threshold_bytes", - "direct_relay_copy_buf_c2s_bytes", - "direct_relay_copy_buf_s2c_bytes", - "crypto_pending_buffer", - "max_client_frame", - "desync_all_full", - "beobachten", - "beobachten_minutes", - "beobachten_flush_secs", - "beobachten_file", - "hardswap", - "me_warmup_stagger_enabled", - "me_warmup_step_delay_ms", - "me_warmup_step_jitter_ms", - "me_reconnect_max_concurrent_per_dc", - "me_reconnect_backoff_base_ms", - "me_reconnect_backoff_cap_ms", - "me_reconnect_fast_retry_count", - "me_single_endpoint_shadow_writers", - "me_single_endpoint_outage_mode_enabled", - "me_single_endpoint_outage_disable_quarantine", - "me_single_endpoint_outage_backoff_min_ms", - "me_single_endpoint_outage_backoff_max_ms", - "me_single_endpoint_shadow_rotate_every_secs", - "me_floor_mode", - "me_adaptive_floor_idle_secs", - "me_adaptive_floor_min_writers_single_endpoint", - "me_adaptive_floor_min_writers_multi_endpoint", - "me_adaptive_floor_recover_grace_secs", - "me_adaptive_floor_writers_per_core_total", - "me_adaptive_floor_cpu_cores_override", - "me_adaptive_floor_max_extra_writers_single_per_core", - "me_adaptive_floor_max_extra_writers_multi_per_core", - "me_adaptive_floor_max_active_writers_per_core", - "me_adaptive_floor_max_warm_writers_per_core", - "me_adaptive_floor_max_active_writers_global", - "me_adaptive_floor_max_warm_writers_global", - "upstream_connect_retry_attempts", - "upstream_connect_retry_backoff_ms", - "upstream_connect_budget_ms", - "tg_connect", - "upstream_unhealthy_fail_threshold", - "upstream_connect_failfast_hard_errors", - "stun_iface_mismatch_ignore", - "unknown_dc_log_path", - "unknown_dc_file_log_enabled", - "log_level", - "disable_colors", - "telemetry", - "me_socks_kdf_policy", - "me_route_backpressure_enabled", - "me_route_fairshare_enabled", - "me_route_backpressure_base_timeout_ms", - "me_route_backpressure_high_timeout_ms", - "me_route_backpressure_high_watermark_pct", - "me_health_interval_ms_unhealthy", - "me_health_interval_ms_healthy", - "me_admission_poll_ms", - "me_warn_rate_limit_ms", - "me_route_no_writer_mode", - "me_route_no_writer_wait_ms", - "me_route_hybrid_max_wait_ms", - "me_route_blocking_send_timeout_ms", - "me_route_inline_recovery_attempts", - "me_route_inline_recovery_wait_ms", - "links", - "fast_mode_min_tls_record", - "update_every", - "me_reinit_every_secs", - "me_hardswap_warmup_delay_min_ms", - "me_hardswap_warmup_delay_max_ms", - "me_hardswap_warmup_extra_passes", - "me_hardswap_warmup_pass_backoff_base_ms", - "me_config_stable_snapshots", - "me_config_apply_cooldown_secs", - "me_snapshot_require_http_2xx", - "me_snapshot_reject_empty_map", - "me_snapshot_min_proxy_for_lines", - "proxy_secret_stable_snapshots", - "proxy_secret_rotate_runtime", - "me_secret_atomic_snapshot", - "proxy_secret_len_max", - "me_pool_drain_ttl_secs", - "me_instadrain", - "me_pool_drain_threshold", - "me_pool_drain_soft_evict_enabled", - "me_pool_drain_soft_evict_grace_secs", - "me_pool_drain_soft_evict_per_writer", - "me_pool_drain_soft_evict_budget_per_core", - "me_pool_drain_soft_evict_cooldown_ms", - "me_bind_stale_mode", - "me_bind_stale_ttl_secs", - "me_pool_min_fresh_ratio", - "me_reinit_drain_timeout_secs", - "proxy_secret_auto_reload_secs", - "proxy_config_auto_reload_secs", - "me_reinit_singleflight", - "me_reinit_trigger_channel", - "me_reinit_coalesce_window_ms", - "me_deterministic_writer_sort", - "me_writer_pick_mode", - "me_writer_pick_sample_size", - "ntp_check", - "ntp_servers", - "auto_degradation_enabled", - "degradation_min_unavailable_dc_groups", - "rst_on_close", -]; - -const NETWORK_CONFIG_KEYS: &[&str] = &[ - "ipv4", - "ipv6", - "prefer", - "multipath", - "stun_use", - "stun_servers", - "stun_tcp_fallback", - "http_ip_detect_urls", - "cache_public_ip_path", - "dns_overrides", -]; - -const SERVER_CONFIG_KEYS: &[&str] = &[ - "port", - "listen_addr_ipv4", - "listen_addr_ipv6", - "listen_unix_sock", - "listen_unix_sock_perm", - "listen_tcp", - "client_mss", - "client_mss_bulk", - "proxy_protocol", - "proxy_protocol_header_timeout_ms", - "proxy_protocol_trusted_cidrs", - "metrics_port", - "metrics_listen", - "metrics_whitelist", - "api", - "admin_api", - "listeners", - "listen_backlog", - "max_connections", - "accept_permit_timeout_ms", - "conntrack_control", -]; - -const API_CONFIG_KEYS: &[&str] = &[ - "enabled", - "listen", - "whitelist", - "gray_action", - "auth_header", - "request_body_limit_bytes", - "minimal_runtime_enabled", - "minimal_runtime_cache_ttl_ms", - "runtime_edge_enabled", - "runtime_edge_cache_ttl_ms", - "runtime_edge_top_n", - "runtime_edge_events_capacity", - "read_only", -]; - -const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[ - "inline_conntrack_control", - "mode", - "backend", - "profile", - "hybrid_listener_ips", - "pressure_high_watermark_pct", - "pressure_low_watermark_pct", - "delete_budget_per_sec", -]; - -const LISTENER_CONFIG_KEYS: &[&str] = &[ - "ip", - "port", - "client_mss", - "synlimit", - "synlimit_seconds", - "synlimit_hitcount", - "synlimit_burst", - "announce", - "announce_ip", - "proxy_protocol", - "reuse_allow", -]; - -const TIMEOUTS_CONFIG_KEYS: &[&str] = &[ - "client_first_byte_idle_secs", - "client_handshake", - "relay_idle_policy_v2_enabled", - "relay_client_idle_soft_secs", - "relay_client_idle_hard_secs", - "relay_idle_grace_after_downstream_activity_secs", - "client_keepalive", - "client_ack", - "me_one_retry", - "me_one_timeout_ms", -]; - -const CENSORSHIP_CONFIG_KEYS: &[&str] = &[ - "tls_domain", - "tls_domains", - "unknown_sni_action", - "tls_fetch_scope", - "tls_fetch", - "mask", - "mask_dynamic", - "mask_host", - "mask_port", - "exclusive_mask", - "mask_unix_sock", - "fake_cert_len", - "tls_emulation", - "tls_front_dir", - "server_hello_delay_min_ms", - "server_hello_delay_max_ms", - "tls_new_session_tickets", - "serverhello_compact", - "tls_full_cert_ttl_secs", - "alpn_enforce", - "mask_proxy_protocol", - "mask_shape_hardening", - "mask_shape_hardening_aggressive_mode", - "mask_shape_bucket_floor_bytes", - "mask_shape_bucket_cap_bytes", - "mask_shape_above_cap_blur", - "mask_shape_above_cap_blur_max_bytes", - "mask_relay_max_bytes", - "mask_relay_timeout_ms", - "mask_relay_idle_timeout_ms", - "mask_classifier_prefetch_timeout_ms", - "mask_timing_normalization_enabled", - "mask_timing_normalization_floor_ms", - "mask_timing_normalization_ceiling_ms", -]; - -const TLS_FETCH_CONFIG_KEYS: &[&str] = &[ - "profiles", - "strict_route", - "attempt_timeout_ms", - "total_budget_ms", - "grease_enabled", - "deterministic", - "profile_cache_ttl_secs", -]; - -const ACCESS_CONFIG_KEYS: &[&str] = &[ - "users", - "user_enabled", - "user_ad_tags", - "user_max_tcp_conns", - "user_max_tcp_conns_global_each", - "user_expirations", - "user_data_quota", - "user_rate_limits", - "cidr_rate_limits", - "user_max_unique_ips", - "user_max_unique_ips_global_each", - "user_max_unique_ips_mode", - "user_max_unique_ips_window_secs", - "replay_check_len", - "replay_window_secs", - "ignore_time_skew", -]; - -const RATE_LIMIT_BPS_CONFIG_KEYS: &[&str] = &["up_bps", "down_bps"]; - -const UPSTREAM_CONFIG_KEYS: &[&str] = &[ - "type", - "interface", - "bind_addresses", - "bindtodevice", - "force_bind", - "address", - "user_id", - "username", - "password", - "url", - "weight", - "enabled", - "scopes", - "ipv4", - "ipv6", -]; - -const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"]; -const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"]; -const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"]; -const LOGGING_CONFIG_KEYS: &[&str] = &[ - "destination", - "path", - "rotation", - "max_size_bytes", - "max_files", - "max_age_secs", -]; - -#[derive(Debug)] -struct UnknownConfigKey { - path: String, - suggestion: Option, -} - -fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - current.as_table() -} - -fn is_strict_config(parsed_toml: &toml::Value) -> bool { - table_at(parsed_toml, &["general"]) - .and_then(|table| table.get("config_strict")) - .and_then(toml::Value::as_bool) - .unwrap_or(false) -} - -fn known_config_keys_for_suggestion() -> Vec<&'static str> { - let mut keys = Vec::new(); - for group in [ - TOP_LEVEL_CONFIG_KEYS, - GENERAL_CONFIG_KEYS, - NETWORK_CONFIG_KEYS, - SERVER_CONFIG_KEYS, - API_CONFIG_KEYS, - CONNTRACK_CONTROL_CONFIG_KEYS, - LISTENER_CONFIG_KEYS, - TIMEOUTS_CONFIG_KEYS, - CENSORSHIP_CONFIG_KEYS, - TLS_FETCH_CONFIG_KEYS, - ACCESS_CONFIG_KEYS, - RATE_LIMIT_BPS_CONFIG_KEYS, - UPSTREAM_CONFIG_KEYS, - PROXY_MODES_CONFIG_KEYS, - TELEMETRY_CONFIG_KEYS, - LINKS_CONFIG_KEYS, - LOGGING_CONFIG_KEYS, - ] { - keys.extend_from_slice(group); - } - keys -} - -fn levenshtein_distance(a: &str, b: &str) -> usize { - let b_chars: Vec = b.chars().collect(); - let mut prev: Vec = (0..=b_chars.len()).collect(); - let mut curr = vec![0usize; b_chars.len() + 1]; - - for (i, ca) in a.chars().enumerate() { - curr[0] = i + 1; - for (j, cb) in b_chars.iter().enumerate() { - let replace = if ca == *cb { prev[j] } else { prev[j] + 1 }; - curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace); - } - std::mem::swap(&mut prev, &mut curr); - } - - prev[b_chars.len()] -} - -fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option { - let normalized = key.to_ascii_lowercase(); - let mut best: Option<(&str, usize)> = None; - for known in known_keys { - let distance = levenshtein_distance(&normalized, known); - let is_better = match best { - Some((_, best_distance)) => distance < best_distance, - None => true, - }; - if distance <= 4 && is_better { - best = Some((known, distance)); - } - } - best.map(|(known, _)| known.to_string()) -} - -fn push_unknown_keys( - unknown: &mut Vec, - known_for_suggestion: &[&'static str], - path: &str, - table: &toml::Table, - allowed: &[&str], -) { - for key in table.keys() { - if !allowed.contains(&key.as_str()) { - let full_path = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - unknown.push(UnknownConfigKey { - path: full_path, - suggestion: unknown_key_suggestion(key, known_for_suggestion), - }); - } - } -} - -fn check_known_table( - parsed_toml: &toml::Value, - unknown: &mut Vec, - known_for_suggestion: &[&'static str], - path: &[&str], - allowed: &[&str], -) { - if let Some(table) = table_at(parsed_toml, path) { - push_unknown_keys( - unknown, - known_for_suggestion, - &path.join("."), - table, - allowed, - ); - } -} - -fn check_nested_table_value( - unknown: &mut Vec, - known_for_suggestion: &[&'static str], - path: String, - value: &toml::Value, - allowed: &[&str], -) { - if let Some(table) = value.as_table() { - push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed); - } -} - -fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec { - let known_for_suggestion = known_config_keys_for_suggestion(); - let mut unknown = Vec::new(); - - if let Some(root) = parsed_toml.as_table() { - push_unknown_keys( - &mut unknown, - &known_for_suggestion, - "", - root, - TOP_LEVEL_CONFIG_KEYS, - ); - } - - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general"], - GENERAL_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general", "modes"], - PROXY_MODES_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general", "telemetry"], - TELEMETRY_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general", "links"], - LINKS_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["logging"], - LOGGING_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["network"], - NETWORK_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server"], - SERVER_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server", "api"], - API_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server", "admin_api"], - API_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server", "conntrack_control"], - CONNTRACK_CONTROL_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["timeouts"], - TIMEOUTS_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["censorship"], - CENSORSHIP_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["censorship", "tls_fetch"], - TLS_FETCH_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["access"], - ACCESS_CONFIG_KEYS, - ); - - if let Some(listeners) = table_at(parsed_toml, &["server"]) - .and_then(|table| table.get("listeners")) - .and_then(toml::Value::as_array) - { - for (idx, listener) in listeners.iter().enumerate() { - check_nested_table_value( - &mut unknown, - &known_for_suggestion, - format!("server.listeners[{idx}]"), - listener, - LISTENER_CONFIG_KEYS, - ); - } - } - - if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) { - for (idx, upstream) in upstreams.iter().enumerate() { - check_nested_table_value( - &mut unknown, - &known_for_suggestion, - format!("upstreams[{idx}]"), - upstream, - UPSTREAM_CONFIG_KEYS, - ); - } - } - - for access_map in ["user_rate_limits", "cidr_rate_limits"] { - if let Some(table) = table_at(parsed_toml, &["access"]) - .and_then(|access| access.get(access_map)) - .and_then(toml::Value::as_table) - { - for (entry_name, value) in table { - check_nested_table_value( - &mut unknown, - &known_for_suggestion, - format!("access.{access_map}.{entry_name}"), - value, - RATE_LIMIT_BPS_CONFIG_KEYS, - ); - } - } - } - - unknown -} - -fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> { - let unknown = collect_unknown_config_keys(parsed_toml); - if unknown.is_empty() { - return Ok(()); - } - - for item in &unknown { - if let Some(suggestion) = item.suggestion.as_deref() { - warn!( - key = %item.path, - suggestion = %suggestion, - "Unknown config key ignored; did you mean the suggested key?" - ); - } else { - warn!(key = %item.path, "Unknown config key ignored"); - } - } - - if is_strict_config(parsed_toml) { - let mut paths = Vec::with_capacity(unknown.len()); - for item in unknown { - if let Some(suggestion) = item.suggestion { - paths.push(format!("{} (did you mean `{}`?)", item.path, suggestion)); - } else { - paths.push(item.path); - } - } - return Err(ProxyError::Config(format!( - "unknown config keys are not allowed when general.config_strict=true: {}", - paths.join(", ") - ))); - } - - Ok(()) -} - #[derive(Debug, Clone)] pub(crate) struct LoadedConfig { pub(crate) config: ProxyConfig, @@ -804,299 +52,10 @@ pub(crate) struct LoadedConfig { pub(crate) rendered_hash: u64, } -/// Precomputed, immutable user authentication data used by handshake hot paths. -#[derive(Debug, Clone, Default)] -pub(crate) struct UserAuthSnapshot { - entries: Vec, - by_name: HashMap, - sni_index: HashMap>, - sni_initial_index: HashMap>, -} - -#[derive(Debug, Clone)] -pub(crate) struct UserAuthEntry { - pub(crate) user: String, - pub(crate) secret: [u8; ACCESS_SECRET_BYTES], -} - -impl UserAuthSnapshot { - fn from_users(users: &HashMap) -> Result { - let mut entries = Vec::with_capacity(users.len()); - let mut by_name = HashMap::with_capacity(users.len()); - let mut sni_index = HashMap::with_capacity(users.len()); - let mut sni_initial_index = HashMap::with_capacity(users.len()); - - for (user, secret_hex) in users { - let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret { - user: user.clone(), - reason: "Must be 32 hex characters".to_string(), - })?; - if decoded.len() != ACCESS_SECRET_BYTES { - return Err(ProxyError::InvalidSecret { - user: user.clone(), - reason: "Must be 32 hex characters".to_string(), - }); - } - - let user_id = u32::try_from(entries.len()).map_err(|_| { - ProxyError::Config("Too many users for runtime auth snapshot".to_string()) - })?; - - let mut secret = [0u8; ACCESS_SECRET_BYTES]; - secret.copy_from_slice(&decoded); - entries.push(UserAuthEntry { - user: user.clone(), - secret, - }); - by_name.insert(user.clone(), user_id); - sni_index - .entry(Self::sni_lookup_hash(user)) - .or_insert_with(Vec::new) - .push(user_id); - if let Some(initial) = user - .as_bytes() - .first() - .map(|byte| byte.to_ascii_lowercase()) - { - sni_initial_index - .entry(initial) - .or_insert_with(Vec::new) - .push(user_id); - } - } - - Ok(Self { - entries, - by_name, - sni_index, - sni_initial_index, - }) - } - - pub(crate) fn entries(&self) -> &[UserAuthEntry] { - &self.entries - } - - pub(crate) fn user_id_by_name(&self, user: &str) -> Option { - self.by_name.get(user).copied() - } - - pub(crate) fn entry_by_id(&self, user_id: u32) -> Option<&UserAuthEntry> { - let idx = usize::try_from(user_id).ok()?; - self.entries.get(idx) - } - - pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> { - self.sni_index - .get(&Self::sni_lookup_hash(sni)) - .map(Vec::as_slice) - } - - pub(crate) fn sni_initial_candidates(&self, sni: &str) -> Option<&[u32]> { - let initial = sni - .as_bytes() - .first() - .map(|byte| byte.to_ascii_lowercase())?; - self.sni_initial_index.get(&initial).map(Vec::as_slice) - } - - fn sni_lookup_hash(value: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - for byte in value.bytes() { - hasher.write_u8(byte.to_ascii_lowercase()); - } - hasher.finish() - } -} - -fn normalize_config_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| { - if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|_| path.to_path_buf()) - } - }) -} - -fn hash_rendered_snapshot(rendered: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - rendered.hash(&mut hasher); - hasher.finish() -} - -fn preprocess_includes( - content: &str, - base_dir: &Path, - depth: u8, - source_files: &mut BTreeSet, -) -> Result { - if depth > 10 { - return Err(ProxyError::Config("Include depth > 10".into())); - } - let mut output = String::with_capacity(content.len()); - for line in content.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("include") { - let rest = rest.trim(); - if let Some(rest) = rest.strip_prefix('=') { - let path_str = rest.trim().trim_matches('"'); - let resolved = base_dir.join(path_str); - source_files.insert(normalize_config_path(&resolved)); - let included = std::fs::read_to_string(&resolved) - .map_err(|e| ProxyError::Config(e.to_string()))?; - let included_dir = resolved.parent().unwrap_or(base_dir); - output.push_str(&preprocess_includes( - &included, - included_dir, - depth + 1, - source_files, - )?); - output.push('\n'); - continue; - } - } - output.push_str(line); - output.push('\n'); - } - Ok(output) -} - -fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> { - if !net.ipv4 && matches!(net.ipv6, Some(false)) { - return Err(ProxyError::Config( - "Both ipv4 and ipv6 are disabled in [network]".to_string(), - )); - } - - if net.prefer != 4 && net.prefer != 6 { - return Err(ProxyError::Config( - "network.prefer must be 4 or 6".to_string(), - )); - } - - if !net.ipv4 && net.prefer == 4 { - warn!("prefer=4 but ipv4=false; forcing prefer=6"); - net.prefer = 6; - } - - if matches!(net.ipv6, Some(false)) && net.prefer == 6 { - warn!("prefer=6 but ipv6=false; forcing prefer=4"); - net.prefer = 4; - } - - Ok(()) -} - -fn push_unique_nonempty(target: &mut Vec, value: String) { - let trimmed = value.trim(); - if trimmed.is_empty() { - return; - } - if !target.iter().any(|existing| existing == trimmed) { - target.push(trimmed.to_string()); - } -} - -fn is_valid_ad_tag(tag: &str) -> bool { - tag.len() == 32 && tag.chars().all(|ch| ch.is_ascii_hexdigit()) -} - -fn sanitize_ad_tag(ad_tag: &mut Option) { - let Some(tag) = ad_tag.as_ref() else { - return; - }; - - if !is_valid_ad_tag(tag) { - warn!("Invalid general.ad_tag value, expected exactly 32 hex chars; ad_tag is disabled"); - *ad_tag = None; - } -} - -fn validate_logging_config(logging: &LoggingConfig) -> Result<()> { - if let Some(path) = logging.path.as_ref() - && path.trim().is_empty() - { - return Err(ProxyError::Config( - "logging.path cannot be empty when provided".to_string(), - )); - } - - if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() { - return Err(ProxyError::Config( - "logging.path must be set when logging.destination=\"file\"".to_string(), - )); - } - - Ok(()) -} - -fn validate_upstreams(config: &ProxyConfig) -> Result<()> { - let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| { - upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. }) - }); - - if has_enabled_shadowsocks && config.general.use_middle_proxy { - return Err(ProxyError::Config( - "shadowsocks upstreams require general.use_middle_proxy = false".to_string(), - )); - } - - 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 Some(prefer) = upstream.prefer - && prefer != 4 - && prefer != 6 - { - return Err(ProxyError::Config( - "upstream.prefer must be 4 or 6".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}")))?; - if parsed.plugin().is_some() { - return Err(ProxyError::Config( - "shadowsocks plugins are not supported".to_string(), - )); - } - } - } - - Ok(()) -} - -fn normalize_upstream_family_policy(config: &mut ProxyConfig) { - for (idx, upstream) in config.upstreams.iter_mut().enumerate() { - if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) { - warn!( - upstream = idx, - "upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6" - ); - upstream.prefer = Some(6); - } - - if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) { - warn!( - upstream = idx, - "upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4" - ); - upstream.prefer = Some(4); - } - } -} - -// Main runtime configuration loaded from TOML. - +/// Main runtime configuration loaded from TOML. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProxyConfig { + /// General runtime options shared across proxy subsystems. #[serde(default)] pub general: GeneralConfig, @@ -1104,24 +63,31 @@ pub struct ProxyConfig { #[serde(default)] pub logging: LoggingConfig, + /// Network binding, routing, and socket-level configuration. #[serde(default)] pub network: NetworkConfig, + /// Server-side listener, fallback, and API configuration. #[serde(default)] pub server: ServerConfig, + /// Timeout values used by client, fallback, and upstream operations. #[serde(default)] pub timeouts: TimeoutsConfig, + /// Anti-censorship behavior and traffic shaping configuration. #[serde(default)] pub censorship: AntiCensorshipConfig, + /// User authentication secrets and admission policy. #[serde(default)] pub access: AccessConfig, + /// Telegram upstream endpoint configuration. #[serde(default)] pub upstreams: Vec, + /// Optional proxy link rendering controls. #[serde(default)] pub show_link: ShowLink, @@ -1146,6 +112,7 @@ pub struct ProxyConfig { } impl ProxyConfig { + /// Loads runtime configuration from a TOML file. pub fn load>(path: P) -> Result { Self::load_with_metadata(path).map(|loaded| loaded.config) } @@ -2014,6 +981,31 @@ impl ProxyConfig { "server.listeners[{idx}].synlimit_burst must be > 0" ))); } + if listener.synlimit_ios_seconds == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_ios_seconds must be > 0" + ))); + } + if listener.synlimit_ios_hitcount == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_ios_hitcount must be > 0" + ))); + } + if listener.synlimit_ios_burst == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_ios_burst must be > 0" + ))); + } + if listener.synlimit_hashlimit_expire_ms == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_hashlimit_expire_ms must be > 0" + ))); + } + if listener.synlimit_hashlimit_size == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_hashlimit_size must be > 0" + ))); + } } if config.server.accept_permit_timeout_ms > 60_000 { @@ -2256,6 +1248,11 @@ impl ProxyConfig { synlimit_seconds: default_synlimit_seconds(), synlimit_hitcount: default_synlimit_hitcount(), synlimit_burst: default_synlimit_burst(), + synlimit_ios_seconds: default_synlimit_ios_seconds(), + synlimit_ios_hitcount: default_synlimit_ios_hitcount(), + synlimit_ios_burst: default_synlimit_ios_burst(), + synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(), + synlimit_hashlimit_size: default_synlimit_hashlimit_size(), announce: None, announce_ip: None, proxy_protocol: None, @@ -2273,6 +1270,11 @@ impl ProxyConfig { synlimit_seconds: default_synlimit_seconds(), synlimit_hitcount: default_synlimit_hitcount(), synlimit_burst: default_synlimit_burst(), + synlimit_ios_seconds: default_synlimit_ios_seconds(), + synlimit_ios_hitcount: default_synlimit_ios_hitcount(), + synlimit_ios_burst: default_synlimit_ios_burst(), + synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(), + synlimit_hashlimit_size: default_synlimit_hashlimit_size(), announce: None, announce_ip: None, proxy_protocol: None, @@ -2348,6 +1350,7 @@ impl ProxyConfig { self.runtime_user_auth.as_deref() } + /// Validates cross-field configuration invariants after deserialization. pub fn validate(&self) -> Result<()> { if self.access.users.is_empty() { return Err(ProxyError::Config("No users configured".to_string())); @@ -2430,2275 +1433,5 @@ mod load_mask_classifier_prefetch_timeout_security_tests; mod load_memory_envelope_tests; #[cfg(test)] -mod tests { - use super::*; - - 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 - } - - fn load_config_error_from_temp_toml(toml: &str) -> String { - 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_error_{nonce}")); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("config.toml"); - std::fs::write(&path, toml).unwrap(); - let error = ProxyConfig::load(&path).unwrap_err().to_string(); - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_dir(dir); - error - } - - #[test] - fn serde_defaults_remain_unchanged_for_present_sections() { - let toml = r#" - [network] - [general] - [server] - [access] - "#; - let cfg: ProxyConfig = toml::from_str(toml).unwrap(); - - assert_eq!(cfg.logging, LoggingConfig::default()); - assert_eq!(cfg.network.ipv6, default_network_ipv6()); - assert_eq!(cfg.network.stun_use, default_true()); - assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); - assert_eq!( - cfg.general.middle_proxy_warm_standby, - default_middle_proxy_warm_standby() - ); - assert_eq!( - cfg.general.me_reconnect_max_concurrent_per_dc, - default_me_reconnect_max_concurrent_per_dc() - ); - assert_eq!( - cfg.general.me_reconnect_fast_retry_count, - default_me_reconnect_fast_retry_count() - ); - assert_eq!( - cfg.general.me_init_retry_attempts, - default_me_init_retry_attempts() - ); - assert_eq!(cfg.general.me2dc_fallback, default_me2dc_fallback()); - assert_eq!(cfg.general.me2dc_fast, default_me2dc_fast()); - assert_eq!( - cfg.general.proxy_config_v4_cache_path, - default_proxy_config_v4_cache_path() - ); - assert_eq!( - cfg.general.proxy_config_v6_cache_path, - default_proxy_config_v6_cache_path() - ); - assert_eq!( - cfg.general.me_single_endpoint_shadow_writers, - default_me_single_endpoint_shadow_writers() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_mode_enabled, - default_me_single_endpoint_outage_mode_enabled() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_disable_quarantine, - default_me_single_endpoint_outage_disable_quarantine() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_backoff_min_ms, - default_me_single_endpoint_outage_backoff_min_ms() - ); - assert_eq!( - cfg.general.me_single_endpoint_outage_backoff_max_ms, - default_me_single_endpoint_outage_backoff_max_ms() - ); - assert_eq!( - cfg.general.me_single_endpoint_shadow_rotate_every_secs, - default_me_single_endpoint_shadow_rotate_every_secs() - ); - assert_eq!(cfg.general.me_floor_mode, MeFloorMode::default()); - assert_eq!( - cfg.general.me_adaptive_floor_idle_secs, - default_me_adaptive_floor_idle_secs() - ); - assert_eq!( - cfg.general.me_adaptive_floor_min_writers_single_endpoint, - default_me_adaptive_floor_min_writers_single_endpoint() - ); - assert_eq!( - cfg.general.me_adaptive_floor_recover_grace_secs, - default_me_adaptive_floor_recover_grace_secs() - ); - assert_eq!( - cfg.general.upstream_connect_retry_attempts, - default_upstream_connect_retry_attempts() - ); - assert_eq!( - cfg.general.upstream_connect_retry_backoff_ms, - default_upstream_connect_retry_backoff_ms() - ); - assert_eq!( - cfg.general.upstream_unhealthy_fail_threshold, - default_upstream_unhealthy_fail_threshold() - ); - assert_eq!( - cfg.general.upstream_connect_failfast_hard_errors, - default_upstream_connect_failfast_hard_errors() - ); - assert_eq!( - cfg.general.rpc_proxy_req_every, - default_rpc_proxy_req_every() - ); - assert_eq!(cfg.general.beobachten_file, default_beobachten_file()); - assert_eq!(cfg.general.update_every, default_update_every()); - assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); - assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); - assert_eq!(cfg.server.client_mss_value(), Ok(None)); - assert_eq!( - cfg.server.proxy_protocol_trusted_cidrs, - default_proxy_protocol_trusted_cidrs() - ); - assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); - assert_eq!(cfg.server.api.listen, default_api_listen()); - assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); - assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop); - assert_eq!( - cfg.server.api.request_body_limit_bytes, - default_api_request_body_limit_bytes() - ); - assert_eq!( - cfg.server.api.minimal_runtime_enabled, - default_api_minimal_runtime_enabled() - ); - assert_eq!( - cfg.server.api.minimal_runtime_cache_ttl_ms, - default_api_minimal_runtime_cache_ttl_ms() - ); - assert_eq!( - cfg.server.api.runtime_edge_enabled, - default_api_runtime_edge_enabled() - ); - assert_eq!( - cfg.server.api.runtime_edge_cache_ttl_ms, - default_api_runtime_edge_cache_ttl_ms() - ); - assert_eq!( - cfg.server.api.runtime_edge_top_n, - default_api_runtime_edge_top_n() - ); - assert_eq!( - cfg.server.api.runtime_edge_events_capacity, - default_api_runtime_edge_events_capacity() - ); - assert_eq!( - cfg.server.conntrack_control.inline_conntrack_control, - default_conntrack_control_enabled() - ); - assert_eq!(cfg.server.conntrack_control.mode, ConntrackMode::default()); - assert_eq!( - cfg.server.conntrack_control.backend, - ConntrackBackend::default() - ); - assert_eq!( - cfg.server.conntrack_control.profile, - ConntrackPressureProfile::default() - ); - assert_eq!( - cfg.server.conntrack_control.pressure_high_watermark_pct, - default_conntrack_pressure_high_watermark_pct() - ); - assert_eq!( - cfg.server.conntrack_control.pressure_low_watermark_pct, - default_conntrack_pressure_low_watermark_pct() - ); - assert_eq!( - cfg.server.conntrack_control.delete_budget_per_sec, - default_conntrack_delete_budget_per_sec() - ); - assert_eq!(cfg.access.users, default_access_users()); - assert_eq!( - cfg.access.user_max_tcp_conns_global_each, - default_user_max_tcp_conns_global_each() - ); - assert_eq!( - cfg.access.user_max_unique_ips_mode, - UserMaxUniqueIpsMode::default() - ); - assert_eq!( - cfg.access.user_max_unique_ips_window_secs, - default_user_max_unique_ips_window_secs() - ); - } - - #[test] - fn logging_config_is_loaded_from_strict_config() { - let cfg = load_config_from_temp_toml( - r#" - [general] - config_strict = true - - [general.modes] - classic = false - secure = false - tls = true - - [logging] - destination = "file" - path = "/tmp/telemt.log" - rotation = "daily" - max_size_bytes = 1024 - max_files = 3 - max_age_secs = 60 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#, - ); - - assert_eq!(cfg.logging.destination, LoggingDestination::File); - assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log")); - assert_eq!(cfg.logging.rotation, LogRotation::Daily); - assert_eq!(cfg.logging.max_size_bytes, 1024); - assert_eq!(cfg.logging.max_files, 3); - assert_eq!(cfg.logging.max_age_secs, 60); - } - - #[test] - fn file_logging_requires_path() { - let error = load_config_error_from_temp_toml( - r#" - [general.modes] - classic = false - secure = false - tls = true - - [logging] - destination = "file" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#, - ); - - assert!(error.contains("logging.path must be set")); - } - - #[test] - fn impl_defaults_are_sourced_from_default_helpers() { - let network = NetworkConfig::default(); - assert_eq!(network.ipv6, default_network_ipv6()); - assert_eq!(network.stun_use, default_true()); - assert_eq!(network.stun_tcp_fallback, default_stun_tcp_fallback()); - - let general = GeneralConfig::default(); - assert_eq!( - general.middle_proxy_warm_standby, - default_middle_proxy_warm_standby() - ); - assert_eq!( - general.me_reconnect_max_concurrent_per_dc, - default_me_reconnect_max_concurrent_per_dc() - ); - assert_eq!( - general.me_reconnect_fast_retry_count, - default_me_reconnect_fast_retry_count() - ); - assert_eq!( - general.me_init_retry_attempts, - default_me_init_retry_attempts() - ); - assert_eq!(general.me2dc_fallback, default_me2dc_fallback()); - assert_eq!(general.me2dc_fast, default_me2dc_fast()); - assert_eq!( - general.proxy_config_v4_cache_path, - default_proxy_config_v4_cache_path() - ); - assert_eq!( - general.proxy_config_v6_cache_path, - default_proxy_config_v6_cache_path() - ); - assert_eq!( - general.me_single_endpoint_shadow_writers, - default_me_single_endpoint_shadow_writers() - ); - assert_eq!( - general.me_single_endpoint_outage_mode_enabled, - default_me_single_endpoint_outage_mode_enabled() - ); - assert_eq!( - general.me_single_endpoint_outage_disable_quarantine, - default_me_single_endpoint_outage_disable_quarantine() - ); - assert_eq!( - general.me_single_endpoint_outage_backoff_min_ms, - default_me_single_endpoint_outage_backoff_min_ms() - ); - assert_eq!( - general.me_single_endpoint_outage_backoff_max_ms, - default_me_single_endpoint_outage_backoff_max_ms() - ); - assert_eq!( - general.me_single_endpoint_shadow_rotate_every_secs, - default_me_single_endpoint_shadow_rotate_every_secs() - ); - assert_eq!(general.me_floor_mode, MeFloorMode::default()); - assert_eq!( - general.me_adaptive_floor_idle_secs, - default_me_adaptive_floor_idle_secs() - ); - assert_eq!( - general.me_adaptive_floor_min_writers_single_endpoint, - default_me_adaptive_floor_min_writers_single_endpoint() - ); - assert_eq!( - general.me_adaptive_floor_recover_grace_secs, - default_me_adaptive_floor_recover_grace_secs() - ); - assert_eq!( - general.upstream_connect_retry_attempts, - default_upstream_connect_retry_attempts() - ); - assert_eq!( - general.upstream_connect_retry_backoff_ms, - default_upstream_connect_retry_backoff_ms() - ); - assert_eq!( - general.upstream_unhealthy_fail_threshold, - default_upstream_unhealthy_fail_threshold() - ); - assert_eq!( - general.upstream_connect_failfast_hard_errors, - default_upstream_connect_failfast_hard_errors() - ); - assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every()); - assert_eq!(general.beobachten_file, default_beobachten_file()); - assert_eq!(general.update_every, default_update_every()); - - let server = ServerConfig::default(); - assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6())); - assert_eq!( - server.proxy_protocol_trusted_cidrs, - default_proxy_protocol_trusted_cidrs() - ); - assert_eq!( - AntiCensorshipConfig::default().unknown_sni_action, - UnknownSniAction::Drop - ); - assert_eq!(server.api.listen, default_api_listen()); - assert_eq!(server.api.whitelist, default_api_whitelist()); - assert_eq!(server.api.gray_action, ApiGrayAction::Drop); - assert_eq!( - server.api.request_body_limit_bytes, - default_api_request_body_limit_bytes() - ); - assert_eq!( - server.api.minimal_runtime_enabled, - default_api_minimal_runtime_enabled() - ); - assert_eq!( - server.api.minimal_runtime_cache_ttl_ms, - default_api_minimal_runtime_cache_ttl_ms() - ); - assert_eq!( - server.api.runtime_edge_enabled, - default_api_runtime_edge_enabled() - ); - assert_eq!( - server.api.runtime_edge_cache_ttl_ms, - default_api_runtime_edge_cache_ttl_ms() - ); - assert_eq!( - server.api.runtime_edge_top_n, - default_api_runtime_edge_top_n() - ); - assert_eq!( - server.api.runtime_edge_events_capacity, - default_api_runtime_edge_events_capacity() - ); - assert_eq!( - server.conntrack_control.inline_conntrack_control, - default_conntrack_control_enabled() - ); - assert_eq!(server.conntrack_control.mode, ConntrackMode::default()); - assert_eq!( - server.conntrack_control.backend, - ConntrackBackend::default() - ); - assert_eq!( - server.conntrack_control.profile, - ConntrackPressureProfile::default() - ); - assert_eq!( - server.conntrack_control.pressure_high_watermark_pct, - default_conntrack_pressure_high_watermark_pct() - ); - assert_eq!( - server.conntrack_control.pressure_low_watermark_pct, - default_conntrack_pressure_low_watermark_pct() - ); - assert_eq!( - server.conntrack_control.delete_budget_per_sec, - default_conntrack_delete_budget_per_sec() - ); - - let access = AccessConfig::default(); - assert_eq!(access.users, default_access_users()); - assert_eq!( - access.user_max_tcp_conns_global_each, - default_user_max_tcp_conns_global_each() - ); - } - - #[test] - fn proxy_protocol_trusted_cidrs_missing_uses_trust_all_but_explicit_empty_stays_empty() { - let cfg_missing: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - "#, - ) - .unwrap(); - assert_eq!( - cfg_missing.server.proxy_protocol_trusted_cidrs, - default_proxy_protocol_trusted_cidrs() - ); - - let cfg_explicit_empty: ProxyConfig = toml::from_str( - r#" - [server] - proxy_protocol_trusted_cidrs = [] - - [general] - [network] - [access] - "#, - ) - .unwrap(); - assert!( - cfg_explicit_empty - .server - .proxy_protocol_trusted_cidrs - .is_empty() - ); - } - - #[test] - fn conntrack_inline_explicit_flag_is_false_when_omitted() { - let cfg = load_config_from_temp_toml( - r#" - [general] - [network] - [server] - [server.conntrack_control] - [access] - "#, - ); - assert!( - !cfg.server - .conntrack_control - .inline_conntrack_control_explicit - ); - } - - #[test] - fn conntrack_inline_explicit_flag_is_true_when_present() { - let cfg = load_config_from_temp_toml( - r#" - [general] - [network] - [server] - [server.conntrack_control] - inline_conntrack_control = true - [access] - "#, - ); - assert!( - cfg.server - .conntrack_control - .inline_conntrack_control_explicit - ); - } - - #[test] - fn unknown_sni_action_parses_and_defaults_to_drop() { - let cfg_default: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - "#, - ) - .unwrap(); - assert_eq!( - cfg_default.censorship.unknown_sni_action, - UnknownSniAction::Drop - ); - - let cfg_mask: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - unknown_sni_action = "mask" - "#, - ) - .unwrap(); - assert_eq!( - cfg_mask.censorship.unknown_sni_action, - UnknownSniAction::Mask - ); - - let cfg_accept: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - unknown_sni_action = "accept" - "#, - ) - .unwrap(); - assert_eq!( - cfg_accept.censorship.unknown_sni_action, - UnknownSniAction::Accept - ); - - let cfg_reject: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [censorship] - unknown_sni_action = "reject_handshake" - "#, - ) - .unwrap(); - assert_eq!( - cfg_reject.censorship.unknown_sni_action, - UnknownSniAction::RejectHandshake - ); - } - - #[test] - fn exclusive_mask_parses_domain_target_map() { - let cfg = load_config_from_temp_toml( - r#" - [general] - [network] - [server] - [access] - [censorship] - tls_domain = "weißbiergärten.de" - tls_domains = ["bürgeramt.de"] - [censorship.exclusive_mask] - "bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443" - "ipv6.example" = "[::1]:443" - "#, - ); - - assert!(cfg.censorship.tls_domain.is_ascii()); - assert!(cfg.censorship.tls_domain.contains("xn--")); - assert_eq!(cfg.censorship.tls_domains.len(), 1); - let normalized_extra = &cfg.censorship.tls_domains[0]; - assert!(normalized_extra.is_ascii()); - assert!(normalized_extra.contains("xn--")); - - let normalized_target = cfg - .censorship - .exclusive_mask - .get(normalized_extra) - .expect("exclusive_mask key must match normalized tls_domains entry"); - assert!(normalized_target.is_ascii()); - assert!(normalized_target.contains("xn--")); - assert!(normalized_target.ends_with(":443")); - assert_eq!( - cfg.censorship.exclusive_mask.get("ipv6.example"), - Some(&"[::1]:443".to_string()) - ); - } - - #[test] - fn api_gray_action_parses_and_defaults_to_drop() { - let cfg_default: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - "#, - ) - .unwrap(); - assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop); - - let cfg_api: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [server.api] - gray_action = "api" - "#, - ) - .unwrap(); - assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api); - - let cfg_200: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [server.api] - gray_action = "200" - "#, - ) - .unwrap(); - assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200); - - let cfg_drop: ProxyConfig = toml::from_str( - r#" - [server] - [general] - [network] - [access] - [server.api] - gray_action = "drop" - "#, - ) - .unwrap(); - assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); - } - - #[test] - fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() { - let cfg = load_config_from_temp_toml( - r#" - beobachten = false - beobachten_minutes = 7 - beobachten_flush_secs = 3 - beobachten_file = "tmp/legacy-beob.txt" - - [server] - [general] - [network] - [access] - "#, - ); - - assert!(!cfg.general.beobachten); - assert_eq!(cfg.general.beobachten_minutes, 7); - assert_eq!(cfg.general.beobachten_flush_secs, 3); - assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt"); - } - - #[test] - fn general_beobachten_keys_have_priority_over_legacy_top_level() { - let cfg = load_config_from_temp_toml( - r#" - beobachten = true - beobachten_minutes = 30 - beobachten_flush_secs = 30 - beobachten_file = "tmp/legacy-beob.txt" - - [server] - [general] - beobachten = false - beobachten_minutes = 5 - beobachten_flush_secs = 2 - beobachten_file = "tmp/general-beob.txt" - [network] - [access] - "#, - ); - - assert!(!cfg.general.beobachten); - assert_eq!(cfg.general.beobachten_minutes, 5); - assert_eq!(cfg.general.beobachten_flush_secs, 2); - assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt"); - } - - #[test] - fn dc_overrides_allow_string_and_array() { - let toml = r#" - [dc_overrides] - "201" = "149.154.175.50:443" - "202" = ["149.154.167.51:443", "149.154.175.100:443"] - "#; - let cfg: ProxyConfig = toml::from_str(toml).unwrap(); - assert_eq!(cfg.dc_overrides["201"], vec!["149.154.175.50:443"]); - assert_eq!( - cfg.dc_overrides["202"], - vec!["149.154.167.51:443", "149.154.175.100:443"] - ); - } - - #[test] - fn load_with_metadata_collects_include_files() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("telemt_load_metadata_{nonce}")); - std::fs::create_dir_all(&dir).unwrap(); - let main_path = dir.join("config.toml"); - let include_path = dir.join("included.toml"); - - std::fs::write( - &include_path, - r#" - [access.users] - user = "00000000000000000000000000000000" - "#, - ) - .unwrap(); - std::fs::write( - &main_path, - r#" - include = "included.toml" - - [censorship] - tls_domain = "example.com" - "#, - ) - .unwrap(); - - let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap(); - let main_normalized = normalize_config_path(&main_path); - let include_normalized = normalize_config_path(&include_path); - - assert!(loaded.source_files.contains(&main_normalized)); - assert!(loaded.source_files.contains(&include_normalized)); - - let _ = std::fs::remove_file(main_path); - let _ = std::fs::remove_file(include_path); - let _ = std::fs::remove_dir(dir); - } - - #[test] - fn dc_overrides_inject_dc203_default() { - let toml = r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_dc_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!( - cfg.dc_overrides - .get("203") - .map(|v| v.contains(&"91.105.192.100:443".to_string())) - .unwrap_or(false) - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn update_every_overrides_legacy_fields() { - let toml = r#" - [general] - update_every = 123 - proxy_secret_auto_reload_secs = 700 - proxy_config_auto_reload_secs = 800 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_update_every_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.effective_update_every_secs(), 123); - let _ = std::fs::remove_file(path); - } - - #[test] - fn update_every_fallback_to_legacy_min() { - let toml = r#" - [general] - proxy_secret_auto_reload_secs = 600 - proxy_config_auto_reload_secs = 120 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_update_every_legacy_min_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.update_every, None); - assert_eq!(cfg.general.effective_update_every_secs(), 120); - let _ = std::fs::remove_file(path); - } - - #[test] - fn update_every_zero_is_rejected() { - let toml = r#" - [general] - update_every = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_update_every_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.update_every must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn stun_nat_probe_concurrency_zero_is_rejected() { - let toml = r#" - [general] - stun_nat_probe_concurrency = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_stun_nat_probe_concurrency_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.stun_nat_probe_concurrency must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_reinit_every_default_is_set() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_reinit_every_default_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.me_reinit_every_secs, - default_me_reinit_every_secs() - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_reinit_every_zero_is_rejected() { - let toml = r#" - [general] - me_reinit_every_secs = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_reinit_every_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_reinit_every_secs must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_single_endpoint_outage_backoff_range_is_validated() { - let toml = r#" - [general] - me_single_endpoint_outage_backoff_min_ms = 4000 - me_single_endpoint_outage_backoff_max_ms = 3000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_single_endpoint_outage_backoff_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms" - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_single_endpoint_shadow_writers_too_large_is_rejected() { - let toml = r#" - [general] - me_single_endpoint_shadow_writers = 33 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_single_endpoint_shadow_writers_limit_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_single_endpoint_shadow_writers must be within [0, 32]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_adaptive_floor_min_writers_out_of_range_is_rejected() { - let toml = r#" - [general] - me_adaptive_floor_min_writers_single_endpoint = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_adaptive_floor_min_writers_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]" - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_floor_mode_adaptive_is_parsed() { - let toml = r#" - [general] - me_floor_mode = "adaptive" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_floor_mode_adaptive_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_floor_mode, MeFloorMode::Adaptive); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_adaptive_floor_max_active_writers_per_core_zero_is_rejected() { - let toml = r#" - [general] - me_adaptive_floor_max_active_writers_per_core = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_adaptive_floor_max_active_per_core_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_adaptive_floor_max_active_writers_per_core must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_adaptive_floor_max_warm_writers_global_zero_is_rejected() { - let toml = r#" - [general] - me_adaptive_floor_max_warm_writers_global = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_adaptive_floor_max_warm_global_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_adaptive_floor_max_warm_writers_global must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn upstream_connect_retry_attempts_zero_is_rejected() { - let toml = r#" - [general] - upstream_connect_retry_attempts = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_upstream_connect_retry_attempts_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.upstream_connect_retry_attempts must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn upstream_unhealthy_fail_threshold_zero_is_rejected() { - let toml = r#" - [general] - upstream_unhealthy_fail_threshold = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_upstream_unhealthy_fail_threshold_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.upstream_unhealthy_fail_threshold must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tg_connect_zero_is_rejected() { - let toml = r#" - [general] - tg_connect = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tg_connect_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.tg_connect must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn rpc_proxy_req_every_out_of_range_is_rejected() { - let toml = r#" - [general] - rpc_proxy_req_every = 9 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_rpc_proxy_req_every_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.rpc_proxy_req_every must be 0 or within [10, 300]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn rpc_proxy_req_every_zero_and_valid_range_are_accepted() { - let toml_zero = r#" - [general] - rpc_proxy_req_every = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path_zero = dir.join("telemt_rpc_proxy_req_every_zero_ok_test.toml"); - std::fs::write(&path_zero, toml_zero).unwrap(); - let cfg_zero = ProxyConfig::load(&path_zero).unwrap(); - assert_eq!(cfg_zero.general.rpc_proxy_req_every, 0); - let _ = std::fs::remove_file(path_zero); - - let toml_valid = r#" - [general] - rpc_proxy_req_every = 40 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let path_valid = dir.join("telemt_rpc_proxy_req_every_valid_ok_test.toml"); - std::fs::write(&path_valid, toml_valid).unwrap(); - let cfg_valid = ProxyConfig::load(&path_valid).unwrap(); - assert_eq!(cfg_valid.general.rpc_proxy_req_every, 40); - let _ = std::fs::remove_file(path_valid); - } - - #[test] - fn me_route_backpressure_base_timeout_ms_out_of_range_is_rejected() { - let toml = r#" - [general] - me_route_backpressure_base_timeout_ms = 5001 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_backpressure_base_timeout_ms_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!( - err.contains("general.me_route_backpressure_base_timeout_ms must be within [1, 5000]") - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_route_backpressure_high_timeout_ms_out_of_range_is_rejected() { - let toml = r#" - [general] - me_route_backpressure_base_timeout_ms = 100 - me_route_backpressure_high_timeout_ms = 5001 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_backpressure_high_timeout_ms_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!( - err.contains("general.me_route_backpressure_high_timeout_ms must be within [1, 5000]") - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_route_no_writer_wait_ms_out_of_range_is_rejected() { - let toml = r#" - [general] - me_route_no_writer_wait_ms = 5 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_no_writer_wait_ms_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_route_no_writer_wait_ms must be within [10, 5000]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_route_blocking_send_timeout_ms_zero_is_rejected() { - let toml = r#" - [general] - me_route_blocking_send_timeout_ms = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_route_no_writer_mode_is_parsed() { - let toml = r#" - [general] - me_route_no_writer_mode = "inline_recovery_legacy" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_route_no_writer_mode_parse_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.me_route_no_writer_mode, - crate::config::MeRouteNoWriterMode::InlineRecoveryLegacy - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn proxy_config_cache_paths_empty_are_rejected() { - let toml = r#" - [general] - proxy_config_v4_cache_path = " " - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_proxy_config_v4_cache_path_empty_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.proxy_config_v4_cache_path cannot be empty")); - let _ = std::fs::remove_file(path); - - let toml_v6 = r#" - [general] - proxy_config_v6_cache_path = "" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let path_v6 = dir.join("telemt_proxy_config_v6_cache_path_empty_test.toml"); - std::fs::write(&path_v6, toml_v6).unwrap(); - let err_v6 = ProxyConfig::load(&path_v6).unwrap_err().to_string(); - assert!(err_v6.contains("general.proxy_config_v6_cache_path cannot be empty")); - let _ = std::fs::remove_file(path_v6); - } - - #[test] - fn me_hardswap_warmup_defaults_are_set() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_defaults_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.me_hardswap_warmup_delay_min_ms, - default_me_hardswap_warmup_delay_min_ms() - ); - assert_eq!( - cfg.general.me_hardswap_warmup_delay_max_ms, - default_me_hardswap_warmup_delay_max_ms() - ); - assert_eq!( - cfg.general.me_hardswap_warmup_extra_passes, - default_me_hardswap_warmup_extra_passes() - ); - assert_eq!( - cfg.general.me_hardswap_warmup_pass_backoff_base_ms, - default_me_hardswap_warmup_pass_backoff_base_ms() - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_hardswap_warmup_delay_range_is_validated() { - let toml = r#" - [general] - me_hardswap_warmup_delay_min_ms = 2001 - me_hardswap_warmup_delay_max_ms = 2000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_delay_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms" - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_hardswap_warmup_delay_max_zero_is_rejected() { - let toml = r#" - [general] - me_hardswap_warmup_delay_max_ms = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_delay_max_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_hardswap_warmup_delay_max_ms must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_hardswap_warmup_extra_passes_out_of_range_is_rejected() { - let toml = r#" - [general] - me_hardswap_warmup_extra_passes = 11 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_extra_passes_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_hardswap_warmup_extra_passes must be within [0, 10]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_hardswap_warmup_pass_backoff_zero_is_rejected() { - let toml = r#" - [general] - me_hardswap_warmup_pass_backoff_base_ms = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_hardswap_warmup_backoff_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_hardswap_warmup_pass_backoff_base_ms must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_config_stable_snapshots_zero_is_rejected() { - let toml = r#" - [general] - me_config_stable_snapshots = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_config_stable_snapshots_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_config_stable_snapshots must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn proxy_secret_stable_snapshots_zero_is_rejected() { - let toml = r#" - [general] - proxy_secret_stable_snapshots = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_proxy_secret_stable_snapshots_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.proxy_secret_stable_snapshots must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn proxy_secret_len_max_out_of_range_is_rejected() { - let toml = r#" - [general] - proxy_secret_len_max = 16 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_proxy_secret_len_max_out_of_range_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.proxy_secret_len_max must be within [32, 4096]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn me_pool_min_fresh_ratio_out_of_range_is_rejected() { - let toml = r#" - [general] - me_pool_min_fresh_ratio = 1.5 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_me_pool_min_ratio_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("general.me_pool_min_fresh_ratio must be within [0.0, 1.0]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn api_minimal_runtime_cache_ttl_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - minimal_runtime_cache_ttl_ms = 70000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_minimal_runtime_cache_ttl_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn client_mss_presets_and_listener_override_are_resolved() { - let toml = r#" - [server] - client_mss = "tspu" - - [[server.listeners]] - ip = "127.0.0.1" - port = 1443 - - [[server.listeners]] - ip = "127.0.0.2" - port = 1444 - client_mss = "2in8" - - [[server.listeners]] - ip = "127.0.0.3" - port = 1445 - client_mss = "" - - [[server.listeners]] - ip = "127.0.0.4" - port = 1446 - client_mss = "extreme-low" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_valid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); - assert_eq!( - cfg.server.listeners[0].effective_client_mss(&cfg.server), - Ok(Some(92)) - ); - assert_eq!( - cfg.server.listeners[1].effective_client_mss(&cfg.server), - Ok(Some(256)) - ); - assert_eq!( - cfg.server.listeners[2].effective_client_mss(&cfg.server), - Ok(None) - ); - assert_eq!( - cfg.server.listeners[3].effective_client_mss(&cfg.server), - Ok(Some(88)) - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn client_mss_custom_value_is_accepted() { - let toml = r#" - [server] - client_mss = "4096" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_custom_valid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096))); - let _ = std::fs::remove_file(path); - } - - #[test] - fn client_mss_out_of_range_is_rejected() { - for value in ["87", "4097"] { - let toml = format!( - r#" - [server] - client_mss = "{value}" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "# - ); - let dir = std::env::temp_dir(); - let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml")); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("server.client_mss custom value must be within [88, 4096]")); - let _ = std::fs::remove_file(path); - } - } - - #[test] - fn client_mss_unquoted_number_is_rejected() { - let toml = r#" - [server] - client_mss = 256 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_client_mss_unquoted_number_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("client_mss")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn listener_client_mss_invalid_preset_is_rejected() { - let toml = r#" - [[server.listeners]] - ip = "127.0.0.1" - port = 1443 - client_mss = "tiny" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_listener_client_mss_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("server.listeners[0].client_mss")); - assert!(err.contains("must be \"\", extreme-low, tspu, 2in8")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - runtime_edge_cache_ttl_ms = 70000 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn api_runtime_edge_top_n_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - runtime_edge_top_n = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn api_runtime_edge_events_capacity_out_of_range_is_rejected() { - let toml = r#" - [server.api] - enabled = true - listen = "127.0.0.1:9091" - runtime_edge_events_capacity = 8 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn conntrack_pressure_high_watermark_out_of_range_is_rejected() { - let toml = r#" - [server.conntrack_control] - pressure_high_watermark_pct = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_high_watermark_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]" - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn conntrack_pressure_low_watermark_must_be_below_high() { - let toml = r#" - [server.conntrack_control] - pressure_high_watermark_pct = 50 - pressure_low_watermark_pct = 50 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_low_watermark_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!( - err.contains( - "server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct" - ) - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn conntrack_delete_budget_zero_is_rejected() { - let toml = r#" - [server.conntrack_control] - delete_budget_per_sec = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_delete_budget_invalid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("server.conntrack_control.delete_budget_per_sec must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn conntrack_hybrid_mode_requires_listener_allow_list() { - let toml = r#" - [server.conntrack_control] - mode = "hybrid" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_hybrid_requires_ips_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains( - "server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid" - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn conntrack_profile_is_loaded_from_config() { - let toml = r#" - [server.conntrack_control] - profile = "aggressive" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_conntrack_profile_parse_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.server.conntrack_control.profile, - ConntrackPressureProfile::Aggressive - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn force_close_default_matches_drain_ttl() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_force_close_default_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); - assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 90); - let _ = std::fs::remove_file(path); - } - - #[test] - fn force_close_zero_uses_runtime_safety_fallback() { - let toml = r#" - [general] - me_reinit_drain_timeout_secs = 0 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_force_close_zero_fallback_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 0); - assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 300); - let _ = std::fs::remove_file(path); - } - - #[test] - fn force_close_bumped_when_below_drain_ttl() { - let toml = r#" - [general] - me_pool_drain_ttl_secs = 90 - me_reinit_drain_timeout_secs = 30 - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_force_close_bump_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_scope_default_is_empty() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_scope_default_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!(cfg.censorship.tls_fetch_scope.is_empty()); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_scope_is_trimmed_during_load() { - let toml = r#" - [censorship] - tls_domain = "example.com" - tls_fetch_scope = " me " - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_scope_trim_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.censorship.tls_fetch_scope, "me"); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_scope_whitespace_becomes_empty() { - let toml = r#" - [censorship] - tls_domain = "example.com" - tls_fetch_scope = " " - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_scope_blank_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!(cfg.censorship.tls_fetch_scope.is_empty()); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_defaults_are_applied() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_defaults_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.censorship.tls_fetch.profiles, - TlsFetchConfig::default().profiles - ); - assert!(cfg.censorship.tls_fetch.strict_route); - assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000); - assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000); - assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_profiles_are_deduplicated_preserving_order() { - let toml = r#" - [censorship] - tls_domain = "example.com" - [censorship.tls_fetch] - profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"] - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.censorship.tls_fetch.profiles, - vec![ - TlsFetchProfile::CompatTls12, - TlsFetchProfile::ModernChromeLike, - TlsFetchProfile::LegacyMinimal - ] - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_attempt_timeout_zero_is_rejected() { - let toml = r#" - [censorship] - tls_domain = "example.com" - [censorship.tls_fetch] - attempt_timeout_ms = 0 - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn tls_fetch_total_budget_zero_is_rejected() { - let toml = r#" - [censorship] - tls_domain = "example.com" - [censorship.tls_fetch] - total_budget_ms = 0 - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_ad_tag_is_disabled_during_load() { - let toml = r#" - [general] - ad_tag = "not_hex" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_invalid_ad_tag_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert!(cfg.general.ad_tag.is_none()); - let _ = std::fs::remove_file(path); - } - - #[test] - fn valid_ad_tag_is_preserved_during_load() { - let toml = r#" - [general] - ad_tag = "00112233445566778899aabbccddeeff" - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_valid_ad_tag_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!( - cfg.general.ad_tag.as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn shadowsocks_upstream_url_loads_successfully() { - let toml = format!( - r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "{url}" - interface = "127.0.0.2" - "#, - url = TEST_SHADOWSOCKS_URL, - ); - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_valid_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - - assert!(matches!( - &cfg.upstreams[0].upstream_type, - UpstreamType::Shadowsocks { url, interface } - if url == TEST_SHADOWSOCKS_URL && interface.as_deref() == Some("127.0.0.2") - )); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn shadowsocks_requires_direct_mode() { - let toml = format!( - r#" - [general] - use_middle_proxy = true - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "{url}" - "#, - url = TEST_SHADOWSOCKS_URL, - ); - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_me_reject_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("shadowsocks upstreams require general.use_middle_proxy = false")); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_shadowsocks_url_is_rejected() { - let toml = r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "not-a-valid-ss-url" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_invalid_url_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("invalid shadowsocks url")); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn shadowsocks_plugins_are_rejected() { - let toml = format!( - r#" - [general] - use_middle_proxy = false - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - - [[upstreams]] - type = "shadowsocks" - url = "{url}?plugin=obfs-local%3Bobfs%3Dhttp" - "#, - url = TEST_SHADOWSOCKS_URL, - ); - let dir = std::env::temp_dir(); - let path = dir.join("telemt_shadowsocks_plugin_reject_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - - assert!(err.contains("shadowsocks plugins are not supported")); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_user_ad_tag_reports_access_user_ad_tags_key() { - let toml = r#" - [censorship] - tls_domain = "example.com" - - [access.users] - alice = "00000000000000000000000000000000" - - [access.user_ad_tags] - alice = "not_hex" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_invalid_user_ad_tag_message_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - let err = cfg.validate().unwrap_err().to_string(); - assert!(err.contains("access.user_ad_tags['alice'] must be exactly 32 hex characters")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_dns_override_is_rejected() { - let toml = r#" - [network] - dns_overrides = ["example.com:443:2001:db8::10"] - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_invalid_dns_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let err = ProxyConfig::load(&path).unwrap_err().to_string(); - assert!(err.contains("must be bracketed")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn valid_dns_override_is_accepted() { - let toml = r#" - [network] - dns_overrides = ["example.com:443:127.0.0.1", "example.net:443:[2001:db8::10]"] - - [censorship] - tls_domain = "example.com" - - [access.users] - user = "00000000000000000000000000000000" - "#; - let dir = std::env::temp_dir(); - let path = dir.join("telemt_valid_dns_override_test.toml"); - std::fs::write(&path, toml).unwrap(); - let cfg = ProxyConfig::load(&path).unwrap(); - assert_eq!(cfg.network.dns_overrides.len(), 2); - let _ = std::fs::remove_file(path); - } -} +#[path = "tests/load_basic_tests.rs"] +mod tests; diff --git a/src/config/load/includes.rs b/src/config/load/includes.rs new file mode 100644 index 0000000..222887b --- /dev/null +++ b/src/config/load/includes.rs @@ -0,0 +1,60 @@ +use std::collections::BTreeSet; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use crate::error::{ProxyError, Result}; + +pub(super) fn normalize_config_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } + }) +} + +pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + rendered.hash(&mut hasher); + hasher.finish() +} + +pub(super) fn preprocess_includes( + content: &str, + base_dir: &Path, + depth: u8, + source_files: &mut BTreeSet, +) -> Result { + if depth > 10 { + return Err(ProxyError::Config("Include depth > 10".into())); + } + let mut output = String::with_capacity(content.len()); + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("include") { + let rest = rest.trim(); + if let Some(rest) = rest.strip_prefix('=') { + let path_str = rest.trim().trim_matches('"'); + let resolved = base_dir.join(path_str); + source_files.insert(normalize_config_path(&resolved)); + let included = std::fs::read_to_string(&resolved) + .map_err(|e| ProxyError::Config(e.to_string()))?; + let included_dir = resolved.parent().unwrap_or(base_dir); + output.push_str(&preprocess_includes( + &included, + included_dir, + depth + 1, + source_files, + )?); + output.push('\n'); + continue; + } + } + output.push_str(line); + output.push('\n'); + } + Ok(output) +} diff --git a/src/config/load/normalize.rs b/src/config/load/normalize.rs new file mode 100644 index 0000000..21103fb --- /dev/null +++ b/src/config/load/normalize.rs @@ -0,0 +1,115 @@ +use crate::error::{ProxyError, Result}; +use tracing::warn; + +pub(super) fn is_valid_tls_domain_name(domain: &str) -> bool { + !domain.is_empty() + && !domain + .chars() + .any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\')) +} + +pub(super) fn normalize_domain_to_ascii(domain: &str, field: &str) -> Result { + let domain = domain.trim(); + if !is_valid_tls_domain_name(domain) { + return Err(ProxyError::Config(format!( + "Invalid {field}: '{}'. Must be a valid domain name", + domain + ))); + } + + let parsed = url::Url::parse(&format!("https://{domain}/")).map_err(|error| { + ProxyError::Config(format!( + "Invalid {field}: '{}'. IDNA conversion failed: {error}", + domain + )) + })?; + let host = parsed.host_str().ok_or_else(|| { + ProxyError::Config(format!("Invalid {field}: '{}'. Host is empty", domain)) + })?; + Ok(host.to_ascii_lowercase()) +} + +pub(super) fn normalize_mask_host_to_ascii(host: &str, field: &str) -> Result { + let host = host.trim(); + if host.starts_with('[') && host.ends_with(']') { + let inner = &host[1..host.len() - 1]; + let ip = inner.parse::().map_err(|_| { + ProxyError::Config(format!( + "Invalid {field}: '{}'. IPv6 literal is invalid", + host + )) + })?; + return match ip { + std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")), + std::net::IpAddr::V4(v4) => Ok(v4.to_string()), + }; + } + if let Ok(ip) = host.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => Ok(v4.to_string()), + std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")), + }; + } + + normalize_domain_to_ascii(host, field) +} + +pub(super) fn parse_exclusive_mask_target(target: &str) -> Option<(&str, u16)> { + let target = target.trim(); + if target.is_empty() { + return None; + } + + if target.starts_with('[') { + let end = target.find(']')?; + if target.get(end + 1..end + 2)? != ":" { + return None; + } + let host = &target[..=end]; + let port = target[end + 2..].parse::().ok()?; + return (port > 0).then_some((host, port)); + } + + let (host, port) = target.rsplit_once(':')?; + if host.is_empty() || host.contains(':') { + return None; + } + let port = port.parse::().ok()?; + (port > 0).then_some((host, port)) +} + +pub(super) fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result { + let (host, port) = parse_exclusive_mask_target(target).ok_or_else(|| { + ProxyError::Config(format!( + "Invalid {field}: '{}'. Expected host:port with port > 0", + target + )) + })?; + let host = normalize_mask_host_to_ascii(host, field)?; + Ok(format!("{host}:{port}")) +} + +pub(super) fn push_unique_nonempty(target: &mut Vec, value: String) { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + if !target.iter().any(|existing| existing == trimmed) { + target.push(trimmed.to_string()); + } +} + +pub(super) fn is_valid_ad_tag(tag: &str) -> bool { + tag.len() == 32 && tag.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +pub(super) fn sanitize_ad_tag(ad_tag: &mut Option) { + let Some(tag) = ad_tag.as_ref() else { + return; + }; + + if !is_valid_ad_tag(tag) { + warn!("Invalid general.ad_tag value, expected exactly 32 hex chars; ad_tag is disabled"); + *ad_tag = None; + } +} diff --git a/src/config/load/runtime_auth.rs b/src/config/load/runtime_auth.rs new file mode 100644 index 0000000..01d1c4b --- /dev/null +++ b/src/config/load/runtime_auth.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::Hasher; + +use crate::error::{ProxyError, Result}; + +const ACCESS_SECRET_BYTES: usize = 16; + +/// Precomputed, immutable user authentication data used by handshake hot paths. +#[derive(Debug, Clone, Default)] +pub(crate) struct UserAuthSnapshot { + entries: Vec, + by_name: HashMap, + sni_index: HashMap>, + sni_initial_index: HashMap>, +} + +#[derive(Debug, Clone)] +pub(crate) struct UserAuthEntry { + pub(crate) user: String, + pub(crate) secret: [u8; ACCESS_SECRET_BYTES], +} + +impl UserAuthSnapshot { + pub(super) fn from_users(users: &HashMap) -> Result { + let mut entries = Vec::with_capacity(users.len()); + let mut by_name = HashMap::with_capacity(users.len()); + let mut sni_index = HashMap::with_capacity(users.len()); + let mut sni_initial_index = HashMap::with_capacity(users.len()); + + for (user, secret_hex) in users { + let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret { + user: user.clone(), + reason: "Must be 32 hex characters".to_string(), + })?; + if decoded.len() != ACCESS_SECRET_BYTES { + return Err(ProxyError::InvalidSecret { + user: user.clone(), + reason: "Must be 32 hex characters".to_string(), + }); + } + + let user_id = u32::try_from(entries.len()).map_err(|_| { + ProxyError::Config("Too many users for runtime auth snapshot".to_string()) + })?; + + let mut secret = [0u8; ACCESS_SECRET_BYTES]; + secret.copy_from_slice(&decoded); + entries.push(UserAuthEntry { + user: user.clone(), + secret, + }); + by_name.insert(user.clone(), user_id); + sni_index + .entry(Self::sni_lookup_hash(user)) + .or_insert_with(Vec::new) + .push(user_id); + if let Some(initial) = user + .as_bytes() + .first() + .map(|byte| byte.to_ascii_lowercase()) + { + sni_initial_index + .entry(initial) + .or_insert_with(Vec::new) + .push(user_id); + } + } + + Ok(Self { + entries, + by_name, + sni_index, + sni_initial_index, + }) + } + + pub(crate) fn entries(&self) -> &[UserAuthEntry] { + &self.entries + } + + pub(crate) fn user_id_by_name(&self, user: &str) -> Option { + self.by_name.get(user).copied() + } + + pub(crate) fn entry_by_id(&self, user_id: u32) -> Option<&UserAuthEntry> { + let idx = usize::try_from(user_id).ok()?; + self.entries.get(idx) + } + + pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> { + self.sni_index + .get(&Self::sni_lookup_hash(sni)) + .map(Vec::as_slice) + } + + pub(crate) fn sni_initial_candidates(&self, sni: &str) -> Option<&[u32]> { + let initial = sni + .as_bytes() + .first() + .map(|byte| byte.to_ascii_lowercase())?; + self.sni_initial_index.get(&initial).map(Vec::as_slice) + } + + fn sni_lookup_hash(value: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + for byte in value.bytes() { + hasher.write_u8(byte.to_ascii_lowercase()); + } + hasher.finish() + } +} diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs new file mode 100644 index 0000000..9d832e4 --- /dev/null +++ b/src/config/load/strict_keys.rs @@ -0,0 +1,693 @@ +use tracing::warn; + +use crate::error::{ProxyError, Result}; + +const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ + "general", + "logging", + "network", + "server", + "timeouts", + "censorship", + "access", + "upstreams", + "show_link", + "dc_overrides", + "default_dc", + "beobachten", + "beobachten_minutes", + "beobachten_flush_secs", + "beobachten_file", + "include", +]; + +const GENERAL_CONFIG_KEYS: &[&str] = &[ + "data_path", + "quota_state_path", + "config_strict", + "modes", + "prefer_ipv6", + "fast_mode", + "use_middle_proxy", + "proxy_secret_path", + "proxy_secret_url", + "proxy_config_v4_cache_path", + "proxy_config_v4_url", + "proxy_config_v6_cache_path", + "proxy_config_v6_url", + "ad_tag", + "middle_proxy_nat_ip", + "middle_proxy_nat_probe", + "middle_proxy_nat_stun", + "middle_proxy_nat_stun_servers", + "stun_nat_probe_concurrency", + "middle_proxy_pool_size", + "middle_proxy_warm_standby", + "me_init_retry_attempts", + "me2dc_fallback", + "me2dc_fast", + "me_keepalive_enabled", + "me_keepalive_interval_secs", + "me_keepalive_jitter_secs", + "me_keepalive_payload_random", + "rpc_proxy_req_every", + "me_writer_cmd_channel_capacity", + "me_route_channel_capacity", + "me_c2me_channel_capacity", + "me_c2me_send_timeout_ms", + "me_reader_route_data_wait_ms", + "me_d2c_flush_batch_max_frames", + "me_d2c_flush_batch_max_bytes", + "me_d2c_flush_batch_max_delay_us", + "me_d2c_ack_flush_immediate", + "me_quota_soft_overshoot_bytes", + "me_d2c_frame_buf_shrink_threshold_bytes", + "direct_relay_copy_buf_c2s_bytes", + "direct_relay_copy_buf_s2c_bytes", + "crypto_pending_buffer", + "max_client_frame", + "desync_all_full", + "beobachten", + "beobachten_minutes", + "beobachten_flush_secs", + "beobachten_file", + "hardswap", + "me_warmup_stagger_enabled", + "me_warmup_step_delay_ms", + "me_warmup_step_jitter_ms", + "me_reconnect_max_concurrent_per_dc", + "me_reconnect_backoff_base_ms", + "me_reconnect_backoff_cap_ms", + "me_reconnect_fast_retry_count", + "me_single_endpoint_shadow_writers", + "me_single_endpoint_outage_mode_enabled", + "me_single_endpoint_outage_disable_quarantine", + "me_single_endpoint_outage_backoff_min_ms", + "me_single_endpoint_outage_backoff_max_ms", + "me_single_endpoint_shadow_rotate_every_secs", + "me_floor_mode", + "me_adaptive_floor_idle_secs", + "me_adaptive_floor_min_writers_single_endpoint", + "me_adaptive_floor_min_writers_multi_endpoint", + "me_adaptive_floor_recover_grace_secs", + "me_adaptive_floor_writers_per_core_total", + "me_adaptive_floor_cpu_cores_override", + "me_adaptive_floor_max_extra_writers_single_per_core", + "me_adaptive_floor_max_extra_writers_multi_per_core", + "me_adaptive_floor_max_active_writers_per_core", + "me_adaptive_floor_max_warm_writers_per_core", + "me_adaptive_floor_max_active_writers_global", + "me_adaptive_floor_max_warm_writers_global", + "upstream_connect_retry_attempts", + "upstream_connect_retry_backoff_ms", + "upstream_connect_budget_ms", + "tg_connect", + "upstream_unhealthy_fail_threshold", + "upstream_connect_failfast_hard_errors", + "stun_iface_mismatch_ignore", + "unknown_dc_log_path", + "unknown_dc_file_log_enabled", + "log_level", + "disable_colors", + "telemetry", + "me_socks_kdf_policy", + "me_route_backpressure_enabled", + "me_route_fairshare_enabled", + "me_route_backpressure_base_timeout_ms", + "me_route_backpressure_high_timeout_ms", + "me_route_backpressure_high_watermark_pct", + "me_health_interval_ms_unhealthy", + "me_health_interval_ms_healthy", + "me_admission_poll_ms", + "me_warn_rate_limit_ms", + "me_route_no_writer_mode", + "me_route_no_writer_wait_ms", + "me_route_hybrid_max_wait_ms", + "me_route_blocking_send_timeout_ms", + "me_route_inline_recovery_attempts", + "me_route_inline_recovery_wait_ms", + "links", + "fast_mode_min_tls_record", + "update_every", + "me_reinit_every_secs", + "me_hardswap_warmup_delay_min_ms", + "me_hardswap_warmup_delay_max_ms", + "me_hardswap_warmup_extra_passes", + "me_hardswap_warmup_pass_backoff_base_ms", + "me_config_stable_snapshots", + "me_config_apply_cooldown_secs", + "me_snapshot_require_http_2xx", + "me_snapshot_reject_empty_map", + "me_snapshot_min_proxy_for_lines", + "proxy_secret_stable_snapshots", + "proxy_secret_rotate_runtime", + "me_secret_atomic_snapshot", + "proxy_secret_len_max", + "me_pool_drain_ttl_secs", + "me_instadrain", + "me_pool_drain_threshold", + "me_pool_drain_soft_evict_enabled", + "me_pool_drain_soft_evict_grace_secs", + "me_pool_drain_soft_evict_per_writer", + "me_pool_drain_soft_evict_budget_per_core", + "me_pool_drain_soft_evict_cooldown_ms", + "me_bind_stale_mode", + "me_bind_stale_ttl_secs", + "me_pool_min_fresh_ratio", + "me_reinit_drain_timeout_secs", + "proxy_secret_auto_reload_secs", + "proxy_config_auto_reload_secs", + "me_reinit_singleflight", + "me_reinit_trigger_channel", + "me_reinit_coalesce_window_ms", + "me_deterministic_writer_sort", + "me_writer_pick_mode", + "me_writer_pick_sample_size", + "ntp_check", + "ntp_servers", + "auto_degradation_enabled", + "degradation_min_unavailable_dc_groups", + "rst_on_close", +]; + +const NETWORK_CONFIG_KEYS: &[&str] = &[ + "ipv4", + "ipv6", + "prefer", + "multipath", + "stun_use", + "stun_servers", + "stun_tcp_fallback", + "http_ip_detect_urls", + "cache_public_ip_path", + "dns_overrides", +]; + +const SERVER_CONFIG_KEYS: &[&str] = &[ + "port", + "listen_addr_ipv4", + "listen_addr_ipv6", + "listen_unix_sock", + "listen_unix_sock_perm", + "listen_tcp", + "client_mss", + "client_mss_bulk", + "proxy_protocol", + "proxy_protocol_header_timeout_ms", + "proxy_protocol_trusted_cidrs", + "metrics_port", + "metrics_listen", + "metrics_whitelist", + "api", + "admin_api", + "listeners", + "listen_backlog", + "max_connections", + "accept_permit_timeout_ms", + "conntrack_control", +]; + +const API_CONFIG_KEYS: &[&str] = &[ + "enabled", + "listen", + "whitelist", + "gray_action", + "auth_header", + "request_body_limit_bytes", + "minimal_runtime_enabled", + "minimal_runtime_cache_ttl_ms", + "runtime_edge_enabled", + "runtime_edge_cache_ttl_ms", + "runtime_edge_top_n", + "runtime_edge_events_capacity", + "read_only", +]; + +const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[ + "inline_conntrack_control", + "mode", + "backend", + "profile", + "hybrid_listener_ips", + "pressure_high_watermark_pct", + "pressure_low_watermark_pct", + "delete_budget_per_sec", +]; + +const LISTENER_CONFIG_KEYS: &[&str] = &[ + "ip", + "port", + "client_mss", + "synlimit", + "synlimit_seconds", + "synlimit_hitcount", + "synlimit_burst", + "synlimit_ios_seconds", + "synlimit_ios_hitcount", + "synlimit_ios_burst", + "synlimit_hashlimit_expire_ms", + "synlimit_hashlimit_size", + "announce", + "announce_ip", + "proxy_protocol", + "reuse_allow", +]; + +const TIMEOUTS_CONFIG_KEYS: &[&str] = &[ + "client_first_byte_idle_secs", + "client_handshake", + "relay_idle_policy_v2_enabled", + "relay_client_idle_soft_secs", + "relay_client_idle_hard_secs", + "relay_idle_grace_after_downstream_activity_secs", + "client_keepalive", + "client_ack", + "me_one_retry", + "me_one_timeout_ms", +]; + +const CENSORSHIP_CONFIG_KEYS: &[&str] = &[ + "tls_domain", + "tls_domains", + "unknown_sni_action", + "tls_fetch_scope", + "tls_fetch", + "mask", + "mask_dynamic", + "mask_host", + "mask_port", + "exclusive_mask", + "mask_unix_sock", + "fake_cert_len", + "tls_emulation", + "tls_front_dir", + "server_hello_delay_min_ms", + "server_hello_delay_max_ms", + "tls_new_session_tickets", + "serverhello_compact", + "tls_full_cert_ttl_secs", + "alpn_enforce", + "mask_proxy_protocol", + "mask_shape_hardening", + "mask_shape_hardening_aggressive_mode", + "mask_shape_bucket_floor_bytes", + "mask_shape_bucket_cap_bytes", + "mask_shape_above_cap_blur", + "mask_shape_above_cap_blur_max_bytes", + "mask_relay_max_bytes", + "mask_relay_timeout_ms", + "mask_relay_idle_timeout_ms", + "mask_classifier_prefetch_timeout_ms", + "mask_timing_normalization_enabled", + "mask_timing_normalization_floor_ms", + "mask_timing_normalization_ceiling_ms", +]; + +const TLS_FETCH_CONFIG_KEYS: &[&str] = &[ + "profiles", + "strict_route", + "attempt_timeout_ms", + "total_budget_ms", + "grease_enabled", + "deterministic", + "profile_cache_ttl_secs", +]; + +const ACCESS_CONFIG_KEYS: &[&str] = &[ + "users", + "user_enabled", + "user_ad_tags", + "user_max_tcp_conns", + "user_max_tcp_conns_global_each", + "user_expirations", + "user_data_quota", + "user_rate_limits", + "cidr_rate_limits", + "user_max_unique_ips", + "user_max_unique_ips_global_each", + "user_max_unique_ips_mode", + "user_max_unique_ips_window_secs", + "replay_check_len", + "replay_window_secs", + "ignore_time_skew", +]; + +const RATE_LIMIT_BPS_CONFIG_KEYS: &[&str] = &["up_bps", "down_bps"]; + +const UPSTREAM_CONFIG_KEYS: &[&str] = &[ + "type", + "interface", + "bind_addresses", + "bindtodevice", + "force_bind", + "address", + "user_id", + "username", + "password", + "url", + "weight", + "enabled", + "scopes", + "ipv4", + "ipv6", +]; + +const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"]; +const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"]; +const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"]; +const LOGGING_CONFIG_KEYS: &[&str] = &[ + "destination", + "path", + "rotation", + "max_size_bytes", + "max_files", + "max_age_secs", +]; + +#[derive(Debug)] +struct UnknownConfigKey { + path: String, + suggestion: Option, +} + +fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_table() +} + +fn is_strict_config(parsed_toml: &toml::Value) -> bool { + table_at(parsed_toml, &["general"]) + .and_then(|table| table.get("config_strict")) + .and_then(toml::Value::as_bool) + .unwrap_or(false) +} + +fn known_config_keys_for_suggestion() -> Vec<&'static str> { + let mut keys = Vec::new(); + for group in [ + TOP_LEVEL_CONFIG_KEYS, + GENERAL_CONFIG_KEYS, + NETWORK_CONFIG_KEYS, + SERVER_CONFIG_KEYS, + API_CONFIG_KEYS, + CONNTRACK_CONTROL_CONFIG_KEYS, + LISTENER_CONFIG_KEYS, + TIMEOUTS_CONFIG_KEYS, + CENSORSHIP_CONFIG_KEYS, + TLS_FETCH_CONFIG_KEYS, + ACCESS_CONFIG_KEYS, + RATE_LIMIT_BPS_CONFIG_KEYS, + UPSTREAM_CONFIG_KEYS, + PROXY_MODES_CONFIG_KEYS, + TELEMETRY_CONFIG_KEYS, + LINKS_CONFIG_KEYS, + LOGGING_CONFIG_KEYS, + ] { + keys.extend_from_slice(group); + } + keys +} + +fn levenshtein_distance(a: &str, b: &str) -> usize { + let b_chars: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b_chars.len()).collect(); + let mut curr = vec![0usize; b_chars.len() + 1]; + + for (i, ca) in a.chars().enumerate() { + curr[0] = i + 1; + for (j, cb) in b_chars.iter().enumerate() { + let replace = if ca == *cb { prev[j] } else { prev[j] + 1 }; + curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace); + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[b_chars.len()] +} + +fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option { + let normalized = key.to_ascii_lowercase(); + let mut best: Option<(&str, usize)> = None; + for known in known_keys { + let distance = levenshtein_distance(&normalized, known); + let is_better = match best { + Some((_, best_distance)) => distance < best_distance, + None => true, + }; + if distance <= 4 && is_better { + best = Some((known, distance)); + } + } + best.map(|(known, _)| known.to_string()) +} + +fn push_unknown_keys( + unknown: &mut Vec, + known_for_suggestion: &[&'static str], + path: &str, + table: &toml::Table, + allowed: &[&str], +) { + for key in table.keys() { + if !allowed.contains(&key.as_str()) { + let full_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + unknown.push(UnknownConfigKey { + path: full_path, + suggestion: unknown_key_suggestion(key, known_for_suggestion), + }); + } + } +} + +fn check_known_table( + parsed_toml: &toml::Value, + unknown: &mut Vec, + known_for_suggestion: &[&'static str], + path: &[&str], + allowed: &[&str], +) { + if let Some(table) = table_at(parsed_toml, path) { + push_unknown_keys( + unknown, + known_for_suggestion, + &path.join("."), + table, + allowed, + ); + } +} + +fn check_nested_table_value( + unknown: &mut Vec, + known_for_suggestion: &[&'static str], + path: String, + value: &toml::Value, + allowed: &[&str], +) { + if let Some(table) = value.as_table() { + push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed); + } +} + +fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec { + let known_for_suggestion = known_config_keys_for_suggestion(); + let mut unknown = Vec::new(); + + if let Some(root) = parsed_toml.as_table() { + push_unknown_keys( + &mut unknown, + &known_for_suggestion, + "", + root, + TOP_LEVEL_CONFIG_KEYS, + ); + } + + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general"], + GENERAL_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general", "modes"], + PROXY_MODES_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general", "telemetry"], + TELEMETRY_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general", "links"], + LINKS_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["logging"], + LOGGING_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["network"], + NETWORK_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server"], + SERVER_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server", "api"], + API_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server", "admin_api"], + API_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server", "conntrack_control"], + CONNTRACK_CONTROL_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["timeouts"], + TIMEOUTS_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["censorship"], + CENSORSHIP_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["censorship", "tls_fetch"], + TLS_FETCH_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["access"], + ACCESS_CONFIG_KEYS, + ); + + if let Some(listeners) = table_at(parsed_toml, &["server"]) + .and_then(|table| table.get("listeners")) + .and_then(toml::Value::as_array) + { + for (idx, listener) in listeners.iter().enumerate() { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("server.listeners[{idx}]"), + listener, + LISTENER_CONFIG_KEYS, + ); + } + } + + if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) { + for (idx, upstream) in upstreams.iter().enumerate() { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("upstreams[{idx}]"), + upstream, + UPSTREAM_CONFIG_KEYS, + ); + } + } + + for access_map in ["user_rate_limits", "cidr_rate_limits"] { + if let Some(table) = table_at(parsed_toml, &["access"]) + .and_then(|access| access.get(access_map)) + .and_then(toml::Value::as_table) + { + for (entry_name, value) in table { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("access.{access_map}.{entry_name}"), + value, + RATE_LIMIT_BPS_CONFIG_KEYS, + ); + } + } + } + + unknown +} + +pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> { + let unknown = collect_unknown_config_keys(parsed_toml); + if unknown.is_empty() { + return Ok(()); + } + + for item in &unknown { + if let Some(suggestion) = item.suggestion.as_deref() { + warn!( + key = %item.path, + suggestion = %suggestion, + "Unknown config key ignored; did you mean the suggested key?" + ); + } else { + warn!(key = %item.path, "Unknown config key ignored"); + } + } + + if is_strict_config(parsed_toml) { + let mut paths = Vec::with_capacity(unknown.len()); + for item in unknown { + if let Some(suggestion) = item.suggestion { + paths.push(format!("{} (did you mean `{}`?)", item.path, suggestion)); + } else { + paths.push(item.path); + } + } + return Err(ProxyError::Config(format!( + "unknown config keys are not allowed when general.config_strict=true: {}", + paths.join(", ") + ))); + } + + Ok(()) +} diff --git a/src/config/load/validation.rs b/src/config/load/validation.rs new file mode 100644 index 0000000..b95f73b --- /dev/null +++ b/src/config/load/validation.rs @@ -0,0 +1,111 @@ +use shadowsocks::config::ServerConfig as ShadowsocksServerConfig; +use tracing::warn; + +use crate::error::{ProxyError, Result}; + +use super::super::types::{LoggingConfig, LoggingDestination, NetworkConfig, UpstreamType}; +use super::ProxyConfig; + +pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> { + if !net.ipv4 && matches!(net.ipv6, Some(false)) { + return Err(ProxyError::Config( + "Both ipv4 and ipv6 are disabled in [network]".to_string(), + )); + } + + if net.prefer != 4 && net.prefer != 6 { + return Err(ProxyError::Config( + "network.prefer must be 4 or 6".to_string(), + )); + } + + if !net.ipv4 && net.prefer == 4 { + warn!("prefer=4 but ipv4=false; forcing prefer=6"); + net.prefer = 6; + } + + if matches!(net.ipv6, Some(false)) && net.prefer == 6 { + warn!("prefer=6 but ipv6=false; forcing prefer=4"); + net.prefer = 4; + } + + Ok(()) +} + +pub(super) fn validate_logging_config(logging: &LoggingConfig) -> Result<()> { + if let Some(path) = logging.path.as_ref() + && path.trim().is_empty() + { + return Err(ProxyError::Config( + "logging.path cannot be empty when provided".to_string(), + )); + } + + if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() { + return Err(ProxyError::Config( + "logging.path must be set when logging.destination=\"file\"".to_string(), + )); + } + + Ok(()) +} + +pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> { + let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| { + upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. }) + }); + + if has_enabled_shadowsocks && config.general.use_middle_proxy { + return Err(ProxyError::Config( + "shadowsocks upstreams require general.use_middle_proxy = false".to_string(), + )); + } + + 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 Some(prefer) = upstream.prefer + && prefer != 4 + && prefer != 6 + { + return Err(ProxyError::Config( + "upstream.prefer must be 4 or 6".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}")))?; + if parsed.plugin().is_some() { + return Err(ProxyError::Config( + "shadowsocks plugins are not supported".to_string(), + )); + } + } + } + + Ok(()) +} + +pub(super) fn normalize_upstream_family_policy(config: &mut ProxyConfig) { + for (idx, upstream) in config.upstreams.iter_mut().enumerate() { + if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) { + warn!( + upstream = idx, + "upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6" + ); + upstream.prefer = Some(6); + } + + if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) { + warn!( + upstream = idx, + "upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4" + ); + upstream.prefer = Some(4); + } + } +} diff --git a/src/config/tests/load_basic_tests.rs b/src/config/tests/load_basic_tests.rs new file mode 100644 index 0000000..4e46bfe --- /dev/null +++ b/src/config/tests/load_basic_tests.rs @@ -0,0 +1,2342 @@ +use super::*; + +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 +} + +fn load_config_error_from_temp_toml(toml: &str) -> String { + 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_error_{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, toml).unwrap(); + let error = ProxyConfig::load(&path).unwrap_err().to_string(); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(dir); + error +} + +#[test] +fn synlimit_synfix_defaults_are_loaded_for_listener() { + let cfg = load_config_from_temp_toml( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + "#, + ); + + let listener = &cfg.server.listeners[0]; + assert_eq!(listener.synlimit_seconds, 60); + assert_eq!(listener.synlimit_hitcount, 48); + assert_eq!(listener.synlimit_burst, 1); + assert_eq!(listener.synlimit_ios_seconds, 1); + assert_eq!(listener.synlimit_ios_hitcount, 12); + assert_eq!(listener.synlimit_ios_burst, 24); + assert_eq!(listener.synlimit_hashlimit_expire_ms, 60_000); + assert_eq!(listener.synlimit_hashlimit_size, 32_768); +} + +#[test] +fn synlimit_synfix_zero_values_are_rejected() { + for (field, expected) in [ + ( + "synlimit_ios_seconds", + "server.listeners[0].synlimit_ios_seconds must be > 0", + ), + ( + "synlimit_ios_hitcount", + "server.listeners[0].synlimit_ios_hitcount must be > 0", + ), + ( + "synlimit_ios_burst", + "server.listeners[0].synlimit_ios_burst must be > 0", + ), + ( + "synlimit_hashlimit_expire_ms", + "server.listeners[0].synlimit_hashlimit_expire_ms must be > 0", + ), + ( + "synlimit_hashlimit_size", + "server.listeners[0].synlimit_hashlimit_size must be > 0", + ), + ] { + let toml = format!( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + {field} = 0 + "# + ); + let error = load_config_error_from_temp_toml(&toml); + assert!(error.contains(expected), "{field}: {error}"); + } +} + +#[test] +fn serde_defaults_remain_unchanged_for_present_sections() { + let toml = r#" + [network] + [general] + [server] + [access] + "#; + let cfg: ProxyConfig = toml::from_str(toml).unwrap(); + + assert_eq!(cfg.logging, LoggingConfig::default()); + assert_eq!(cfg.network.ipv6, default_network_ipv6()); + assert_eq!(cfg.network.stun_use, default_true()); + assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); + assert_eq!( + cfg.general.middle_proxy_warm_standby, + default_middle_proxy_warm_standby() + ); + assert_eq!( + cfg.general.me_reconnect_max_concurrent_per_dc, + default_me_reconnect_max_concurrent_per_dc() + ); + assert_eq!( + cfg.general.me_reconnect_fast_retry_count, + default_me_reconnect_fast_retry_count() + ); + assert_eq!( + cfg.general.me_init_retry_attempts, + default_me_init_retry_attempts() + ); + assert_eq!(cfg.general.me2dc_fallback, default_me2dc_fallback()); + assert_eq!(cfg.general.me2dc_fast, default_me2dc_fast()); + assert_eq!( + cfg.general.proxy_config_v4_cache_path, + default_proxy_config_v4_cache_path() + ); + assert_eq!( + cfg.general.proxy_config_v6_cache_path, + default_proxy_config_v6_cache_path() + ); + assert_eq!( + cfg.general.me_single_endpoint_shadow_writers, + default_me_single_endpoint_shadow_writers() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_mode_enabled, + default_me_single_endpoint_outage_mode_enabled() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_disable_quarantine, + default_me_single_endpoint_outage_disable_quarantine() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_backoff_min_ms, + default_me_single_endpoint_outage_backoff_min_ms() + ); + assert_eq!( + cfg.general.me_single_endpoint_outage_backoff_max_ms, + default_me_single_endpoint_outage_backoff_max_ms() + ); + assert_eq!( + cfg.general.me_single_endpoint_shadow_rotate_every_secs, + default_me_single_endpoint_shadow_rotate_every_secs() + ); + assert_eq!(cfg.general.me_floor_mode, MeFloorMode::default()); + assert_eq!( + cfg.general.me_adaptive_floor_idle_secs, + default_me_adaptive_floor_idle_secs() + ); + assert_eq!( + cfg.general.me_adaptive_floor_min_writers_single_endpoint, + default_me_adaptive_floor_min_writers_single_endpoint() + ); + assert_eq!( + cfg.general.me_adaptive_floor_recover_grace_secs, + default_me_adaptive_floor_recover_grace_secs() + ); + assert_eq!( + cfg.general.upstream_connect_retry_attempts, + default_upstream_connect_retry_attempts() + ); + assert_eq!( + cfg.general.upstream_connect_retry_backoff_ms, + default_upstream_connect_retry_backoff_ms() + ); + assert_eq!( + cfg.general.upstream_unhealthy_fail_threshold, + default_upstream_unhealthy_fail_threshold() + ); + assert_eq!( + cfg.general.upstream_connect_failfast_hard_errors, + default_upstream_connect_failfast_hard_errors() + ); + assert_eq!( + cfg.general.rpc_proxy_req_every, + default_rpc_proxy_req_every() + ); + assert_eq!(cfg.general.beobachten_file, default_beobachten_file()); + assert_eq!(cfg.general.update_every, default_update_every()); + assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); + assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); + assert_eq!(cfg.server.client_mss_value(), Ok(None)); + assert_eq!( + cfg.server.proxy_protocol_trusted_cidrs, + default_proxy_protocol_trusted_cidrs() + ); + assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); + assert_eq!(cfg.server.api.listen, default_api_listen()); + assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); + assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop); + assert_eq!( + cfg.server.api.request_body_limit_bytes, + default_api_request_body_limit_bytes() + ); + assert_eq!( + cfg.server.api.minimal_runtime_enabled, + default_api_minimal_runtime_enabled() + ); + assert_eq!( + cfg.server.api.minimal_runtime_cache_ttl_ms, + default_api_minimal_runtime_cache_ttl_ms() + ); + assert_eq!( + cfg.server.api.runtime_edge_enabled, + default_api_runtime_edge_enabled() + ); + assert_eq!( + cfg.server.api.runtime_edge_cache_ttl_ms, + default_api_runtime_edge_cache_ttl_ms() + ); + assert_eq!( + cfg.server.api.runtime_edge_top_n, + default_api_runtime_edge_top_n() + ); + assert_eq!( + cfg.server.api.runtime_edge_events_capacity, + default_api_runtime_edge_events_capacity() + ); + assert_eq!( + cfg.server.conntrack_control.inline_conntrack_control, + default_conntrack_control_enabled() + ); + assert_eq!(cfg.server.conntrack_control.mode, ConntrackMode::default()); + assert_eq!( + cfg.server.conntrack_control.backend, + ConntrackBackend::default() + ); + assert_eq!( + cfg.server.conntrack_control.profile, + ConntrackPressureProfile::default() + ); + assert_eq!( + cfg.server.conntrack_control.pressure_high_watermark_pct, + default_conntrack_pressure_high_watermark_pct() + ); + assert_eq!( + cfg.server.conntrack_control.pressure_low_watermark_pct, + default_conntrack_pressure_low_watermark_pct() + ); + assert_eq!( + cfg.server.conntrack_control.delete_budget_per_sec, + default_conntrack_delete_budget_per_sec() + ); + assert_eq!(cfg.access.users, default_access_users()); + assert_eq!( + cfg.access.user_max_tcp_conns_global_each, + default_user_max_tcp_conns_global_each() + ); + assert_eq!( + cfg.access.user_max_unique_ips_mode, + UserMaxUniqueIpsMode::default() + ); + assert_eq!( + cfg.access.user_max_unique_ips_window_secs, + default_user_max_unique_ips_window_secs() + ); +} + +#[test] +fn logging_config_is_loaded_from_strict_config() { + let cfg = load_config_from_temp_toml( + r#" + [general] + config_strict = true + + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + path = "/tmp/telemt.log" + rotation = "daily" + max_size_bytes = 1024 + max_files = 3 + max_age_secs = 60 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert_eq!(cfg.logging.destination, LoggingDestination::File); + assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log")); + assert_eq!(cfg.logging.rotation, LogRotation::Daily); + assert_eq!(cfg.logging.max_size_bytes, 1024); + assert_eq!(cfg.logging.max_files, 3); + assert_eq!(cfg.logging.max_age_secs, 60); +} + +#[test] +fn file_logging_requires_path() { + let error = load_config_error_from_temp_toml( + r#" + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert!(error.contains("logging.path must be set")); +} + +#[test] +fn impl_defaults_are_sourced_from_default_helpers() { + let network = NetworkConfig::default(); + assert_eq!(network.ipv6, default_network_ipv6()); + assert_eq!(network.stun_use, default_true()); + assert_eq!(network.stun_tcp_fallback, default_stun_tcp_fallback()); + + let general = GeneralConfig::default(); + assert_eq!( + general.middle_proxy_warm_standby, + default_middle_proxy_warm_standby() + ); + assert_eq!( + general.me_reconnect_max_concurrent_per_dc, + default_me_reconnect_max_concurrent_per_dc() + ); + assert_eq!( + general.me_reconnect_fast_retry_count, + default_me_reconnect_fast_retry_count() + ); + assert_eq!( + general.me_init_retry_attempts, + default_me_init_retry_attempts() + ); + assert_eq!(general.me2dc_fallback, default_me2dc_fallback()); + assert_eq!(general.me2dc_fast, default_me2dc_fast()); + assert_eq!( + general.proxy_config_v4_cache_path, + default_proxy_config_v4_cache_path() + ); + assert_eq!( + general.proxy_config_v6_cache_path, + default_proxy_config_v6_cache_path() + ); + assert_eq!( + general.me_single_endpoint_shadow_writers, + default_me_single_endpoint_shadow_writers() + ); + assert_eq!( + general.me_single_endpoint_outage_mode_enabled, + default_me_single_endpoint_outage_mode_enabled() + ); + assert_eq!( + general.me_single_endpoint_outage_disable_quarantine, + default_me_single_endpoint_outage_disable_quarantine() + ); + assert_eq!( + general.me_single_endpoint_outage_backoff_min_ms, + default_me_single_endpoint_outage_backoff_min_ms() + ); + assert_eq!( + general.me_single_endpoint_outage_backoff_max_ms, + default_me_single_endpoint_outage_backoff_max_ms() + ); + assert_eq!( + general.me_single_endpoint_shadow_rotate_every_secs, + default_me_single_endpoint_shadow_rotate_every_secs() + ); + assert_eq!(general.me_floor_mode, MeFloorMode::default()); + assert_eq!( + general.me_adaptive_floor_idle_secs, + default_me_adaptive_floor_idle_secs() + ); + assert_eq!( + general.me_adaptive_floor_min_writers_single_endpoint, + default_me_adaptive_floor_min_writers_single_endpoint() + ); + assert_eq!( + general.me_adaptive_floor_recover_grace_secs, + default_me_adaptive_floor_recover_grace_secs() + ); + assert_eq!( + general.upstream_connect_retry_attempts, + default_upstream_connect_retry_attempts() + ); + assert_eq!( + general.upstream_connect_retry_backoff_ms, + default_upstream_connect_retry_backoff_ms() + ); + assert_eq!( + general.upstream_unhealthy_fail_threshold, + default_upstream_unhealthy_fail_threshold() + ); + assert_eq!( + general.upstream_connect_failfast_hard_errors, + default_upstream_connect_failfast_hard_errors() + ); + assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every()); + assert_eq!(general.beobachten_file, default_beobachten_file()); + assert_eq!(general.update_every, default_update_every()); + + let server = ServerConfig::default(); + assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6())); + assert_eq!( + server.proxy_protocol_trusted_cidrs, + default_proxy_protocol_trusted_cidrs() + ); + assert_eq!( + AntiCensorshipConfig::default().unknown_sni_action, + UnknownSniAction::Drop + ); + assert_eq!(server.api.listen, default_api_listen()); + assert_eq!(server.api.whitelist, default_api_whitelist()); + assert_eq!(server.api.gray_action, ApiGrayAction::Drop); + assert_eq!( + server.api.request_body_limit_bytes, + default_api_request_body_limit_bytes() + ); + assert_eq!( + server.api.minimal_runtime_enabled, + default_api_minimal_runtime_enabled() + ); + assert_eq!( + server.api.minimal_runtime_cache_ttl_ms, + default_api_minimal_runtime_cache_ttl_ms() + ); + assert_eq!( + server.api.runtime_edge_enabled, + default_api_runtime_edge_enabled() + ); + assert_eq!( + server.api.runtime_edge_cache_ttl_ms, + default_api_runtime_edge_cache_ttl_ms() + ); + assert_eq!( + server.api.runtime_edge_top_n, + default_api_runtime_edge_top_n() + ); + assert_eq!( + server.api.runtime_edge_events_capacity, + default_api_runtime_edge_events_capacity() + ); + assert_eq!( + server.conntrack_control.inline_conntrack_control, + default_conntrack_control_enabled() + ); + assert_eq!(server.conntrack_control.mode, ConntrackMode::default()); + assert_eq!( + server.conntrack_control.backend, + ConntrackBackend::default() + ); + assert_eq!( + server.conntrack_control.profile, + ConntrackPressureProfile::default() + ); + assert_eq!( + server.conntrack_control.pressure_high_watermark_pct, + default_conntrack_pressure_high_watermark_pct() + ); + assert_eq!( + server.conntrack_control.pressure_low_watermark_pct, + default_conntrack_pressure_low_watermark_pct() + ); + assert_eq!( + server.conntrack_control.delete_budget_per_sec, + default_conntrack_delete_budget_per_sec() + ); + + let access = AccessConfig::default(); + assert_eq!(access.users, default_access_users()); + assert_eq!( + access.user_max_tcp_conns_global_each, + default_user_max_tcp_conns_global_each() + ); +} + +#[test] +fn proxy_protocol_trusted_cidrs_missing_uses_trust_all_but_explicit_empty_stays_empty() { + let cfg_missing: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert_eq!( + cfg_missing.server.proxy_protocol_trusted_cidrs, + default_proxy_protocol_trusted_cidrs() + ); + + let cfg_explicit_empty: ProxyConfig = toml::from_str( + r#" + [server] + proxy_protocol_trusted_cidrs = [] + + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert!( + cfg_explicit_empty + .server + .proxy_protocol_trusted_cidrs + .is_empty() + ); +} + +#[test] +fn conntrack_inline_explicit_flag_is_false_when_omitted() { + let cfg = load_config_from_temp_toml( + r#" + [general] + [network] + [server] + [server.conntrack_control] + [access] + "#, + ); + assert!( + !cfg.server + .conntrack_control + .inline_conntrack_control_explicit + ); +} + +#[test] +fn conntrack_inline_explicit_flag_is_true_when_present() { + let cfg = load_config_from_temp_toml( + r#" + [general] + [network] + [server] + [server.conntrack_control] + inline_conntrack_control = true + [access] + "#, + ); + assert!( + cfg.server + .conntrack_control + .inline_conntrack_control_explicit + ); +} + +#[test] +fn unknown_sni_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + "#, + ) + .unwrap(); + assert_eq!( + cfg_default.censorship.unknown_sni_action, + UnknownSniAction::Drop + ); + + let cfg_mask: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "mask" + "#, + ) + .unwrap(); + assert_eq!( + cfg_mask.censorship.unknown_sni_action, + UnknownSniAction::Mask + ); + + let cfg_accept: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "accept" + "#, + ) + .unwrap(); + assert_eq!( + cfg_accept.censorship.unknown_sni_action, + UnknownSniAction::Accept + ); + + let cfg_reject: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "reject_handshake" + "#, + ) + .unwrap(); + assert_eq!( + cfg_reject.censorship.unknown_sni_action, + UnknownSniAction::RejectHandshake + ); +} + +#[test] +fn exclusive_mask_parses_domain_target_map() { + let cfg = load_config_from_temp_toml( + r#" + [general] + [network] + [server] + [access] + [censorship] + tls_domain = "weißbiergärten.de" + tls_domains = ["bürgeramt.de"] + [censorship.exclusive_mask] + "bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443" + "ipv6.example" = "[::1]:443" + "#, + ); + + assert!(cfg.censorship.tls_domain.is_ascii()); + assert!(cfg.censorship.tls_domain.contains("xn--")); + assert_eq!(cfg.censorship.tls_domains.len(), 1); + let normalized_extra = &cfg.censorship.tls_domains[0]; + assert!(normalized_extra.is_ascii()); + assert!(normalized_extra.contains("xn--")); + + let normalized_target = cfg + .censorship + .exclusive_mask + .get(normalized_extra) + .expect("exclusive_mask key must match normalized tls_domains entry"); + assert!(normalized_target.is_ascii()); + assert!(normalized_target.contains("xn--")); + assert!(normalized_target.ends_with(":443")); + assert_eq!( + cfg.censorship.exclusive_mask.get("ipv6.example"), + Some(&"[::1]:443".to_string()) + ); +} + +#[test] +fn api_gray_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop); + + let cfg_api: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "api" + "#, + ) + .unwrap(); + assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api); + + let cfg_200: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "200" + "#, + ) + .unwrap(); + assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200); + + let cfg_drop: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "drop" + "#, + ) + .unwrap(); + assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); +} + +#[test] +fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() { + let cfg = load_config_from_temp_toml( + r#" + beobachten = false + beobachten_minutes = 7 + beobachten_flush_secs = 3 + beobachten_file = "tmp/legacy-beob.txt" + + [server] + [general] + [network] + [access] + "#, + ); + + assert!(!cfg.general.beobachten); + assert_eq!(cfg.general.beobachten_minutes, 7); + assert_eq!(cfg.general.beobachten_flush_secs, 3); + assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt"); +} + +#[test] +fn general_beobachten_keys_have_priority_over_legacy_top_level() { + let cfg = load_config_from_temp_toml( + r#" + beobachten = true + beobachten_minutes = 30 + beobachten_flush_secs = 30 + beobachten_file = "tmp/legacy-beob.txt" + + [server] + [general] + beobachten = false + beobachten_minutes = 5 + beobachten_flush_secs = 2 + beobachten_file = "tmp/general-beob.txt" + [network] + [access] + "#, + ); + + assert!(!cfg.general.beobachten); + assert_eq!(cfg.general.beobachten_minutes, 5); + assert_eq!(cfg.general.beobachten_flush_secs, 2); + assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt"); +} + +#[test] +fn dc_overrides_allow_string_and_array() { + let toml = r#" + [dc_overrides] + "201" = "149.154.175.50:443" + "202" = ["149.154.167.51:443", "149.154.175.100:443"] + "#; + let cfg: ProxyConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.dc_overrides["201"], vec!["149.154.175.50:443"]); + assert_eq!( + cfg.dc_overrides["202"], + vec!["149.154.167.51:443", "149.154.175.100:443"] + ); +} + +#[test] +fn load_with_metadata_collects_include_files() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("telemt_load_metadata_{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let main_path = dir.join("config.toml"); + let include_path = dir.join("included.toml"); + + std::fs::write( + &include_path, + r#" + [access.users] + user = "00000000000000000000000000000000" + "#, + ) + .unwrap(); + std::fs::write( + &main_path, + r#" + include = "included.toml" + + [censorship] + tls_domain = "example.com" + "#, + ) + .unwrap(); + + let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap(); + let main_normalized = normalize_config_path(&main_path); + let include_normalized = normalize_config_path(&include_path); + + assert!(loaded.source_files.contains(&main_normalized)); + assert!(loaded.source_files.contains(&include_normalized)); + + let _ = std::fs::remove_file(main_path); + let _ = std::fs::remove_file(include_path); + let _ = std::fs::remove_dir(dir); +} + +#[test] +fn dc_overrides_inject_dc203_default() { + let toml = r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_dc_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!( + cfg.dc_overrides + .get("203") + .map(|v| v.contains(&"91.105.192.100:443".to_string())) + .unwrap_or(false) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn update_every_overrides_legacy_fields() { + let toml = r#" + [general] + update_every = 123 + proxy_secret_auto_reload_secs = 700 + proxy_config_auto_reload_secs = 800 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_update_every_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.effective_update_every_secs(), 123); + let _ = std::fs::remove_file(path); +} + +#[test] +fn update_every_fallback_to_legacy_min() { + let toml = r#" + [general] + proxy_secret_auto_reload_secs = 600 + proxy_config_auto_reload_secs = 120 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_update_every_legacy_min_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.update_every, None); + assert_eq!(cfg.general.effective_update_every_secs(), 120); + let _ = std::fs::remove_file(path); +} + +#[test] +fn update_every_zero_is_rejected() { + let toml = r#" + [general] + update_every = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_update_every_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.update_every must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn stun_nat_probe_concurrency_zero_is_rejected() { + let toml = r#" + [general] + stun_nat_probe_concurrency = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_stun_nat_probe_concurrency_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.stun_nat_probe_concurrency must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_reinit_every_default_is_set() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_reinit_every_default_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.me_reinit_every_secs, + default_me_reinit_every_secs() + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_reinit_every_zero_is_rejected() { + let toml = r#" + [general] + me_reinit_every_secs = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_reinit_every_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_reinit_every_secs must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_single_endpoint_outage_backoff_range_is_validated() { + let toml = r#" + [general] + me_single_endpoint_outage_backoff_min_ms = 4000 + me_single_endpoint_outage_backoff_max_ms = 3000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_single_endpoint_outage_backoff_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains( + "general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_single_endpoint_shadow_writers_too_large_is_rejected() { + let toml = r#" + [general] + me_single_endpoint_shadow_writers = 33 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_single_endpoint_shadow_writers_limit_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_single_endpoint_shadow_writers must be within [0, 32]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_adaptive_floor_min_writers_out_of_range_is_rejected() { + let toml = r#" + [general] + me_adaptive_floor_min_writers_single_endpoint = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_adaptive_floor_min_writers_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!( + err.contains( + "general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]" + ) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_floor_mode_adaptive_is_parsed() { + let toml = r#" + [general] + me_floor_mode = "adaptive" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_floor_mode_adaptive_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_floor_mode, MeFloorMode::Adaptive); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_adaptive_floor_max_active_writers_per_core_zero_is_rejected() { + let toml = r#" + [general] + me_adaptive_floor_max_active_writers_per_core = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_adaptive_floor_max_active_per_core_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_adaptive_floor_max_active_writers_per_core must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_adaptive_floor_max_warm_writers_global_zero_is_rejected() { + let toml = r#" + [general] + me_adaptive_floor_max_warm_writers_global = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_adaptive_floor_max_warm_global_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_adaptive_floor_max_warm_writers_global must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn upstream_connect_retry_attempts_zero_is_rejected() { + let toml = r#" + [general] + upstream_connect_retry_attempts = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_upstream_connect_retry_attempts_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.upstream_connect_retry_attempts must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn upstream_unhealthy_fail_threshold_zero_is_rejected() { + let toml = r#" + [general] + upstream_unhealthy_fail_threshold = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_upstream_unhealthy_fail_threshold_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.upstream_unhealthy_fail_threshold must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tg_connect_zero_is_rejected() { + let toml = r#" + [general] + tg_connect = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tg_connect_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.tg_connect must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn rpc_proxy_req_every_out_of_range_is_rejected() { + let toml = r#" + [general] + rpc_proxy_req_every = 9 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_rpc_proxy_req_every_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.rpc_proxy_req_every must be 0 or within [10, 300]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn rpc_proxy_req_every_zero_and_valid_range_are_accepted() { + let toml_zero = r#" + [general] + rpc_proxy_req_every = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path_zero = dir.join("telemt_rpc_proxy_req_every_zero_ok_test.toml"); + std::fs::write(&path_zero, toml_zero).unwrap(); + let cfg_zero = ProxyConfig::load(&path_zero).unwrap(); + assert_eq!(cfg_zero.general.rpc_proxy_req_every, 0); + let _ = std::fs::remove_file(path_zero); + + let toml_valid = r#" + [general] + rpc_proxy_req_every = 40 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let path_valid = dir.join("telemt_rpc_proxy_req_every_valid_ok_test.toml"); + std::fs::write(&path_valid, toml_valid).unwrap(); + let cfg_valid = ProxyConfig::load(&path_valid).unwrap(); + assert_eq!(cfg_valid.general.rpc_proxy_req_every, 40); + let _ = std::fs::remove_file(path_valid); +} + +#[test] +fn me_route_backpressure_base_timeout_ms_out_of_range_is_rejected() { + let toml = r#" + [general] + me_route_backpressure_base_timeout_ms = 5001 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_backpressure_base_timeout_ms_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_backpressure_base_timeout_ms must be within [1, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_backpressure_high_timeout_ms_out_of_range_is_rejected() { + let toml = r#" + [general] + me_route_backpressure_base_timeout_ms = 100 + me_route_backpressure_high_timeout_ms = 5001 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_backpressure_high_timeout_ms_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_backpressure_high_timeout_ms must be within [1, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_no_writer_wait_ms_out_of_range_is_rejected() { + let toml = r#" + [general] + me_route_no_writer_wait_ms = 5 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_no_writer_wait_ms_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_no_writer_wait_ms must be within [10, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_blocking_send_timeout_ms_zero_is_rejected() { + let toml = r#" + [general] + me_route_blocking_send_timeout_ms = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_route_no_writer_mode_is_parsed() { + let toml = r#" + [general] + me_route_no_writer_mode = "inline_recovery_legacy" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_route_no_writer_mode_parse_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.me_route_no_writer_mode, + crate::config::MeRouteNoWriterMode::InlineRecoveryLegacy + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn proxy_config_cache_paths_empty_are_rejected() { + let toml = r#" + [general] + proxy_config_v4_cache_path = " " + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_proxy_config_v4_cache_path_empty_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.proxy_config_v4_cache_path cannot be empty")); + let _ = std::fs::remove_file(path); + + let toml_v6 = r#" + [general] + proxy_config_v6_cache_path = "" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let path_v6 = dir.join("telemt_proxy_config_v6_cache_path_empty_test.toml"); + std::fs::write(&path_v6, toml_v6).unwrap(); + let err_v6 = ProxyConfig::load(&path_v6).unwrap_err().to_string(); + assert!(err_v6.contains("general.proxy_config_v6_cache_path cannot be empty")); + let _ = std::fs::remove_file(path_v6); +} + +#[test] +fn me_hardswap_warmup_defaults_are_set() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_defaults_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.me_hardswap_warmup_delay_min_ms, + default_me_hardswap_warmup_delay_min_ms() + ); + assert_eq!( + cfg.general.me_hardswap_warmup_delay_max_ms, + default_me_hardswap_warmup_delay_max_ms() + ); + assert_eq!( + cfg.general.me_hardswap_warmup_extra_passes, + default_me_hardswap_warmup_extra_passes() + ); + assert_eq!( + cfg.general.me_hardswap_warmup_pass_backoff_base_ms, + default_me_hardswap_warmup_pass_backoff_base_ms() + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_delay_range_is_validated() { + let toml = r#" + [general] + me_hardswap_warmup_delay_min_ms = 2001 + me_hardswap_warmup_delay_max_ms = 2000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_delay_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains( + "general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_delay_max_zero_is_rejected() { + let toml = r#" + [general] + me_hardswap_warmup_delay_max_ms = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_delay_max_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_hardswap_warmup_delay_max_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_extra_passes_out_of_range_is_rejected() { + let toml = r#" + [general] + me_hardswap_warmup_extra_passes = 11 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_extra_passes_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_hardswap_warmup_extra_passes must be within [0, 10]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_hardswap_warmup_pass_backoff_zero_is_rejected() { + let toml = r#" + [general] + me_hardswap_warmup_pass_backoff_base_ms = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_hardswap_warmup_backoff_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_hardswap_warmup_pass_backoff_base_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_config_stable_snapshots_zero_is_rejected() { + let toml = r#" + [general] + me_config_stable_snapshots = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_config_stable_snapshots_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_config_stable_snapshots must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn proxy_secret_stable_snapshots_zero_is_rejected() { + let toml = r#" + [general] + proxy_secret_stable_snapshots = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_proxy_secret_stable_snapshots_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.proxy_secret_stable_snapshots must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn proxy_secret_len_max_out_of_range_is_rejected() { + let toml = r#" + [general] + proxy_secret_len_max = 16 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_proxy_secret_len_max_out_of_range_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.proxy_secret_len_max must be within [32, 4096]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn me_pool_min_fresh_ratio_out_of_range_is_rejected() { + let toml = r#" + [general] + me_pool_min_fresh_ratio = 1.5 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_me_pool_min_ratio_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("general.me_pool_min_fresh_ratio must be within [0.0, 1.0]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn api_minimal_runtime_cache_ttl_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + minimal_runtime_cache_ttl_ms = 70000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_minimal_runtime_cache_ttl_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn client_mss_presets_and_listener_override_are_resolved() { + let toml = r#" + [server] + client_mss = "tspu" + + [[server.listeners]] + ip = "127.0.0.1" + port = 1443 + + [[server.listeners]] + ip = "127.0.0.2" + port = 1444 + client_mss = "2in8" + + [[server.listeners]] + ip = "127.0.0.3" + port = 1445 + client_mss = "" + + [[server.listeners]] + ip = "127.0.0.4" + port = 1446 + client_mss = "extreme-low" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); + assert_eq!( + cfg.server.listeners[0].effective_client_mss(&cfg.server), + Ok(Some(92)) + ); + assert_eq!( + cfg.server.listeners[1].effective_client_mss(&cfg.server), + Ok(Some(256)) + ); + assert_eq!( + cfg.server.listeners[2].effective_client_mss(&cfg.server), + Ok(None) + ); + assert_eq!( + cfg.server.listeners[3].effective_client_mss(&cfg.server), + Ok(Some(88)) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn client_mss_custom_value_is_accepted() { + let toml = r#" + [server] + client_mss = "4096" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_custom_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096))); + let _ = std::fs::remove_file(path); +} + +#[test] +fn client_mss_out_of_range_is_rejected() { + for value in ["87", "4097"] { + let toml = format!( + r#" + [server] + client_mss = "{value}" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "# + ); + let dir = std::env::temp_dir(); + let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml")); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.client_mss custom value must be within [88, 4096]")); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn client_mss_unquoted_number_is_rejected() { + let toml = r#" + [server] + client_mss = 256 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_unquoted_number_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("client_mss")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn listener_client_mss_invalid_preset_is_rejected() { + let toml = r#" + [[server.listeners]] + ip = "127.0.0.1" + port = 1443 + client_mss = "tiny" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_listener_client_mss_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.listeners[0].client_mss")); + assert!(err.contains("must be \"\", extreme-low, tspu, 2in8")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_cache_ttl_ms = 70000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn api_runtime_edge_top_n_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_top_n = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn api_runtime_edge_events_capacity_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_events_capacity = 8 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_pressure_high_watermark_out_of_range_is_rejected() { + let toml = r#" + [server.conntrack_control] + pressure_high_watermark_pct = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_high_watermark_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!( + err.contains( + "server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]" + ) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_pressure_low_watermark_must_be_below_high() { + let toml = r#" + [server.conntrack_control] + pressure_high_watermark_pct = 50 + pressure_low_watermark_pct = 50 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_low_watermark_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains( + "server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_delete_budget_zero_is_rejected() { + let toml = r#" + [server.conntrack_control] + delete_budget_per_sec = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_delete_budget_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.conntrack_control.delete_budget_per_sec must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_hybrid_mode_requires_listener_allow_list() { + let toml = r#" + [server.conntrack_control] + mode = "hybrid" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_hybrid_requires_ips_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!( + err.contains( + "server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid" + ) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn conntrack_profile_is_loaded_from_config() { + let toml = r#" + [server.conntrack_control] + profile = "aggressive" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_conntrack_profile_parse_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.server.conntrack_control.profile, + ConntrackPressureProfile::Aggressive + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn force_close_default_matches_drain_ttl() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_force_close_default_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); + assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 90); + let _ = std::fs::remove_file(path); +} + +#[test] +fn force_close_zero_uses_runtime_safety_fallback() { + let toml = r#" + [general] + me_reinit_drain_timeout_secs = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_force_close_zero_fallback_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 0); + assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 300); + let _ = std::fs::remove_file(path); +} + +#[test] +fn force_close_bumped_when_below_drain_ttl() { + let toml = r#" + [general] + me_pool_drain_ttl_secs = 90 + me_reinit_drain_timeout_secs = 30 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_force_close_bump_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_scope_default_is_empty() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_scope_default_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!(cfg.censorship.tls_fetch_scope.is_empty()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_scope_is_trimmed_during_load() { + let toml = r#" + [censorship] + tls_domain = "example.com" + tls_fetch_scope = " me " + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_scope_trim_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.censorship.tls_fetch_scope, "me"); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_scope_whitespace_becomes_empty() { + let toml = r#" + [censorship] + tls_domain = "example.com" + tls_fetch_scope = " " + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_scope_blank_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!(cfg.censorship.tls_fetch_scope.is_empty()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_defaults_are_applied() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_defaults_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.censorship.tls_fetch.profiles, + TlsFetchConfig::default().profiles + ); + assert!(cfg.censorship.tls_fetch.strict_route); + assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000); + assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000); + assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_profiles_are_deduplicated_preserving_order() { + let toml = r#" + [censorship] + tls_domain = "example.com" + [censorship.tls_fetch] + profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"] + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.censorship.tls_fetch.profiles, + vec![ + TlsFetchProfile::CompatTls12, + TlsFetchProfile::ModernChromeLike, + TlsFetchProfile::LegacyMinimal + ] + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_attempt_timeout_zero_is_rejected() { + let toml = r#" + [censorship] + tls_domain = "example.com" + [censorship.tls_fetch] + attempt_timeout_ms = 0 + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn tls_fetch_total_budget_zero_is_rejected() { + let toml = r#" + [censorship] + tls_domain = "example.com" + [censorship.tls_fetch] + total_budget_ms = 0 + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_ad_tag_is_disabled_during_load() { + let toml = r#" + [general] + ad_tag = "not_hex" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_invalid_ad_tag_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert!(cfg.general.ad_tag.is_none()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn valid_ad_tag_is_preserved_during_load() { + let toml = r#" + [general] + ad_tag = "00112233445566778899aabbccddeeff" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_valid_ad_tag_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!( + cfg.general.ad_tag.as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn shadowsocks_upstream_url_loads_successfully() { + let toml = format!( + r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "{url}" + interface = "127.0.0.2" + "#, + url = TEST_SHADOWSOCKS_URL, + ); + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert!(matches!( + &cfg.upstreams[0].upstream_type, + UpstreamType::Shadowsocks { url, interface } + if url == TEST_SHADOWSOCKS_URL && interface.as_deref() == Some("127.0.0.2") + )); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn shadowsocks_requires_direct_mode() { + let toml = format!( + r#" + [general] + use_middle_proxy = true + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "{url}" + "#, + url = TEST_SHADOWSOCKS_URL, + ); + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_me_reject_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("shadowsocks upstreams require general.use_middle_proxy = false")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_shadowsocks_url_is_rejected() { + let toml = r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "not-a-valid-ss-url" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_invalid_url_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("invalid shadowsocks url")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn shadowsocks_plugins_are_rejected() { + let toml = format!( + r#" + [general] + use_middle_proxy = false + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[upstreams]] + type = "shadowsocks" + url = "{url}?plugin=obfs-local%3Bobfs%3Dhttp" + "#, + url = TEST_SHADOWSOCKS_URL, + ); + let dir = std::env::temp_dir(); + let path = dir.join("telemt_shadowsocks_plugin_reject_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("shadowsocks plugins are not supported")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_user_ad_tag_reports_access_user_ad_tags_key() { + let toml = r#" + [censorship] + tls_domain = "example.com" + + [access.users] + alice = "00000000000000000000000000000000" + + [access.user_ad_tags] + alice = "not_hex" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_invalid_user_ad_tag_message_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("access.user_ad_tags['alice'] must be exactly 32 hex characters")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_dns_override_is_rejected() { + let toml = r#" + [network] + dns_overrides = ["example.com:443:2001:db8::10"] + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_invalid_dns_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("must be bracketed")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn valid_dns_override_is_accepted() { + let toml = r#" + [network] + dns_overrides = ["example.com:443:127.0.0.1", "example.net:443:[2001:db8::10]"] + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_valid_dns_override_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + assert_eq!(cfg.network.dns_overrides.len(), 2); + let _ = std::fs::remove_file(path); +} diff --git a/src/config/types.rs b/src/config/types.rs index 6d38882..c249746 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1455,9 +1455,9 @@ pub enum SynLimitMode { /// Disable SYN limiting for this listener. #[default] Off, - /// Use iptables/ip6tables filter rules with the hashlimit match. + /// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match. Iptables, - /// Use nftables rules with per-source token-bucket meters. + /// Use nftables two-tier SYN-fix rules with per-source token-bucket meters. Nftables, } @@ -2266,15 +2266,30 @@ pub struct ListenerConfig { /// Per-listener SYN limiter mode. #[serde(default)] pub synlimit: SynLimitMode, - /// Token-bucket rate interval for the per-listener SYN limiter. + /// Generic SYN-fix token-bucket rate interval. #[serde(default = "default_synlimit_seconds")] pub synlimit_seconds: u32, - /// Token-bucket rate amount for the per-listener SYN limiter. + /// Generic SYN-fix token-bucket rate amount. #[serde(default = "default_synlimit_hitcount")] pub synlimit_hitcount: u32, - /// Token-bucket burst size for the per-listener SYN limiter. + /// Generic SYN-fix token-bucket burst size. #[serde(default = "default_synlimit_burst")] pub synlimit_burst: u32, + /// iOS-like SYN-fix token-bucket rate interval. + #[serde(default = "default_synlimit_ios_seconds")] + pub synlimit_ios_seconds: u32, + /// iOS-like SYN-fix token-bucket rate amount. + #[serde(default = "default_synlimit_ios_hitcount")] + pub synlimit_ios_hitcount: u32, + /// iOS-like SYN-fix token-bucket burst size. + #[serde(default = "default_synlimit_ios_burst")] + pub synlimit_ios_burst: u32, + /// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules. + #[serde(default = "default_synlimit_hashlimit_expire_ms")] + pub synlimit_hashlimit_expire_ms: u32, + /// Hashlimit table size for iptables/ip6tables rules. + #[serde(default = "default_synlimit_hashlimit_size")] + pub synlimit_hashlimit_size: u32, /// IP address or hostname to announce in proxy links. /// Takes precedence over `announce_ip` if both are set. #[serde(default)] diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index cf2dcd2..82ee893 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -5,7 +5,7 @@ pub mod hash; pub mod random; pub use aes::{AesCbc, AesCtr}; -pub use hash::{ - build_middleproxy_prekey, crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac, -}; +#[allow(unused_imports)] +pub use hash::build_middleproxy_prekey; +pub use hash::{crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac}; pub use random::SecureRandom; diff --git a/src/proxy/middle_relay/d2c.rs b/src/proxy/middle_relay/d2c.rs index 7faa702..a1906fc 100644 --- a/src/proxy/middle_relay/d2c.rs +++ b/src/proxy/middle_relay/d2c.rs @@ -55,8 +55,8 @@ pub(super) fn classify_me_d2c_flush_reason( MeD2cFlushReason::QueueDrain } -pub(super) fn me_d2c_flush_reason_requires_client_flush(reason: MeD2cFlushReason) -> bool { - !matches!(reason, MeD2cFlushReason::QueueDrain) +pub(super) fn me_d2c_flush_reason_requires_client_flush(_reason: MeD2cFlushReason) -> bool { + true } #[cfg(test)] @@ -64,8 +64,8 @@ mod tests { use super::*; #[test] - fn queue_drain_is_not_a_physical_flush_trigger() { - assert!(!me_d2c_flush_reason_requires_client_flush( + fn all_flush_reasons_trigger_physical_flush() { + assert!(me_d2c_flush_reason_requires_client_flush( MeD2cFlushReason::QueueDrain )); assert!(me_d2c_flush_reason_requires_client_flush( diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs deleted file mode 100644 index 8ca8d7c..0000000 --- a/src/synlimit_control.rs +++ /dev/null @@ -1,605 +0,0 @@ -use std::collections::BTreeSet; -use std::net::IpAddr; -use std::path::PathBuf; -use std::sync::Arc; - -use tokio::io::AsyncWriteExt; -use tokio::process::Command; -use tokio::sync::watch; -use tracing::warn; - -use crate::config::{ProxyConfig, SynLimitMode}; - -const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT"; -const IPTABLES_HASHLIMIT_NAME: &str = "TELEMT-BUMPER"; -const NFT_TABLE: &str = "telemt_synlimit"; -const NFT_CHAIN: &str = "input"; -type SynLimitTarget = (Option, u16, u32, u32, u32); - -#[derive(Default)] -struct SynLimitTargets { - iptables_v4: Vec, - iptables_v6: Vec, - nft_v4: Vec, - nft_v6: Vec, -} - -#[derive(Clone, Copy)] -struct NftTableFamilies { - inet: bool, - ip: bool, - ip6: bool, -} - -#[derive(Clone, Copy)] -enum NftFamily { - Inet, - Ip, - Ip6, -} - -struct NftApplyPlan<'a> { - family: NftFamily, - v4_targets: &'a [SynLimitTarget], - v6_targets: &'a [SynLimitTarget], -} - -impl SynLimitTargets { - fn is_empty(&self) -> bool { - self.iptables_v4.is_empty() - && self.iptables_v6.is_empty() - && self.nft_v4.is_empty() - && self.nft_v6.is_empty() - } - - fn has_iptables_targets(&self) -> bool { - !self.iptables_v4.is_empty() || !self.iptables_v6.is_empty() - } - - fn has_nft_targets(&self) -> bool { - !self.nft_v4.is_empty() || !self.nft_v6.is_empty() - } -} -impl NftFamily { - fn as_str(self) -> &'static str { - match self { - Self::Inet => "inet", - Self::Ip => "ip", - Self::Ip6 => "ip6", - } - } -} - -pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver>) { - if !cfg!(target_os = "linux") { - if has_synlimit_config(&config_rx.borrow()) { - warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules"); - } - return; - } - - tokio::spawn(async move { - wait_for_config_channel_close_and_reconcile(config_rx).await; - if let Err(error) = clear_synlimit_rules_all_backends().await { - warn!(error = %error, "Failed to clear SYN limiter rules after config channel close"); - } - }); -} - -async fn wait_for_config_channel_close_and_reconcile( - mut config_rx: watch::Receiver>, -) { - while config_rx.changed().await.is_ok() { - let cfg = config_rx.borrow_and_update().clone(); - reconcile_synlimit_rules(&cfg).await; - } -} - -pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { - if let Err(error) = clear_synlimit_rules_all_backends().await { - warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile"); - } - - let targets = synlimit_targets(cfg); - if targets.is_empty() { - return; - } - if !has_cap_net_admin() { - warn!( - "SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied" - ); - return; - } - - if targets.has_iptables_targets() - && let Err(error) = apply_iptables_synlimit_rules(&targets).await - { - warn!(error = %error, "Failed to apply iptables SYN limiter rules"); - } - if targets.has_nft_targets() - && let Err(error) = apply_nft_synlimit_rules(&targets).await - { - warn!(error = %error, "Failed to apply nftables SYN limiter rules"); - } -} - -pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> { - if !has_cap_net_admin() { - return Ok(()); - } - - let mut errors = Vec::new(); - if let Err(error) = clear_nft_synlimit_rules_all_families().await { - errors.push(error); - } - if let Err(error) = clear_iptables_synlimit_rules_for_binary("iptables").await { - errors.push(error); - } - if let Err(error) = clear_iptables_synlimit_rules_for_binary("ip6tables").await { - errors.push(error); - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } -} - -fn has_synlimit_config(cfg: &ProxyConfig) -> bool { - cfg.server - .listeners - .iter() - .any(|listener| !matches!(listener.synlimit, SynLimitMode::Off)) -} - -fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { - let mut iptables_v4 = BTreeSet::new(); - let mut iptables_v6 = BTreeSet::new(); - let mut nft_v4 = BTreeSet::new(); - let mut nft_v6 = BTreeSet::new(); - - for listener in &cfg.server.listeners { - let backend = listener.synlimit; - if matches!(backend, SynLimitMode::Off) { - continue; - } - let port = listener.port.unwrap_or(cfg.server.port); - let ip = (!listener.ip.is_unspecified()).then_some(listener.ip); - let seconds = listener.synlimit_seconds; - let hitcount = listener.synlimit_hitcount; - let burst = listener.synlimit_burst; - - match (backend, listener.ip.is_ipv4()) { - (SynLimitMode::Iptables, true) => { - iptables_v4.insert((ip, port, seconds, hitcount, burst)); - } - (SynLimitMode::Iptables, false) => { - iptables_v6.insert((ip, port, seconds, hitcount, burst)); - } - (SynLimitMode::Nftables, true) => { - nft_v4.insert((ip, port, seconds, hitcount, burst)); - } - (SynLimitMode::Nftables, false) => { - nft_v6.insert((ip, port, seconds, hitcount, burst)); - } - (SynLimitMode::Off, _) => {} - } - } - - SynLimitTargets { - iptables_v4: iptables_v4.into_iter().collect(), - iptables_v6: iptables_v6.into_iter().collect(), - nft_v4: nft_v4.into_iter().collect(), - nft_v6: nft_v6.into_iter().collect(), - } -} - -async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> { - apply_iptables_synlimit_rules_for_binary("iptables", &targets.iptables_v4).await?; - apply_iptables_synlimit_rules_for_binary("ip6tables", &targets.iptables_v6).await -} - -async fn apply_iptables_synlimit_rules_for_binary( - binary: &str, - targets: &[SynLimitTarget], -) -> Result<(), String> { - if targets.is_empty() { - return Ok(()); - } - let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await; - run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?; - if run_command( - binary, - &["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN], - None, - ) - .await - .is_err() - { - run_command( - binary, - &["-t", "filter", "-A", "INPUT", "-j", IPTABLES_CHAIN], - None, - ) - .await?; - } - - for (idx, (ip, port, seconds, hitcount, burst)) in targets.iter().enumerate() { - let hashlimit_name = format!("{IPTABLES_HASHLIMIT_NAME}-{idx}"); - let accept_args = iptables_hashlimit_accept_rule_args( - ip, - *port, - *seconds, - *hitcount, - *burst, - &hashlimit_name, - ); - let drop_args = iptables_synlimit_drop_rule_args(ip, *port); - let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect(); - let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect(); - run_command(binary, &accept_refs, None).await?; - run_command(binary, &drop_refs, None).await?; - } - run_command( - binary, - &["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"], - None, - ) - .await?; - - Ok(()) -} - -fn iptables_hashlimit_accept_rule_args( - ip: &Option, - port: u16, - seconds: u32, - hitcount: u32, - burst: u32, - hashlimit_name: &str, -) -> Vec { - let mut args = vec![ - "-t".to_string(), - "filter".to_string(), - "-A".to_string(), - IPTABLES_CHAIN.to_string(), - "-p".to_string(), - "tcp".to_string(), - "--syn".to_string(), - ]; - if let Some(ip) = ip { - args.push("-d".to_string()); - args.push(ip.to_string()); - } - let rate = synlimit_rate_arg(seconds, hitcount); - args.extend([ - "--dport".to_string(), - port.to_string(), - "-m".to_string(), - "hashlimit".to_string(), - "--hashlimit-name".to_string(), - hashlimit_name.to_string(), - "--hashlimit-mode".to_string(), - "srcip".to_string(), - "--hashlimit-upto".to_string(), - rate, - "--hashlimit-burst".to_string(), - burst.to_string(), - "--hashlimit-htable-expire".to_string(), - "15000".to_string(), - "-j".to_string(), - "ACCEPT".to_string(), - ]); - args -} - -fn iptables_synlimit_drop_rule_args(ip: &Option, port: u16) -> Vec { - let mut args = vec![ - "-t".to_string(), - "filter".to_string(), - "-A".to_string(), - IPTABLES_CHAIN.to_string(), - "-p".to_string(), - "tcp".to_string(), - "--syn".to_string(), - ]; - if let Some(ip) = ip { - args.push("-d".to_string()); - args.push(ip.to_string()); - } - args.extend([ - "--dport".to_string(), - port.to_string(), - "-j".to_string(), - "DROP".to_string(), - ]); - args -} - -fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String { - let seconds = u64::from(seconds.max(1)); - let hitcount = u64::from(hitcount.max(1)); - for (unit_seconds, unit_name) in [ - (1_u64, "second"), - (60_u64, "minute"), - (3_600_u64, "hour"), - (86_400_u64, "day"), - ] { - let amount = hitcount.saturating_mul(unit_seconds); - if amount >= seconds && amount % seconds == 0 { - return format!("{}/{}", amount / seconds, unit_name); - } - } - let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds; - format!("{}/day", amount.max(1)) -} - -async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> { - let mut errors = Vec::new(); - for _ in 0..8 { - match run_command( - binary, - &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], - None, - ) - .await - { - Ok(()) => {} - Err(error) if is_missing_command_or_iptables_rule(&error) => break, - Err(error) => { - errors.push(format!("{binary} delete INPUT jump failed: {error}")); - break; - } - } - } - if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await - && !is_missing_command_or_iptables_rule(&error) - { - errors.push(format!("{binary} flush chain failed: {error}")); - } - if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await - && !is_missing_command_or_iptables_rule(&error) - { - errors.push(format!("{binary} delete chain failed: {error}")); - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join(", ")) - } -} - -async fn apply_nft_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> { - let families = detect_nft_table_families().await; - for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) { - let script = nft_synlimit_script(plan); - run_command("nft", &["-f", "-"], Some(script)).await?; - } - - Ok(()) -} - -async fn detect_nft_table_families() -> NftTableFamilies { - let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else { - return NftTableFamilies { - inet: false, - ip: false, - ip6: false, - }; - }; - - let mut families = NftTableFamilies { - inet: false, - ip: false, - ip6: false, - }; - for line in output.lines() { - let mut fields = line.split_whitespace(); - if fields.next() != Some("table") { - continue; - } - match fields.next() { - Some("inet") => families.inet = true, - Some("ip") => families.ip = true, - Some("ip6") => families.ip6 = true, - _ => {} - } - } - families -} -fn nft_apply_plan<'a>( - families: NftTableFamilies, - v4_targets: &'a [SynLimitTarget], - v6_targets: &'a [SynLimitTarget], -) -> Vec> { - if !v4_targets.is_empty() && !v6_targets.is_empty() { - return vec![NftApplyPlan { - family: NftFamily::Inet, - v4_targets, - v6_targets, - }]; - } - if !v4_targets.is_empty() { - return vec![NftApplyPlan { - family: if families.inet || !families.ip { - NftFamily::Inet - } else { - NftFamily::Ip - }, - v4_targets, - v6_targets: &[], - }]; - } - if !v6_targets.is_empty() { - return vec![NftApplyPlan { - family: if families.inet || !families.ip6 { - NftFamily::Inet - } else { - NftFamily::Ip6 - }, - v4_targets: &[], - v6_targets, - }]; - } - Vec::new() -} -fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String { - let mut script = String::new(); - script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str())); - script.push_str(&format!(" chain {NFT_CHAIN} {{\n")); - script.push_str(" type filter hook input priority filter; policy accept;\n"); - for (idx, (ip, port, seconds, hitcount, burst)) in plan.v4_targets.iter().enumerate() { - let daddr = ip - .map(|ip| format!(" ip daddr {ip}")) - .unwrap_or_else(String::new); - let rate = synlimit_rate_arg(*seconds, *hitcount); - script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v4_{idx} {{ ip saddr limit rate over {rate} burst {burst} packets }} drop\n" - )); - script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n" - )); - } - for (idx, (ip, port, seconds, hitcount, burst)) in plan.v6_targets.iter().enumerate() { - let daddr = ip - .map(|ip| format!(" ip6 daddr {ip}")) - .unwrap_or_else(String::new); - let rate = synlimit_rate_arg(*seconds, *hitcount); - script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v6_{idx} {{ ip6 saddr limit rate over {rate} burst {burst} packets }} drop\n" - )); - script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n" - )); - } - script.push_str(" }\n"); - script.push_str("}\n"); - script -} - -async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> { - let mut errors = Vec::new(); - for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] { - if let Err(error) = run_command( - "nft", - &["delete", "table", family.as_str(), NFT_TABLE], - None, - ) - .await - && !is_missing_command_or_nft_table(&error) - { - errors.push(format!( - "nft delete table {} {NFT_TABLE} failed: {error}", - family.as_str() - )); - } - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join(", ")) - } -} - -fn is_missing_command_or_iptables_rule(error: &str) -> bool { - error.contains("is not available") - || error.contains("No chain/target/match by that name") - || error.contains("does not exist") -} - -fn is_missing_command_or_nft_table(error: &str) -> bool { - error.contains("is not available") || error.contains("No such file or directory") -} - -async fn run_command(binary: &str, args: &[&str], stdin: Option) -> Result<(), String> { - let Some(command_path) = resolve_command(binary) else { - return Err(format!("{binary} is not available")); - }; - let mut command = Command::new(command_path); - command.args(args); - if stdin.is_some() { - command.stdin(std::process::Stdio::piped()); - } - command.stdout(std::process::Stdio::null()); - command.stderr(std::process::Stdio::piped()); - let mut child = command - .spawn() - .map_err(|e| format!("spawn {binary} failed: {e}"))?; - if let Some(blob) = stdin - && let Some(mut writer) = child.stdin.take() - { - writer - .write_all(blob.as_bytes()) - .await - .map_err(|e| format!("stdin write {binary} failed: {e}"))?; - } - let output = child - .wait_with_output() - .await - .map_err(|e| format!("wait {binary} failed: {e}"))?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(if stderr.is_empty() { - format!("{binary} exited with status {}", output.status) - } else { - stderr - }) -} - -async fn run_command_stdout(binary: &str, args: &[&str]) -> Result { - let Some(command_path) = resolve_command(binary) else { - return Err(format!("{binary} is not available")); - }; - let output = Command::new(command_path) - .args(args) - .output() - .await - .map_err(|e| format!("wait {binary} failed: {e}"))?; - if output.status.success() { - return Ok(String::from_utf8_lossy(&output.stdout).to_string()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(if stderr.is_empty() { - format!("{binary} exited with status {}", output.status) - } else { - stderr - }) -} - -fn resolve_command(binary: &str) -> Option { - let mut dirs = std::env::var_os("PATH") - .map(|path| std::env::split_paths(&path).collect::>()) - .unwrap_or_default(); - dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from)); - dirs.into_iter() - .map(|dir| dir.join(binary)) - .find(|candidate| candidate.exists() && candidate.is_file()) -} - -fn has_cap_net_admin() -> bool { - #[cfg(target_os = "linux")] - { - let Ok(status) = std::fs::read_to_string("/proc/self/status") else { - return false; - }; - for line in status.lines() { - if let Some(raw) = line.strip_prefix("CapEff:") { - let caps = raw.trim(); - if let Ok(bits) = u64::from_str_radix(caps, 16) { - const CAP_NET_ADMIN_BIT: u64 = 12; - return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0; - } - } - } - false - } - #[cfg(not(target_os = "linux"))] - { - false - } -} diff --git a/src/synlimit_control/command.rs b/src/synlimit_control/command.rs new file mode 100644 index 0000000..863b86f --- /dev/null +++ b/src/synlimit_control/command.rs @@ -0,0 +1,98 @@ +use std::path::PathBuf; + +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +pub(super) async fn run_command( + binary: &str, + args: &[&str], + stdin: Option, +) -> Result<(), String> { + let Some(command_path) = resolve_command(binary) else { + return Err(format!("{binary} is not available")); + }; + let mut command = Command::new(command_path); + command.args(args); + if stdin.is_some() { + command.stdin(std::process::Stdio::piped()); + } + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::piped()); + let mut child = command + .spawn() + .map_err(|e| format!("spawn {binary} failed: {e}"))?; + if let Some(blob) = stdin + && let Some(mut writer) = child.stdin.take() + { + writer + .write_all(blob.as_bytes()) + .await + .map_err(|e| format!("stdin write {binary} failed: {e}"))?; + } + let output = child + .wait_with_output() + .await + .map_err(|e| format!("wait {binary} failed: {e}"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{binary} exited with status {}", output.status) + } else { + stderr + }) +} + +pub(super) async fn run_command_stdout(binary: &str, args: &[&str]) -> Result { + let Some(command_path) = resolve_command(binary) else { + return Err(format!("{binary} is not available")); + }; + let output = Command::new(command_path) + .args(args) + .output() + .await + .map_err(|e| format!("wait {binary} failed: {e}"))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{binary} exited with status {}", output.status) + } else { + stderr + }) +} + +fn resolve_command(binary: &str) -> Option { + let mut dirs = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from)); + dirs.into_iter() + .map(|dir| dir.join(binary)) + .find(|candidate| candidate.exists() && candidate.is_file()) +} + +pub(super) fn has_cap_net_admin() -> bool { + #[cfg(target_os = "linux")] + { + let Ok(status) = std::fs::read_to_string("/proc/self/status") else { + return false; + }; + for line in status.lines() { + if let Some(raw) = line.strip_prefix("CapEff:") { + let caps = raw.trim(); + if let Ok(bits) = u64::from_str_radix(caps, 16) { + const CAP_NET_ADMIN_BIT: u64 = 12; + return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0; + } + } + } + false + } + #[cfg(not(target_os = "linux"))] + { + false + } +} diff --git a/src/synlimit_control/iptables.rs b/src/synlimit_control/iptables.rs new file mode 100644 index 0000000..4139e67 --- /dev/null +++ b/src/synlimit_control/iptables.rs @@ -0,0 +1,311 @@ +use std::net::IpAddr; + +use super::command::run_command; +use super::model::{SynLimitRule, SynLimitTargets, synlimit_rate_arg}; + +const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT"; +const IPTABLES_HASHLIMIT_PREFIX: &str = "TMT-SYN"; +const IPV4_IOS_PACKET_LENGTH: u16 = 64; +const IPV6_IOS_PACKET_LENGTH: u16 = 84; +const IOS_TTL_LIMIT: u8 = 65; + +#[derive(Clone, Copy)] +enum IpTablesFamily { + V4, + V6, +} + +impl IpTablesFamily { + fn ios_packet_length(self) -> u16 { + match self { + Self::V4 => IPV4_IOS_PACKET_LENGTH, + Self::V6 => IPV6_IOS_PACKET_LENGTH, + } + } + + fn ttl_match(self) -> [&'static str; 3] { + match self { + Self::V4 => ["-m", "ttl", "--ttl-lt"], + Self::V6 => ["-m", "hl", "--hl-lt"], + } + } + + fn hashlimit_tag(self) -> &'static str { + match self { + Self::V4 => "4", + Self::V6 => "6", + } + } +} + +pub(super) async fn apply_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> { + apply_rules_for_binary("iptables", &targets.iptables_v4, IpTablesFamily::V4).await?; + apply_rules_for_binary("ip6tables", &targets.iptables_v6, IpTablesFamily::V6).await +} + +async fn apply_rules_for_binary( + binary: &str, + targets: &[SynLimitRule], + family: IpTablesFamily, +) -> Result<(), String> { + if targets.is_empty() { + return Ok(()); + } + let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await; + run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?; + if run_command( + binary, + &["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN], + None, + ) + .await + .is_err() + { + run_command( + binary, + &["-t", "filter", "-I", "INPUT", "1", "-j", IPTABLES_CHAIN], + None, + ) + .await?; + } + + for (idx, target) in targets.iter().enumerate() { + for rule in iptables_synfix_rule_args(target, idx, family) { + let refs: Vec<&str> = rule.iter().map(String::as_str).collect(); + run_command(binary, &refs, None).await?; + } + } + run_command( + binary, + &["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"], + None, + ) + .await?; + + Ok(()) +} + +fn iptables_synfix_rule_args( + target: &SynLimitRule, + idx: usize, + family: IpTablesFamily, +) -> Vec> { + vec![ + iptables_ios_accept_rule_args(target, idx, family), + iptables_ios_reject_rule_args(target, family), + iptables_generic_accept_rule_args(target, idx, family), + iptables_generic_reject_rule_args(target), + ] +} + +fn iptables_ios_accept_rule_args( + target: &SynLimitRule, + idx: usize, + family: IpTablesFamily, +) -> Vec { + let hashlimit_name = format!( + "{IPTABLES_HASHLIMIT_PREFIX}-I{}-{idx}", + family.hashlimit_tag() + ); + let mut args = iptables_base_rule_args(target.ip, target.port); + args.extend(iptables_ios_match_args(family)); + args.extend(iptables_hashlimit_args( + &hashlimit_name, + target.ios_seconds, + target.ios_hitcount, + target.ios_burst, + target.hashlimit_expire_ms, + target.hashlimit_size, + )); + args.extend(["-j".to_string(), "ACCEPT".to_string()]); + args +} + +fn iptables_ios_reject_rule_args(target: &SynLimitRule, family: IpTablesFamily) -> Vec { + let mut args = iptables_base_rule_args(target.ip, target.port); + args.extend(iptables_ios_match_args(family)); + args.extend(iptables_reject_args()); + args +} + +fn iptables_generic_accept_rule_args( + target: &SynLimitRule, + idx: usize, + family: IpTablesFamily, +) -> Vec { + let hashlimit_name = format!( + "{IPTABLES_HASHLIMIT_PREFIX}-G{}-{idx}", + family.hashlimit_tag() + ); + let mut args = iptables_base_rule_args(target.ip, target.port); + args.extend(iptables_hashlimit_args( + &hashlimit_name, + target.generic_seconds, + target.generic_hitcount, + target.generic_burst, + target.hashlimit_expire_ms, + target.hashlimit_size, + )); + args.extend(["-j".to_string(), "ACCEPT".to_string()]); + args +} + +fn iptables_generic_reject_rule_args(target: &SynLimitRule) -> Vec { + let mut args = iptables_base_rule_args(target.ip, target.port); + args.extend(iptables_reject_args()); + args +} + +fn iptables_base_rule_args(ip: Option, port: u16) -> Vec { + let mut args = vec![ + "-t".to_string(), + "filter".to_string(), + "-A".to_string(), + IPTABLES_CHAIN.to_string(), + "-p".to_string(), + "tcp".to_string(), + "--syn".to_string(), + "-m".to_string(), + "tcp".to_string(), + "--tcp-flags".to_string(), + "SYN".to_string(), + "SYN".to_string(), + ]; + if let Some(ip) = ip { + args.push("-d".to_string()); + args.push(ip.to_string()); + } + args.extend(["--dport".to_string(), port.to_string()]); + args +} + +fn iptables_ios_match_args(family: IpTablesFamily) -> Vec { + let mut args = vec![ + "-m".to_string(), + "length".to_string(), + "--length".to_string(), + family.ios_packet_length().to_string(), + ]; + args.extend(family.ttl_match().map(str::to_string)); + args.push(IOS_TTL_LIMIT.to_string()); + args +} + +fn iptables_hashlimit_args( + name: &str, + seconds: u32, + hitcount: u32, + burst: u32, + expire_ms: u32, + size: u32, +) -> Vec { + vec![ + "-m".to_string(), + "hashlimit".to_string(), + "--hashlimit-name".to_string(), + name.to_string(), + "--hashlimit-mode".to_string(), + "srcip".to_string(), + "--hashlimit-upto".to_string(), + synlimit_rate_arg(seconds, hitcount), + "--hashlimit-burst".to_string(), + burst.to_string(), + "--hashlimit-htable-expire".to_string(), + expire_ms.to_string(), + "--hashlimit-htable-size".to_string(), + size.to_string(), + ] +} + +fn iptables_reject_args() -> Vec { + vec![ + "-j".to_string(), + "REJECT".to_string(), + "--reject-with".to_string(), + "tcp-reset".to_string(), + ] +} + +pub(super) async fn clear_rules_for_binary(binary: &str) -> Result<(), String> { + let mut errors = Vec::new(); + for _ in 0..8 { + match run_command( + binary, + &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], + None, + ) + .await + { + Ok(()) => {} + Err(error) if is_missing_command_or_iptables_rule(&error) => break, + Err(error) => { + errors.push(format!("{binary} delete INPUT jump failed: {error}")); + break; + } + } + } + if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await + && !is_missing_command_or_iptables_rule(&error) + { + errors.push(format!("{binary} flush chain failed: {error}")); + } + if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await + && !is_missing_command_or_iptables_rule(&error) + { + errors.push(format!("{binary} delete chain failed: {error}")); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(", ")) + } +} + +fn is_missing_command_or_iptables_rule(error: &str) -> bool { + error.contains("is not available") + || error.contains("No chain/target/match by that name") + || error.contains("does not exist") +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use super::*; + use crate::synlimit_control::model::test_rule; + + fn has_pair(args: &[String], key: &str, value: &str) -> bool { + args.windows(2) + .any(|pair| pair[0].as_str() == key && pair[1].as_str() == value) + } + + #[test] + fn iptables_rules_use_synfix_order_and_rejects() { + let target = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443); + let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4); + + assert_eq!(rules.len(), 4); + assert!(has_pair(&rules[0], "--length", "64")); + assert!(has_pair(&rules[0], "--ttl-lt", "65")); + assert!(has_pair(&rules[0], "--hashlimit-upto", "12/second")); + assert!(has_pair(&rules[0], "--hashlimit-burst", "24")); + assert!(has_pair(&rules[0], "--hashlimit-htable-expire", "60000")); + assert!(has_pair(&rules[0], "--hashlimit-htable-size", "32768")); + assert!(has_pair(&rules[0], "-j", "ACCEPT")); + assert!(has_pair(&rules[1], "-j", "REJECT")); + assert!(has_pair(&rules[1], "--reject-with", "tcp-reset")); + assert!(has_pair(&rules[2], "--hashlimit-upto", "48/minute")); + assert!(has_pair(&rules[3], "--reject-with", "tcp-reset")); + } + + #[test] + fn ip6tables_rules_use_ipv6_hoplimit_classifier() { + let target = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443); + let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V6); + + assert!(has_pair(&rules[0], "--length", "84")); + assert!(has_pair(&rules[0], "--hl-lt", "65")); + assert!(has_pair(&rules[0], "-d", "::1")); + } +} diff --git a/src/synlimit_control/mod.rs b/src/synlimit_control/mod.rs new file mode 100644 index 0000000..40195db --- /dev/null +++ b/src/synlimit_control/mod.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use tokio::sync::watch; +use tracing::warn; + +use crate::config::{ProxyConfig, SynLimitMode}; + +mod command; +mod iptables; +mod model; +mod nftables; + +use self::command::has_cap_net_admin; +use self::model::synlimit_targets; + +pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver>) { + if !cfg!(target_os = "linux") { + if has_synlimit_config(&config_rx.borrow()) { + warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules"); + } + return; + } + + tokio::spawn(async move { + wait_for_config_channel_close_and_reconcile(config_rx).await; + if let Err(error) = clear_synlimit_rules_all_backends().await { + warn!(error = %error, "Failed to clear SYN limiter rules after config channel close"); + } + }); +} + +async fn wait_for_config_channel_close_and_reconcile( + mut config_rx: watch::Receiver>, +) { + while config_rx.changed().await.is_ok() { + let cfg = config_rx.borrow_and_update().clone(); + reconcile_synlimit_rules(&cfg).await; + } +} + +pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { + if let Err(error) = clear_synlimit_rules_all_backends().await { + warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile"); + } + + let targets = synlimit_targets(cfg); + if targets.is_empty() { + return; + } + if !has_cap_net_admin() { + warn!( + "SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied" + ); + return; + } + + if targets.has_iptables_targets() + && let Err(error) = iptables::apply_synlimit_rules(&targets).await + { + warn!(error = %error, "Failed to apply iptables SYN limiter rules"); + } + if targets.has_nft_targets() + && let Err(error) = nftables::apply_synlimit_rules(&targets).await + { + warn!(error = %error, "Failed to apply nftables SYN limiter rules"); + } +} + +pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> { + if !has_cap_net_admin() { + return Ok(()); + } + + let mut errors = Vec::new(); + if let Err(error) = nftables::clear_rules_all_families().await { + errors.push(error); + } + if let Err(error) = iptables::clear_rules_for_binary("iptables").await { + errors.push(error); + } + if let Err(error) = iptables::clear_rules_for_binary("ip6tables").await { + errors.push(error); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +fn has_synlimit_config(cfg: &ProxyConfig) -> bool { + cfg.server + .listeners + .iter() + .any(|listener| !matches!(listener.synlimit, SynLimitMode::Off)) +} diff --git a/src/synlimit_control/model.rs b/src/synlimit_control/model.rs new file mode 100644 index 0000000..3678bb8 --- /dev/null +++ b/src/synlimit_control/model.rs @@ -0,0 +1,126 @@ +use std::collections::BTreeSet; +use std::net::IpAddr; + +use crate::config::{ProxyConfig, SynLimitMode}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(super) struct SynLimitRule { + pub(super) ip: Option, + pub(super) port: u16, + pub(super) generic_seconds: u32, + pub(super) generic_hitcount: u32, + pub(super) generic_burst: u32, + pub(super) ios_seconds: u32, + pub(super) ios_hitcount: u32, + pub(super) ios_burst: u32, + pub(super) hashlimit_expire_ms: u32, + pub(super) hashlimit_size: u32, +} + +#[derive(Default)] +pub(super) struct SynLimitTargets { + pub(super) iptables_v4: Vec, + pub(super) iptables_v6: Vec, + pub(super) nft_v4: Vec, + pub(super) nft_v6: Vec, +} + +impl SynLimitTargets { + pub(super) fn is_empty(&self) -> bool { + self.iptables_v4.is_empty() + && self.iptables_v6.is_empty() + && self.nft_v4.is_empty() + && self.nft_v6.is_empty() + } + + pub(super) fn has_iptables_targets(&self) -> bool { + !self.iptables_v4.is_empty() || !self.iptables_v6.is_empty() + } + + pub(super) fn has_nft_targets(&self) -> bool { + !self.nft_v4.is_empty() || !self.nft_v6.is_empty() + } +} + +pub(super) fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { + let mut iptables_v4 = BTreeSet::new(); + let mut iptables_v6 = BTreeSet::new(); + let mut nft_v4 = BTreeSet::new(); + let mut nft_v6 = BTreeSet::new(); + + for listener in &cfg.server.listeners { + let backend = listener.synlimit; + if matches!(backend, SynLimitMode::Off) { + continue; + } + let target = SynLimitRule { + ip: (!listener.ip.is_unspecified()).then_some(listener.ip), + port: listener.port.unwrap_or(cfg.server.port), + generic_seconds: listener.synlimit_seconds, + generic_hitcount: listener.synlimit_hitcount, + generic_burst: listener.synlimit_burst, + ios_seconds: listener.synlimit_ios_seconds, + ios_hitcount: listener.synlimit_ios_hitcount, + ios_burst: listener.synlimit_ios_burst, + hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms, + hashlimit_size: listener.synlimit_hashlimit_size, + }; + + match (backend, listener.ip.is_ipv4()) { + (SynLimitMode::Iptables, true) => { + iptables_v4.insert(target); + } + (SynLimitMode::Iptables, false) => { + iptables_v6.insert(target); + } + (SynLimitMode::Nftables, true) => { + nft_v4.insert(target); + } + (SynLimitMode::Nftables, false) => { + nft_v6.insert(target); + } + (SynLimitMode::Off, _) => {} + } + } + + SynLimitTargets { + iptables_v4: iptables_v4.into_iter().collect(), + iptables_v6: iptables_v6.into_iter().collect(), + nft_v4: nft_v4.into_iter().collect(), + nft_v6: nft_v6.into_iter().collect(), + } +} + +pub(super) fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String { + let seconds = u64::from(seconds.max(1)); + let hitcount = u64::from(hitcount.max(1)); + for (unit_seconds, unit_name) in [ + (1_u64, "second"), + (60_u64, "minute"), + (3_600_u64, "hour"), + (86_400_u64, "day"), + ] { + let amount = hitcount.saturating_mul(unit_seconds); + if amount >= seconds && amount % seconds == 0 { + return format!("{}/{}", amount / seconds, unit_name); + } + } + let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds; + format!("{}/day", amount.max(1)) +} + +#[cfg(test)] +pub(super) fn test_rule(ip: Option, port: u16) -> SynLimitRule { + SynLimitRule { + ip, + port, + generic_seconds: 60, + generic_hitcount: 48, + generic_burst: 1, + ios_seconds: 1, + ios_hitcount: 12, + ios_burst: 24, + hashlimit_expire_ms: 60_000, + hashlimit_size: 32_768, + } +} diff --git a/src/synlimit_control/nftables.rs b/src/synlimit_control/nftables.rs new file mode 100644 index 0000000..601ef35 --- /dev/null +++ b/src/synlimit_control/nftables.rs @@ -0,0 +1,256 @@ +use super::command::{run_command, run_command_stdout}; +use super::model::{SynLimitRule, SynLimitTargets, synlimit_rate_arg}; + +const NFT_TABLE: &str = "telemt_synlimit"; +const NFT_CHAIN: &str = "input"; +const NFT_INPUT_PRIORITY: i16 = -5; +const IPV4_IOS_PACKET_LENGTH: u16 = 64; +const IPV6_IOS_PACKET_LENGTH: u16 = 84; +const IOS_TTL_LIMIT: u8 = 65; + +#[derive(Clone, Copy)] +struct NftTableFamilies { + inet: bool, + ip: bool, + ip6: bool, +} + +#[derive(Clone, Copy)] +enum NftFamily { + Inet, + Ip, + Ip6, +} + +struct NftApplyPlan<'a> { + family: NftFamily, + v4_targets: &'a [SynLimitRule], + v6_targets: &'a [SynLimitRule], +} + +impl NftFamily { + fn as_str(self) -> &'static str { + match self { + Self::Inet => "inet", + Self::Ip => "ip", + Self::Ip6 => "ip6", + } + } +} + +pub(super) async fn apply_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> { + let families = detect_nft_table_families().await; + for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) { + let script = nft_synlimit_script(plan); + run_command("nft", &["-f", "-"], Some(script)).await?; + } + + Ok(()) +} + +async fn detect_nft_table_families() -> NftTableFamilies { + let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else { + return NftTableFamilies { + inet: false, + ip: false, + ip6: false, + }; + }; + + let mut families = NftTableFamilies { + inet: false, + ip: false, + ip6: false, + }; + for line in output.lines() { + let mut fields = line.split_whitespace(); + if fields.next() != Some("table") { + continue; + } + match fields.next() { + Some("inet") => families.inet = true, + Some("ip") => families.ip = true, + Some("ip6") => families.ip6 = true, + _ => {} + } + } + families +} + +fn nft_apply_plan<'a>( + families: NftTableFamilies, + v4_targets: &'a [SynLimitRule], + v6_targets: &'a [SynLimitRule], +) -> Vec> { + if !v4_targets.is_empty() && !v6_targets.is_empty() { + return vec![NftApplyPlan { + family: NftFamily::Inet, + v4_targets, + v6_targets, + }]; + } + if !v4_targets.is_empty() { + return vec![NftApplyPlan { + family: if families.inet || !families.ip { + NftFamily::Inet + } else { + NftFamily::Ip + }, + v4_targets, + v6_targets: &[], + }]; + } + if !v6_targets.is_empty() { + return vec![NftApplyPlan { + family: if families.inet || !families.ip6 { + NftFamily::Inet + } else { + NftFamily::Ip6 + }, + v4_targets: &[], + v6_targets, + }]; + } + Vec::new() +} + +fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String { + let mut script = String::new(); + script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str())); + script.push_str(&format!(" chain {NFT_CHAIN} {{\n")); + script.push_str(&format!( + " type filter hook input priority {NFT_INPUT_PRIORITY}; policy accept;\n" + )); + for (idx, target) in plan.v4_targets.iter().enumerate() { + push_nft_v4_rules(&mut script, target, idx); + } + for (idx, target) in plan.v6_targets.iter().enumerate() { + push_nft_v6_rules(&mut script, target, idx); + } + script.push_str(" }\n"); + script.push_str("}\n"); + script +} + +fn push_nft_v4_rules(script: &mut String, target: &SynLimitRule, idx: usize) { + let daddr = target + .ip + .map(|ip| format!(" ip daddr {ip}")) + .unwrap_or_default(); + let ios_rate = synlimit_rate_arg(target.ios_seconds, target.ios_hitcount); + let generic_rate = synlimit_rate_arg(target.generic_seconds, target.generic_hitcount); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV4_IOS_PACKET_LENGTH} ip ttl < {IOS_TTL_LIMIT} tcp dport {port} meter telemt_synfix_ios_v4_{idx} {{ ip saddr limit rate over {ios_rate} burst {ios_burst} packets }} reject with tcp reset\n", + port = target.port, + ios_burst = target.ios_burst, + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV4_IOS_PACKET_LENGTH} ip ttl < {IOS_TTL_LIMIT} tcp dport {port} accept\n", + port = target.port, + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synfix_v4_{idx} {{ ip saddr limit rate over {generic_rate} burst {generic_burst} packets }} reject with tcp reset\n", + port = target.port, + generic_burst = target.generic_burst, + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n", + port = target.port, + )); +} + +fn push_nft_v6_rules(script: &mut String, target: &SynLimitRule, idx: usize) { + let daddr = target + .ip + .map(|ip| format!(" ip6 daddr {ip}")) + .unwrap_or_default(); + let ios_rate = synlimit_rate_arg(target.ios_seconds, target.ios_hitcount); + let generic_rate = synlimit_rate_arg(target.generic_seconds, target.generic_hitcount); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV6_IOS_PACKET_LENGTH} ip6 hoplimit < {IOS_TTL_LIMIT} tcp dport {port} meter telemt_synfix_ios_v6_{idx} {{ ip6 saddr limit rate over {ios_rate} burst {ios_burst} packets }} reject with tcp reset\n", + port = target.port, + ios_burst = target.ios_burst, + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV6_IOS_PACKET_LENGTH} ip6 hoplimit < {IOS_TTL_LIMIT} tcp dport {port} accept\n", + port = target.port, + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synfix_v6_{idx} {{ ip6 saddr limit rate over {generic_rate} burst {generic_burst} packets }} reject with tcp reset\n", + port = target.port, + generic_burst = target.generic_burst, + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n", + port = target.port, + )); +} + +pub(super) async fn clear_rules_all_families() -> Result<(), String> { + let mut errors = Vec::new(); + for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] { + if let Err(error) = run_command( + "nft", + &["delete", "table", family.as_str(), NFT_TABLE], + None, + ) + .await + && !is_missing_command_or_nft_table(&error) + { + errors.push(format!( + "nft delete table {} {NFT_TABLE} failed: {error}", + family.as_str() + )); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(", ")) + } +} + +fn is_missing_command_or_nft_table(error: &str) -> bool { + error.contains("is not available") || error.contains("No such file or directory") +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use super::*; + use crate::synlimit_control::model::test_rule; + + #[test] + fn nft_script_uses_synfix_v4_rules_and_early_priority() { + let rule = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443); + let script = nft_synlimit_script(NftApplyPlan { + family: NftFamily::Inet, + v4_targets: &[rule], + v6_targets: &[], + }); + + assert!(script.contains("type filter hook input priority -5; policy accept;")); + assert!(script.contains("ip daddr 203.0.113.7")); + assert!(script.contains("meta length 64 ip ttl < 65")); + assert!(script.contains("limit rate over 12/second burst 24 packets")); + assert!(script.contains("limit rate over 48/minute burst 1 packets")); + assert!(script.contains("reject with tcp reset")); + } + + #[test] + fn nft_script_uses_ipv6_hoplimit_classifier() { + let rule = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443); + let script = nft_synlimit_script(NftApplyPlan { + family: NftFamily::Inet, + v4_targets: &[], + v6_targets: &[rule], + }); + + assert!(script.contains("ip6 daddr ::1")); + assert!(script.contains("meta length 84 ip6 hoplimit < 65")); + assert!(script.contains("ip6 saddr limit rate over 12/second burst 24 packets")); + assert!(script.contains("ip6 saddr limit rate over 48/minute burst 1 packets")); + } +}