mirror of
https://github.com/telemt/telemt.git
synced 2026-06-29 14:21:13 +03:00
Synlimit V2
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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` (необязательный параметр). Не должен быть пустым, если задан.
|
||||
|
||||
+28
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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#"
|
||||
|
||||
+20
-5
@@ -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)]
|
||||
|
||||
@@ -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<String>,
|
||||
) -> 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<String, String> {
|
||||
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<PathBuf> {
|
||||
let mut dirs = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -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<String>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let mut args = iptables_base_rule_args(target.ip, target.port);
|
||||
args.extend(iptables_reject_args());
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_base_rule_args(ip: Option<IpAddr>, port: u16) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Arc<ProxyConfig>>) {
|
||||
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<Arc<ProxyConfig>>,
|
||||
) {
|
||||
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))
|
||||
}
|
||||
@@ -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<IpAddr>,
|
||||
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<SynLimitRule>,
|
||||
pub(super) iptables_v6: Vec<SynLimitRule>,
|
||||
pub(super) nft_v4: Vec<SynLimitRule>,
|
||||
pub(super) nft_v6: Vec<SynLimitRule>,
|
||||
}
|
||||
|
||||
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<IpAddr>, 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,
|
||||
}
|
||||
}
|
||||
@@ -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<NftApplyPlan<'a>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user