mirror of
https://github.com/telemt/telemt.git
synced 2026-07-01 23:31:10 +03:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7aee991416 | |||
| 9a9fd3f55d | |||
| 3a5fe31262 | |||
| 82f63d0d8a | |||
| fce75163b0 | |||
| fe56621a83 | |||
| 1f2910f5bc | |||
| d67e7c5a6f | |||
| 558f352a57 | |||
| 1ee9a234d7 | |||
| 2e13f89f6d |
+12
-2
@@ -55,6 +55,16 @@ RUN set -eux; \
|
|||||||
strip --strip-unneeded /telemt || true; \
|
strip --strip-unneeded /telemt || true; \
|
||||||
rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt
|
rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt
|
||||||
|
|
||||||
|
RUN --mount=type=bind,target=/tmp \
|
||||||
|
mkdir -p /app && \
|
||||||
|
if [ -f /tmp/config.toml ]; then \
|
||||||
|
cp /tmp/config.toml /app/config.toml; \
|
||||||
|
elif [ -f /tmp/config/config.toml ]; then \
|
||||||
|
cp /tmp/config/config.toml /app/config.toml; \
|
||||||
|
else \
|
||||||
|
echo "Config file not found" && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
# ==========================
|
# ==========================
|
||||||
# Debug Image
|
# Debug Image
|
||||||
# ==========================
|
# ==========================
|
||||||
@@ -99,7 +109,7 @@ RUN set -eux; \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=minimal /telemt /app/telemt
|
COPY --from=minimal /telemt /app/telemt
|
||||||
COPY ./config/config.toml /app/config.toml
|
COPY --from=minimal /app/config.toml /app/config.toml
|
||||||
|
|
||||||
EXPOSE 443 9090 9091
|
EXPOSE 443 9090 9091
|
||||||
|
|
||||||
@@ -116,7 +126,7 @@ FROM gcr.io/distroless/static-debian12 AS prod
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=minimal /telemt /app/telemt
|
COPY --from=minimal /telemt /app/telemt
|
||||||
COPY ./config/config.toml /app/config.toml
|
COPY --from=minimal /app/config.toml /app/config.toml
|
||||||
|
|
||||||
USER nonroot:nonroot
|
USER nonroot:nonroot
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -1,12 +1,24 @@
|
|||||||
// Cryptobench
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
use criterion::{Criterion, black_box, criterion_group};
|
use std::hint::black_box;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
#[path = "../src/crypto/aes.rs"]
|
||||||
|
mod aes_impl;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
#[path = "../src/error.rs"]
|
||||||
|
mod error;
|
||||||
|
|
||||||
|
use aes_impl::AesCtr;
|
||||||
|
|
||||||
fn bench_aes_ctr(c: &mut Criterion) {
|
fn bench_aes_ctr(c: &mut Criterion) {
|
||||||
c.bench_function("aes_ctr_encrypt_64kb", |b| {
|
c.bench_function("aes_ctr_encrypt_64kb", |b| {
|
||||||
let data = vec![0u8; 65536];
|
let data = vec![0u8; 65536];
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
let mut enc = AesCtr::new(&[0u8; 32], 0);
|
let mut enc = AesCtr::new(&[0u8; 32], 0);
|
||||||
black_box(enc.encrypt(&data))
|
black_box(enc.encrypt(black_box(data.as_slice())))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_aes_ctr);
|
||||||
|
criterion_main!(benches);
|
||||||
|
|||||||
@@ -1972,6 +1972,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
|||||||
## client_mss
|
## 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`.
|
- **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.
|
- **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)`.
|
- **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`.
|
||||||
- **Example**:
|
- **Example**:
|
||||||
|
|
||||||
@@ -2311,9 +2312,14 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||||
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✔` |
|
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✔` |
|
||||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` |
|
||||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` |
|
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` |
|
||||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` |
|
| [`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`](#announce) | `String` | — | `✘` |
|
||||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
| [`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)
|
## synlimit (server.listeners)
|
||||||
- **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener.
|
- **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**:
|
- **Example**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2366,8 +2373,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
synlimit = "nftables"
|
synlimit = "nftables"
|
||||||
```
|
```
|
||||||
## synlimit_seconds (server.listeners)
|
## synlimit_seconds (server.listeners)
|
||||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60`.
|
||||||
- **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`).
|
- **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**:
|
- **Example**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2375,11 +2382,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 443
|
port = 443
|
||||||
synlimit = "iptables"
|
synlimit = "iptables"
|
||||||
synlimit_seconds = 1
|
synlimit_seconds = 60
|
||||||
```
|
```
|
||||||
## synlimit_hitcount (server.listeners)
|
## synlimit_hitcount (server.listeners)
|
||||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
- **Constraints / validation**: `u32`, must be `> 0`. Default is `48`.
|
||||||
- **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.
|
- **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**:
|
- **Example**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2387,11 +2394,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 443
|
port = 443
|
||||||
synlimit = "iptables"
|
synlimit = "iptables"
|
||||||
synlimit_hitcount = 1
|
synlimit_hitcount = 48
|
||||||
```
|
```
|
||||||
## synlimit_burst (server.listeners)
|
## synlimit_burst (server.listeners)
|
||||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `2`.
|
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||||
- **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.
|
- **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**:
|
- **Example**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2399,7 +2406,67 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 443
|
port = 443
|
||||||
synlimit = "iptables"
|
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
|
## announce
|
||||||
- **Constraints / validation**: `String` (optional). Must not be empty when set.
|
- **Constraints / validation**: `String` (optional). Must not be empty when set.
|
||||||
|
|||||||
@@ -1894,6 +1894,7 @@
|
|||||||
## client_mss
|
## client_mss
|
||||||
- **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`.
|
- **Ограничения / валидация**: `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’ов.
|
- **Описание**: 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)`.
|
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
|
||||||
- **Пример**:
|
- **Пример**:
|
||||||
|
|
||||||
@@ -2237,9 +2238,14 @@
|
|||||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||||
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✔` |
|
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✔` |
|
||||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` |
|
||||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` |
|
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` |
|
||||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` |
|
| [`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`](#announce) | `String` | — | `✘` |
|
||||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||||
@@ -2277,7 +2283,8 @@
|
|||||||
```
|
```
|
||||||
## synlimit (server.listeners)
|
## synlimit (server.listeners)
|
||||||
- **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен.
|
- **Ограничения / валидация**: `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
|
```toml
|
||||||
@@ -2292,8 +2299,8 @@
|
|||||||
synlimit = "nftables"
|
synlimit = "nftables"
|
||||||
```
|
```
|
||||||
## synlimit_seconds (server.listeners)
|
## synlimit_seconds (server.listeners)
|
||||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60`.
|
||||||
- **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`).
|
- **Описание**: 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
|
```toml
|
||||||
@@ -2301,11 +2308,11 @@
|
|||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 443
|
port = 443
|
||||||
synlimit = "iptables"
|
synlimit = "iptables"
|
||||||
synlimit_seconds = 1
|
synlimit_seconds = 60
|
||||||
```
|
```
|
||||||
## synlimit_hitcount (server.listeners)
|
## synlimit_hitcount (server.listeners)
|
||||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `48`.
|
||||||
- **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться.
|
- **Описание**: Generic SYN-fix token-bucket rate amount. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets получат TCP RST.
|
||||||
- **Пример**:
|
- **Пример**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2313,11 +2320,11 @@
|
|||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 443
|
port = 443
|
||||||
synlimit = "iptables"
|
synlimit = "iptables"
|
||||||
synlimit_hitcount = 1
|
synlimit_hitcount = 48
|
||||||
```
|
```
|
||||||
## synlimit_burst (server.listeners)
|
## synlimit_burst (server.listeners)
|
||||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`.
|
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||||
- **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
|
- **Описание**: Generic SYN-fix token-bucket burst size. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
|
||||||
- **Пример**:
|
- **Пример**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2325,7 +2332,67 @@
|
|||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 443
|
port = 443
|
||||||
synlimit = "iptables"
|
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
|
## announce
|
||||||
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
|
- **Ограничения / валидация**: `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_HIGH_WATERMARK_PCT: u8 = 85;
|
||||||
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
|
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
|
||||||
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
|
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
|
||||||
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
|
const DEFAULT_SYNLIMIT_SECONDS: u32 = 60;
|
||||||
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1;
|
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 48;
|
||||||
const DEFAULT_SYNLIMIT_BURST: u32 = 2;
|
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_CONNECT_RETRY_ATTEMPTS: u32 = 2;
|
||||||
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
|
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
|
||||||
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
|
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
|
||||||
@@ -258,6 +263,26 @@ pub(crate) fn default_synlimit_burst() -> u32 {
|
|||||||
DEFAULT_SYNLIMIT_BURST
|
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 {
|
pub(crate) fn default_prefer_4() -> u8 {
|
||||||
4
|
4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ pub struct ListenerSynLimitHotFields {
|
|||||||
pub synlimit_seconds: u32,
|
pub synlimit_seconds: u32,
|
||||||
pub synlimit_hitcount: u32,
|
pub synlimit_hitcount: u32,
|
||||||
pub synlimit_burst: 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 {
|
impl HotFields {
|
||||||
@@ -293,6 +298,11 @@ impl ListenerSynLimitHotFields {
|
|||||||
synlimit_seconds: listener.synlimit_seconds,
|
synlimit_seconds: listener.synlimit_seconds,
|
||||||
synlimit_hitcount: listener.synlimit_hitcount,
|
synlimit_hitcount: listener.synlimit_hitcount,
|
||||||
synlimit_burst: listener.synlimit_burst,
|
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_seconds = new_listener.synlimit_seconds;
|
||||||
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
|
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
|
||||||
old_listener.synlimit_burst = new_listener.synlimit_burst;
|
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));
|
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]
|
#[test]
|
||||||
fn reload_applies_hot_change_on_first_observed_snapshot() {
|
fn reload_applies_hot_change_on_first_observed_snapshot() {
|
||||||
let initial_tag = "11111111111111111111111111111111";
|
let initial_tag = "11111111111111111111111111111111";
|
||||||
|
|||||||
+72
-3339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
|
pub(super) fn normalize_config_path(path: &Path) -> PathBuf {
|
||||||
|
path.canonicalize().unwrap_or_else(|_| {
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map(|cwd| cwd.join(path))
|
||||||
|
.unwrap_or_else(|_| path.to_path_buf())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
rendered.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn preprocess_includes(
|
||||||
|
content: &str,
|
||||||
|
base_dir: &Path,
|
||||||
|
depth: u8,
|
||||||
|
source_files: &mut BTreeSet<PathBuf>,
|
||||||
|
) -> Result<String> {
|
||||||
|
if depth > 10 {
|
||||||
|
return Err(ProxyError::Config("Include depth > 10".into()));
|
||||||
|
}
|
||||||
|
let mut output = String::with_capacity(content.len());
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("include") {
|
||||||
|
let rest = rest.trim();
|
||||||
|
if let Some(rest) = rest.strip_prefix('=') {
|
||||||
|
let path_str = rest.trim().trim_matches('"');
|
||||||
|
let resolved = base_dir.join(path_str);
|
||||||
|
source_files.insert(normalize_config_path(&resolved));
|
||||||
|
let included = std::fs::read_to_string(&resolved)
|
||||||
|
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
|
let included_dir = resolved.parent().unwrap_or(base_dir);
|
||||||
|
output.push_str(&preprocess_includes(
|
||||||
|
&included,
|
||||||
|
included_dir,
|
||||||
|
depth + 1,
|
||||||
|
source_files,
|
||||||
|
)?);
|
||||||
|
output.push('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push_str(line);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use crate::error::{ProxyError, Result};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
pub(super) fn is_valid_tls_domain_name(domain: &str) -> bool {
|
||||||
|
!domain.is_empty()
|
||||||
|
&& !domain
|
||||||
|
.chars()
|
||||||
|
.any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\'))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_domain_to_ascii(domain: &str, field: &str) -> Result<String> {
|
||||||
|
let domain = domain.trim();
|
||||||
|
if !is_valid_tls_domain_name(domain) {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"Invalid {field}: '{}'. Must be a valid domain name",
|
||||||
|
domain
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = url::Url::parse(&format!("https://{domain}/")).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"Invalid {field}: '{}'. IDNA conversion failed: {error}",
|
||||||
|
domain
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let host = parsed.host_str().ok_or_else(|| {
|
||||||
|
ProxyError::Config(format!("Invalid {field}: '{}'. Host is empty", domain))
|
||||||
|
})?;
|
||||||
|
Ok(host.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_mask_host_to_ascii(host: &str, field: &str) -> Result<String> {
|
||||||
|
let host = host.trim();
|
||||||
|
if host.starts_with('[') && host.ends_with(']') {
|
||||||
|
let inner = &host[1..host.len() - 1];
|
||||||
|
let ip = inner.parse::<std::net::IpAddr>().map_err(|_| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"Invalid {field}: '{}'. IPv6 literal is invalid",
|
||||||
|
host
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
return match ip {
|
||||||
|
std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")),
|
||||||
|
std::net::IpAddr::V4(v4) => Ok(v4.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||||
|
return match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => Ok(v4.to_string()),
|
||||||
|
std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_domain_to_ascii(host, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_exclusive_mask_target(target: &str) -> Option<(&str, u16)> {
|
||||||
|
let target = target.trim();
|
||||||
|
if target.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.starts_with('[') {
|
||||||
|
let end = target.find(']')?;
|
||||||
|
if target.get(end + 1..end + 2)? != ":" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let host = &target[..=end];
|
||||||
|
let port = target[end + 2..].parse::<u16>().ok()?;
|
||||||
|
return (port > 0).then_some((host, port));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (host, port) = target.rsplit_once(':')?;
|
||||||
|
if host.is_empty() || host.contains(':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let port = port.parse::<u16>().ok()?;
|
||||||
|
(port > 0).then_some((host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String> {
|
||||||
|
let (host, port) = parse_exclusive_mask_target(target).ok_or_else(|| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"Invalid {field}: '{}'. Expected host:port with port > 0",
|
||||||
|
target
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let host = normalize_mask_host_to_ascii(host, field)?;
|
||||||
|
Ok(format!("{host}:{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn push_unique_nonempty(target: &mut Vec<String>, value: String) {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !target.iter().any(|existing| existing == trimmed) {
|
||||||
|
target.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_valid_ad_tag(tag: &str) -> bool {
|
||||||
|
tag.len() == 32 && tag.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
|
||||||
|
let Some(tag) = ad_tag.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_valid_ad_tag(tag) {
|
||||||
|
warn!("Invalid general.ad_tag value, expected exactly 32 hex chars; ad_tag is disabled");
|
||||||
|
*ad_tag = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::Hasher;
|
||||||
|
|
||||||
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
|
const ACCESS_SECRET_BYTES: usize = 16;
|
||||||
|
|
||||||
|
/// Precomputed, immutable user authentication data used by handshake hot paths.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct UserAuthSnapshot {
|
||||||
|
entries: Vec<UserAuthEntry>,
|
||||||
|
by_name: HashMap<String, u32>,
|
||||||
|
sni_index: HashMap<u64, Vec<u32>>,
|
||||||
|
sni_initial_index: HashMap<u8, Vec<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct UserAuthEntry {
|
||||||
|
pub(crate) user: String,
|
||||||
|
pub(crate) secret: [u8; ACCESS_SECRET_BYTES],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserAuthSnapshot {
|
||||||
|
pub(super) fn from_users(users: &HashMap<String, String>) -> Result<Self> {
|
||||||
|
let mut entries = Vec::with_capacity(users.len());
|
||||||
|
let mut by_name = HashMap::with_capacity(users.len());
|
||||||
|
let mut sni_index = HashMap::with_capacity(users.len());
|
||||||
|
let mut sni_initial_index = HashMap::with_capacity(users.len());
|
||||||
|
|
||||||
|
for (user, secret_hex) in users {
|
||||||
|
let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret {
|
||||||
|
user: user.clone(),
|
||||||
|
reason: "Must be 32 hex characters".to_string(),
|
||||||
|
})?;
|
||||||
|
if decoded.len() != ACCESS_SECRET_BYTES {
|
||||||
|
return Err(ProxyError::InvalidSecret {
|
||||||
|
user: user.clone(),
|
||||||
|
reason: "Must be 32 hex characters".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = u32::try_from(entries.len()).map_err(|_| {
|
||||||
|
ProxyError::Config("Too many users for runtime auth snapshot".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut secret = [0u8; ACCESS_SECRET_BYTES];
|
||||||
|
secret.copy_from_slice(&decoded);
|
||||||
|
entries.push(UserAuthEntry {
|
||||||
|
user: user.clone(),
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
by_name.insert(user.clone(), user_id);
|
||||||
|
sni_index
|
||||||
|
.entry(Self::sni_lookup_hash(user))
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(user_id);
|
||||||
|
if let Some(initial) = user
|
||||||
|
.as_bytes()
|
||||||
|
.first()
|
||||||
|
.map(|byte| byte.to_ascii_lowercase())
|
||||||
|
{
|
||||||
|
sni_initial_index
|
||||||
|
.entry(initial)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
entries,
|
||||||
|
by_name,
|
||||||
|
sni_index,
|
||||||
|
sni_initial_index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn entries(&self) -> &[UserAuthEntry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn user_id_by_name(&self, user: &str) -> Option<u32> {
|
||||||
|
self.by_name.get(user).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn entry_by_id(&self, user_id: u32) -> Option<&UserAuthEntry> {
|
||||||
|
let idx = usize::try_from(user_id).ok()?;
|
||||||
|
self.entries.get(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> {
|
||||||
|
self.sni_index
|
||||||
|
.get(&Self::sni_lookup_hash(sni))
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sni_initial_candidates(&self, sni: &str) -> Option<&[u32]> {
|
||||||
|
let initial = sni
|
||||||
|
.as_bytes()
|
||||||
|
.first()
|
||||||
|
.map(|byte| byte.to_ascii_lowercase())?;
|
||||||
|
self.sni_initial_index.get(&initial).map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sni_lookup_hash(value: &str) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
for byte in value.bytes() {
|
||||||
|
hasher.write_u8(byte.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
|
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"general",
|
||||||
|
"logging",
|
||||||
|
"network",
|
||||||
|
"server",
|
||||||
|
"timeouts",
|
||||||
|
"censorship",
|
||||||
|
"access",
|
||||||
|
"upstreams",
|
||||||
|
"show_link",
|
||||||
|
"dc_overrides",
|
||||||
|
"default_dc",
|
||||||
|
"beobachten",
|
||||||
|
"beobachten_minutes",
|
||||||
|
"beobachten_flush_secs",
|
||||||
|
"beobachten_file",
|
||||||
|
"include",
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENERAL_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"data_path",
|
||||||
|
"quota_state_path",
|
||||||
|
"config_strict",
|
||||||
|
"modes",
|
||||||
|
"prefer_ipv6",
|
||||||
|
"fast_mode",
|
||||||
|
"use_middle_proxy",
|
||||||
|
"proxy_secret_path",
|
||||||
|
"proxy_secret_url",
|
||||||
|
"proxy_config_v4_cache_path",
|
||||||
|
"proxy_config_v4_url",
|
||||||
|
"proxy_config_v6_cache_path",
|
||||||
|
"proxy_config_v6_url",
|
||||||
|
"ad_tag",
|
||||||
|
"middle_proxy_nat_ip",
|
||||||
|
"middle_proxy_nat_probe",
|
||||||
|
"middle_proxy_nat_stun",
|
||||||
|
"middle_proxy_nat_stun_servers",
|
||||||
|
"stun_nat_probe_concurrency",
|
||||||
|
"middle_proxy_pool_size",
|
||||||
|
"middle_proxy_warm_standby",
|
||||||
|
"me_init_retry_attempts",
|
||||||
|
"me2dc_fallback",
|
||||||
|
"me2dc_fast",
|
||||||
|
"me_keepalive_enabled",
|
||||||
|
"me_keepalive_interval_secs",
|
||||||
|
"me_keepalive_jitter_secs",
|
||||||
|
"me_keepalive_payload_random",
|
||||||
|
"rpc_proxy_req_every",
|
||||||
|
"me_writer_cmd_channel_capacity",
|
||||||
|
"me_route_channel_capacity",
|
||||||
|
"me_c2me_channel_capacity",
|
||||||
|
"me_c2me_send_timeout_ms",
|
||||||
|
"me_reader_route_data_wait_ms",
|
||||||
|
"me_d2c_flush_batch_max_frames",
|
||||||
|
"me_d2c_flush_batch_max_bytes",
|
||||||
|
"me_d2c_flush_batch_max_delay_us",
|
||||||
|
"me_d2c_ack_flush_immediate",
|
||||||
|
"me_quota_soft_overshoot_bytes",
|
||||||
|
"me_d2c_frame_buf_shrink_threshold_bytes",
|
||||||
|
"direct_relay_copy_buf_c2s_bytes",
|
||||||
|
"direct_relay_copy_buf_s2c_bytes",
|
||||||
|
"crypto_pending_buffer",
|
||||||
|
"max_client_frame",
|
||||||
|
"desync_all_full",
|
||||||
|
"beobachten",
|
||||||
|
"beobachten_minutes",
|
||||||
|
"beobachten_flush_secs",
|
||||||
|
"beobachten_file",
|
||||||
|
"hardswap",
|
||||||
|
"me_warmup_stagger_enabled",
|
||||||
|
"me_warmup_step_delay_ms",
|
||||||
|
"me_warmup_step_jitter_ms",
|
||||||
|
"me_reconnect_max_concurrent_per_dc",
|
||||||
|
"me_reconnect_backoff_base_ms",
|
||||||
|
"me_reconnect_backoff_cap_ms",
|
||||||
|
"me_reconnect_fast_retry_count",
|
||||||
|
"me_single_endpoint_shadow_writers",
|
||||||
|
"me_single_endpoint_outage_mode_enabled",
|
||||||
|
"me_single_endpoint_outage_disable_quarantine",
|
||||||
|
"me_single_endpoint_outage_backoff_min_ms",
|
||||||
|
"me_single_endpoint_outage_backoff_max_ms",
|
||||||
|
"me_single_endpoint_shadow_rotate_every_secs",
|
||||||
|
"me_floor_mode",
|
||||||
|
"me_adaptive_floor_idle_secs",
|
||||||
|
"me_adaptive_floor_min_writers_single_endpoint",
|
||||||
|
"me_adaptive_floor_min_writers_multi_endpoint",
|
||||||
|
"me_adaptive_floor_recover_grace_secs",
|
||||||
|
"me_adaptive_floor_writers_per_core_total",
|
||||||
|
"me_adaptive_floor_cpu_cores_override",
|
||||||
|
"me_adaptive_floor_max_extra_writers_single_per_core",
|
||||||
|
"me_adaptive_floor_max_extra_writers_multi_per_core",
|
||||||
|
"me_adaptive_floor_max_active_writers_per_core",
|
||||||
|
"me_adaptive_floor_max_warm_writers_per_core",
|
||||||
|
"me_adaptive_floor_max_active_writers_global",
|
||||||
|
"me_adaptive_floor_max_warm_writers_global",
|
||||||
|
"upstream_connect_retry_attempts",
|
||||||
|
"upstream_connect_retry_backoff_ms",
|
||||||
|
"upstream_connect_budget_ms",
|
||||||
|
"tg_connect",
|
||||||
|
"upstream_unhealthy_fail_threshold",
|
||||||
|
"upstream_connect_failfast_hard_errors",
|
||||||
|
"stun_iface_mismatch_ignore",
|
||||||
|
"unknown_dc_log_path",
|
||||||
|
"unknown_dc_file_log_enabled",
|
||||||
|
"log_level",
|
||||||
|
"disable_colors",
|
||||||
|
"telemetry",
|
||||||
|
"me_socks_kdf_policy",
|
||||||
|
"me_route_backpressure_enabled",
|
||||||
|
"me_route_fairshare_enabled",
|
||||||
|
"me_route_backpressure_base_timeout_ms",
|
||||||
|
"me_route_backpressure_high_timeout_ms",
|
||||||
|
"me_route_backpressure_high_watermark_pct",
|
||||||
|
"me_health_interval_ms_unhealthy",
|
||||||
|
"me_health_interval_ms_healthy",
|
||||||
|
"me_admission_poll_ms",
|
||||||
|
"me_warn_rate_limit_ms",
|
||||||
|
"me_route_no_writer_mode",
|
||||||
|
"me_route_no_writer_wait_ms",
|
||||||
|
"me_route_hybrid_max_wait_ms",
|
||||||
|
"me_route_blocking_send_timeout_ms",
|
||||||
|
"me_route_inline_recovery_attempts",
|
||||||
|
"me_route_inline_recovery_wait_ms",
|
||||||
|
"links",
|
||||||
|
"fast_mode_min_tls_record",
|
||||||
|
"update_every",
|
||||||
|
"me_reinit_every_secs",
|
||||||
|
"me_hardswap_warmup_delay_min_ms",
|
||||||
|
"me_hardswap_warmup_delay_max_ms",
|
||||||
|
"me_hardswap_warmup_extra_passes",
|
||||||
|
"me_hardswap_warmup_pass_backoff_base_ms",
|
||||||
|
"me_config_stable_snapshots",
|
||||||
|
"me_config_apply_cooldown_secs",
|
||||||
|
"me_snapshot_require_http_2xx",
|
||||||
|
"me_snapshot_reject_empty_map",
|
||||||
|
"me_snapshot_min_proxy_for_lines",
|
||||||
|
"proxy_secret_stable_snapshots",
|
||||||
|
"proxy_secret_rotate_runtime",
|
||||||
|
"me_secret_atomic_snapshot",
|
||||||
|
"proxy_secret_len_max",
|
||||||
|
"me_pool_drain_ttl_secs",
|
||||||
|
"me_instadrain",
|
||||||
|
"me_pool_drain_threshold",
|
||||||
|
"me_pool_drain_soft_evict_enabled",
|
||||||
|
"me_pool_drain_soft_evict_grace_secs",
|
||||||
|
"me_pool_drain_soft_evict_per_writer",
|
||||||
|
"me_pool_drain_soft_evict_budget_per_core",
|
||||||
|
"me_pool_drain_soft_evict_cooldown_ms",
|
||||||
|
"me_bind_stale_mode",
|
||||||
|
"me_bind_stale_ttl_secs",
|
||||||
|
"me_pool_min_fresh_ratio",
|
||||||
|
"me_reinit_drain_timeout_secs",
|
||||||
|
"proxy_secret_auto_reload_secs",
|
||||||
|
"proxy_config_auto_reload_secs",
|
||||||
|
"me_reinit_singleflight",
|
||||||
|
"me_reinit_trigger_channel",
|
||||||
|
"me_reinit_coalesce_window_ms",
|
||||||
|
"me_deterministic_writer_sort",
|
||||||
|
"me_writer_pick_mode",
|
||||||
|
"me_writer_pick_sample_size",
|
||||||
|
"ntp_check",
|
||||||
|
"ntp_servers",
|
||||||
|
"auto_degradation_enabled",
|
||||||
|
"degradation_min_unavailable_dc_groups",
|
||||||
|
"rst_on_close",
|
||||||
|
];
|
||||||
|
|
||||||
|
const NETWORK_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"ipv4",
|
||||||
|
"ipv6",
|
||||||
|
"prefer",
|
||||||
|
"multipath",
|
||||||
|
"stun_use",
|
||||||
|
"stun_servers",
|
||||||
|
"stun_tcp_fallback",
|
||||||
|
"http_ip_detect_urls",
|
||||||
|
"cache_public_ip_path",
|
||||||
|
"dns_overrides",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERVER_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"port",
|
||||||
|
"listen_addr_ipv4",
|
||||||
|
"listen_addr_ipv6",
|
||||||
|
"listen_unix_sock",
|
||||||
|
"listen_unix_sock_perm",
|
||||||
|
"listen_tcp",
|
||||||
|
"client_mss",
|
||||||
|
"client_mss_bulk",
|
||||||
|
"proxy_protocol",
|
||||||
|
"proxy_protocol_header_timeout_ms",
|
||||||
|
"proxy_protocol_trusted_cidrs",
|
||||||
|
"metrics_port",
|
||||||
|
"metrics_listen",
|
||||||
|
"metrics_whitelist",
|
||||||
|
"api",
|
||||||
|
"admin_api",
|
||||||
|
"listeners",
|
||||||
|
"listen_backlog",
|
||||||
|
"max_connections",
|
||||||
|
"accept_permit_timeout_ms",
|
||||||
|
"conntrack_control",
|
||||||
|
];
|
||||||
|
|
||||||
|
const API_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"enabled",
|
||||||
|
"listen",
|
||||||
|
"whitelist",
|
||||||
|
"gray_action",
|
||||||
|
"auth_header",
|
||||||
|
"request_body_limit_bytes",
|
||||||
|
"minimal_runtime_enabled",
|
||||||
|
"minimal_runtime_cache_ttl_ms",
|
||||||
|
"runtime_edge_enabled",
|
||||||
|
"runtime_edge_cache_ttl_ms",
|
||||||
|
"runtime_edge_top_n",
|
||||||
|
"runtime_edge_events_capacity",
|
||||||
|
"read_only",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"inline_conntrack_control",
|
||||||
|
"mode",
|
||||||
|
"backend",
|
||||||
|
"profile",
|
||||||
|
"hybrid_listener_ips",
|
||||||
|
"pressure_high_watermark_pct",
|
||||||
|
"pressure_low_watermark_pct",
|
||||||
|
"delete_budget_per_sec",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"ip",
|
||||||
|
"port",
|
||||||
|
"client_mss",
|
||||||
|
"synlimit",
|
||||||
|
"synlimit_seconds",
|
||||||
|
"synlimit_hitcount",
|
||||||
|
"synlimit_burst",
|
||||||
|
"synlimit_ios_seconds",
|
||||||
|
"synlimit_ios_hitcount",
|
||||||
|
"synlimit_ios_burst",
|
||||||
|
"synlimit_hashlimit_expire_ms",
|
||||||
|
"synlimit_hashlimit_size",
|
||||||
|
"announce",
|
||||||
|
"announce_ip",
|
||||||
|
"proxy_protocol",
|
||||||
|
"reuse_allow",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"client_first_byte_idle_secs",
|
||||||
|
"client_handshake",
|
||||||
|
"relay_idle_policy_v2_enabled",
|
||||||
|
"relay_client_idle_soft_secs",
|
||||||
|
"relay_client_idle_hard_secs",
|
||||||
|
"relay_idle_grace_after_downstream_activity_secs",
|
||||||
|
"client_keepalive",
|
||||||
|
"client_ack",
|
||||||
|
"me_one_retry",
|
||||||
|
"me_one_timeout_ms",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"tls_domain",
|
||||||
|
"tls_domains",
|
||||||
|
"unknown_sni_action",
|
||||||
|
"tls_fetch_scope",
|
||||||
|
"tls_fetch",
|
||||||
|
"mask",
|
||||||
|
"mask_dynamic",
|
||||||
|
"mask_host",
|
||||||
|
"mask_port",
|
||||||
|
"exclusive_mask",
|
||||||
|
"mask_unix_sock",
|
||||||
|
"fake_cert_len",
|
||||||
|
"tls_emulation",
|
||||||
|
"tls_front_dir",
|
||||||
|
"server_hello_delay_min_ms",
|
||||||
|
"server_hello_delay_max_ms",
|
||||||
|
"tls_new_session_tickets",
|
||||||
|
"serverhello_compact",
|
||||||
|
"tls_full_cert_ttl_secs",
|
||||||
|
"alpn_enforce",
|
||||||
|
"mask_proxy_protocol",
|
||||||
|
"mask_shape_hardening",
|
||||||
|
"mask_shape_hardening_aggressive_mode",
|
||||||
|
"mask_shape_bucket_floor_bytes",
|
||||||
|
"mask_shape_bucket_cap_bytes",
|
||||||
|
"mask_shape_above_cap_blur",
|
||||||
|
"mask_shape_above_cap_blur_max_bytes",
|
||||||
|
"mask_relay_max_bytes",
|
||||||
|
"mask_relay_timeout_ms",
|
||||||
|
"mask_relay_idle_timeout_ms",
|
||||||
|
"mask_classifier_prefetch_timeout_ms",
|
||||||
|
"mask_timing_normalization_enabled",
|
||||||
|
"mask_timing_normalization_floor_ms",
|
||||||
|
"mask_timing_normalization_ceiling_ms",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TLS_FETCH_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"profiles",
|
||||||
|
"strict_route",
|
||||||
|
"attempt_timeout_ms",
|
||||||
|
"total_budget_ms",
|
||||||
|
"grease_enabled",
|
||||||
|
"deterministic",
|
||||||
|
"profile_cache_ttl_secs",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACCESS_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"users",
|
||||||
|
"user_enabled",
|
||||||
|
"user_ad_tags",
|
||||||
|
"user_max_tcp_conns",
|
||||||
|
"user_max_tcp_conns_global_each",
|
||||||
|
"user_expirations",
|
||||||
|
"user_data_quota",
|
||||||
|
"user_rate_limits",
|
||||||
|
"cidr_rate_limits",
|
||||||
|
"user_max_unique_ips",
|
||||||
|
"user_max_unique_ips_global_each",
|
||||||
|
"user_max_unique_ips_mode",
|
||||||
|
"user_max_unique_ips_window_secs",
|
||||||
|
"replay_check_len",
|
||||||
|
"replay_window_secs",
|
||||||
|
"ignore_time_skew",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RATE_LIMIT_BPS_CONFIG_KEYS: &[&str] = &["up_bps", "down_bps"];
|
||||||
|
|
||||||
|
const UPSTREAM_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"type",
|
||||||
|
"interface",
|
||||||
|
"bind_addresses",
|
||||||
|
"bindtodevice",
|
||||||
|
"force_bind",
|
||||||
|
"address",
|
||||||
|
"user_id",
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
"url",
|
||||||
|
"weight",
|
||||||
|
"enabled",
|
||||||
|
"scopes",
|
||||||
|
"ipv4",
|
||||||
|
"ipv6",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"];
|
||||||
|
const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"];
|
||||||
|
const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"];
|
||||||
|
const LOGGING_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"destination",
|
||||||
|
"path",
|
||||||
|
"rotation",
|
||||||
|
"max_size_bytes",
|
||||||
|
"max_files",
|
||||||
|
"max_age_secs",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UnknownConfigKey {
|
||||||
|
path: String,
|
||||||
|
suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
|
||||||
|
let mut current = value;
|
||||||
|
for segment in path {
|
||||||
|
current = current.get(*segment)?;
|
||||||
|
}
|
||||||
|
current.as_table()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_strict_config(parsed_toml: &toml::Value) -> bool {
|
||||||
|
table_at(parsed_toml, &["general"])
|
||||||
|
.and_then(|table| table.get("config_strict"))
|
||||||
|
.and_then(toml::Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for group in [
|
||||||
|
TOP_LEVEL_CONFIG_KEYS,
|
||||||
|
GENERAL_CONFIG_KEYS,
|
||||||
|
NETWORK_CONFIG_KEYS,
|
||||||
|
SERVER_CONFIG_KEYS,
|
||||||
|
API_CONFIG_KEYS,
|
||||||
|
CONNTRACK_CONTROL_CONFIG_KEYS,
|
||||||
|
LISTENER_CONFIG_KEYS,
|
||||||
|
TIMEOUTS_CONFIG_KEYS,
|
||||||
|
CENSORSHIP_CONFIG_KEYS,
|
||||||
|
TLS_FETCH_CONFIG_KEYS,
|
||||||
|
ACCESS_CONFIG_KEYS,
|
||||||
|
RATE_LIMIT_BPS_CONFIG_KEYS,
|
||||||
|
UPSTREAM_CONFIG_KEYS,
|
||||||
|
PROXY_MODES_CONFIG_KEYS,
|
||||||
|
TELEMETRY_CONFIG_KEYS,
|
||||||
|
LINKS_CONFIG_KEYS,
|
||||||
|
LOGGING_CONFIG_KEYS,
|
||||||
|
] {
|
||||||
|
keys.extend_from_slice(group);
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||||
|
let b_chars: Vec<char> = b.chars().collect();
|
||||||
|
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
||||||
|
let mut curr = vec![0usize; b_chars.len() + 1];
|
||||||
|
|
||||||
|
for (i, ca) in a.chars().enumerate() {
|
||||||
|
curr[0] = i + 1;
|
||||||
|
for (j, cb) in b_chars.iter().enumerate() {
|
||||||
|
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
|
||||||
|
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut curr);
|
||||||
|
}
|
||||||
|
|
||||||
|
prev[b_chars.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
|
||||||
|
let normalized = key.to_ascii_lowercase();
|
||||||
|
let mut best: Option<(&str, usize)> = None;
|
||||||
|
for known in known_keys {
|
||||||
|
let distance = levenshtein_distance(&normalized, known);
|
||||||
|
let is_better = match best {
|
||||||
|
Some((_, best_distance)) => distance < best_distance,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if distance <= 4 && is_better {
|
||||||
|
best = Some((known, distance));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(known, _)| known.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unknown_keys(
|
||||||
|
unknown: &mut Vec<UnknownConfigKey>,
|
||||||
|
known_for_suggestion: &[&'static str],
|
||||||
|
path: &str,
|
||||||
|
table: &toml::Table,
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
for key in table.keys() {
|
||||||
|
if !allowed.contains(&key.as_str()) {
|
||||||
|
let full_path = if path.is_empty() {
|
||||||
|
key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{path}.{key}")
|
||||||
|
};
|
||||||
|
unknown.push(UnknownConfigKey {
|
||||||
|
path: full_path,
|
||||||
|
suggestion: unknown_key_suggestion(key, known_for_suggestion),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_known_table(
|
||||||
|
parsed_toml: &toml::Value,
|
||||||
|
unknown: &mut Vec<UnknownConfigKey>,
|
||||||
|
known_for_suggestion: &[&'static str],
|
||||||
|
path: &[&str],
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
if let Some(table) = table_at(parsed_toml, path) {
|
||||||
|
push_unknown_keys(
|
||||||
|
unknown,
|
||||||
|
known_for_suggestion,
|
||||||
|
&path.join("."),
|
||||||
|
table,
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_nested_table_value(
|
||||||
|
unknown: &mut Vec<UnknownConfigKey>,
|
||||||
|
known_for_suggestion: &[&'static str],
|
||||||
|
path: String,
|
||||||
|
value: &toml::Value,
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
if let Some(table) = value.as_table() {
|
||||||
|
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
|
||||||
|
let known_for_suggestion = known_config_keys_for_suggestion();
|
||||||
|
let mut unknown = Vec::new();
|
||||||
|
|
||||||
|
if let Some(root) = parsed_toml.as_table() {
|
||||||
|
push_unknown_keys(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
"",
|
||||||
|
root,
|
||||||
|
TOP_LEVEL_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general"],
|
||||||
|
GENERAL_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general", "modes"],
|
||||||
|
PROXY_MODES_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general", "telemetry"],
|
||||||
|
TELEMETRY_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general", "links"],
|
||||||
|
LINKS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["logging"],
|
||||||
|
LOGGING_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["network"],
|
||||||
|
NETWORK_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server"],
|
||||||
|
SERVER_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server", "api"],
|
||||||
|
API_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server", "admin_api"],
|
||||||
|
API_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server", "conntrack_control"],
|
||||||
|
CONNTRACK_CONTROL_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["timeouts"],
|
||||||
|
TIMEOUTS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["censorship"],
|
||||||
|
CENSORSHIP_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["censorship", "tls_fetch"],
|
||||||
|
TLS_FETCH_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["access"],
|
||||||
|
ACCESS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(listeners) = table_at(parsed_toml, &["server"])
|
||||||
|
.and_then(|table| table.get("listeners"))
|
||||||
|
.and_then(toml::Value::as_array)
|
||||||
|
{
|
||||||
|
for (idx, listener) in listeners.iter().enumerate() {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("server.listeners[{idx}]"),
|
||||||
|
listener,
|
||||||
|
LISTENER_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
|
||||||
|
for (idx, upstream) in upstreams.iter().enumerate() {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("upstreams[{idx}]"),
|
||||||
|
upstream,
|
||||||
|
UPSTREAM_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
|
||||||
|
if let Some(table) = table_at(parsed_toml, &["access"])
|
||||||
|
.and_then(|access| access.get(access_map))
|
||||||
|
.and_then(toml::Value::as_table)
|
||||||
|
{
|
||||||
|
for (entry_name, value) in table {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("access.{access_map}.{entry_name}"),
|
||||||
|
value,
|
||||||
|
RATE_LIMIT_BPS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
|
||||||
|
let unknown = collect_unknown_config_keys(parsed_toml);
|
||||||
|
if unknown.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &unknown {
|
||||||
|
if let Some(suggestion) = item.suggestion.as_deref() {
|
||||||
|
warn!(
|
||||||
|
key = %item.path,
|
||||||
|
suggestion = %suggestion,
|
||||||
|
"Unknown config key ignored; did you mean the suggested key?"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(key = %item.path, "Unknown config key ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_strict_config(parsed_toml) {
|
||||||
|
let mut paths = Vec::with_capacity(unknown.len());
|
||||||
|
for item in unknown {
|
||||||
|
if let Some(suggestion) = item.suggestion {
|
||||||
|
paths.push(format!("{} (did you mean `{}`?)", item.path, suggestion));
|
||||||
|
} else {
|
||||||
|
paths.push(item.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"unknown config keys are not allowed when general.config_strict=true: {}",
|
||||||
|
paths.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use shadowsocks::config::ServerConfig as ShadowsocksServerConfig;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
|
use super::super::types::{LoggingConfig, LoggingDestination, NetworkConfig, UpstreamType};
|
||||||
|
use super::ProxyConfig;
|
||||||
|
|
||||||
|
pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> {
|
||||||
|
if !net.ipv4 && matches!(net.ipv6, Some(false)) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"Both ipv4 and ipv6 are disabled in [network]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if net.prefer != 4 && net.prefer != 6 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"network.prefer must be 4 or 6".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !net.ipv4 && net.prefer == 4 {
|
||||||
|
warn!("prefer=4 but ipv4=false; forcing prefer=6");
|
||||||
|
net.prefer = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(net.ipv6, Some(false)) && net.prefer == 6 {
|
||||||
|
warn!("prefer=6 but ipv6=false; forcing prefer=4");
|
||||||
|
net.prefer = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_logging_config(logging: &LoggingConfig) -> Result<()> {
|
||||||
|
if let Some(path) = logging.path.as_ref()
|
||||||
|
&& path.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"logging.path cannot be empty when provided".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"logging.path must be set when logging.destination=\"file\"".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
||||||
|
let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| {
|
||||||
|
upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. })
|
||||||
|
});
|
||||||
|
|
||||||
|
if has_enabled_shadowsocks && config.general.use_middle_proxy {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"shadowsocks upstreams require general.use_middle_proxy = false".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for upstream in &config.upstreams {
|
||||||
|
if matches!(upstream.ipv4, Some(false)) && matches!(upstream.ipv6, Some(false)) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(prefer) = upstream.prefer
|
||||||
|
&& prefer != 4
|
||||||
|
&& prefer != 6
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"upstream.prefer must be 4 or 6".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
|
||||||
|
let parsed = ShadowsocksServerConfig::from_url(url)
|
||||||
|
.map_err(|error| ProxyError::Config(format!("invalid shadowsocks url: {error}")))?;
|
||||||
|
if parsed.plugin().is_some() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"shadowsocks plugins are not supported".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
|
||||||
|
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
|
||||||
|
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
|
||||||
|
warn!(
|
||||||
|
upstream = idx,
|
||||||
|
"upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6"
|
||||||
|
);
|
||||||
|
upstream.prefer = Some(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) {
|
||||||
|
warn!(
|
||||||
|
upstream = idx,
|
||||||
|
"upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4"
|
||||||
|
);
|
||||||
|
upstream.prefer = Some(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+20
-5
@@ -1455,9 +1455,9 @@ pub enum SynLimitMode {
|
|||||||
/// Disable SYN limiting for this listener.
|
/// Disable SYN limiting for this listener.
|
||||||
#[default]
|
#[default]
|
||||||
Off,
|
Off,
|
||||||
/// Use iptables/ip6tables filter rules with the hashlimit match.
|
/// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match.
|
||||||
Iptables,
|
Iptables,
|
||||||
/// Use nftables rules with per-source token-bucket meters.
|
/// Use nftables two-tier SYN-fix rules with per-source token-bucket meters.
|
||||||
Nftables,
|
Nftables,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2266,15 +2266,30 @@ pub struct ListenerConfig {
|
|||||||
/// Per-listener SYN limiter mode.
|
/// Per-listener SYN limiter mode.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub synlimit: SynLimitMode,
|
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")]
|
#[serde(default = "default_synlimit_seconds")]
|
||||||
pub synlimit_seconds: u32,
|
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")]
|
#[serde(default = "default_synlimit_hitcount")]
|
||||||
pub synlimit_hitcount: u32,
|
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")]
|
#[serde(default = "default_synlimit_burst")]
|
||||||
pub synlimit_burst: u32,
|
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.
|
/// IP address or hostname to announce in proxy links.
|
||||||
/// Takes precedence over `announce_ip` if both are set.
|
/// Takes precedence over `announce_ip` if both are set.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+3
-3
@@ -5,7 +5,7 @@ pub mod hash;
|
|||||||
pub mod random;
|
pub mod random;
|
||||||
|
|
||||||
pub use aes::{AesCbc, AesCtr};
|
pub use aes::{AesCbc, AesCtr};
|
||||||
pub use hash::{
|
#[allow(unused_imports)]
|
||||||
build_middleproxy_prekey, crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac,
|
pub use hash::build_middleproxy_prekey;
|
||||||
};
|
pub use hash::{crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac};
|
||||||
pub use random::SecureRandom;
|
pub use random::SecureRandom;
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ pub(super) fn classify_me_d2c_flush_reason(
|
|||||||
MeD2cFlushReason::QueueDrain
|
MeD2cFlushReason::QueueDrain
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn me_d2c_flush_reason_requires_client_flush(reason: MeD2cFlushReason) -> bool {
|
pub(super) fn me_d2c_flush_reason_requires_client_flush(_reason: MeD2cFlushReason) -> bool {
|
||||||
!matches!(reason, MeD2cFlushReason::QueueDrain)
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -64,8 +64,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_drain_is_not_a_physical_flush_trigger() {
|
fn all_flush_reasons_trigger_physical_flush() {
|
||||||
assert!(!me_d2c_flush_reason_requires_client_flush(
|
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||||
MeD2cFlushReason::QueueDrain
|
MeD2cFlushReason::QueueDrain
|
||||||
));
|
));
|
||||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||||
|
|||||||
@@ -1,605 +0,0 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::process::Command;
|
|
||||||
use tokio::sync::watch;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::config::{ProxyConfig, SynLimitMode};
|
|
||||||
|
|
||||||
const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT";
|
|
||||||
const IPTABLES_HASHLIMIT_NAME: &str = "TELEMT-BUMPER";
|
|
||||||
const NFT_TABLE: &str = "telemt_synlimit";
|
|
||||||
const NFT_CHAIN: &str = "input";
|
|
||||||
type SynLimitTarget = (Option<IpAddr>, u16, u32, u32, u32);
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct SynLimitTargets {
|
|
||||||
iptables_v4: Vec<SynLimitTarget>,
|
|
||||||
iptables_v6: Vec<SynLimitTarget>,
|
|
||||||
nft_v4: Vec<SynLimitTarget>,
|
|
||||||
nft_v6: Vec<SynLimitTarget>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct NftTableFamilies {
|
|
||||||
inet: bool,
|
|
||||||
ip: bool,
|
|
||||||
ip6: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum NftFamily {
|
|
||||||
Inet,
|
|
||||||
Ip,
|
|
||||||
Ip6,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NftApplyPlan<'a> {
|
|
||||||
family: NftFamily,
|
|
||||||
v4_targets: &'a [SynLimitTarget],
|
|
||||||
v6_targets: &'a [SynLimitTarget],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SynLimitTargets {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.iptables_v4.is_empty()
|
|
||||||
&& self.iptables_v6.is_empty()
|
|
||||||
&& self.nft_v4.is_empty()
|
|
||||||
&& self.nft_v6.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_iptables_targets(&self) -> bool {
|
|
||||||
!self.iptables_v4.is_empty() || !self.iptables_v6.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_nft_targets(&self) -> bool {
|
|
||||||
!self.nft_v4.is_empty() || !self.nft_v6.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl NftFamily {
|
|
||||||
fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Inet => "inet",
|
|
||||||
Self::Ip => "ip",
|
|
||||||
Self::Ip6 => "ip6",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<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) = apply_iptables_synlimit_rules(&targets).await
|
|
||||||
{
|
|
||||||
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
|
|
||||||
}
|
|
||||||
if targets.has_nft_targets()
|
|
||||||
&& let Err(error) = apply_nft_synlimit_rules(&targets).await
|
|
||||||
{
|
|
||||||
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
|
|
||||||
if !has_cap_net_admin() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
if let Err(error) = clear_nft_synlimit_rules_all_families().await {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
if let Err(error) = clear_iptables_synlimit_rules_for_binary("iptables").await {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
if let Err(error) = clear_iptables_synlimit_rules_for_binary("ip6tables").await {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(errors.join("; "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
|
|
||||||
cfg.server
|
|
||||||
.listeners
|
|
||||||
.iter()
|
|
||||||
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets {
|
|
||||||
let mut iptables_v4 = BTreeSet::new();
|
|
||||||
let mut iptables_v6 = BTreeSet::new();
|
|
||||||
let mut nft_v4 = BTreeSet::new();
|
|
||||||
let mut nft_v6 = BTreeSet::new();
|
|
||||||
|
|
||||||
for listener in &cfg.server.listeners {
|
|
||||||
let backend = listener.synlimit;
|
|
||||||
if matches!(backend, SynLimitMode::Off) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let port = listener.port.unwrap_or(cfg.server.port);
|
|
||||||
let ip = (!listener.ip.is_unspecified()).then_some(listener.ip);
|
|
||||||
let seconds = listener.synlimit_seconds;
|
|
||||||
let hitcount = listener.synlimit_hitcount;
|
|
||||||
let burst = listener.synlimit_burst;
|
|
||||||
|
|
||||||
match (backend, listener.ip.is_ipv4()) {
|
|
||||||
(SynLimitMode::Iptables, true) => {
|
|
||||||
iptables_v4.insert((ip, port, seconds, hitcount, burst));
|
|
||||||
}
|
|
||||||
(SynLimitMode::Iptables, false) => {
|
|
||||||
iptables_v6.insert((ip, port, seconds, hitcount, burst));
|
|
||||||
}
|
|
||||||
(SynLimitMode::Nftables, true) => {
|
|
||||||
nft_v4.insert((ip, port, seconds, hitcount, burst));
|
|
||||||
}
|
|
||||||
(SynLimitMode::Nftables, false) => {
|
|
||||||
nft_v6.insert((ip, port, seconds, hitcount, burst));
|
|
||||||
}
|
|
||||||
(SynLimitMode::Off, _) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SynLimitTargets {
|
|
||||||
iptables_v4: iptables_v4.into_iter().collect(),
|
|
||||||
iptables_v6: iptables_v6.into_iter().collect(),
|
|
||||||
nft_v4: nft_v4.into_iter().collect(),
|
|
||||||
nft_v6: nft_v6.into_iter().collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
|
|
||||||
apply_iptables_synlimit_rules_for_binary("iptables", &targets.iptables_v4).await?;
|
|
||||||
apply_iptables_synlimit_rules_for_binary("ip6tables", &targets.iptables_v6).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_iptables_synlimit_rules_for_binary(
|
|
||||||
binary: &str,
|
|
||||||
targets: &[SynLimitTarget],
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if targets.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await;
|
|
||||||
run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?;
|
|
||||||
if run_command(
|
|
||||||
binary,
|
|
||||||
&["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
run_command(
|
|
||||||
binary,
|
|
||||||
&["-t", "filter", "-A", "INPUT", "-j", IPTABLES_CHAIN],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (idx, (ip, port, seconds, hitcount, burst)) in targets.iter().enumerate() {
|
|
||||||
let hashlimit_name = format!("{IPTABLES_HASHLIMIT_NAME}-{idx}");
|
|
||||||
let accept_args = iptables_hashlimit_accept_rule_args(
|
|
||||||
ip,
|
|
||||||
*port,
|
|
||||||
*seconds,
|
|
||||||
*hitcount,
|
|
||||||
*burst,
|
|
||||||
&hashlimit_name,
|
|
||||||
);
|
|
||||||
let drop_args = iptables_synlimit_drop_rule_args(ip, *port);
|
|
||||||
let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect();
|
|
||||||
let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect();
|
|
||||||
run_command(binary, &accept_refs, None).await?;
|
|
||||||
run_command(binary, &drop_refs, None).await?;
|
|
||||||
}
|
|
||||||
run_command(
|
|
||||||
binary,
|
|
||||||
&["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn iptables_hashlimit_accept_rule_args(
|
|
||||||
ip: &Option<IpAddr>,
|
|
||||||
port: u16,
|
|
||||||
seconds: u32,
|
|
||||||
hitcount: u32,
|
|
||||||
burst: u32,
|
|
||||||
hashlimit_name: &str,
|
|
||||||
) -> 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(),
|
|
||||||
];
|
|
||||||
if let Some(ip) = ip {
|
|
||||||
args.push("-d".to_string());
|
|
||||||
args.push(ip.to_string());
|
|
||||||
}
|
|
||||||
let rate = synlimit_rate_arg(seconds, hitcount);
|
|
||||||
args.extend([
|
|
||||||
"--dport".to_string(),
|
|
||||||
port.to_string(),
|
|
||||||
"-m".to_string(),
|
|
||||||
"hashlimit".to_string(),
|
|
||||||
"--hashlimit-name".to_string(),
|
|
||||||
hashlimit_name.to_string(),
|
|
||||||
"--hashlimit-mode".to_string(),
|
|
||||||
"srcip".to_string(),
|
|
||||||
"--hashlimit-upto".to_string(),
|
|
||||||
rate,
|
|
||||||
"--hashlimit-burst".to_string(),
|
|
||||||
burst.to_string(),
|
|
||||||
"--hashlimit-htable-expire".to_string(),
|
|
||||||
"15000".to_string(),
|
|
||||||
"-j".to_string(),
|
|
||||||
"ACCEPT".to_string(),
|
|
||||||
]);
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
fn iptables_synlimit_drop_rule_args(ip: &Option<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(),
|
|
||||||
];
|
|
||||||
if let Some(ip) = ip {
|
|
||||||
args.push("-d".to_string());
|
|
||||||
args.push(ip.to_string());
|
|
||||||
}
|
|
||||||
args.extend([
|
|
||||||
"--dport".to_string(),
|
|
||||||
port.to_string(),
|
|
||||||
"-j".to_string(),
|
|
||||||
"DROP".to_string(),
|
|
||||||
]);
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
|
|
||||||
let seconds = u64::from(seconds.max(1));
|
|
||||||
let hitcount = u64::from(hitcount.max(1));
|
|
||||||
for (unit_seconds, unit_name) in [
|
|
||||||
(1_u64, "second"),
|
|
||||||
(60_u64, "minute"),
|
|
||||||
(3_600_u64, "hour"),
|
|
||||||
(86_400_u64, "day"),
|
|
||||||
] {
|
|
||||||
let amount = hitcount.saturating_mul(unit_seconds);
|
|
||||||
if amount >= seconds && amount % seconds == 0 {
|
|
||||||
return format!("{}/{}", amount / seconds, unit_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds;
|
|
||||||
format!("{}/day", amount.max(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for _ in 0..8 {
|
|
||||||
match run_command(
|
|
||||||
binary,
|
|
||||||
&["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
|
|
||||||
Err(error) => {
|
|
||||||
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await
|
|
||||||
&& !is_missing_command_or_iptables_rule(&error)
|
|
||||||
{
|
|
||||||
errors.push(format!("{binary} flush chain failed: {error}"));
|
|
||||||
}
|
|
||||||
if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await
|
|
||||||
&& !is_missing_command_or_iptables_rule(&error)
|
|
||||||
{
|
|
||||||
errors.push(format!("{binary} delete chain failed: {error}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(errors.join(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_nft_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
|
|
||||||
let families = detect_nft_table_families().await;
|
|
||||||
for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) {
|
|
||||||
let script = nft_synlimit_script(plan);
|
|
||||||
run_command("nft", &["-f", "-"], Some(script)).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn detect_nft_table_families() -> NftTableFamilies {
|
|
||||||
let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else {
|
|
||||||
return NftTableFamilies {
|
|
||||||
inet: false,
|
|
||||||
ip: false,
|
|
||||||
ip6: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut families = NftTableFamilies {
|
|
||||||
inet: false,
|
|
||||||
ip: false,
|
|
||||||
ip6: false,
|
|
||||||
};
|
|
||||||
for line in output.lines() {
|
|
||||||
let mut fields = line.split_whitespace();
|
|
||||||
if fields.next() != Some("table") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match fields.next() {
|
|
||||||
Some("inet") => families.inet = true,
|
|
||||||
Some("ip") => families.ip = true,
|
|
||||||
Some("ip6") => families.ip6 = true,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
families
|
|
||||||
}
|
|
||||||
fn nft_apply_plan<'a>(
|
|
||||||
families: NftTableFamilies,
|
|
||||||
v4_targets: &'a [SynLimitTarget],
|
|
||||||
v6_targets: &'a [SynLimitTarget],
|
|
||||||
) -> Vec<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(" type filter hook input priority filter; policy accept;\n");
|
|
||||||
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v4_targets.iter().enumerate() {
|
|
||||||
let daddr = ip
|
|
||||||
.map(|ip| format!(" ip daddr {ip}"))
|
|
||||||
.unwrap_or_else(String::new);
|
|
||||||
let rate = synlimit_rate_arg(*seconds, *hitcount);
|
|
||||||
script.push_str(&format!(
|
|
||||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v4_{idx} {{ ip saddr limit rate over {rate} burst {burst} packets }} drop\n"
|
|
||||||
));
|
|
||||||
script.push_str(&format!(
|
|
||||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v6_targets.iter().enumerate() {
|
|
||||||
let daddr = ip
|
|
||||||
.map(|ip| format!(" ip6 daddr {ip}"))
|
|
||||||
.unwrap_or_else(String::new);
|
|
||||||
let rate = synlimit_rate_arg(*seconds, *hitcount);
|
|
||||||
script.push_str(&format!(
|
|
||||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v6_{idx} {{ ip6 saddr limit rate over {rate} burst {burst} packets }} drop\n"
|
|
||||||
));
|
|
||||||
script.push_str(&format!(
|
|
||||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
script.push_str(" }\n");
|
|
||||||
script.push_str("}\n");
|
|
||||||
script
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
|
|
||||||
if let Err(error) = run_command(
|
|
||||||
"nft",
|
|
||||||
&["delete", "table", family.as_str(), NFT_TABLE],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
&& !is_missing_command_or_nft_table(&error)
|
|
||||||
{
|
|
||||||
errors.push(format!(
|
|
||||||
"nft delete table {} {NFT_TABLE} failed: {error}",
|
|
||||||
family.as_str()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(errors.join(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_missing_command_or_iptables_rule(error: &str) -> bool {
|
|
||||||
error.contains("is not available")
|
|
||||||
|| error.contains("No chain/target/match by that name")
|
|
||||||
|| error.contains("does not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_missing_command_or_nft_table(error: &str) -> bool {
|
|
||||||
error.contains("is not available") || error.contains("No such file or directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_command(binary: &str, args: &[&str], stdin: Option<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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
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,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