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..811fe6b 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -352,6 +352,11 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[ "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", @@ -2014,6 +2019,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 +2286,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 +2308,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, @@ -2466,6 +2506,78 @@ mod tests { 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#" 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/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")); + } +}