mirror of
https://github.com/telemt/telemt.git
synced 2026-07-11 11:27:28 +03:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac93c6d49 | ||
|
|
a51e58009b | ||
|
|
d523406c0a | ||
|
|
5b3ad0096b | ||
|
|
b587fdbf94 | ||
|
|
77a45e509a | ||
|
|
b8be805aed | ||
|
|
a1ebd44cee | ||
|
|
25d02a8e0e | ||
|
|
3375017460 | ||
|
|
25e0abae8a | ||
|
|
50538d234e | ||
|
|
e3a7be6786 | ||
|
|
3fc2877205 | ||
|
|
cd1dc2f4c9 | ||
|
|
451227da60 | ||
|
|
f55d8479e3 | ||
|
|
81ae483201 | ||
|
|
ed1895d6df | ||
|
|
88d161a5e9 | ||
|
|
a0ac108807 | ||
|
|
809352fac5 | ||
|
|
22627b498d | ||
|
|
b9c5c71dbc | ||
|
|
7aee991416 | ||
|
|
9a9fd3f55d | ||
|
|
3a5fe31262 | ||
|
|
82f63d0d8a | ||
|
|
fce75163b0 | ||
|
|
fe56621a83 | ||
|
|
1f2910f5bc | ||
|
|
d67e7c5a6f | ||
|
|
558f352a57 | ||
|
|
1ee9a234d7 | ||
|
|
2e13f89f6d |
@@ -1,18 +1,20 @@
|
||||
# Issues
|
||||
## Warnung
|
||||
### Warnung
|
||||
|
||||
Before opening Issue, if it is more question than problem or bug - ask about that [in our chat](https://t.me/telemtrs)
|
||||
|
||||
## What it is not
|
||||
- NOT Question and Answer
|
||||
- NOT Helpdesk
|
||||
|
||||
***Each of your Issues triggers attempts to reproduce problems and analyze them, which are done manually by people***
|
||||
|
||||
Issues is **NOT** about:
|
||||
- Question and Answer
|
||||
- Helpdesk
|
||||
- Configuration or Intergraion Support
|
||||
|
||||
---
|
||||
|
||||
# Pull Requests
|
||||
|
||||
## General
|
||||
### General
|
||||
- ONLY signed and verified commits
|
||||
- ONLY from your name
|
||||
- DO NOT commit with `codex`, `claude`, or other AI tools as author/committer
|
||||
@@ -20,7 +22,7 @@ Before opening Issue, if it is more question than problem or bug - ask about tha
|
||||
|
||||
---
|
||||
|
||||
## Definition of Ready (MANDATORY)
|
||||
### Definition of Ready (MANDATORY)
|
||||
|
||||
A Pull Request WILL be ignored or closed if:
|
||||
|
||||
@@ -32,14 +34,14 @@ A Pull Request WILL be ignored or closed if:
|
||||
|
||||
---
|
||||
|
||||
## Blessed Principles
|
||||
### Blessed Principles
|
||||
- PR must build
|
||||
- PR must pass tests
|
||||
- PR must be understood by author
|
||||
|
||||
---
|
||||
|
||||
## AI Usage Policy
|
||||
### AI Usage Policy
|
||||
|
||||
AI tools (Claude, ChatGPT, Codex, DeepSeek, etc.) are allowed as **assistants**, NOT as decision-makers.
|
||||
|
||||
@@ -60,7 +62,7 @@ PRs that look like unverified AI dumps WILL be closed
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Policy
|
||||
### Maintainer Policy
|
||||
|
||||
Maintainers reserve the right to:
|
||||
|
||||
@@ -72,7 +74,7 @@ Respect the reviewers time
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
### Enforcement
|
||||
|
||||
Pull Requests that violate project standards may be closed without review.
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2899,7 +2899,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.4.19"
|
||||
version = "3.4.23"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.4.19"
|
||||
version = "3.4.23"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -55,6 +55,16 @@ RUN set -eux; \
|
||||
strip --strip-unneeded /telemt || true; \
|
||||
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
|
||||
# ==========================
|
||||
@@ -99,7 +109,7 @@ RUN set -eux; \
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
@@ -116,7 +126,7 @@ FROM gcr.io/distroless/static-debian12 AS prod
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
// Cryptobench
|
||||
use criterion::{Criterion, black_box, criterion_group};
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use std::hint::black_box;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
#[path = "../src/crypto/aes.rs"]
|
||||
mod aes_impl;
|
||||
#[allow(unused_imports)]
|
||||
#[path = "../src/error.rs"]
|
||||
mod error;
|
||||
|
||||
use aes_impl::AesCtr;
|
||||
|
||||
fn bench_aes_ctr(c: &mut Criterion) {
|
||||
c.bench_function("aes_ctr_encrypt_64kb", |b| {
|
||||
let data = vec![0u8; 65536];
|
||||
b.iter(|| {
|
||||
let mut enc = AesCtr::new(&[0u8; 32], 0);
|
||||
black_box(enc.encrypt(&data))
|
||||
black_box(enc.encrypt(black_box(data.as_slice())))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_aes_ctr);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -4,7 +4,6 @@ services:
|
||||
context: .
|
||||
target: prod-netfilter
|
||||
network_mode: host
|
||||
ports: []
|
||||
ports: !reset []
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- NET_ADMIN
|
||||
- NET_ADMIN
|
||||
3574
docs/Config_params/CONFIG_PARAMS.de.md
Normal file
3574
docs/Config_params/CONFIG_PARAMS.de.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1972,6 +1972,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
## client_mss
|
||||
- **Constraints / validation**: `String`. Empty or omitted means do not change kernel MSS. Presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Custom decimal strings must be within `88..=4096`.
|
||||
- **Description**: Client-facing TCP MSS applied to TCP listener sockets before `listen(2)`, so Linux can announce it in SYN/ACK. This affects only proxy client TCP listeners, not API, metrics, Unix sockets, Telegram upstreams, ME sockets, or mask backend connections. Changes require listener restart/rebind.
|
||||
- **Operator note**: The two-tier `synlimit` profile does not require Telemt to disable MSS automatically. Operators that follow external host-tuning recipes should decide explicitly whether to leave MSS shaping enabled for handshake fragmentation or disable it for higher media throughput.
|
||||
- **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`.
|
||||
- **Example**:
|
||||
|
||||
@@ -2311,9 +2312,14 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✔` |
|
||||
| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✔` |
|
||||
| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✔` |
|
||||
| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✔` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
@@ -2351,7 +2357,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
```
|
||||
## synlimit (server.listeners)
|
||||
- **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener.
|
||||
- **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind.
|
||||
- **Description**: Installs per-listener Linux netfilter two-tier SYN-fix rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit`, `length`, and TTL/hop-limit matches. `"nftables"` uses Telemt-owned tables with per-source `meter` rules and equivalent IPv4/IPv6 classifiers. Rules are inserted early in `INPUT`, accept under-limit SYN packets, and reject over-limit SYN packets with TCP RST so clients retry promptly instead of waiting for a silent DROP timeout. The generic bucket is controlled by `synlimit_seconds`, `synlimit_hitcount`, and `synlimit_burst`; the iOS-like TTL/length bucket is controlled by `synlimit_ios_*`. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind.
|
||||
- **Operator note**: Telemt does not persist rules with `iptables-persistent`, write `/etc/sysctl.d`, edit systemd limits, or modify `client_mss`. Apply host-level tuning manually if your deployment policy requires it.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2366,8 +2373,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
synlimit = "nftables"
|
||||
```
|
||||
## synlimit_seconds (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Token-bucket interval for both SYN limiter backends. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`).
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60`.
|
||||
- **Description**: Generic SYN-fix token-bucket interval. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). This bucket handles SYN packets that do not match the iOS-like TTL/length classifier.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2375,11 +2382,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_seconds = 1
|
||||
synlimit_seconds = 60
|
||||
```
|
||||
## synlimit_hitcount (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Token-bucket rate amount for both SYN limiter backends. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets are dropped.
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `48`.
|
||||
- **Description**: Generic SYN-fix token-bucket rate amount. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets receive TCP RST.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2387,11 +2394,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hitcount = 1
|
||||
synlimit_hitcount = 48
|
||||
```
|
||||
## synlimit_burst (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `2`.
|
||||
- **Description**: Token-bucket burst size for both SYN limiter backends. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced.
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Generic SYN-fix token-bucket burst size. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2399,7 +2406,67 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_burst = 2
|
||||
synlimit_burst = 1
|
||||
```
|
||||
## synlimit_ios_seconds (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Token-bucket interval for SYN packets matching the iOS-like classifier. IPv4 matches packet length `64` and TTL `< 65`; IPv6 matches packet length `84` and hop limit `< 65`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_seconds = 1
|
||||
```
|
||||
## synlimit_ios_hitcount (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `12`.
|
||||
- **Description**: Token-bucket rate amount for the iOS-like SYN classifier.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_hitcount = 12
|
||||
```
|
||||
## synlimit_ios_burst (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `24`.
|
||||
- **Description**: Token-bucket burst size for the iOS-like SYN classifier.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_burst = 24
|
||||
```
|
||||
## synlimit_hashlimit_expire_ms (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60000`.
|
||||
- **Description**: Entry expiration in milliseconds for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_expire_ms = 60000
|
||||
```
|
||||
## synlimit_hashlimit_size (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `32768`.
|
||||
- **Description**: Hash table size for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_size = 32768
|
||||
```
|
||||
## announce
|
||||
- **Constraints / validation**: `String` (optional). Must not be empty when set.
|
||||
@@ -3119,7 +3186,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | `✘` |
|
||||
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | `✘` |
|
||||
| [`user_rate_limits`](#user_rate_limits) | `Map<String, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<IpNetwork, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<CidrRateLimitKey, RateLimitBps>` | `{}` | `✔` |
|
||||
|
||||
## users
|
||||
- **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**.
|
||||
@@ -3282,13 +3349,15 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
alice = { up_bps = 1048576, down_bps = 2097152 }
|
||||
```
|
||||
## cidr_rate_limits
|
||||
- **Constraints / validation**: Table `CIDR -> { up_bps, down_bps }`. CIDR must parse as `IpNetwork`; at least one direction must be non-zero.
|
||||
- **Description**: Source-subnet bandwidth caps applied alongside per-user limits.
|
||||
- **Constraints / validation**: Table `CIDR or auto-template -> { up_bps, down_bps }`. Explicit CIDR keys must parse as `IpNetwork`; auto-template keys must be `*4/N` (`N=0..32`), `*6/N` (`N=0..128`), or `*/N` (`N=0..32`). At least one direction must be non-zero. Duplicate normalized auto-templates are rejected.
|
||||
- **Description**: Source-subnet bandwidth caps applied alongside per-user limits. Explicit CIDR rules use longest-prefix-wins and take priority over auto-templates. Auto-templates create buckets lazily per matched source subnet: `*4/N` for IPv4, `*6/N` for IPv6, and `*/N` as a dual-stack shorthand where IPv4 uses `/N` and IPv6 uses `/(N * 4)`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[access.cidr_rate_limits]
|
||||
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
|
||||
"*4/32" = { up_bps = 262144, down_bps = 1048576 }
|
||||
"*6/64" = { up_bps = 262144, down_bps = 1048576 }
|
||||
```
|
||||
# [[upstreams]]
|
||||
|
||||
|
||||
@@ -1894,6 +1894,7 @@
|
||||
## client_mss
|
||||
- **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`.
|
||||
- **Описание**: MSS для входящих TCP-соединений клиентов. Значение применяется к TCP listener-сокетам до `listen(2)`, чтобы Linux мог объявить его в SYN/ACK. Параметр влияет только на proxy client TCP listeners и не применяется к API, metrics, Unix sockets, Telegram upstreams, ME sockets или mask backend connections. Изменение требует restart/rebind listener’ов.
|
||||
- **Operator note**: Two-tier `synlimit` profile больше не требует автоматического отключения MSS внутри Telemt. Оператор должен сам решить, оставлять MSS shaping для handshake fragmentation или отключать его ради более высокой скорости media.
|
||||
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
|
||||
- **Пример**:
|
||||
|
||||
@@ -2237,9 +2238,14 @@
|
||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✔` |
|
||||
| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✔` |
|
||||
| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✔` |
|
||||
| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✔` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
@@ -2277,7 +2283,8 @@
|
||||
```
|
||||
## synlimit (server.listeners)
|
||||
- **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен.
|
||||
- **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind.
|
||||
- **Описание**: Устанавливает per-listener Linux netfilter two-tier SYN-fix rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit`, `length` и TTL/hop-limit matches. `"nftables"` использует Telemt-owned tables с per-source `meter` rules и эквивалентными IPv4/IPv6 classifiers. Rules вставляются рано в `INPUT`, принимают under-limit SYN packets и отвечают TCP RST на over-limit SYN packets, чтобы клиент быстро переподключался вместо ожидания silent DROP timeout. Generic bucket управляется `synlimit_seconds`, `synlimit_hitcount` и `synlimit_burst`; iOS-like TTL/length bucket управляется `synlimit_ios_*`. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind.
|
||||
- **Operator note**: Telemt не сохраняет rules через `iptables-persistent`, не пишет `/etc/sysctl.d`, не меняет systemd limits и не модифицирует `client_mss`. Host-level tuning применяется оператором вручную.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2292,8 +2299,8 @@
|
||||
synlimit = "nftables"
|
||||
```
|
||||
## synlimit_seconds (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`).
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60`.
|
||||
- **Описание**: Generic SYN-fix token-bucket interval. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`). Этот bucket обрабатывает SYN packets, которые не совпали с iOS-like TTL/length classifier.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2301,11 +2308,11 @@
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_seconds = 1
|
||||
synlimit_seconds = 60
|
||||
```
|
||||
## synlimit_hitcount (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться.
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `48`.
|
||||
- **Описание**: Generic SYN-fix token-bucket rate amount. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets получат TCP RST.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2313,11 +2320,11 @@
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hitcount = 1
|
||||
synlimit_hitcount = 48
|
||||
```
|
||||
## synlimit_burst (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`.
|
||||
- **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Generic SYN-fix token-bucket burst size. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2325,7 +2332,67 @@
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_burst = 2
|
||||
synlimit_burst = 1
|
||||
```
|
||||
## synlimit_ios_seconds (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket interval для SYN packets, совпавших с iOS-like classifier. IPv4 match: packet length `64` и TTL `< 65`; IPv6 match: packet length `84` и hop limit `< 65`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_seconds = 1
|
||||
```
|
||||
## synlimit_ios_hitcount (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `12`.
|
||||
- **Описание**: Token-bucket rate amount для iOS-like SYN classifier.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_hitcount = 12
|
||||
```
|
||||
## synlimit_ios_burst (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `24`.
|
||||
- **Описание**: Token-bucket burst size для iOS-like SYN classifier.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_burst = 24
|
||||
```
|
||||
## synlimit_hashlimit_expire_ms (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60000`.
|
||||
- **Описание**: Entry expiration в миллисекундах для iptables/ip6tables hashlimit buckets. nftables meters используют kernel-managed state и не имеют точного аналога этого knob.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_expire_ms = 60000
|
||||
```
|
||||
## synlimit_hashlimit_size (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `32768`.
|
||||
- **Описание**: Hash table size для iptables/ip6tables hashlimit buckets. nftables meters используют kernel-managed state и не имеют точного аналога этого knob.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_size = 32768
|
||||
```
|
||||
## announce
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
|
||||
@@ -3045,7 +3112,7 @@
|
||||
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | `✘` |
|
||||
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | `✘` |
|
||||
| [`user_rate_limits`](#user_rate_limits) | `Map<String, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<IpNetwork, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<CidrRateLimitKey, RateLimitBps>` | `{}` | `✔` |
|
||||
|
||||
## users
|
||||
- **Ограничения / валидация**: Не должно быть пустым (должен существовать хотя бы один пользователь). Каждое значение должно состоять **ровно из 32 шестнадцатеричных символов**.
|
||||
@@ -3198,13 +3265,15 @@
|
||||
alice = { up_bps = 1048576, down_bps = 2097152 }
|
||||
```
|
||||
## cidr_rate_limits
|
||||
- **Ограничения / валидация**: Таблица `CIDR -> { up_bps, down_bps }`. CIDR должен корректно разбираться как `IpNetwork`; хотя бы одно направление должно быть ненулевым.
|
||||
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений.
|
||||
- **Ограничения / валидация**: Таблица `CIDR или auto-template -> { up_bps, down_bps }`. Explicit CIDR-ключи должны корректно разбираться как `IpNetwork`; auto-template ключи должны иметь вид `*4/N` (`N=0..32`), `*6/N` (`N=0..128`) или `*/N` (`N=0..32`). Хотя бы одно направление должно быть ненулевым. Дублирующиеся нормализованные auto-template отклоняются.
|
||||
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений. Explicit CIDR-правила используют longest-prefix-wins и имеют приоритет над auto-template. Auto-template создают bucket’ы лениво по matched source subnet: `*4/N` для IPv4, `*6/N` для IPv6, а `*/N` является dual-stack shorthand, где IPv4 использует `/N`, а IPv6 — `/(N * 4)`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[access.cidr_rate_limits]
|
||||
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
|
||||
"*4/32" = { up_bps = 262144, down_bps = 1048576 }
|
||||
"*6/64" = { up_bps = 262144, down_bps = 1048576 }
|
||||
```
|
||||
# [[upstreams]]
|
||||
|
||||
|
||||
@@ -54,9 +54,14 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true;
|
||||
const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85;
|
||||
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
|
||||
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
|
||||
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_BURST: u32 = 2;
|
||||
const DEFAULT_SYNLIMIT_SECONDS: u32 = 60;
|
||||
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 48;
|
||||
const DEFAULT_SYNLIMIT_BURST: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_IOS_SECONDS: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_IOS_HITCOUNT: u32 = 12;
|
||||
const DEFAULT_SYNLIMIT_IOS_BURST: u32 = 24;
|
||||
const DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS: u32 = 60_000;
|
||||
const DEFAULT_SYNLIMIT_HASHLIMIT_SIZE: u32 = 32_768;
|
||||
const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
|
||||
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
|
||||
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
|
||||
@@ -258,6 +263,26 @@ pub(crate) fn default_synlimit_burst() -> u32 {
|
||||
DEFAULT_SYNLIMIT_BURST
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_ios_seconds() -> u32 {
|
||||
DEFAULT_SYNLIMIT_IOS_SECONDS
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_ios_hitcount() -> u32 {
|
||||
DEFAULT_SYNLIMIT_IOS_HITCOUNT
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_ios_burst() -> u32 {
|
||||
DEFAULT_SYNLIMIT_IOS_BURST
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_hashlimit_expire_ms() -> u32 {
|
||||
DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_hashlimit_size() -> u32 {
|
||||
DEFAULT_SYNLIMIT_HASHLIMIT_SIZE
|
||||
}
|
||||
|
||||
pub(crate) fn default_prefer_4() -> u8 {
|
||||
4
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ use tracing::{error, info, warn};
|
||||
|
||||
use super::load::{LoadedConfig, ProxyConfig};
|
||||
use crate::config::{
|
||||
ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
|
||||
MeWriterPickMode, SynLimitMode,
|
||||
CidrRateLimitKey, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy,
|
||||
MeTelemetryLevel, MeWriterPickMode, SynLimitMode,
|
||||
};
|
||||
|
||||
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||
@@ -128,8 +128,7 @@ pub struct HotFields {
|
||||
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
|
||||
pub user_data_quota: std::collections::HashMap<String, u64>,
|
||||
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
|
||||
pub cidr_rate_limits:
|
||||
std::collections::HashMap<ipnetwork::IpNetwork, crate::config::RateLimitBps>,
|
||||
pub cidr_rate_limits: std::collections::HashMap<CidrRateLimitKey, crate::config::RateLimitBps>,
|
||||
pub user_max_unique_ips: std::collections::HashMap<String, usize>,
|
||||
pub user_max_unique_ips_global_each: usize,
|
||||
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
|
||||
@@ -145,6 +144,11 @@ pub struct ListenerSynLimitHotFields {
|
||||
pub synlimit_seconds: u32,
|
||||
pub synlimit_hitcount: u32,
|
||||
pub synlimit_burst: u32,
|
||||
pub synlimit_ios_seconds: u32,
|
||||
pub synlimit_ios_hitcount: u32,
|
||||
pub synlimit_ios_burst: u32,
|
||||
pub synlimit_hashlimit_expire_ms: u32,
|
||||
pub synlimit_hashlimit_size: u32,
|
||||
}
|
||||
|
||||
impl HotFields {
|
||||
@@ -293,6 +297,11 @@ impl ListenerSynLimitHotFields {
|
||||
synlimit_seconds: listener.synlimit_seconds,
|
||||
synlimit_hitcount: listener.synlimit_hitcount,
|
||||
synlimit_burst: listener.synlimit_burst,
|
||||
synlimit_ios_seconds: listener.synlimit_ios_seconds,
|
||||
synlimit_ios_hitcount: listener.synlimit_ios_hitcount,
|
||||
synlimit_ios_burst: listener.synlimit_ios_burst,
|
||||
synlimit_hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms,
|
||||
synlimit_hashlimit_size: listener.synlimit_hashlimit_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -620,6 +629,11 @@ fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerC
|
||||
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
|
||||
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
|
||||
old_listener.synlimit_burst = new_listener.synlimit_burst;
|
||||
old_listener.synlimit_ios_seconds = new_listener.synlimit_ios_seconds;
|
||||
old_listener.synlimit_ios_hitcount = new_listener.synlimit_ios_hitcount;
|
||||
old_listener.synlimit_ios_burst = new_listener.synlimit_ios_burst;
|
||||
old_listener.synlimit_hashlimit_expire_ms = new_listener.synlimit_hashlimit_expire_ms;
|
||||
old_listener.synlimit_hashlimit_size = new_listener.synlimit_hashlimit_size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1710,6 +1724,51 @@ mod tests {
|
||||
assert!(!config_equal(&applied, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_synlimit_extended_fields_are_hot() {
|
||||
let mut old = sample_config();
|
||||
old.server.listeners.push(ListenerConfig {
|
||||
ip: "0.0.0.0".parse().unwrap(),
|
||||
port: Some(443),
|
||||
client_mss: None,
|
||||
synlimit: SynLimitMode::Iptables,
|
||||
synlimit_seconds: 60,
|
||||
synlimit_hitcount: 48,
|
||||
synlimit_burst: 1,
|
||||
synlimit_ios_seconds: 1,
|
||||
synlimit_ios_hitcount: 12,
|
||||
synlimit_ios_burst: 24,
|
||||
synlimit_hashlimit_expire_ms: 60_000,
|
||||
synlimit_hashlimit_size: 32_768,
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
reuse_allow: false,
|
||||
});
|
||||
let mut new = old.clone();
|
||||
new.server.port = 8443;
|
||||
new.server.listeners[0].synlimit_seconds = 120;
|
||||
new.server.listeners[0].synlimit_hitcount = 96;
|
||||
new.server.listeners[0].synlimit_burst = 2;
|
||||
new.server.listeners[0].synlimit_ios_seconds = 2;
|
||||
new.server.listeners[0].synlimit_ios_hitcount = 18;
|
||||
new.server.listeners[0].synlimit_ios_burst = 36;
|
||||
new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000;
|
||||
new.server.listeners[0].synlimit_hashlimit_size = 65_536;
|
||||
|
||||
let applied = overlay_hot_fields(&old, &new);
|
||||
let listener = &applied.server.listeners[0];
|
||||
assert_eq!(applied.server.port, old.server.port);
|
||||
assert_eq!(listener.synlimit_seconds, 120);
|
||||
assert_eq!(listener.synlimit_hitcount, 96);
|
||||
assert_eq!(listener.synlimit_burst, 2);
|
||||
assert_eq!(listener.synlimit_ios_seconds, 2);
|
||||
assert_eq!(listener.synlimit_ios_hitcount, 18);
|
||||
assert_eq!(listener.synlimit_ios_burst, 36);
|
||||
assert_eq!(listener.synlimit_hashlimit_expire_ms, 90_000);
|
||||
assert_eq!(listener.synlimit_hashlimit_size, 65_536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_applies_hot_change_on_first_observed_snapshot() {
|
||||
let initial_tag = "11111111111111111111111111111111";
|
||||
|
||||
3425
src/config/load.rs
3425
src/config/load.rs
File diff suppressed because it is too large
Load Diff
60
src/config/load/includes.rs
Normal file
60
src/config/load/includes.rs
Normal file
@@ -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)
|
||||
}
|
||||
115
src/config/load/normalize.rs
Normal file
115
src/config/load/normalize.rs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
112
src/config/load/runtime_auth.rs
Normal file
112
src/config/load/runtime_auth.rs
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
693
src/config/load/strict_keys.rs
Normal file
693
src/config/load/strict_keys.rs
Normal file
@@ -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(())
|
||||
}
|
||||
111
src/config/load/validation.rs
Normal file
111
src/config/load/validation.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2446
src/config/tests/load_basic_tests.rs
Normal file
2446
src/config/tests/load_basic_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1455,9 +1456,9 @@ pub enum SynLimitMode {
|
||||
/// Disable SYN limiting for this listener.
|
||||
#[default]
|
||||
Off,
|
||||
/// Use iptables/ip6tables filter rules with the hashlimit match.
|
||||
/// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match.
|
||||
Iptables,
|
||||
/// Use nftables rules with per-source token-bucket meters.
|
||||
/// Use nftables two-tier SYN-fix rules with per-source token-bucket meters.
|
||||
Nftables,
|
||||
}
|
||||
|
||||
@@ -2098,11 +2099,13 @@ pub struct AccessConfig {
|
||||
|
||||
/// Per-CIDR aggregate transport rate limits in bits-per-second.
|
||||
///
|
||||
/// Matching uses longest-prefix-wins semantics. A value of `0` in one
|
||||
/// direction means "unlimited" for that direction. Limits are amortized
|
||||
/// with the same bounded-burst contract as per-user rate limits.
|
||||
/// Explicit CIDR keys use longest-prefix-wins semantics. Auto-template
|
||||
/// keys (`*4/N`, `*6/N`, `*/N`) lazily create per-source-subnet buckets
|
||||
/// after explicit CIDR matching misses. A value of `0` in one direction
|
||||
/// means "unlimited" for that direction. Limits are amortized with the
|
||||
/// same bounded-burst contract as per-user rate limits.
|
||||
#[serde(default)]
|
||||
pub cidr_rate_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||
pub cidr_rate_limits: HashMap<CidrRateLimitKey, RateLimitBps>,
|
||||
|
||||
/// Per-username client source IP/CIDR deny list. Checked after successful
|
||||
/// authentication; matching IPs get the same rejection path as invalid auth
|
||||
@@ -2171,10 +2174,156 @@ impl AccessConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Key used by `access.cidr_rate_limits`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum CidrRateLimitKey {
|
||||
/// Explicit source CIDR rule.
|
||||
Network(IpNetwork),
|
||||
/// IPv4 auto-template that creates one bucket for each matching `/N`.
|
||||
AutoV4(u8),
|
||||
/// IPv6 auto-template that creates one bucket for each matching `/N`.
|
||||
AutoV6(u8),
|
||||
/// Dual-stack auto-template; IPv4 uses `/N`, IPv6 uses `/(N * 4)`.
|
||||
AutoDual(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum CidrAutoTemplateFamily {
|
||||
V4,
|
||||
V6,
|
||||
}
|
||||
|
||||
impl CidrAutoTemplateFamily {
|
||||
pub(crate) fn marker(self) -> &'static str {
|
||||
match self {
|
||||
Self::V4 => "*4",
|
||||
Self::V6 => "*6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct CidrAutoTemplate {
|
||||
pub(crate) family: CidrAutoTemplateFamily,
|
||||
pub(crate) prefix_len: u8,
|
||||
}
|
||||
|
||||
impl fmt::Display for CidrAutoTemplate {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{}/{}", self.family.marker(), self.prefix_len)
|
||||
}
|
||||
}
|
||||
|
||||
impl CidrRateLimitKey {
|
||||
pub(crate) fn auto_templates(&self) -> [Option<CidrAutoTemplate>; 2] {
|
||||
match *self {
|
||||
Self::Network(_) => [None, None],
|
||||
Self::AutoV4(prefix_len) => [
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V4,
|
||||
prefix_len,
|
||||
}),
|
||||
None,
|
||||
],
|
||||
Self::AutoV6(prefix_len) => [
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V6,
|
||||
prefix_len,
|
||||
}),
|
||||
None,
|
||||
],
|
||||
Self::AutoDual(prefix_len) => [
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V4,
|
||||
prefix_len,
|
||||
}),
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V6,
|
||||
prefix_len: prefix_len.saturating_mul(4),
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CidrRateLimitKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Network(cidr) => write!(formatter, "{cidr}"),
|
||||
Self::AutoV4(prefix_len) => write!(formatter, "*4/{prefix_len}"),
|
||||
Self::AutoV6(prefix_len) => write!(formatter, "*6/{prefix_len}"),
|
||||
Self::AutoDual(prefix_len) => write!(formatter, "*/{prefix_len}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for CidrRateLimitKey {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CidrRateLimitKey {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
parse_cidr_rate_limit_key(&value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cidr_rate_limit_key(value: &str) -> std::result::Result<CidrRateLimitKey, String> {
|
||||
if let Some(prefix) = value.strip_prefix("*4/") {
|
||||
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoV4);
|
||||
}
|
||||
if let Some(prefix) = value.strip_prefix("*6/") {
|
||||
return parse_cidr_auto_prefix(value, prefix, 128).map(CidrRateLimitKey::AutoV6);
|
||||
}
|
||||
if let Some(prefix) = value.strip_prefix("*/") {
|
||||
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoDual);
|
||||
}
|
||||
if value.starts_with('*') {
|
||||
return Err(format!(
|
||||
"invalid CIDR rate limit key {value:?}; expected CIDR, *4/N, *6/N, or */N"
|
||||
));
|
||||
}
|
||||
value
|
||||
.parse::<IpNetwork>()
|
||||
.map(CidrRateLimitKey::Network)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"invalid CIDR rate limit key {value:?}: {error}; expected CIDR, *4/N, *6/N, or */N"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cidr_auto_prefix(
|
||||
key: &str,
|
||||
prefix: &str,
|
||||
max_prefix: u8,
|
||||
) -> std::result::Result<u8, String> {
|
||||
let prefix = prefix.parse::<u8>().map_err(|_| {
|
||||
format!("invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}")
|
||||
})?;
|
||||
if prefix > max_prefix {
|
||||
return Err(format!(
|
||||
"invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}"
|
||||
));
|
||||
}
|
||||
Ok(prefix)
|
||||
}
|
||||
|
||||
/// Transport rate limit in bits-per-second.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RateLimitBps {
|
||||
/// Upload direction limit in bits-per-second; `0` means unlimited.
|
||||
#[serde(default)]
|
||||
pub up_bps: u64,
|
||||
/// Download direction limit in bits-per-second; `0` means unlimited.
|
||||
#[serde(default)]
|
||||
pub down_bps: u64,
|
||||
}
|
||||
@@ -2266,15 +2415,30 @@ pub struct ListenerConfig {
|
||||
/// Per-listener SYN limiter mode.
|
||||
#[serde(default)]
|
||||
pub synlimit: SynLimitMode,
|
||||
/// Token-bucket rate interval for the per-listener SYN limiter.
|
||||
/// Generic SYN-fix token-bucket rate interval.
|
||||
#[serde(default = "default_synlimit_seconds")]
|
||||
pub synlimit_seconds: u32,
|
||||
/// Token-bucket rate amount for the per-listener SYN limiter.
|
||||
/// Generic SYN-fix token-bucket rate amount.
|
||||
#[serde(default = "default_synlimit_hitcount")]
|
||||
pub synlimit_hitcount: u32,
|
||||
/// Token-bucket burst size for the per-listener SYN limiter.
|
||||
/// Generic SYN-fix token-bucket burst size.
|
||||
#[serde(default = "default_synlimit_burst")]
|
||||
pub synlimit_burst: u32,
|
||||
/// iOS-like SYN-fix token-bucket rate interval.
|
||||
#[serde(default = "default_synlimit_ios_seconds")]
|
||||
pub synlimit_ios_seconds: u32,
|
||||
/// iOS-like SYN-fix token-bucket rate amount.
|
||||
#[serde(default = "default_synlimit_ios_hitcount")]
|
||||
pub synlimit_ios_hitcount: u32,
|
||||
/// iOS-like SYN-fix token-bucket burst size.
|
||||
#[serde(default = "default_synlimit_ios_burst")]
|
||||
pub synlimit_ios_burst: u32,
|
||||
/// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules.
|
||||
#[serde(default = "default_synlimit_hashlimit_expire_ms")]
|
||||
pub synlimit_hashlimit_expire_ms: u32,
|
||||
/// Hashlimit table size for iptables/ip6tables rules.
|
||||
#[serde(default = "default_synlimit_hashlimit_size")]
|
||||
pub synlimit_hashlimit_size: u32,
|
||||
/// IP address or hostname to announce in proxy links.
|
||||
/// Takes precedence over `announce_ip` if both are set.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod hash;
|
||||
pub mod random;
|
||||
|
||||
pub use aes::{AesCbc, AesCtr};
|
||||
pub use hash::{
|
||||
build_middleproxy_prekey, crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use hash::build_middleproxy_prekey;
|
||||
pub use hash::{crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac};
|
||||
pub use random::SecureRandom;
|
||||
|
||||
@@ -246,7 +246,7 @@ pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// Generate padding length for Secure Intermediate protocol.
|
||||
/// Telegram Desktop uses a 4-bit random padding length for VersionD packets.
|
||||
/// Outbound padding is 1..=3 so a receiver can strip it by 4-byte alignment.
|
||||
pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize {
|
||||
debug_assert!(
|
||||
is_valid_secure_payload_len(data_len),
|
||||
@@ -425,15 +425,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_padding_matches_tdesktop_range() {
|
||||
fn secure_padding_never_produces_aligned_total() {
|
||||
let rng = SecureRandom::new();
|
||||
for data_len in (0..1000).step_by(4) {
|
||||
for _ in 0..100 {
|
||||
let padding = secure_padding_len(data_len, &rng);
|
||||
assert!(
|
||||
padding <= 15,
|
||||
(1..=3).contains(&padding),
|
||||
"padding out of range: data_len={data_len}, padding={padding}"
|
||||
);
|
||||
assert_ne!(
|
||||
(data_len + padding) % 4,
|
||||
0,
|
||||
"invariant violated: data_len={data_len}, padding={padding}, total={}",
|
||||
data_len + padding
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ pub(crate) const INTERMEDIATE_QUICKACK_FLAG: u32 = 0x8000_0000;
|
||||
/// Payload length mask used by Intermediate and Secure Intermediate headers.
|
||||
pub(crate) const INTERMEDIATE_WIRE_LEN_MASK: u32 = 0x7fff_ffff;
|
||||
|
||||
/// Maximum random tail length used by Telegram Desktop VersionD packets.
|
||||
pub(crate) const SECURE_VERSION_D_PADDING_MAX: usize = 15;
|
||||
/// Maximum outbound Secure tail length that keeps wire lengths non-aligned.
|
||||
pub(crate) const SECURE_VERSION_D_PADDING_MAX: usize = 3;
|
||||
|
||||
/// Parsed Intermediate/Secure Intermediate length header.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -51,9 +51,9 @@ pub(crate) fn secure_version_d_body_len_from_wire_len(wire_len: usize) -> Option
|
||||
Some(wire_len - (wire_len % 4))
|
||||
}
|
||||
|
||||
/// Generate Telegram Desktop-compatible VersionD random tail length.
|
||||
/// Generate outbound Secure tail length without ambiguous full-word padding.
|
||||
pub(crate) fn secure_version_d_padding_len(rng: &SecureRandom) -> usize {
|
||||
rng.range(SECURE_VERSION_D_PADDING_MAX + 1)
|
||||
rng.range(SECURE_VERSION_D_PADDING_MAX) + 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -69,7 +69,9 @@ use self::quota::{
|
||||
#[cfg(test)]
|
||||
use self::c2me::enqueue_c2me_command;
|
||||
#[cfg(test)]
|
||||
use self::d2c::{compute_intermediate_secure_wire_len, process_me_writer_response};
|
||||
use self::d2c::{
|
||||
compute_intermediate_secure_wire_len, process_me_writer_response, write_client_payload,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::desync::{
|
||||
clear_desync_dedup_for_testing_in_shared, desync_dedup_get_for_testing,
|
||||
@@ -166,3 +168,7 @@ mod middle_relay_atomic_quota_invariant_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "tests/middle_relay_baseline_invariant_tests.rs"]
|
||||
mod middle_relay_baseline_invariant_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/middle_relay_d2c_flush_padding_security_tests.rs"]
|
||||
mod middle_relay_d2c_flush_padding_security_tests;
|
||||
|
||||
@@ -55,8 +55,8 @@ pub(super) fn classify_me_d2c_flush_reason(
|
||||
MeD2cFlushReason::QueueDrain
|
||||
}
|
||||
|
||||
pub(super) fn me_d2c_flush_reason_requires_client_flush(reason: MeD2cFlushReason) -> bool {
|
||||
!matches!(reason, MeD2cFlushReason::QueueDrain)
|
||||
pub(super) fn me_d2c_flush_reason_requires_client_flush(_reason: MeD2cFlushReason) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -64,8 +64,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn queue_drain_is_not_a_physical_flush_trigger() {
|
||||
assert!(!me_d2c_flush_reason_requires_client_flush(
|
||||
fn all_flush_reasons_trigger_physical_flush() {
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::QueueDrain
|
||||
));
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
|
||||
150
src/proxy/tests/middle_relay_d2c_flush_padding_security_tests.rs
Normal file
150
src/proxy/tests/middle_relay_d2c_flush_padding_security_tests.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
use super::*;
|
||||
use crate::crypto::AesCtr;
|
||||
use crate::protocol::framing::INTERMEDIATE_WIRE_LEN_MASK;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingWriter {
|
||||
writes: Arc<Mutex<Vec<u8>>>,
|
||||
flushes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl RecordingWriter {
|
||||
fn captured(&self) -> Vec<u8> {
|
||||
self.writes
|
||||
.lock()
|
||||
.expect("test writer capture lock must not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for RecordingWriter {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
self.writes
|
||||
.lock()
|
||||
.expect("test writer capture lock must not be poisoned")
|
||||
.extend_from_slice(buf);
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.flushes.fetch_add(1, Ordering::Relaxed);
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn crypto_writer(inner: RecordingWriter) -> CryptoWriter<RecordingWriter> {
|
||||
let key = [0u8; 32];
|
||||
CryptoWriter::new(inner, AesCtr::new(&key, 0), 8 * 1024 * 1024)
|
||||
}
|
||||
|
||||
fn decrypt_capture(mut encrypted: Vec<u8>) -> Vec<u8> {
|
||||
let key = [0u8; 32];
|
||||
let mut cipher = AesCtr::new(&key, 0);
|
||||
cipher.apply(&mut encrypted);
|
||||
encrypted
|
||||
}
|
||||
|
||||
fn secure_wire_len(cleartext: &[u8]) -> usize {
|
||||
let header = cleartext
|
||||
.get(..4)
|
||||
.expect("secure frame must include an intermediate header");
|
||||
(u32::from_le_bytes(
|
||||
header
|
||||
.try_into()
|
||||
.expect("secure frame header must be four bytes"),
|
||||
) & INTERMEDIATE_WIRE_LEN_MASK) as usize
|
||||
}
|
||||
|
||||
async fn write_secure_payload(payload_len: usize) -> (MeD2cWriteMode, Vec<u8>) {
|
||||
let inner = RecordingWriter::default();
|
||||
let capture = inner.clone();
|
||||
let mut writer = crypto_writer(inner);
|
||||
let payload = vec![0xa5; payload_len];
|
||||
let mut frame_buf = Vec::new();
|
||||
let cancel = CancellationToken::new();
|
||||
let rng = SecureRandom::new();
|
||||
|
||||
let mode = write_client_payload(
|
||||
&mut writer,
|
||||
ProtoTag::Secure,
|
||||
0,
|
||||
&payload,
|
||||
&rng,
|
||||
&mut frame_buf,
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.expect("secure payload write must succeed");
|
||||
flush_client_or_cancel(&mut writer, &cancel)
|
||||
.await
|
||||
.expect("secure payload flush must succeed");
|
||||
|
||||
(mode, decrypt_capture(capture.captured()))
|
||||
}
|
||||
|
||||
fn assert_secure_payload_with_tail_padding(cleartext: &[u8], payload_len: usize) {
|
||||
let wire_len = secure_wire_len(cleartext);
|
||||
assert_eq!(cleartext.len(), 4 + wire_len);
|
||||
assert!(
|
||||
cleartext[4..4 + payload_len]
|
||||
.iter()
|
||||
.all(|byte| *byte == 0xa5)
|
||||
);
|
||||
|
||||
let padding_len = wire_len
|
||||
.checked_sub(payload_len)
|
||||
.expect("secure wire length must include payload bytes");
|
||||
assert!((1..=3).contains(&padding_len));
|
||||
assert_ne!(wire_len % 4, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_drain_flush_reason_performs_physical_client_flush() {
|
||||
let inner = RecordingWriter::default();
|
||||
let flushes = inner.flushes.clone();
|
||||
let mut writer = crypto_writer(inner);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::QueueDrain
|
||||
));
|
||||
flush_client_or_cancel(&mut writer, &cancel)
|
||||
.await
|
||||
.expect("client flush must succeed");
|
||||
|
||||
assert_eq!(flushes.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_payload_coalesced_path_keeps_tail_padding() {
|
||||
let payload_len = 8;
|
||||
let (mode, cleartext) = write_secure_payload(payload_len).await;
|
||||
|
||||
assert!(matches!(mode, MeD2cWriteMode::Coalesced));
|
||||
assert_secure_payload_with_tail_padding(&cleartext, payload_len);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_payload_split_path_keeps_tail_padding() {
|
||||
let payload_len = ME_D2C_SINGLE_WRITE_COALESCE_MAX_BYTES;
|
||||
let (mode, cleartext) = write_secure_payload(payload_len).await;
|
||||
|
||||
assert!(matches!(mode, MeD2cWriteMode::Split));
|
||||
assert_secure_payload_with_tail_padding(&cleartext, payload_len);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use arc_swap::ArcSwap;
|
||||
use dashmap::DashMap;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::RateLimitBps;
|
||||
use crate::config::{CidrRateLimitKey, RateLimitBps};
|
||||
|
||||
const REGISTRY_SHARDS: usize = 64;
|
||||
const FAIR_EPOCH_MS: u64 = 20;
|
||||
@@ -413,16 +413,29 @@ struct CidrRule {
|
||||
prefix_len: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CidrAutoRule {
|
||||
prefix_len: u8,
|
||||
limits: RateLimitBps,
|
||||
}
|
||||
|
||||
enum CidrPolicyMatch<'a> {
|
||||
Explicit(&'a CidrRule),
|
||||
Auto { key: String, limits: RateLimitBps },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicySnapshot {
|
||||
user_limits: HashMap<String, RateLimitBps>,
|
||||
cidr_rules_v4: Vec<CidrRule>,
|
||||
cidr_rules_v6: Vec<CidrRule>,
|
||||
cidr_auto_rules_v4: Vec<CidrAutoRule>,
|
||||
cidr_auto_rules_v6: Vec<CidrAutoRule>,
|
||||
cidr_rule_keys: HashSet<String>,
|
||||
}
|
||||
|
||||
impl PolicySnapshot {
|
||||
fn match_cidr(&self, ip: IpAddr) -> Option<&CidrRule> {
|
||||
fn match_cidr(&self, ip: IpAddr) -> Option<CidrPolicyMatch<'_>> {
|
||||
match ip {
|
||||
IpAddr::V4(_) => self
|
||||
.cidr_rules_v4
|
||||
@@ -433,6 +446,20 @@ impl PolicySnapshot {
|
||||
.iter()
|
||||
.find(|rule| rule.cidr.contains(ip)),
|
||||
}
|
||||
.map(CidrPolicyMatch::Explicit)
|
||||
.or_else(|| self.match_auto_cidr(ip))
|
||||
}
|
||||
|
||||
fn match_auto_cidr(&self, ip: IpAddr) -> Option<CidrPolicyMatch<'_>> {
|
||||
let rule = match ip {
|
||||
IpAddr::V4(_) => self.cidr_auto_rules_v4.first()?,
|
||||
IpAddr::V6(_) => self.cidr_auto_rules_v6.first()?,
|
||||
};
|
||||
let key = auto_cidr_bucket_key(ip, rule.prefix_len)?;
|
||||
Some(CidrPolicyMatch::Auto {
|
||||
key,
|
||||
limits: rule.limits,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +660,7 @@ impl TrafficLimiter {
|
||||
pub fn apply_policy(
|
||||
&self,
|
||||
user_limits: HashMap<String, RateLimitBps>,
|
||||
cidr_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||
cidr_limits: HashMap<CidrRateLimitKey, RateLimitBps>,
|
||||
) {
|
||||
let filtered_users = user_limits
|
||||
.into_iter()
|
||||
@@ -642,39 +669,64 @@ impl TrafficLimiter {
|
||||
|
||||
let mut cidr_rules_v4 = Vec::new();
|
||||
let mut cidr_rules_v6 = Vec::new();
|
||||
let mut cidr_auto_rules_v4 = Vec::new();
|
||||
let mut cidr_auto_rules_v6 = Vec::new();
|
||||
let mut cidr_rule_keys = HashSet::new();
|
||||
for (cidr, limits) in cidr_limits {
|
||||
for (key, limits) in cidr_limits {
|
||||
if limits.up_bps == 0 && limits.down_bps == 0 {
|
||||
continue;
|
||||
}
|
||||
let key = cidr.to_string();
|
||||
let rule = CidrRule {
|
||||
key: key.clone(),
|
||||
cidr,
|
||||
limits,
|
||||
prefix_len: cidr.prefix(),
|
||||
};
|
||||
cidr_rule_keys.insert(key);
|
||||
match rule.cidr {
|
||||
IpNetwork::V4(_) => cidr_rules_v4.push(rule),
|
||||
IpNetwork::V6(_) => cidr_rules_v6.push(rule),
|
||||
match key {
|
||||
CidrRateLimitKey::Network(cidr) => {
|
||||
let key = cidr.to_string();
|
||||
let rule = CidrRule {
|
||||
key: key.clone(),
|
||||
cidr,
|
||||
limits,
|
||||
prefix_len: cidr.prefix(),
|
||||
};
|
||||
cidr_rule_keys.insert(key);
|
||||
match rule.cidr {
|
||||
IpNetwork::V4(_) => cidr_rules_v4.push(rule),
|
||||
IpNetwork::V6(_) => cidr_rules_v6.push(rule),
|
||||
}
|
||||
}
|
||||
CidrRateLimitKey::AutoV4(prefix_len) => {
|
||||
cidr_auto_rules_v4.push(CidrAutoRule { prefix_len, limits });
|
||||
}
|
||||
CidrRateLimitKey::AutoV6(prefix_len) => {
|
||||
cidr_auto_rules_v6.push(CidrAutoRule { prefix_len, limits });
|
||||
}
|
||||
CidrRateLimitKey::AutoDual(prefix_len) => {
|
||||
cidr_auto_rules_v4.push(CidrAutoRule { prefix_len, limits });
|
||||
cidr_auto_rules_v6.push(CidrAutoRule {
|
||||
prefix_len: prefix_len.saturating_mul(4),
|
||||
limits,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cidr_rules_v4.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
cidr_rules_v6.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
cidr_auto_rules_v4.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
cidr_auto_rules_v6.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
let cidr_policy_entries =
|
||||
cidr_rule_keys.len() + cidr_auto_rules_v4.len() + cidr_auto_rules_v6.len();
|
||||
|
||||
self.user_scope
|
||||
.policy_entries
|
||||
.store(filtered_users.len() as u64, Ordering::Relaxed);
|
||||
self.cidr_scope
|
||||
.policy_entries
|
||||
.store(cidr_rule_keys.len() as u64, Ordering::Relaxed);
|
||||
.store(cidr_policy_entries as u64, Ordering::Relaxed);
|
||||
|
||||
self.policy.store(Arc::new(PolicySnapshot {
|
||||
user_limits: filtered_users,
|
||||
cidr_rules_v4,
|
||||
cidr_rules_v6,
|
||||
cidr_auto_rules_v4,
|
||||
cidr_auto_rules_v6,
|
||||
cidr_rule_keys,
|
||||
}));
|
||||
|
||||
@@ -703,11 +755,15 @@ impl TrafficLimiter {
|
||||
let mut cidr_bucket = None;
|
||||
let mut cidr_user_key = None;
|
||||
let mut cidr_user_share = None;
|
||||
if let Some(rule) = policy.match_cidr(client_ip) {
|
||||
if let Some(rule_match) = policy.match_cidr(client_ip) {
|
||||
let (key, limits) = match &rule_match {
|
||||
CidrPolicyMatch::Explicit(rule) => (rule.key.as_str(), rule.limits),
|
||||
CidrPolicyMatch::Auto { key, limits } => (key.as_str(), *limits),
|
||||
};
|
||||
let bucket = self
|
||||
.cidr_buckets
|
||||
.get_or_insert_with(rule.key.as_str(), || CidrBucket::new(rule.limits));
|
||||
bucket.set_rates(rule.limits);
|
||||
.get_or_insert_with(key, || CidrBucket::new(limits));
|
||||
bucket.set_rates(limits);
|
||||
bucket.active_leases.fetch_add(1, Ordering::Relaxed);
|
||||
self.cidr_scope
|
||||
.active_leases
|
||||
@@ -841,6 +897,16 @@ fn bytes_per_epoch(bps: u64) -> u64 {
|
||||
bytes.max(1)
|
||||
}
|
||||
|
||||
fn auto_cidr_bucket_key(ip: IpAddr, prefix_len: u8) -> Option<String> {
|
||||
let cidr = IpNetwork::new(ip, prefix_len).ok()?;
|
||||
let network = IpNetwork::new(cidr.network(), prefix_len).ok()?;
|
||||
let family = match network {
|
||||
IpNetwork::V4(_) => "4",
|
||||
IpNetwork::V6(_) => "6",
|
||||
};
|
||||
Some(format!("auto:{family}:{network}"))
|
||||
}
|
||||
|
||||
fn current_epoch() -> u64 {
|
||||
let start = limiter_epoch_start();
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
@@ -851,3 +917,82 @@ fn limiter_epoch_start() -> &'static Instant {
|
||||
static START: OnceLock<Instant> = OnceLock::new();
|
||||
START.get_or_init(Instant::now)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rate(up_bps: u64, down_bps: u64) -> RateLimitBps {
|
||||
RateLimitBps { up_bps, down_bps }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_cidr_rule_wins_over_auto_template() {
|
||||
let limiter = TrafficLimiter::new();
|
||||
let mut cidr_limits = HashMap::new();
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoV4(24), rate(1_000, 0));
|
||||
cidr_limits.insert(
|
||||
CidrRateLimitKey::Network("203.0.113.7/32".parse().unwrap()),
|
||||
rate(2_000, 0),
|
||||
);
|
||||
|
||||
limiter.apply_policy(HashMap::new(), cidr_limits);
|
||||
let policy = limiter.policy.load_full();
|
||||
let matched = policy.match_cidr("203.0.113.7".parse().unwrap()).unwrap();
|
||||
|
||||
match matched {
|
||||
CidrPolicyMatch::Explicit(rule) => assert_eq!(rule.key.as_str(), "203.0.113.7/32"),
|
||||
CidrPolicyMatch::Auto { .. } => panic!("explicit CIDR must have priority"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_template_uses_longest_prefix() {
|
||||
let limiter = TrafficLimiter::new();
|
||||
let mut cidr_limits = HashMap::new();
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoV4(24), rate(1_000, 0));
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoV4(32), rate(2_000, 0));
|
||||
|
||||
limiter.apply_policy(HashMap::new(), cidr_limits);
|
||||
let policy = limiter.policy.load_full();
|
||||
let matched = policy.match_cidr("203.0.113.129".parse().unwrap()).unwrap();
|
||||
|
||||
match matched {
|
||||
CidrPolicyMatch::Auto { key, limits } => {
|
||||
assert_eq!(key, "auto:4:203.0.113.129/32");
|
||||
assert_eq!(limits.up_bps, 2_000);
|
||||
}
|
||||
CidrPolicyMatch::Explicit(_) => panic!("auto-template match expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dual_auto_template_maps_v6_prefix_by_four() {
|
||||
let limiter = TrafficLimiter::new();
|
||||
let mut cidr_limits = HashMap::new();
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoDual(32), rate(1_000, 0));
|
||||
|
||||
limiter.apply_policy(HashMap::new(), cidr_limits);
|
||||
let policy = limiter.policy.load_full();
|
||||
let matched = policy.match_cidr("2001:db8::1".parse().unwrap()).unwrap();
|
||||
|
||||
match matched {
|
||||
CidrPolicyMatch::Auto { key, .. } => {
|
||||
assert_eq!(key, "auto:6:2001:db8::1/128");
|
||||
}
|
||||
CidrPolicyMatch::Explicit(_) => panic!("auto-template match expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_cidr_bucket_key_canonicalizes_network_address() {
|
||||
assert_eq!(
|
||||
auto_cidr_bucket_key("203.0.113.129".parse().unwrap(), 24).unwrap(),
|
||||
"auto:4:203.0.113.0/24"
|
||||
);
|
||||
assert_eq!(
|
||||
auto_cidr_bucket_key("2001:db8::abcd".parse().unwrap(), 64).unwrap(),
|
||||
"auto:6:2001:db8::/64"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ fn encode_secure(frame: &Frame, dst: &mut BytesMut, rng: &SecureRandom) -> io::R
|
||||
));
|
||||
}
|
||||
|
||||
// Telegram Desktop VersionD uses a 4-bit random padding length.
|
||||
// Outbound Secure padding avoids full-word tails that readers cannot strip.
|
||||
let padding_len = secure_padding_len(data.len(), rng);
|
||||
|
||||
let total_len = data.len() + padding_len;
|
||||
@@ -521,13 +521,7 @@ mod tests {
|
||||
use tokio_util::codec::{FramedRead, FramedWrite};
|
||||
|
||||
fn assert_secure_decoded_payload(decoded: &[u8], original: &[u8]) {
|
||||
assert!(decoded.starts_with(original));
|
||||
assert!(
|
||||
(original.len()..=original.len() + 12).contains(&decoded.len()),
|
||||
"Secure decoded payload may retain up to 12 bytes of full-word padding, got {}",
|
||||
decoded.len()
|
||||
);
|
||||
assert_eq!(decoded.len() % 4, 0);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -653,7 +647,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_codec_uses_tdesktop_padding_range_and_jitters_wire_length() {
|
||||
fn secure_codec_uses_non_aligned_padding_and_jitters_wire_length() {
|
||||
let codec = SecureCodec::new(Arc::new(SecureRandom::new()));
|
||||
let payload = Bytes::from_static(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
let mut wire_lens = HashSet::new();
|
||||
@@ -666,9 +660,10 @@ mod tests {
|
||||
let wire_len = u32::from_le_bytes([out[0], out[1], out[2], out[3]]) as usize;
|
||||
assert_eq!(out.len(), 4 + wire_len);
|
||||
assert!(
|
||||
(payload.len()..=payload.len() + 15).contains(&wire_len),
|
||||
"Secure wire length must be payload+0..15, got {wire_len}"
|
||||
(payload.len() + 1..=payload.len() + 3).contains(&wire_len),
|
||||
"Secure wire length must be payload+1..3, got {wire_len}"
|
||||
);
|
||||
assert_ne!(wire_len % 4, 0);
|
||||
wire_lens.insert(wire_len);
|
||||
}
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ impl<W: AsyncWrite + Unpin> SecureIntermediateFrameWriter<W> {
|
||||
));
|
||||
}
|
||||
|
||||
// Telegram Desktop VersionD uses a 4-bit random padding length.
|
||||
// Outbound Secure padding avoids full-word tails that readers cannot strip.
|
||||
let padding_len = secure_padding_len(data.len(), &self.rng);
|
||||
let padding = self.rng.bytes(padding_len);
|
||||
|
||||
@@ -633,13 +633,7 @@ mod tests {
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
fn assert_secure_decoded_payload(decoded: &[u8], original: &[u8]) {
|
||||
assert!(decoded.starts_with(original));
|
||||
assert!(
|
||||
(original.len()..=original.len() + 12).contains(&decoded.len()),
|
||||
"Secure decoded payload may retain up to 12 bytes of full-word padding, got {}",
|
||||
decoded.len()
|
||||
);
|
||||
assert_eq!(decoded.len() % 4, 0);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
98
src/synlimit_control/command.rs
Normal file
98
src/synlimit_control/command.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
389
src/synlimit_control/iptables.rs
Normal file
389
src/synlimit_control/iptables.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use super::command::run_command;
|
||||
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_rate_arg};
|
||||
|
||||
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,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<(), String> {
|
||||
apply_rules_for_binary(
|
||||
"iptables",
|
||||
&targets.iptables_v4,
|
||||
IpTablesFamily::V4,
|
||||
namespace,
|
||||
)
|
||||
.await?;
|
||||
apply_rules_for_binary(
|
||||
"ip6tables",
|
||||
&targets.iptables_v6,
|
||||
IpTablesFamily::V6,
|
||||
namespace,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn apply_rules_for_binary(
|
||||
binary: &str,
|
||||
targets: &[SynLimitRule],
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<(), String> {
|
||||
if targets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let chain = namespace.iptables_chain.as_str();
|
||||
let _ = run_command(binary, &["-t", "filter", "-N", chain], None).await;
|
||||
run_command(binary, &["-t", "filter", "-F", chain], None).await?;
|
||||
if run_command(binary, &["-t", "filter", "-C", "INPUT", "-j", chain], None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
run_command(
|
||||
binary,
|
||||
&["-t", "filter", "-I", "INPUT", "1", "-j", chain],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (idx, target) in targets.iter().enumerate() {
|
||||
for rule in iptables_synfix_rule_args(target, idx, family, namespace) {
|
||||
let refs: Vec<&str> = rule.iter().map(String::as_str).collect();
|
||||
run_command(binary, &refs, None).await?;
|
||||
}
|
||||
}
|
||||
run_command(binary, &["-t", "filter", "-A", chain, "-j", "RETURN"], None).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn iptables_synfix_rule_args(
|
||||
target: &SynLimitRule,
|
||||
idx: usize,
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<Vec<String>> {
|
||||
vec![
|
||||
iptables_ios_accept_rule_args(target, idx, family, namespace),
|
||||
iptables_ios_reject_rule_args(target, family, namespace),
|
||||
iptables_generic_accept_rule_args(target, idx, family, namespace),
|
||||
iptables_generic_reject_rule_args(target, namespace),
|
||||
]
|
||||
}
|
||||
|
||||
fn iptables_ios_accept_rule_args(
|
||||
target: &SynLimitRule,
|
||||
idx: usize,
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let hashlimit_name = format!(
|
||||
"{}-I{}-{idx}",
|
||||
namespace.iptables_hashlimit_prefix,
|
||||
family.hashlimit_tag()
|
||||
);
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), 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,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), 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,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let hashlimit_name = format!(
|
||||
"{}-G{}-{idx}",
|
||||
namespace.iptables_hashlimit_prefix,
|
||||
family.hashlimit_tag()
|
||||
);
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), 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,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), target.ip, target.port);
|
||||
args.extend(iptables_reject_args());
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_base_rule_args(chain: &str, ip: Option<IpAddr>, port: u16) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-t".to_string(),
|
||||
"filter".to_string(),
|
||||
"-A".to_string(),
|
||||
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,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<bool, String> {
|
||||
let mut errors = Vec::new();
|
||||
let mut removed = false;
|
||||
let chain = namespace.iptables_chain.as_str();
|
||||
for _ in 0..8 {
|
||||
match run_command(binary, &["-t", "filter", "-D", "INPUT", "-j", chain], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match run_command(binary, &["-t", "filter", "-F", chain], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => {}
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} flush chain failed: {error}"));
|
||||
}
|
||||
}
|
||||
match run_command(binary, &["-t", "filter", "-X", chain], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => {}
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} delete chain failed: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(removed)
|
||||
} 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")
|
||||
|| error.contains("Couldn't load target")
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
fn has_key(args: &[String], key: &str) -> bool {
|
||||
args.iter().any(|arg| arg == key)
|
||||
}
|
||||
|
||||
fn test_namespace() -> SynLimitNamespace {
|
||||
SynLimitNamespace {
|
||||
nft_table: "telemt_synlimit_test".to_string(),
|
||||
iptables_chain: "TMT_SYN_TEST".to_string(),
|
||||
iptables_hashlimit_prefix: "TMTTEST".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_rules_use_synfix_order_and_rejects() {
|
||||
let target = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
||||
let namespace = test_namespace();
|
||||
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4, &namespace);
|
||||
|
||||
assert_eq!(rules.len(), 4);
|
||||
assert!(has_pair(&rules[0], "-A", "TMT_SYN_TEST"));
|
||||
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 namespace = test_namespace();
|
||||
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V6, &namespace);
|
||||
|
||||
assert!(has_pair(&rules[0], "--length", "84"));
|
||||
assert!(has_pair(&rules[0], "--hl-lt", "65"));
|
||||
assert!(has_pair(&rules[0], "-d", "::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_missing_rule_errors_are_cleanup_benign() {
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"iptables is not available"
|
||||
));
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"iptables: No chain/target/match by that name."
|
||||
));
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"iptables: Chain TELEMT_SYNLIMIT does not exist."
|
||||
));
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"Couldn't load target `TELEMT_SYNLIMIT': No such file or directory"
|
||||
));
|
||||
assert!(!is_missing_command_or_iptables_rule(
|
||||
"iptables: Permission denied"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_wildcard_rule_omits_destination_match() {
|
||||
let target = test_rule(None, 443);
|
||||
let namespace = test_namespace();
|
||||
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4, &namespace);
|
||||
|
||||
for rule in rules {
|
||||
assert!(!has_key(&rule, "-d"));
|
||||
assert!(has_pair(&rule, "--dport", "443"));
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/synlimit_control/mod.rs
Normal file
170
src/synlimit_control/mod.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
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::{SynLimitNamespace, synlimit_namespace, synlimit_targets};
|
||||
|
||||
static ACTIVE_SYNLIMIT_NAMESPACE: Mutex<Option<SynLimitNamespace>> = Mutex::new(None);
|
||||
|
||||
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) {
|
||||
let targets = synlimit_targets(cfg);
|
||||
let namespace = synlimit_namespace(&targets);
|
||||
if let Some(previous_namespace) = set_active_synlimit_namespace(namespace.clone()) {
|
||||
match clear_synlimit_rules_for_namespace(&previous_namespace).await {
|
||||
Ok(true) => {
|
||||
warn!("Removed previous SYN limiter namespace before reconcile");
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to clear previous SYN limiter namespace before reconcile");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(namespace) = namespace else {
|
||||
return;
|
||||
};
|
||||
if !has_cap_net_admin() {
|
||||
warn!(
|
||||
"SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
match clear_synlimit_rules_for_namespace(&namespace).await {
|
||||
Ok(true) => {
|
||||
warn!("Removed stale SYN limiter rules left by a previous run before reconcile");
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to clear stale SYN limiter rules before reconcile");
|
||||
}
|
||||
}
|
||||
|
||||
if targets.has_iptables_targets()
|
||||
&& let Err(error) = iptables::apply_synlimit_rules(&targets, &namespace).await
|
||||
{
|
||||
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
|
||||
}
|
||||
if targets.has_nft_targets()
|
||||
&& let Err(error) = nftables::apply_synlimit_rules(&targets, &namespace).await
|
||||
{
|
||||
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<bool, String> {
|
||||
let Some(namespace) = take_active_synlimit_namespace() else {
|
||||
return Ok(false);
|
||||
};
|
||||
clear_synlimit_rules_for_namespace(&namespace).await
|
||||
}
|
||||
|
||||
async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Result<bool, String> {
|
||||
if !has_cap_net_admin() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut removed = false;
|
||||
match nftables::clear_rules_all_families(namespace).await {
|
||||
Ok(value) => {
|
||||
removed |= value;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
match iptables::clear_rules_for_binary("iptables", namespace).await {
|
||||
Ok(value) => {
|
||||
removed |= value;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
match iptables::clear_rules_for_binary("ip6tables", namespace).await {
|
||||
Ok(value) => {
|
||||
removed |= value;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(removed)
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_active_synlimit_namespace(next: Option<SynLimitNamespace>) -> Option<SynLimitNamespace> {
|
||||
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||
Ok(mut active) => {
|
||||
if *active == next {
|
||||
None
|
||||
} else {
|
||||
std::mem::replace(&mut *active, next)
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to update active SYN limiter namespace");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_active_synlimit_namespace() -> Option<SynLimitNamespace> {
|
||||
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||
Ok(mut active) => active.take(),
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to read active SYN limiter namespace");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
|
||||
cfg.server
|
||||
.listeners
|
||||
.iter()
|
||||
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
|
||||
}
|
||||
365
src/synlimit_control/model.rs
Normal file
365
src/synlimit_control/model.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
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(Clone, Debug, Eq, PartialEq)]
|
||||
pub(super) struct SynLimitNamespace {
|
||||
pub(super) nft_table: String,
|
||||
pub(super) iptables_chain: String,
|
||||
pub(super) iptables_hashlimit_prefix: String,
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
struct SynLimitNamespaceHasher {
|
||||
value: u64,
|
||||
}
|
||||
|
||||
impl SynLimitNamespaceHasher {
|
||||
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
value: Self::OFFSET,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
self.value ^= u64::from(*byte);
|
||||
self.value = self.value.wrapping_mul(Self::PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_u8(&mut self, value: u8) {
|
||||
self.write(&[value]);
|
||||
}
|
||||
|
||||
fn write_u16(&mut self, value: u16) {
|
||||
self.write(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32(&mut self, value: u32) {
|
||||
self.write(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn finish(self) -> u64 {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
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_namespace(targets: &SynLimitTargets) -> Option<SynLimitNamespace> {
|
||||
if targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut hasher = SynLimitNamespaceHasher::new();
|
||||
write_namespace_rule_group(&mut hasher, b"iptables-v4", &targets.iptables_v4);
|
||||
write_namespace_rule_group(&mut hasher, b"iptables-v6", &targets.iptables_v6);
|
||||
write_namespace_rule_group(&mut hasher, b"nft-v4", &targets.nft_v4);
|
||||
write_namespace_rule_group(&mut hasher, b"nft-v6", &targets.nft_v6);
|
||||
|
||||
let suffix = format!("{:016x}", hasher.finish());
|
||||
let iptables_suffix = &suffix[..12];
|
||||
let hashlimit_suffix = &suffix[..10];
|
||||
Some(SynLimitNamespace {
|
||||
nft_table: format!("telemt_synlimit_{suffix}"),
|
||||
iptables_chain: format!("TMT_SYN_{iptables_suffix}"),
|
||||
iptables_hashlimit_prefix: format!("TMT{hashlimit_suffix}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_namespace_rule_group(
|
||||
hasher: &mut SynLimitNamespaceHasher,
|
||||
group: &[u8],
|
||||
rules: &[SynLimitRule],
|
||||
) {
|
||||
hasher.write(group);
|
||||
hasher.write_u32(rules.len() as u32);
|
||||
for rule in rules {
|
||||
write_namespace_rule(hasher, rule);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_namespace_rule(hasher: &mut SynLimitNamespaceHasher, rule: &SynLimitRule) {
|
||||
match rule.ip {
|
||||
Some(IpAddr::V4(ip)) => {
|
||||
hasher.write_u8(4);
|
||||
hasher.write(&ip.octets());
|
||||
}
|
||||
Some(IpAddr::V6(ip)) => {
|
||||
hasher.write_u8(6);
|
||||
hasher.write(&ip.octets());
|
||||
}
|
||||
None => {
|
||||
hasher.write_u8(0);
|
||||
}
|
||||
}
|
||||
hasher.write_u16(rule.port);
|
||||
hasher.write_u32(rule.generic_seconds);
|
||||
hasher.write_u32(rule.generic_hitcount);
|
||||
hasher.write_u32(rule.generic_burst);
|
||||
hasher.write_u32(rule.ios_seconds);
|
||||
hasher.write_u32(rule.ios_hitcount);
|
||||
hasher.write_u32(rule.ios_burst);
|
||||
hasher.write_u32(rule.hashlimit_expire_ms);
|
||||
hasher.write_u32(rule.hashlimit_size);
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::config::ListenerConfig;
|
||||
|
||||
fn listener(ip: IpAddr, port: Option<u16>, synlimit: SynLimitMode) -> ListenerConfig {
|
||||
ListenerConfig {
|
||||
ip,
|
||||
port,
|
||||
client_mss: None,
|
||||
synlimit,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_targets_deduplicate_and_use_legacy_port_fallback() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.server.port = 9443;
|
||||
cfg.server.listeners = vec![
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
None,
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
None,
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
];
|
||||
|
||||
let targets = synlimit_targets(&cfg);
|
||||
|
||||
assert_eq!(targets.iptables_v4.len(), 1);
|
||||
assert_eq!(targets.iptables_v4[0].ip, None);
|
||||
assert_eq!(targets.iptables_v4[0].port, 9443);
|
||||
assert!(targets.iptables_v6.is_empty());
|
||||
assert!(targets.nft_v4.is_empty());
|
||||
assert!(targets.nft_v6.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_targets_separate_backends_and_ip_families() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.server.listeners = vec![
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)),
|
||||
Some(443),
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
Some(443),
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 2)),
|
||||
Some(444),
|
||||
SynLimitMode::Nftables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
Some(444),
|
||||
SynLimitMode::Nftables,
|
||||
),
|
||||
];
|
||||
|
||||
let targets = synlimit_targets(&cfg);
|
||||
|
||||
assert_eq!(targets.iptables_v4.len(), 1);
|
||||
assert_eq!(targets.iptables_v6.len(), 1);
|
||||
assert_eq!(targets.nft_v4.len(), 1);
|
||||
assert_eq!(targets.nft_v6.len(), 1);
|
||||
assert_eq!(
|
||||
targets.iptables_v4[0].ip,
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
targets.iptables_v6[0].ip,
|
||||
Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
|
||||
);
|
||||
assert_eq!(
|
||||
targets.nft_v4[0].ip,
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 2)))
|
||||
);
|
||||
assert_eq!(targets.nft_v6[0].ip, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_namespace_is_stable_and_changes_by_targets() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.server.listeners = vec![listener(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)),
|
||||
Some(443),
|
||||
SynLimitMode::Nftables,
|
||||
)];
|
||||
let first = synlimit_namespace(&synlimit_targets(&cfg))
|
||||
.expect("configured targets must have a namespace");
|
||||
let second = synlimit_namespace(&synlimit_targets(&cfg))
|
||||
.expect("configured targets must have a namespace");
|
||||
|
||||
cfg.server.listeners[0].port = Some(444);
|
||||
let changed = synlimit_namespace(&synlimit_targets(&cfg))
|
||||
.expect("configured targets must have a namespace");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(first, changed);
|
||||
assert!(first.nft_table.starts_with("telemt_synlimit_"));
|
||||
assert!(first.iptables_chain.starts_with("TMT_SYN_"));
|
||||
assert!(first.iptables_chain.len() <= 28);
|
||||
assert!(first.iptables_hashlimit_prefix.starts_with("TMT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_rate_arg_uses_native_units_without_fractional_rates() {
|
||||
assert_eq!(synlimit_rate_arg(1, 12), "12/second");
|
||||
assert_eq!(synlimit_rate_arg(60, 48), "48/minute");
|
||||
assert_eq!(synlimit_rate_arg(3600, 121), "121/hour");
|
||||
assert_eq!(synlimit_rate_arg(86400, 241), "241/day");
|
||||
}
|
||||
}
|
||||
316
src/synlimit_control/nftables.rs
Normal file
316
src/synlimit_control/nftables.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use super::command::{run_command, run_command_stdout};
|
||||
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_rate_arg};
|
||||
|
||||
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,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> 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, namespace);
|
||||
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<'_>, namespace: &SynLimitNamespace) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
"table {} {} {{\n",
|
||||
plan.family.as_str(),
|
||||
namespace.nft_table
|
||||
));
|
||||
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(
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<bool, String> {
|
||||
let mut errors = Vec::new();
|
||||
let mut removed = false;
|
||||
let table = namespace.nft_table.as_str();
|
||||
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
|
||||
match run_command("nft", &["delete", "table", family.as_str(), table], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_nft_table(&error) => {}
|
||||
Err(error) => {
|
||||
errors.push(format!(
|
||||
"nft delete table {} {table} failed: {error}",
|
||||
family.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(removed)
|
||||
} 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;
|
||||
|
||||
fn test_namespace(table: &str) -> SynLimitNamespace {
|
||||
SynLimitNamespace {
|
||||
nft_table: table.to_string(),
|
||||
iptables_chain: "TMT_SYN_TEST".to_string(),
|
||||
iptables_hashlimit_prefix: "TMTTEST".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 namespace = test_namespace("telemt_synlimit_test_a");
|
||||
let script = nft_synlimit_script(
|
||||
NftApplyPlan {
|
||||
family: NftFamily::Inet,
|
||||
v4_targets: &[rule],
|
||||
v6_targets: &[],
|
||||
},
|
||||
&namespace,
|
||||
);
|
||||
|
||||
assert!(script.contains("table inet telemt_synlimit_test_a"));
|
||||
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 namespace = test_namespace("telemt_synlimit_test_b");
|
||||
let script = nft_synlimit_script(
|
||||
NftApplyPlan {
|
||||
family: NftFamily::Inet,
|
||||
v4_targets: &[],
|
||||
v6_targets: &[rule],
|
||||
},
|
||||
&namespace,
|
||||
);
|
||||
|
||||
assert!(script.contains("table inet telemt_synlimit_test_b"));
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nft_missing_table_errors_are_cleanup_benign() {
|
||||
assert!(is_missing_command_or_nft_table("nft is not available"));
|
||||
assert!(is_missing_command_or_nft_table(
|
||||
"Error: No such file or directory"
|
||||
));
|
||||
assert!(!is_missing_command_or_nft_table(
|
||||
"Error: Operation not permitted"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nft_apply_plan_keeps_dual_stack_rules_in_inet_table() {
|
||||
let v4_rule = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
||||
let v6_rule = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
|
||||
let v4_rules = [v4_rule];
|
||||
let v6_rules = [v6_rule];
|
||||
let plans = nft_apply_plan(
|
||||
NftTableFamilies {
|
||||
inet: false,
|
||||
ip: false,
|
||||
ip6: false,
|
||||
},
|
||||
&v4_rules,
|
||||
&v6_rules,
|
||||
);
|
||||
|
||||
assert_eq!(plans.len(), 1);
|
||||
assert_eq!(plans[0].family.as_str(), "inet");
|
||||
assert_eq!(plans[0].v4_targets, v4_rules.as_slice());
|
||||
assert_eq!(plans[0].v6_targets, v6_rules.as_slice());
|
||||
}
|
||||
}
|
||||
@@ -675,8 +675,122 @@ fn hex_dump(data: &[u8]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
fn upstream_egress(
|
||||
route_kind: UpstreamRouteKind,
|
||||
socks_bound_addr: Option<SocketAddr>,
|
||||
) -> UpstreamEgressInfo {
|
||||
UpstreamEgressInfo {
|
||||
upstream_id: 7,
|
||||
route_kind,
|
||||
local_addr: None,
|
||||
direct_bind_ip: None,
|
||||
socks_bound_addr,
|
||||
socks_proxy_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socks_bound_addr_is_used_only_for_public_same_family_tuple() {
|
||||
let v4_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
|
||||
let v6_bound = SocketAddr::new(
|
||||
IpAddr::V6(
|
||||
"2606:4700:4700::1111"
|
||||
.parse::<Ipv6Addr>()
|
||||
.expect("test IPv6 address must parse"),
|
||||
),
|
||||
443,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(UpstreamRouteKind::Socks5, Some(v4_bound)))
|
||||
),
|
||||
Some(v4_bound)
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V6,
|
||||
Some(upstream_egress(UpstreamRouteKind::Socks5, Some(v6_bound)))
|
||||
),
|
||||
Some(v6_bound)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socks_bound_addr_rejects_bogon_unspecified_wrong_family_and_non_socks_routes() {
|
||||
let bogon_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 443);
|
||||
let unspecified_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443);
|
||||
let public_v4_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
|
||||
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Socks5,
|
||||
Some(bogon_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Socks5,
|
||||
Some(unspecified_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V6,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Socks5,
|
||||
Some(public_v4_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Direct,
|
||||
Some(public_v4_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdf_client_port_source_tracks_only_valid_socks_bound_port() {
|
||||
let bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
|
||||
let zero_port_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 0);
|
||||
|
||||
assert_eq!(
|
||||
KdfClientPortSource::from_socks_bound_port(Some(bound.port())),
|
||||
KdfClientPortSource::SocksBound
|
||||
);
|
||||
assert_eq!(
|
||||
KdfClientPortSource::from_socks_bound_port(
|
||||
Some(zero_port_bound)
|
||||
.filter(|addr| addr.port() != 0)
|
||||
.map(|addr| addr.port())
|
||||
),
|
||||
KdfClientPortSource::LocalSocket
|
||||
);
|
||||
assert_eq!(
|
||||
KdfClientPortSource::from_socks_bound_port(None),
|
||||
KdfClientPortSource::LocalSocket
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_configure_keepalive_loopback() {
|
||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||
|
||||
@@ -464,7 +464,9 @@ impl MePool {
|
||||
if !self.writer_accepts_new_binding(w) {
|
||||
continue;
|
||||
}
|
||||
let (payload, meta) = build_routed_payload(our_addr);
|
||||
// Keep the advertised proxy IP aligned with the selected ME writer source.
|
||||
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port());
|
||||
let (payload, meta) = build_routed_payload(effective_our_addr);
|
||||
match w.tx.clone().try_reserve_owned() {
|
||||
Ok(permit) => {
|
||||
if !self.registry.bind_writer(conn_id, w.id, meta).await {
|
||||
@@ -519,7 +521,9 @@ impl MePool {
|
||||
}
|
||||
self.stats
|
||||
.increment_me_writer_pick_blocking_fallback_total();
|
||||
let (payload, meta) = build_routed_payload(our_addr);
|
||||
// Keep the advertised proxy IP aligned with the selected ME writer source.
|
||||
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port());
|
||||
let (payload, meta) = build_routed_payload(effective_our_addr);
|
||||
let reserve_result =
|
||||
if let Some(timeout) = self.route_runtime.me_route_blocking_send_timeout {
|
||||
match tokio::time::timeout(timeout, w.tx.clone().reserve_owned()).await {
|
||||
|
||||
@@ -323,7 +323,7 @@ async fn send_proxy_req_prunes_iterative_stale_bind_failures_without_data_replay
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_proxy_req_preserves_client_facing_our_addr_when_writer_source_ip_differs() {
|
||||
async fn send_proxy_req_uses_writer_source_ip_when_advertised_our_addr_differs() {
|
||||
let (pool, _rng) = make_pool().await;
|
||||
pool.rr.store(0, Ordering::Relaxed);
|
||||
|
||||
@@ -363,5 +363,68 @@ async fn send_proxy_req_preserves_client_facing_our_addr_when_writer_source_ip_d
|
||||
let payload = recv_first_data_payload(&mut live_rx, Duration::from_millis(50))
|
||||
.await
|
||||
.expect("writer must receive routed payload");
|
||||
assert_eq!(proxy_req_our_addr_from_payload(&payload), our_addr);
|
||||
assert_eq!(
|
||||
proxy_req_our_addr_from_payload(&payload),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 31)), our_addr.port())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_proxy_req_blocking_fallback_uses_writer_source_ip() {
|
||||
let (pool, _rng) = make_pool().await;
|
||||
pool.rr.store(0, Ordering::Relaxed);
|
||||
|
||||
let (conn_id, _rx) = pool.registry.register().await;
|
||||
let mut live_rx = insert_writer(
|
||||
&pool,
|
||||
32,
|
||||
2,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 2, 32)), 443),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let source_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 32));
|
||||
|
||||
let tx = {
|
||||
let mut writers = pool.writers.write().await;
|
||||
let writer = writers
|
||||
.iter_mut()
|
||||
.find(|writer| writer.id == 32)
|
||||
.expect("test writer must exist");
|
||||
writer.source_ip = source_ip;
|
||||
writer.tx.clone()
|
||||
};
|
||||
for _ in 0..8 {
|
||||
tx.try_send(WriterCommand::Close)
|
||||
.expect("test writer channel must accept preload");
|
||||
}
|
||||
|
||||
let our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 8)), 9443);
|
||||
let pool_for_send = pool.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
pool_for_send
|
||||
.send_proxy_req(
|
||||
conn_id,
|
||||
2,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 8)), 30003),
|
||||
our_addr,
|
||||
b"blocking",
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(matches!(live_rx.recv().await, Some(WriterCommand::Close)));
|
||||
|
||||
let result = send_task.await.expect("send task must not panic");
|
||||
assert!(result.is_ok());
|
||||
let payload = recv_first_data_payload(&mut live_rx, Duration::from_millis(50))
|
||||
.await
|
||||
.expect("writer must receive blocking fallback payload");
|
||||
assert_eq!(
|
||||
proxy_req_our_addr_from_payload(&payload),
|
||||
SocketAddr::new(source_ip, our_addr.port())
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user