mirror of
https://github.com/telemt/telemt.git
synced 2026-06-12 21:33:29 +03:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc67727b0 | ||
|
|
2d02fbe548 | ||
|
|
2675779915 | ||
|
|
c4954f745f | ||
|
|
f33abfb09e | ||
|
|
9904da737a | ||
|
|
9a3ff726b2 | ||
|
|
942882f9de | ||
|
|
eeff16c3fd | ||
|
|
c86dc2f65e | ||
|
|
1cbde70a14 | ||
|
|
26cd4734de | ||
|
|
52a1b66ad7 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2938,7 +2938,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.4.16"
|
||||
version = "3.4.18"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.4.16"
|
||||
version = "3.4.18"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -2219,6 +2219,10 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
| [`ip`](#ip) | `IpAddr` | — | `✘` |
|
||||
| [`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` | `✔` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
@@ -2254,6 +2258,58 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
port = 443
|
||||
client_mss = "256"
|
||||
```
|
||||
## 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.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "::"
|
||||
port = 443
|
||||
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`).
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_seconds = 1
|
||||
```
|
||||
## 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.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hitcount = 1
|
||||
```
|
||||
## 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.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_burst = 2
|
||||
```
|
||||
## announce
|
||||
- **Constraints / validation**: `String` (optional). Must not be empty when set.
|
||||
- **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`.
|
||||
|
||||
@@ -2225,6 +2225,10 @@
|
||||
| [`ip`](#ip) | `IpAddr` | — | `✘` |
|
||||
| [`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` | `✔` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
@@ -2260,6 +2264,58 @@
|
||||
port = 443
|
||||
client_mss = "256"
|
||||
```
|
||||
## 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.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "::"
|
||||
port = 443
|
||||
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`).
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_seconds = 1
|
||||
```
|
||||
## synlimit_hitcount (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hitcount = 1
|
||||
```
|
||||
## 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`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_burst = 2
|
||||
```
|
||||
## announce
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
|
||||
- **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`.
|
||||
|
||||
@@ -54,6 +54,9 @@ 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_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
|
||||
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
|
||||
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
|
||||
@@ -243,6 +246,18 @@ pub(crate) fn default_conntrack_delete_budget_per_sec() -> u64 {
|
||||
DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_seconds() -> u32 {
|
||||
DEFAULT_SYNLIMIT_SECONDS
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_hitcount() -> u32 {
|
||||
DEFAULT_SYNLIMIT_HITCOUNT
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_burst() -> u32 {
|
||||
DEFAULT_SYNLIMIT_BURST
|
||||
}
|
||||
|
||||
pub(crate) fn default_prefer_4() -> u8 {
|
||||
4
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
|
||||
//! | `network` | `dns_overrides` | Applied immediately |
|
||||
//! | `access` | All user/quota fields | Effective immediately |
|
||||
//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately |
|
||||
//!
|
||||
//! Fields that require re-binding sockets (`server.listeners`, legacy
|
||||
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
|
||||
//! applied; a warning is emitted.
|
||||
//! applied, except for SYN limiter fields on unchanged listener endpoints; a
|
||||
//! warning is emitted.
|
||||
//! Non-hot changes are never mixed into the runtime config snapshot.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
@@ -34,7 +36,8 @@ use tracing::{error, info, warn};
|
||||
|
||||
use super::load::{LoadedConfig, ProxyConfig};
|
||||
use crate::config::{
|
||||
LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode,
|
||||
ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
|
||||
MeWriterPickMode, SynLimitMode,
|
||||
};
|
||||
|
||||
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||
@@ -131,6 +134,17 @@ pub struct HotFields {
|
||||
pub user_max_unique_ips_global_each: usize,
|
||||
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
|
||||
pub user_max_unique_ips_window_secs: u64,
|
||||
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListenerSynLimitHotFields {
|
||||
pub ip: IpAddr,
|
||||
pub port: Option<u16>,
|
||||
pub synlimit: SynLimitMode,
|
||||
pub synlimit_seconds: u32,
|
||||
pub synlimit_hitcount: u32,
|
||||
pub synlimit_burst: u32,
|
||||
}
|
||||
|
||||
impl HotFields {
|
||||
@@ -260,6 +274,25 @@ impl HotFields {
|
||||
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
|
||||
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
|
||||
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
|
||||
listener_synlimit: cfg
|
||||
.server
|
||||
.listeners
|
||||
.iter()
|
||||
.map(ListenerSynLimitHotFields::from_listener)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ListenerSynLimitHotFields {
|
||||
fn from_listener(listener: &ListenerConfig) -> Self {
|
||||
Self {
|
||||
ip: listener.ip,
|
||||
port: listener.port,
|
||||
synlimit: listener.synlimit,
|
||||
synlimit_seconds: listener.synlimit_seconds,
|
||||
synlimit_hitcount: listener.synlimit_hitcount,
|
||||
synlimit_burst: listener.synlimit_burst,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,6 +599,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
|
||||
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
|
||||
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
|
||||
overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners);
|
||||
|
||||
if cfg.rebuild_runtime_user_auth().is_err() {
|
||||
cfg.runtime_user_auth = None;
|
||||
@@ -574,6 +608,21 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||
cfg
|
||||
}
|
||||
|
||||
fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) {
|
||||
if old.len() != new.len() {
|
||||
return;
|
||||
}
|
||||
for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) {
|
||||
if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port {
|
||||
continue;
|
||||
}
|
||||
old_listener.synlimit = new_listener.synlimit;
|
||||
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
|
||||
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
|
||||
old_listener.synlimit_burst = new_listener.synlimit_burst;
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn if any non-hot fields changed (require restart).
|
||||
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
|
||||
let mut warned = false;
|
||||
@@ -850,6 +899,13 @@ fn log_changes(
|
||||
);
|
||||
}
|
||||
|
||||
if old_hot.listener_synlimit != new_hot.listener_synlimit {
|
||||
info!(
|
||||
"config reload: server.listeners SYN limiter updated ({} listeners)",
|
||||
new_hot.listener_synlimit.len()
|
||||
);
|
||||
}
|
||||
|
||||
if old_hot.desync_all_full != new_hot.desync_all_full {
|
||||
info!(
|
||||
"config reload: desync_all_full: {} → {}",
|
||||
|
||||
@@ -346,6 +346,10 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||
"ip",
|
||||
"port",
|
||||
"client_mss",
|
||||
"synlimit",
|
||||
"synlimit_seconds",
|
||||
"synlimit_hitcount",
|
||||
"synlimit_burst",
|
||||
"announce",
|
||||
"announce_ip",
|
||||
"proxy_protocol",
|
||||
@@ -1948,6 +1952,21 @@ impl ProxyConfig {
|
||||
ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}"))
|
||||
})?;
|
||||
}
|
||||
if listener.synlimit_seconds == 0 {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"server.listeners[{idx}].synlimit_seconds must be > 0"
|
||||
)));
|
||||
}
|
||||
if listener.synlimit_hitcount == 0 {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"server.listeners[{idx}].synlimit_hitcount must be > 0"
|
||||
)));
|
||||
}
|
||||
if listener.synlimit_burst == 0 {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"server.listeners[{idx}].synlimit_burst must be > 0"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if config.server.accept_permit_timeout_ms > 60_000 {
|
||||
@@ -2186,6 +2205,10 @@ impl ProxyConfig {
|
||||
ip: ipv4,
|
||||
port: Some(config.server.port),
|
||||
client_mss: None,
|
||||
synlimit: SynLimitMode::default(),
|
||||
synlimit_seconds: default_synlimit_seconds(),
|
||||
synlimit_hitcount: default_synlimit_hitcount(),
|
||||
synlimit_burst: default_synlimit_burst(),
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
@@ -2199,6 +2222,10 @@ impl ProxyConfig {
|
||||
ip: ipv6,
|
||||
port: Some(config.server.port),
|
||||
client_mss: None,
|
||||
synlimit: SynLimitMode::default(),
|
||||
synlimit_seconds: default_synlimit_seconds(),
|
||||
synlimit_hitcount: default_synlimit_hitcount(),
|
||||
synlimit_burst: default_synlimit_burst(),
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
|
||||
@@ -1369,6 +1369,77 @@ impl ConntrackPressureProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-listener SYN limiter mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SynLimitMode {
|
||||
/// Disable SYN limiting for this listener.
|
||||
#[default]
|
||||
Off,
|
||||
/// Use iptables/ip6tables filter rules with the hashlimit match.
|
||||
Iptables,
|
||||
/// Use nftables rules with per-source token-bucket meters.
|
||||
Nftables,
|
||||
}
|
||||
|
||||
impl Serialize for SynLimitMode {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Off => serializer.serialize_bool(false),
|
||||
Self::Iptables => serializer.serialize_str("iptables"),
|
||||
Self::Nftables => serializer.serialize_str("nftables"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SynLimitMode {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct SynLimitModeVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor {
|
||||
type Value = SynLimitMode;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("false, iptables, or nftables")
|
||||
}
|
||||
|
||||
fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
if value {
|
||||
Err(E::custom(
|
||||
"synlimit=true is ambiguous; use \"iptables\" or \"nftables\"",
|
||||
))
|
||||
} else {
|
||||
Ok(SynLimitMode::Off)
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off),
|
||||
"iptables" => Ok(SynLimitMode::Iptables),
|
||||
"nftables" => Ok(SynLimitMode::Nftables),
|
||||
_ => Err(E::custom(
|
||||
"synlimit must be false, \"iptables\", or \"nftables\"",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(SynLimitModeVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConntrackControlConfig {
|
||||
/// Enables runtime conntrack-control worker for pressure mitigation.
|
||||
@@ -2102,6 +2173,18 @@ pub struct ListenerConfig {
|
||||
/// Empty string disables MSS shaping for this listener.
|
||||
#[serde(default)]
|
||||
pub client_mss: Option<String>,
|
||||
/// Per-listener SYN limiter mode.
|
||||
#[serde(default)]
|
||||
pub synlimit: SynLimitMode,
|
||||
/// Token-bucket rate interval for the per-listener SYN limiter.
|
||||
#[serde(default = "default_synlimit_seconds")]
|
||||
pub synlimit_seconds: u32,
|
||||
/// Token-bucket rate amount for the per-listener SYN limiter.
|
||||
#[serde(default = "default_synlimit_hitcount")]
|
||||
pub synlimit_hitcount: u32,
|
||||
/// Token-bucket burst size for the per-listener SYN limiter.
|
||||
#[serde(default = "default_synlimit_burst")]
|
||||
pub synlimit_burst: u32,
|
||||
/// IP address or hostname to announce in proxy links.
|
||||
/// Takes precedence over `announce_ip` if both are set.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -45,6 +45,7 @@ use crate::stats::beobachten::BeobachtenStore;
|
||||
use crate::stats::telemetry::TelemetryPolicy;
|
||||
use crate::stats::{ReplayChecker, Stats};
|
||||
use crate::stream::BufferPool;
|
||||
use crate::synlimit_control;
|
||||
use crate::transport::UpstreamManager;
|
||||
use crate::transport::middle_proxy::MePool;
|
||||
use helpers::{
|
||||
@@ -909,6 +910,9 @@ async fn run_telemt_core(
|
||||
// On Unix, caller supplies privilege drop after bind (may require root for port < 1024).
|
||||
drop_after_bind();
|
||||
|
||||
synlimit_control::reconcile_synlimit_rules(&config).await;
|
||||
synlimit_control::spawn_synlimit_controller(config_rx.clone());
|
||||
|
||||
runtime_tasks::apply_runtime_log_filter(
|
||||
has_rust_log,
|
||||
&effective_log_level,
|
||||
|
||||
@@ -19,6 +19,7 @@ use tokio::signal::unix::{SignalKind, signal};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::stats::Stats;
|
||||
use crate::synlimit_control;
|
||||
use crate::transport::middle_proxy::MePool;
|
||||
|
||||
use super::helpers::{format_uptime, unit_label};
|
||||
@@ -102,6 +103,10 @@ async fn perform_shutdown(
|
||||
let uptime_secs = process_started_at.elapsed().as_secs();
|
||||
info!("Uptime: {}", format_uptime(uptime_secs));
|
||||
|
||||
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
|
||||
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
|
||||
}
|
||||
|
||||
// Graceful ME pool shutdown
|
||||
if let Some(pool) = &me_pool {
|
||||
match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all())
|
||||
|
||||
@@ -30,6 +30,7 @@ mod service;
|
||||
mod startup;
|
||||
mod stats;
|
||||
mod stream;
|
||||
mod synlimit_control;
|
||||
mod tls_front;
|
||||
mod transport;
|
||||
mod util;
|
||||
|
||||
@@ -638,15 +638,21 @@ fn build_server_hello_key_share_for_group(
|
||||
group: u16,
|
||||
rng: &SecureRandom,
|
||||
) -> Option<ServerHelloKeyShare> {
|
||||
let expected_key_exchange_len = client_hello_key_share_group_len(group)?;
|
||||
client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len)?;
|
||||
|
||||
// FakeTLS clients validate ServerHello shape and digest, not TLS traffic
|
||||
// secrets, so the response must mirror the offered group without binding to
|
||||
// the camouflage key bytes embedded in ClientHello.
|
||||
match group {
|
||||
TLS_NAMED_GROUP_X25519MLKEM768 => {
|
||||
let key_exchange = build_x25519mlkem768_server_key_share(handshake, rng)?;
|
||||
Some(ServerHelloKeyShare::new(group, key_exchange))
|
||||
}
|
||||
TLS_NAMED_GROUP_X25519 => {
|
||||
let key_exchange = build_x25519_server_key_share(handshake, rng)?;
|
||||
Some(ServerHelloKeyShare::new(group, key_exchange))
|
||||
}
|
||||
TLS_NAMED_GROUP_X25519MLKEM768 => Some(ServerHelloKeyShare::new(
|
||||
group,
|
||||
gen_fake_x25519mlkem768_server_key_share(rng),
|
||||
)),
|
||||
TLS_NAMED_GROUP_X25519 => Some(ServerHelloKeyShare::new(
|
||||
group,
|
||||
gen_fake_x25519_key(rng).to_vec(),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
601
src/synlimit_control.rs
Normal file
601
src/synlimit_control.rs
Normal file
@@ -0,0 +1,601 @@
|
||||
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> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -155,57 +155,35 @@ fn push_fallback_size(sizes: &mut Vec<usize>, size: usize) {
|
||||
}
|
||||
|
||||
fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
|
||||
if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls)
|
||||
&& !cached.app_data_records_sizes.is_empty()
|
||||
{
|
||||
return cached.app_data_records_sizes.clone();
|
||||
}
|
||||
|
||||
let family = fallback_shape_family(cached);
|
||||
let mut remaining = fallback_total_app_data_len(cached);
|
||||
let preferred_chunk = match family {
|
||||
FallbackShapeFamily::NginxLike => 2896,
|
||||
FallbackShapeFamily::BoringSslLike => 1369,
|
||||
FallbackShapeFamily::RustlsLike => 2048,
|
||||
let mut sizes = Vec::with_capacity(1);
|
||||
let size = if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) {
|
||||
cached
|
||||
.app_data_records_sizes
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or_else(|| fallback_total_app_data_len(cached))
|
||||
} else {
|
||||
fallback_total_app_data_len(cached)
|
||||
};
|
||||
let split_threshold = match family {
|
||||
FallbackShapeFamily::NginxLike => 4096,
|
||||
FallbackShapeFamily::BoringSslLike => 1536,
|
||||
FallbackShapeFamily::RustlsLike => 3072,
|
||||
};
|
||||
|
||||
if remaining <= split_threshold {
|
||||
return vec![remaining.clamp(MIN_APP_DATA, MAX_APP_DATA)];
|
||||
}
|
||||
|
||||
let mut sizes: Vec<usize> = Vec::new();
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(preferred_chunk).min(MAX_APP_DATA);
|
||||
if chunk < MIN_APP_DATA {
|
||||
if let Some(last) = sizes.last_mut() {
|
||||
*last = (*last).saturating_add(chunk).min(MAX_APP_DATA);
|
||||
} else {
|
||||
push_fallback_size(&mut sizes, chunk);
|
||||
}
|
||||
break;
|
||||
}
|
||||
push_fallback_size(&mut sizes, chunk);
|
||||
remaining = remaining.saturating_sub(chunk);
|
||||
}
|
||||
|
||||
push_fallback_size(&mut sizes, size);
|
||||
sizes
|
||||
}
|
||||
|
||||
fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
|
||||
match cached.behavior_profile.source {
|
||||
TlsProfileSource::Raw | TlsProfileSource::Merged => {
|
||||
if !cached.behavior_profile.app_data_record_sizes.is_empty() {
|
||||
return cached.behavior_profile.app_data_record_sizes.clone();
|
||||
if let Some(size) = cached.behavior_profile.app_data_record_sizes.first() {
|
||||
return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
|
||||
}
|
||||
if !cached.app_data_records_sizes.is_empty() {
|
||||
return cached.app_data_records_sizes.clone();
|
||||
if let Some(size) = cached.app_data_records_sizes.first() {
|
||||
return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
|
||||
}
|
||||
return vec![cached.total_app_data_len.max(1024)];
|
||||
return vec![
|
||||
cached
|
||||
.total_app_data_len
|
||||
.max(1024)
|
||||
.clamp(MIN_APP_DATA, MAX_APP_DATA),
|
||||
];
|
||||
}
|
||||
TlsProfileSource::Default | TlsProfileSource::Rustls => {
|
||||
return fallback_family_app_data_sizes(cached);
|
||||
@@ -417,7 +395,7 @@ pub fn build_emulated_server_hello(
|
||||
alpn: Option<Vec<u8>>,
|
||||
new_session_tickets: u8,
|
||||
) -> Vec<u8> {
|
||||
// --- ServerHello ---
|
||||
// ServerHello carries the authenticated digest bytes that the client verifies.
|
||||
let extensions = build_profiled_server_hello_extensions(cached, server_key_share);
|
||||
let extensions_len = extensions.len() as u16;
|
||||
|
||||
@@ -449,7 +427,7 @@ pub fn build_emulated_server_hello(
|
||||
server_hello.extend_from_slice(&(message.len() as u16).to_be_bytes());
|
||||
server_hello.extend_from_slice(&message);
|
||||
|
||||
// --- ChangeCipherSpec ---
|
||||
// ChangeCipherSpec is part of the client-visible TLS shim prefix.
|
||||
let change_cipher_spec_count = emulated_change_cipher_spec_count(cached);
|
||||
let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6);
|
||||
for _ in 0..change_cipher_spec_count {
|
||||
@@ -463,7 +441,8 @@ pub fn build_emulated_server_hello(
|
||||
]);
|
||||
}
|
||||
|
||||
// --- ApplicationData (fake encrypted records) ---
|
||||
// Telegram clients authenticate the hello prefix and then expose any later
|
||||
// ApplicationData bytes to the MTProto packet parser.
|
||||
let mut sizes = {
|
||||
let base_sizes = emulated_app_data_sizes(cached);
|
||||
match cached.behavior_profile.source {
|
||||
@@ -550,8 +529,7 @@ pub fn build_emulated_server_hello(
|
||||
app_data.extend_from_slice(&rec);
|
||||
}
|
||||
|
||||
// --- Combine ---
|
||||
// Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint).
|
||||
// Optional NewSessionTicket mimic records are an explicit fingerprint opt-in.
|
||||
let mut tickets = Vec::new();
|
||||
for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) {
|
||||
let mut rec = Vec::with_capacity(5 + ticket_len);
|
||||
@@ -570,7 +548,7 @@ pub fn build_emulated_server_hello(
|
||||
response.extend_from_slice(&app_data);
|
||||
response.extend_from_slice(&tickets);
|
||||
|
||||
// --- HMAC ---
|
||||
// The digest authenticates the server response bytes emitted by this builder.
|
||||
let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + response.len());
|
||||
hmac_input.extend_from_slice(client_digest);
|
||||
hmac_input.extend_from_slice(&response);
|
||||
@@ -1062,7 +1040,7 @@ mod tests {
|
||||
app_lens.push(record_len);
|
||||
pos += 5 + record_len;
|
||||
}
|
||||
assert_eq!(app_lens, vec![64, 3905, 537]);
|
||||
assert_eq!(app_lens, vec![64]);
|
||||
assert_eq!(pos, response.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,37 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() {
|
||||
);
|
||||
|
||||
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
|
||||
assert_eq!(app_records, vec![1200, 900]);
|
||||
assert_eq!(app_records, vec![1200]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_server_hello_keeps_default_profile_primary_app_data_single() {
|
||||
let mut cached = make_cached();
|
||||
cached.behavior_profile.source = TlsProfileSource::Default;
|
||||
cached.behavior_profile.app_data_record_sizes.clear();
|
||||
cached.behavior_profile.ticket_record_sizes.clear();
|
||||
cached.app_data_records_sizes = vec![2048, 1024];
|
||||
cached.total_app_data_len = 5000;
|
||||
let rng = SecureRandom::new();
|
||||
|
||||
let response = build_emulated_server_hello(
|
||||
b"secret",
|
||||
&[0x85; 32],
|
||||
&[0x86; 16],
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
[0x13, 0x01],
|
||||
&test_server_key_share(),
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
);
|
||||
|
||||
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
|
||||
assert_eq!(app_records.len(), 1);
|
||||
assert!(app_records[0] >= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -130,5 +160,5 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() {
|
||||
);
|
||||
|
||||
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
|
||||
assert_eq!(app_records, vec![1200, 900, 220, 180]);
|
||||
assert_eq!(app_records, vec![1200, 220, 180]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user