mirror of
https://github.com/telemt/telemt.git
synced 2026-06-25 20:31:11 +03:00
Compare commits
47 Commits
3.4.7
...
f0f2bc0482
| Author | SHA1 | Date | |
|---|---|---|---|
| f0f2bc0482 | |||
| 86573be493 | |||
| 658a565cb3 | |||
| 29fabcb199 | |||
| efdf3bcc1b | |||
| 66c37ad6fd | |||
| 0fcf67ca34 | |||
| df14762a12 | |||
| 4995e83236 | |||
| e0f251ad82 | |||
| b605b1ba7c | |||
| b859fb95c3 | |||
| 8c303ab2b6 | |||
| f70c2936c7 | |||
| d67c37afd7 | |||
| 9f9ca9f270 | |||
| cdd2239047 | |||
| 9ee341a94f | |||
| a7a2f4ab27 | |||
| 9dae14aa66 | |||
| f76c847c44 | |||
| 1aaa9c0bc6 | |||
| e50026e776 | |||
| 7106f38fae | |||
| 2a694470d5 | |||
| b98cd37211 | |||
| 8b62965978 | |||
| d46bda9880 | |||
| c3de07db6a | |||
| 61f9af7ffc | |||
| 1f90e28871 | |||
| 876b74ebf7 | |||
| b34e1d71ae | |||
| b1c947e8e3 | |||
| cfe01dced2 | |||
| 8520955a5f | |||
| 065786b839 | |||
| f0e1a6cf1c | |||
| 236bbb4970 | |||
| 8ef5263fce | |||
| 893cef22e3 | |||
| bdfa641843 | |||
| 007fc86189 | |||
| 10c9bcd97d | |||
| 8ab9405dca | |||
| 9412f089c0 | |||
| d567dfe40b |
Generated
+1
-1
@@ -2791,7 +2791,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telemt"
|
name = "telemt"
|
||||||
version = "3.4.7"
|
version = "3.4.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "telemt"
|
name = "telemt"
|
||||||
version = "3.4.7"
|
version = "3.4.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Telemt - MTProxy on Rust + Tokio
|
# Telemt - MTProxy on Rust + Tokio
|
||||||
|
|
||||||
[](https://github.com/telemt/telemt/releases/latest) [](https://github.com/telemt/telemt/stargazers) [](https://github.com/telemt/telemt/network/members) [](https://t.me/telemtrs)
|
[](https://github.com/telemt/telemt/releases/latest) [](https://github.com/telemt/telemt/stargazers) [](https://github.com/telemt/telemt/network/members)
|
||||||
|
|
||||||
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
|
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://t.me/telemtrs">
|
<a href="https://t.me/telemtrs">
|
||||||
<img src="/docs/assets/telegram_button.svg" width="150"/>
|
<img src="https://github.com/user-attachments/assets/30b7e7b9-974a-4e3d-aab6-b58a85de4507" width="240"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ Monero (XMR) directly:
|
|||||||
8Bk4tZEYPQWSypeD2hrUXG2rKbAKF16GqEN942ZdAP5cFdSqW6h4DwkP5cJMAdszzuPeHeHZPTyjWWFwzeFdjuci3ktfMoB
|
8Bk4tZEYPQWSypeD2hrUXG2rKbAKF16GqEN942ZdAP5cFdSqW6h4DwkP5cJMAdszzuPeHeHZPTyjWWFwzeFdjuci3ktfMoB
|
||||||
```
|
```
|
||||||
|
|
||||||
All donations go toward infrastructure, development, and research.
|
All donations go toward infrastructure, development and research
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -178,6 +178,21 @@ Notes:
|
|||||||
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
|
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
|
||||||
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
|
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
|
||||||
|
|
||||||
|
### `access.user_source_deny` via API
|
||||||
|
- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`.
|
||||||
|
- Configure it in `config.toml` under `[access.user_source_deny]` and apply via normal config reload path.
|
||||||
|
- Runtime behavior after apply:
|
||||||
|
- auth succeeds for username/secret
|
||||||
|
- source IP is checked against `access.user_source_deny[username]`
|
||||||
|
- on match, handshake is rejected with the same fail-closed outcome as invalid auth
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
```toml
|
||||||
|
[access.user_source_deny]
|
||||||
|
alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
|
||||||
|
bob = ["198.51.100.42/32"]
|
||||||
|
```
|
||||||
|
|
||||||
### `RotateSecretRequest`
|
### `RotateSecretRequest`
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|||||||
@@ -128,7 +128,48 @@ Recommended for cleaner testing:
|
|||||||
|
|
||||||
Persisted cache artifacts are useful, but they are not required if packet captures already demonstrate the runtime result.
|
Persisted cache artifacts are useful, but they are not required if packet captures already demonstrate the runtime result.
|
||||||
|
|
||||||
### 4. Capture a direct-origin trace
|
### 4. Check TLS-front profile health metrics
|
||||||
|
|
||||||
|
If the metrics endpoint is enabled, check the TLS-front profile health before packet-capture validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:9999/metrics | grep -E 'telemt_tls_front_profile|telemt_tls_fetch_profile_cache|telemt_tls_front_full_cert'
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile-health metrics expose the runtime state of configured TLS front domains:
|
||||||
|
|
||||||
|
- `telemt_tls_front_profile_domains` shows configured, emitted, and suppressed domain series.
|
||||||
|
- `telemt_tls_front_profile_info` shows profile source and feature flags per domain.
|
||||||
|
- `telemt_tls_front_profile_age_seconds` shows cached profile age.
|
||||||
|
- `telemt_tls_front_profile_app_data_records` shows cached AppData record count.
|
||||||
|
- `telemt_tls_front_profile_ticket_records` shows cached ticket-like tail record count.
|
||||||
|
- `telemt_tls_front_profile_change_cipher_spec_records` shows cached ChangeCipherSpec count.
|
||||||
|
- `telemt_tls_front_profile_app_data_bytes` shows total cached AppData bytes.
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
|
||||||
|
- `source="merged"` or `source="raw"` means real TLS profile data is being used.
|
||||||
|
- `source="default"` or `is_default="true"` means the domain currently uses the synthetic default fallback.
|
||||||
|
- `has_cert_payload="true"` means certificate payload data is available for TLS emulation.
|
||||||
|
- Non-zero AppData/ticket/CCS counters show captured server-flight shape.
|
||||||
|
|
||||||
|
Example healthy output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
telemt_tls_front_profile_domains{status="configured"} 1
|
||||||
|
telemt_tls_front_profile_domains{status="emitted"} 1
|
||||||
|
telemt_tls_front_profile_domains{status="suppressed"} 0
|
||||||
|
telemt_tls_front_profile_info{domain="itunes.apple.com",source="merged",is_default="false",has_cert_info="true",has_cert_payload="true"} 1
|
||||||
|
telemt_tls_front_profile_age_seconds{domain="itunes.apple.com"} 20
|
||||||
|
telemt_tls_front_profile_app_data_records{domain="itunes.apple.com"} 3
|
||||||
|
telemt_tls_front_profile_ticket_records{domain="itunes.apple.com"} 1
|
||||||
|
telemt_tls_front_profile_change_cipher_spec_records{domain="itunes.apple.com"} 1
|
||||||
|
telemt_tls_front_profile_app_data_bytes{domain="itunes.apple.com"} 5240
|
||||||
|
```
|
||||||
|
|
||||||
|
These metrics do not prove byte-level origin equivalence. They are an operational health signal that the configured domain is backed by real cached profile data instead of default fallback data.
|
||||||
|
|
||||||
|
### 5. Capture a direct-origin trace
|
||||||
|
|
||||||
From a separate client host, connect directly to the origin:
|
From a separate client host, connect directly to the origin:
|
||||||
|
|
||||||
@@ -142,7 +183,7 @@ Capture with:
|
|||||||
sudo tcpdump -i any -w origin-direct.pcap host ORIGIN_IP and port 443
|
sudo tcpdump -i any -w origin-direct.pcap host ORIGIN_IP and port 443
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Capture a Telemt FakeTLS success-path trace
|
### 6. Capture a Telemt FakeTLS success-path trace
|
||||||
|
|
||||||
Now connect to Telemt with a real Telegram client through an `ee` proxy link that targets the Telemt instance.
|
Now connect to Telemt with a real Telegram client through an `ee` proxy link that targets the Telemt instance.
|
||||||
|
|
||||||
@@ -154,7 +195,7 @@ Capture with:
|
|||||||
sudo tcpdump -i any -w telemt-emulated.pcap host TELEMT_IP and port 443
|
sudo tcpdump -i any -w telemt-emulated.pcap host TELEMT_IP and port 443
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Decode TLS record structure
|
### 7. Decode TLS record structure
|
||||||
|
|
||||||
Use `tshark` to print record-level structure:
|
Use `tshark` to print record-level structure:
|
||||||
|
|
||||||
@@ -182,7 +223,7 @@ Focus on the server flight after ClientHello:
|
|||||||
- `20` = ChangeCipherSpec
|
- `20` = ChangeCipherSpec
|
||||||
- `23` = ApplicationData
|
- `23` = ApplicationData
|
||||||
|
|
||||||
### 7. Build a comparison table
|
### 8. Build a comparison table
|
||||||
|
|
||||||
A compact table like the following is usually enough:
|
A compact table like the following is usually enough:
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,50 @@ openssl s_client -connect ORIGIN_IP:443 -servername YOUR_DOMAIN </dev/null
|
|||||||
2. Дайте ему получить TLS front profile data для выбранного домена.
|
2. Дайте ему получить TLS front profile data для выбранного домена.
|
||||||
3. Если `tls_front_dir` хранится persistently, убедитесь, что TLS front cache заполнен.
|
3. Если `tls_front_dir` хранится persistently, убедитесь, что TLS front cache заполнен.
|
||||||
|
|
||||||
Persisted cache artifacts полезны, но не обязательны, если packet capture уже показывают runtime result.
|
Сохранённые артефакты кэша полезны, но не обязательны, если packet capture уже показывает результат в runtime.
|
||||||
|
|
||||||
### 4. Снять direct-origin trace
|
### 4. Проверить метрики состояния TLS-front profile
|
||||||
|
|
||||||
|
Если endpoint метрик включён, перед проверкой через packet capture можно быстро проверить состояние TLS-front profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:9999/metrics | grep -E 'telemt_tls_front_profile|telemt_tls_fetch_profile_cache|telemt_tls_front_full_cert'
|
||||||
|
```
|
||||||
|
|
||||||
|
Метрики состояния профиля показывают runtime-состояние настроенных TLS-front доменов:
|
||||||
|
|
||||||
|
- `telemt_tls_front_profile_domains` показывает количество настроенных, экспортируемых и скрытых из-за лимита доменов.
|
||||||
|
- `telemt_tls_front_profile_info` показывает источник профиля и флаги доступных данных по каждому домену.
|
||||||
|
- `telemt_tls_front_profile_age_seconds` показывает возраст закешированного профиля.
|
||||||
|
- `telemt_tls_front_profile_app_data_records` показывает количество закешированных AppData records.
|
||||||
|
- `telemt_tls_front_profile_ticket_records` показывает количество закешированных ticket-like tail records.
|
||||||
|
- `telemt_tls_front_profile_change_cipher_spec_records` показывает закешированное количество ChangeCipherSpec records.
|
||||||
|
- `telemt_tls_front_profile_app_data_bytes` показывает общий размер закешированных AppData bytes.
|
||||||
|
|
||||||
|
Интерпретация:
|
||||||
|
|
||||||
|
- `source="merged"` или `source="raw"` означает, что используются реальные данные TLS-профиля.
|
||||||
|
- `source="default"` или `is_default="true"` означает, что домен сейчас работает на synthetic default fallback.
|
||||||
|
- `has_cert_payload="true"` означает, что certificate payload доступен для TLS emulation.
|
||||||
|
- Ненулевые AppData/ticket/CCS counters показывают захваченную форму server flight.
|
||||||
|
|
||||||
|
Пример здорового состояния:
|
||||||
|
|
||||||
|
```text
|
||||||
|
telemt_tls_front_profile_domains{status="configured"} 1
|
||||||
|
telemt_tls_front_profile_domains{status="emitted"} 1
|
||||||
|
telemt_tls_front_profile_domains{status="suppressed"} 0
|
||||||
|
telemt_tls_front_profile_info{domain="itunes.apple.com",source="merged",is_default="false",has_cert_info="true",has_cert_payload="true"} 1
|
||||||
|
telemt_tls_front_profile_age_seconds{domain="itunes.apple.com"} 20
|
||||||
|
telemt_tls_front_profile_app_data_records{domain="itunes.apple.com"} 3
|
||||||
|
telemt_tls_front_profile_ticket_records{domain="itunes.apple.com"} 1
|
||||||
|
telemt_tls_front_profile_change_cipher_spec_records{domain="itunes.apple.com"} 1
|
||||||
|
telemt_tls_front_profile_app_data_bytes{domain="itunes.apple.com"} 5240
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти метрики не доказывают побайтную эквивалентность с origin. Это эксплуатационный сигнал состояния: настроенный домен действительно основан на реальных закешированных данных профиля, а не на default fallback.
|
||||||
|
|
||||||
|
### 5. Снять direct-origin trace
|
||||||
|
|
||||||
С отдельной клиентской машины подключитесь напрямую к origin:
|
С отдельной клиентской машины подключитесь напрямую к origin:
|
||||||
|
|
||||||
@@ -142,7 +183,7 @@ Capture:
|
|||||||
sudo tcpdump -i any -w origin-direct.pcap host ORIGIN_IP and port 443
|
sudo tcpdump -i any -w origin-direct.pcap host ORIGIN_IP and port 443
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Снять Telemt FakeTLS success-path trace
|
### 6. Снять Telemt FakeTLS success-path trace
|
||||||
|
|
||||||
Теперь подключитесь к Telemt через реальный Telegram client с `ee` proxy link, который указывает на Telemt instance.
|
Теперь подключитесь к Telemt через реальный Telegram client с `ee` proxy link, который указывает на Telemt instance.
|
||||||
|
|
||||||
@@ -154,7 +195,7 @@ Capture:
|
|||||||
sudo tcpdump -i any -w telemt-emulated.pcap host TELEMT_IP and port 443
|
sudo tcpdump -i any -w telemt-emulated.pcap host TELEMT_IP and port 443
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Декодировать структуру TLS records
|
### 7. Декодировать структуру TLS records
|
||||||
|
|
||||||
Используйте `tshark`, чтобы вывести record-level structure:
|
Используйте `tshark`, чтобы вывести record-level structure:
|
||||||
|
|
||||||
@@ -182,7 +223,7 @@ tshark -r telemt-emulated.pcap -Y "tls.record" -T fields \
|
|||||||
- `20` = ChangeCipherSpec
|
- `20` = ChangeCipherSpec
|
||||||
- `23` = ApplicationData
|
- `23` = ApplicationData
|
||||||
|
|
||||||
### 7. Собрать сравнительную таблицу
|
### 8. Собрать сравнительную таблицу
|
||||||
|
|
||||||
Обычно достаточно короткой таблицы такого вида:
|
Обычно достаточно короткой таблицы такого вида:
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ This document lists all configuration keys accepted by `config.toml`.
|
|||||||
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
|
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
|
||||||
| [`disable_colors`](#disable_colors) | `bool` | `false` |
|
| [`disable_colors`](#disable_colors) | `bool` | `false` |
|
||||||
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
|
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
|
||||||
|
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` |
|
||||||
|
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` |
|
||||||
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
|
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
|
||||||
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
|
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
|
||||||
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
|
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
|
||||||
@@ -975,6 +977,24 @@ This document lists all configuration keys accepted by `config.toml`.
|
|||||||
[general]
|
[general]
|
||||||
me_socks_kdf_policy = "strict"
|
me_socks_kdf_policy = "strict"
|
||||||
```
|
```
|
||||||
|
## me_route_backpressure_enabled
|
||||||
|
- **Constraints / validation**: `bool`.
|
||||||
|
- **Description**: Enables channel-pressure-aware route send timeouts.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
me_route_backpressure_enabled = false
|
||||||
|
```
|
||||||
|
## me_route_fairshare_enabled
|
||||||
|
- **Constraints / validation**: `bool`.
|
||||||
|
- **Description**: Enables fair-share routing admission across writer workers.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
me_route_fairshare_enabled = false
|
||||||
|
```
|
||||||
## me_route_backpressure_base_timeout_ms
|
## me_route_backpressure_base_timeout_ms
|
||||||
- **Constraints / validation**: Must be within `1..=5000` (milliseconds).
|
- **Constraints / validation**: Must be within `1..=5000` (milliseconds).
|
||||||
- **Description**: Base backpressure timeout in milliseconds for ME route-channel send.
|
- **Description**: Base backpressure timeout in milliseconds for ME route-channel send.
|
||||||
@@ -1753,6 +1773,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
|||||||
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` |
|
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` |
|
||||||
| [`max_connections`](#max_connections) | `u32` | `10000` |
|
| [`max_connections`](#max_connections) | `u32` | `10000` |
|
||||||
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` |
|
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` |
|
||||||
|
| [`listen_backlog`](#listen_backlog) | `u32` | `1024` |
|
||||||
|
|
||||||
## port
|
## port
|
||||||
- **Constraints / validation**: `u16`.
|
- **Constraints / validation**: `u16`.
|
||||||
@@ -1763,6 +1784,15 @@ This document lists all configuration keys accepted by `config.toml`.
|
|||||||
[server]
|
[server]
|
||||||
port = 443
|
port = 443
|
||||||
```
|
```
|
||||||
|
## listen_backlog
|
||||||
|
- **Constraints / validation**: `u32`. `0` uses the OS default backlog behavior.
|
||||||
|
- **Description**: Listen backlog passed to `listen(2)` for TCP sockets.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
listen_backlog = 1024
|
||||||
|
```
|
||||||
## listen_addr_ipv4
|
## listen_addr_ipv4
|
||||||
- **Constraints / validation**: `String` (optional). When set, must be a valid IPv4 address string.
|
- **Constraints / validation**: `String` (optional). When set, must be a valid IPv4 address string.
|
||||||
- **Description**: IPv4 bind address for TCP listener (omit this key to disable IPv4 bind).
|
- **Description**: IPv4 bind address for TCP listener (omit this key to disable IPv4 bind).
|
||||||
@@ -2005,6 +2035,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` |
|
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` |
|
||||||
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` |
|
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` |
|
||||||
| [`read_only`](#read_only) | `bool` | `false` |
|
| [`read_only`](#read_only) | `bool` | `false` |
|
||||||
|
| [`gray_action`](#gray_action) | `"drop"`, `"api"`, or `"200"` | `"drop"` |
|
||||||
|
|
||||||
## enabled
|
## enabled
|
||||||
- **Constraints / validation**: `bool`.
|
- **Constraints / validation**: `bool`.
|
||||||
@@ -2015,6 +2046,15 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
[server.api]
|
[server.api]
|
||||||
enabled = true
|
enabled = true
|
||||||
```
|
```
|
||||||
|
## gray_action
|
||||||
|
- **Constraints / validation**: `"drop"`, `"api"`, or `"200"`.
|
||||||
|
- **Description**: API response policy for gray/limited states: drop request, serve normal API response, or force `200 OK`.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server.api]
|
||||||
|
gray_action = "drop"
|
||||||
|
```
|
||||||
## listen
|
## listen
|
||||||
- **Constraints / validation**: `String`. Must be in `IP:PORT` format.
|
- **Constraints / validation**: `String`. Must be in `IP:PORT` format.
|
||||||
- **Description**: API bind address in `IP:PORT` format.
|
- **Description**: API bind address in `IP:PORT` format.
|
||||||
@@ -2207,6 +2247,15 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
[timeouts]
|
[timeouts]
|
||||||
client_handshake = 30
|
client_handshake = 30
|
||||||
```
|
```
|
||||||
|
## client_first_byte_idle_secs
|
||||||
|
- **Constraints / validation**: `u64` (seconds). `0` disables first-byte idle enforcement.
|
||||||
|
- **Description**: Maximum idle time to wait for the first client payload byte after session setup.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[timeouts]
|
||||||
|
client_first_byte_idle_secs = 300
|
||||||
|
```
|
||||||
## relay_idle_policy_v2_enabled
|
## relay_idle_policy_v2_enabled
|
||||||
- **Constraints / validation**: `bool`.
|
- **Constraints / validation**: `bool`.
|
||||||
- **Description**: Enables soft/hard middle-relay client idle policy.
|
- **Description**: Enables soft/hard middle-relay client idle policy.
|
||||||
@@ -2311,6 +2360,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
|
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
|
||||||
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
|
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
|
||||||
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
|
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
|
||||||
|
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` |
|
||||||
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
|
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
|
||||||
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
|
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
|
||||||
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
|
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
|
||||||
@@ -2488,6 +2538,15 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
[censorship]
|
[censorship]
|
||||||
tls_full_cert_ttl_secs = 90
|
tls_full_cert_ttl_secs = 90
|
||||||
```
|
```
|
||||||
|
## serverhello_compact
|
||||||
|
- **Constraints / validation**: `bool`.
|
||||||
|
- **Description**: Enables compact ServerHello/Fake-TLS profile to reduce response-size signature.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[censorship]
|
||||||
|
serverhello_compact = false
|
||||||
|
```
|
||||||
## alpn_enforce
|
## alpn_enforce
|
||||||
- **Constraints / validation**: `bool`.
|
- **Constraints / validation**: `bool`.
|
||||||
- **Description**: Enforces ALPN echo behavior based on client preference.
|
- **Description**: Enforces ALPN echo behavior based on client preference.
|
||||||
@@ -2827,9 +2886,12 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
|||||||
| [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` |
|
| [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` |
|
||||||
| [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` |
|
| [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` |
|
||||||
| [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` |
|
| [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` |
|
||||||
|
| [`user_source_deny`](#user_source_deny) | `Map<String, IpNetwork[]>` | `{}` |
|
||||||
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` |
|
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` |
|
||||||
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` |
|
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` |
|
||||||
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` |
|
| [`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>` | `{}` |
|
||||||
|
|
||||||
## users
|
## users
|
||||||
- **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**.
|
- **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**.
|
||||||
@@ -2929,6 +2991,20 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
|||||||
[access]
|
[access]
|
||||||
user_max_unique_ips_window_secs = 30
|
user_max_unique_ips_window_secs = 30
|
||||||
```
|
```
|
||||||
|
## user_source_deny
|
||||||
|
- **Constraints / validation**: Table `username -> IpNetwork[]`. Each network must parse as CIDR (for example `203.0.113.0/24` or `2001:db8::/32`).
|
||||||
|
- **Description**: Per-user source IP/CIDR deny-list applied **after successful auth** in TLS and MTProto handshake paths. A matched source IP is rejected via the same fail-closed path as invalid auth.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[access.user_source_deny]
|
||||||
|
alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
|
||||||
|
bob = ["198.51.100.42/32"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **How it works (quick check)**:
|
||||||
|
- connection from user `alice` and source `203.0.113.55` -> rejected (matches `203.0.113.0/24`)
|
||||||
|
- connection from user `alice` and source `198.51.100.10` -> allowed by this rule set (no match)
|
||||||
## replay_check_len
|
## replay_check_len
|
||||||
- **Constraints / validation**: `usize`.
|
- **Constraints / validation**: `usize`.
|
||||||
- **Description**: Replay-protection storage length (number of entries tracked for duplicate detection).
|
- **Description**: Replay-protection storage length (number of entries tracked for duplicate detection).
|
||||||
@@ -2958,6 +3034,24 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## user_rate_limits
|
||||||
|
- **Constraints / validation**: Table `username -> { up_bps, down_bps }`. At least one direction must be non-zero.
|
||||||
|
- **Description**: Per-user bandwidth caps in bytes/sec for upload (`up_bps`) and download (`down_bps`).
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[access.user_rate_limits]
|
||||||
|
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.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[access.cidr_rate_limits]
|
||||||
|
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
|
||||||
|
```
|
||||||
# [[upstreams]]
|
# [[upstreams]]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,8 @@
|
|||||||
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
|
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
|
||||||
| [`disable_colors`](#disable_colors) | `bool` | `false` |
|
| [`disable_colors`](#disable_colors) | `bool` | `false` |
|
||||||
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
|
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
|
||||||
|
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` |
|
||||||
|
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` |
|
||||||
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
|
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
|
||||||
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
|
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
|
||||||
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
|
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
|
||||||
@@ -975,6 +977,24 @@
|
|||||||
[general]
|
[general]
|
||||||
me_socks_kdf_policy = "strict"
|
me_socks_kdf_policy = "strict"
|
||||||
```
|
```
|
||||||
|
## me_route_backpressure_enabled
|
||||||
|
- **Ограничения / валидация**: `bool`.
|
||||||
|
- **Описание**: Включает адаптивные таймауты записи маршрута в зависимости от заполнения канала.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
me_route_backpressure_enabled = false
|
||||||
|
```
|
||||||
|
## me_route_fairshare_enabled
|
||||||
|
- **Ограничения / валидация**: `bool`.
|
||||||
|
- **Описание**: Включает справедливое распределение нагрузки маршрутизации между writer-потоками.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
me_route_fairshare_enabled = false
|
||||||
|
```
|
||||||
## me_route_backpressure_base_timeout_ms
|
## me_route_backpressure_base_timeout_ms
|
||||||
- **Ограничения / валидация**: Должно быть в пределах `1..=5000` (миллисекунд).
|
- **Ограничения / валидация**: Должно быть в пределах `1..=5000` (миллисекунд).
|
||||||
- **Описание**: Базовый таймаут (в миллисекундах) ожидания при режиме **backpressure** (ситуация, при которой данные обрабатываются медленне, чем получаются) для отправки через ME route-channel.
|
- **Описание**: Базовый таймаут (в миллисекундах) ожидания при режиме **backpressure** (ситуация, при которой данные обрабатываются медленне, чем получаются) для отправки через ME route-channel.
|
||||||
@@ -1755,6 +1775,7 @@
|
|||||||
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` |
|
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` |
|
||||||
| [`max_connections`](#max_connections) | `u32` | `10000` |
|
| [`max_connections`](#max_connections) | `u32` | `10000` |
|
||||||
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` |
|
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` |
|
||||||
|
| [`listen_backlog`](#listen_backlog) | `u32` | `1024` |
|
||||||
|
|
||||||
## port
|
## port
|
||||||
- **Ограничения / валидация**: `u16`.
|
- **Ограничения / валидация**: `u16`.
|
||||||
@@ -1765,6 +1786,15 @@
|
|||||||
[server]
|
[server]
|
||||||
port = 443
|
port = 443
|
||||||
```
|
```
|
||||||
|
## listen_backlog
|
||||||
|
- **Ограничения / валидация**: `u32`. `0` использует системный backlog по умолчанию.
|
||||||
|
- **Описание**: Значение backlog, передаваемое в `listen(2)` для TCP-сокетов.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
listen_backlog = 1024
|
||||||
|
```
|
||||||
## listen_addr_ipv4
|
## listen_addr_ipv4
|
||||||
- **Ограничения / валидация**: `String` (необязательный параметр). Если задан, должен содержать валидный IPv4-адрес в формате строки.
|
- **Ограничения / валидация**: `String` (необязательный параметр). Если задан, должен содержать валидный IPv4-адрес в формате строки.
|
||||||
- **Описание**: Прослушиваемый адрес в формате IPv4 (не задавайте этот параметр, если необходимо отключить прослушивание по IPv4).
|
- **Описание**: Прослушиваемый адрес в формате IPv4 (не задавайте этот параметр, если необходимо отключить прослушивание по IPv4).
|
||||||
@@ -2011,6 +2041,7 @@
|
|||||||
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` |
|
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` |
|
||||||
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` |
|
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` |
|
||||||
| [`read_only`](#read_only) | `bool` | `false` |
|
| [`read_only`](#read_only) | `bool` | `false` |
|
||||||
|
| [`gray_action`](#gray_action) | `"drop"`, `"api"`, or `"200"` | `"drop"` |
|
||||||
|
|
||||||
## enabled
|
## enabled
|
||||||
- **Ограничения / валидация**: `bool`.
|
- **Ограничения / валидация**: `bool`.
|
||||||
@@ -2021,6 +2052,15 @@
|
|||||||
[server.api]
|
[server.api]
|
||||||
enabled = true
|
enabled = true
|
||||||
```
|
```
|
||||||
|
## gray_action
|
||||||
|
- **Ограничения / валидация**: `"drop"`, `"api"` или `"200"`.
|
||||||
|
- **Описание**: Политика ответа API в «серых» (ограниченных) состояниях: сброс, обычный API-ответ, либо `200 OK`.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server.api]
|
||||||
|
gray_action = "drop"
|
||||||
|
```
|
||||||
## listen
|
## listen
|
||||||
- **Ограничения / валидация**: `String`. Должно быть в формате `IP:PORT`.
|
- **Ограничения / валидация**: `String`. Должно быть в формате `IP:PORT`.
|
||||||
- **Описание**: Адрес биндинга API в формате `IP:PORT`.
|
- **Описание**: Адрес биндинга API в формате `IP:PORT`.
|
||||||
@@ -2213,6 +2253,15 @@
|
|||||||
[timeouts]
|
[timeouts]
|
||||||
client_handshake = 30
|
client_handshake = 30
|
||||||
```
|
```
|
||||||
|
## client_first_byte_idle_secs
|
||||||
|
- **Ограничения / валидация**: `u64` (секунды). `0` отключает проверку простоя до первого байта.
|
||||||
|
- **Описание**: Максимальное время ожидания первого байта полезной нагрузки от клиента после установления сессии.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[timeouts]
|
||||||
|
client_first_byte_idle_secs = 300
|
||||||
|
```
|
||||||
## relay_idle_policy_v2_enabled
|
## relay_idle_policy_v2_enabled
|
||||||
- **Ограничения / валидация**: `bool`.
|
- **Ограничения / валидация**: `bool`.
|
||||||
- **Описание**: Включает политику простоя клиента для промежуточного узла.
|
- **Описание**: Включает политику простоя клиента для промежуточного узла.
|
||||||
@@ -2317,6 +2366,7 @@
|
|||||||
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
|
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
|
||||||
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
|
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
|
||||||
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
|
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
|
||||||
|
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` |
|
||||||
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
|
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
|
||||||
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
|
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
|
||||||
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
|
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
|
||||||
@@ -2493,6 +2543,15 @@
|
|||||||
[censorship]
|
[censorship]
|
||||||
tls_full_cert_ttl_secs = 90
|
tls_full_cert_ttl_secs = 90
|
||||||
```
|
```
|
||||||
|
## serverhello_compact
|
||||||
|
- **Ограничения / валидация**: `bool`.
|
||||||
|
- **Описание**: Включает компактный профиль ServerHello/Fake-TLS для снижения сигнатуры размера ответа.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[censorship]
|
||||||
|
serverhello_compact = false
|
||||||
|
```
|
||||||
## alpn_enforce
|
## alpn_enforce
|
||||||
- **Ограничения / валидация**: `bool`.
|
- **Ограничения / валидация**: `bool`.
|
||||||
- **Описание**: Принудительно изменяет поведение возврата ALPN в соответствии с предпочтениями клиента.
|
- **Описание**: Принудительно изменяет поведение возврата ALPN в соответствии с предпочтениями клиента.
|
||||||
@@ -2837,6 +2896,8 @@
|
|||||||
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` |
|
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` |
|
||||||
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` |
|
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` |
|
||||||
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` |
|
| [`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>` | `{}` |
|
||||||
|
|
||||||
## users
|
## users
|
||||||
- **Ограничения / валидация**: Не должно быть пустым (должен существовать хотя бы один пользователь). Каждое значение должно состоять **ровно из 32 шестнадцатеричных символов**.
|
- **Ограничения / валидация**: Не должно быть пустым (должен существовать хотя бы один пользователь). Каждое значение должно состоять **ровно из 32 шестнадцатеричных символов**.
|
||||||
@@ -2965,6 +3026,24 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## user_rate_limits
|
||||||
|
- **Ограничения / валидация**: Таблица `username -> { up_bps, down_bps }`. Должно быть ненулевое значение хотя бы в одном направлении.
|
||||||
|
- **Описание**: Персональные лимиты скорости по пользователям в байтах/сек для отправки (`up_bps`) и получения (`down_bps`).
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[access.user_rate_limits]
|
||||||
|
alice = { up_bps = 1048576, down_bps = 2097152 }
|
||||||
|
```
|
||||||
|
## cidr_rate_limits
|
||||||
|
- **Ограничения / валидация**: Таблица `CIDR -> { up_bps, down_bps }`. CIDR должен корректно разбираться как `IpNetwork`; хотя бы одно направление должно быть ненулевым.
|
||||||
|
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений.
|
||||||
|
- **Example**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[access.cidr_rate_limits]
|
||||||
|
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
|
||||||
|
```
|
||||||
# [[upstreams]]
|
# [[upstreams]]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ tls_front_dir = "tlsfront" # Директория кэша для эмуляц
|
|||||||
hello = "00000000000000000000000000000000"
|
hello = "00000000000000000000000000000000"
|
||||||
```
|
```
|
||||||
|
|
||||||
Затем нажмите Ctrl+S -> Ctrl+X, чтобы сохранить
|
Затем нажмите Ctrl+O -> Ctrl+X, чтобы сохранить
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Замените значение параметра `hello` на значение, которое вы получили в пункте 0.
|
> Замените значение параметра `hello` на значение, которое вы получили в пункте 0.
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ set_language() {
|
|||||||
L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА"
|
L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА"
|
||||||
L_OUT_UNINST_H="УДАЛЕНИЕ ЗАВЕРШЕНО"
|
L_OUT_UNINST_H="УДАЛЕНИЕ ЗАВЕРШЕНО"
|
||||||
L_OUT_LINK="Ваша ссылка для подключения к Telegram Proxy:\n"
|
L_OUT_LINK="Ваша ссылка для подключения к Telegram Proxy:\n"
|
||||||
|
L_ERR_INCORR_ROOT_LOGIN="Используйте 'su -' или 'sudo -i' для входа под пользователем root"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
L_ERR_DOMAIN_REQ="requires a domain argument."
|
L_ERR_DOMAIN_REQ="requires a domain argument."
|
||||||
@@ -176,6 +177,7 @@ set_language() {
|
|||||||
L_OUT_SUCC_H="INSTALLATION SUCCESS"
|
L_OUT_SUCC_H="INSTALLATION SUCCESS"
|
||||||
L_OUT_UNINST_H="UNINSTALLATION COMPLETE"
|
L_OUT_UNINST_H="UNINSTALLATION COMPLETE"
|
||||||
L_OUT_LINK="Your Telegram Proxy connection link:\n"
|
L_OUT_LINK="Your Telegram Proxy connection link:\n"
|
||||||
|
L_ERR_INCORR_ROOT_LOGIN="Use 'su -' or 'sudo -i' to login under root"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -388,6 +390,9 @@ verify_common() {
|
|||||||
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
die "$L_ERR_INCORR_ROOT_LOGIN"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
command -v sudo >/dev/null 2>&1 || die "$L_ERR_ROOT"
|
command -v sudo >/dev/null 2>&1 || die "$L_ERR_ROOT"
|
||||||
SUDO="sudo"
|
SUDO="sudo"
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyCon
|
|||||||
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
|
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(super) async fn save_config_to_disk(
|
pub(super) async fn save_config_to_disk(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
cfg: &ProxyConfig,
|
cfg: &ProxyConfig,
|
||||||
@@ -106,6 +107,12 @@ pub(super) async fn save_access_sections_to_disk(
|
|||||||
if applied.contains(section) {
|
if applied.contains(section) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if find_toml_table_bounds(&content, section.table_name()).is_none()
|
||||||
|
&& access_section_is_empty(cfg, *section)
|
||||||
|
{
|
||||||
|
applied.push(*section);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let rendered = render_access_section(cfg, *section)?;
|
let rendered = render_access_section(cfg, *section)?;
|
||||||
content = upsert_toml_table(&content, section.table_name(), &rendered);
|
content = upsert_toml_table(&content, section.table_name(), &rendered);
|
||||||
applied.push(*section);
|
applied.push(*section);
|
||||||
@@ -183,6 +190,17 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
||||||
|
match section {
|
||||||
|
AccessSection::Users => cfg.access.users.is_empty(),
|
||||||
|
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
||||||
|
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
||||||
|
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
||||||
|
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
||||||
|
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
||||||
toml::to_string(value)
|
toml::to_string(value)
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
||||||
|
|||||||
+54
-1
@@ -43,7 +43,7 @@ use events::ApiEventStore;
|
|||||||
use http_utils::{error_response, read_json, read_optional_json, success_response};
|
use http_utils::{error_response, read_json, read_optional_json, success_response};
|
||||||
use model::{
|
use model::{
|
||||||
ApiFailure, ClassCount, CreateUserRequest, DeleteUserResponse, HealthData, HealthReadyData,
|
ApiFailure, ClassCount, CreateUserRequest, DeleteUserResponse, HealthData, HealthReadyData,
|
||||||
PatchUserRequest, RotateSecretRequest, SummaryData, UserActiveIps,
|
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
|
||||||
};
|
};
|
||||||
use runtime_edge::{
|
use runtime_edge::{
|
||||||
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
|
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
|
||||||
@@ -80,6 +80,7 @@ pub(super) struct ApiShared {
|
|||||||
pub(super) me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
pub(super) me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||||
pub(super) upstream_manager: Arc<UpstreamManager>,
|
pub(super) upstream_manager: Arc<UpstreamManager>,
|
||||||
pub(super) config_path: PathBuf,
|
pub(super) config_path: PathBuf,
|
||||||
|
pub(super) quota_state_path: PathBuf,
|
||||||
pub(super) detected_ips_rx: watch::Receiver<(Option<IpAddr>, Option<IpAddr>)>,
|
pub(super) detected_ips_rx: watch::Receiver<(Option<IpAddr>, Option<IpAddr>)>,
|
||||||
pub(super) mutation_lock: Arc<Mutex<()>>,
|
pub(super) mutation_lock: Arc<Mutex<()>>,
|
||||||
pub(super) minimal_cache: Arc<Mutex<Option<MinimalCacheEntry>>>,
|
pub(super) minimal_cache: Arc<Mutex<Option<MinimalCacheEntry>>>,
|
||||||
@@ -112,6 +113,7 @@ pub async fn serve(
|
|||||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||||
admission_rx: watch::Receiver<bool>,
|
admission_rx: watch::Receiver<bool>,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
|
quota_state_path: PathBuf,
|
||||||
detected_ips_rx: watch::Receiver<(Option<IpAddr>, Option<IpAddr>)>,
|
detected_ips_rx: watch::Receiver<(Option<IpAddr>, Option<IpAddr>)>,
|
||||||
process_started_at_epoch_secs: u64,
|
process_started_at_epoch_secs: u64,
|
||||||
startup_tracker: Arc<StartupTracker>,
|
startup_tracker: Arc<StartupTracker>,
|
||||||
@@ -143,6 +145,7 @@ pub async fn serve(
|
|||||||
me_pool,
|
me_pool,
|
||||||
upstream_manager,
|
upstream_manager,
|
||||||
config_path,
|
config_path,
|
||||||
|
quota_state_path,
|
||||||
detected_ips_rx,
|
detected_ips_rx,
|
||||||
mutation_lock: Arc::new(Mutex::new(())),
|
mutation_lock: Arc::new(Mutex::new(())),
|
||||||
minimal_cache: Arc::new(Mutex::new(None)),
|
minimal_cache: Arc::new(Mutex::new(None)),
|
||||||
@@ -491,6 +494,56 @@ async fn handle(
|
|||||||
Ok(success_response(status, data, revision))
|
Ok(success_response(status, data, revision))
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
if method == Method::POST
|
||||||
|
&& let Some(user) = normalized_path
|
||||||
|
.strip_prefix("/v1/users/")
|
||||||
|
.and_then(|path| path.strip_suffix("/reset-quota"))
|
||||||
|
&& !user.is_empty()
|
||||||
|
&& !user.contains('/')
|
||||||
|
{
|
||||||
|
if api_cfg.read_only {
|
||||||
|
return Ok(error_response(
|
||||||
|
request_id,
|
||||||
|
ApiFailure::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"read_only",
|
||||||
|
"API runs in read-only mode",
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let snapshot = match crate::quota_state::reset_user_quota(
|
||||||
|
&shared.quota_state_path,
|
||||||
|
shared.stats.as_ref(),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(snapshot) => snapshot,
|
||||||
|
Err(error) => {
|
||||||
|
shared.runtime_events.record(
|
||||||
|
"api.user.reset_quota.failed",
|
||||||
|
format!("username={} error={}", user, error),
|
||||||
|
);
|
||||||
|
return Err(ApiFailure::internal(format!(
|
||||||
|
"Failed to reset user quota: {}",
|
||||||
|
error
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shared
|
||||||
|
.runtime_events
|
||||||
|
.record("api.user.reset_quota.ok", format!("username={}", user));
|
||||||
|
let revision = current_revision(&shared.config_path).await?;
|
||||||
|
return Ok(success_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
ResetUserQuotaResponse {
|
||||||
|
username: user.to_string(),
|
||||||
|
used_bytes: snapshot.used_bytes,
|
||||||
|
last_reset_epoch_secs: snapshot.last_reset_epoch_secs,
|
||||||
|
},
|
||||||
|
revision,
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(user) = normalized_path.strip_prefix("/v1/users/")
|
if let Some(user) = normalized_path.strip_prefix("/v1/users/")
|
||||||
&& !user.is_empty()
|
&& !user.is_empty()
|
||||||
&& !user.contains('/')
|
&& !user.contains('/')
|
||||||
|
|||||||
@@ -456,6 +456,13 @@ pub(super) struct UserLinks {
|
|||||||
pub(super) classic: Vec<String>,
|
pub(super) classic: Vec<String>,
|
||||||
pub(super) secure: Vec<String>,
|
pub(super) secure: Vec<String>,
|
||||||
pub(super) tls: Vec<String>,
|
pub(super) tls: Vec<String>,
|
||||||
|
pub(super) tls_domains: Vec<TlsDomainLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct TlsDomainLink {
|
||||||
|
pub(super) domain: String,
|
||||||
|
pub(super) link: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -494,6 +501,13 @@ pub(super) struct DeleteUserResponse {
|
|||||||
pub(super) in_runtime: bool,
|
pub(super) in_runtime: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct ResetUserQuotaResponse {
|
||||||
|
pub(super) username: String,
|
||||||
|
pub(super) used_bytes: u64,
|
||||||
|
pub(super) last_reset_epoch_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct CreateUserRequest {
|
pub(super) struct CreateUserRequest {
|
||||||
pub(super) username: String,
|
pub(super) username: String,
|
||||||
|
|||||||
+139
-4
@@ -8,12 +8,12 @@ use crate::stats::Stats;
|
|||||||
|
|
||||||
use super::ApiShared;
|
use super::ApiShared;
|
||||||
use super::config_store::{
|
use super::config_store::{
|
||||||
AccessSection, ensure_expected_revision, load_config_from_disk, save_access_sections_to_disk,
|
AccessSection, current_revision, ensure_expected_revision, load_config_from_disk,
|
||||||
save_config_to_disk,
|
save_access_sections_to_disk,
|
||||||
};
|
};
|
||||||
use super::model::{
|
use super::model::{
|
||||||
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
||||||
UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username,
|
TlsDomainLink, UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username,
|
||||||
parse_optional_expiration, parse_patch_expiration, random_user_secret,
|
parse_optional_expiration, parse_patch_expiration, random_user_secret,
|
||||||
};
|
};
|
||||||
use super::patch::Patch;
|
use super::patch::Patch;
|
||||||
@@ -176,6 +176,13 @@ pub(super) async fn patch_user(
|
|||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<(UserInfo, String), ApiFailure> {
|
) -> Result<(UserInfo, String), ApiFailure> {
|
||||||
|
let touches_users = body.secret.is_some();
|
||||||
|
let touches_user_ad_tags = !matches!(&body.user_ad_tag, Patch::Unchanged);
|
||||||
|
let touches_user_max_tcp_conns = !matches!(&body.max_tcp_conns, Patch::Unchanged);
|
||||||
|
let touches_user_expirations = !matches!(&body.expiration_rfc3339, Patch::Unchanged);
|
||||||
|
let touches_user_data_quota = !matches!(&body.data_quota_bytes, Patch::Unchanged);
|
||||||
|
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
|
||||||
|
|
||||||
if let Some(secret) = body.secret.as_ref()
|
if let Some(secret) = body.secret.as_ref()
|
||||||
&& !is_valid_user_secret(secret)
|
&& !is_valid_user_secret(secret)
|
||||||
{
|
{
|
||||||
@@ -265,7 +272,31 @@ pub(super) async fn patch_user(
|
|||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
|
||||||
let revision = save_config_to_disk(&shared.config_path, &cfg).await?;
|
let mut touched_sections = Vec::new();
|
||||||
|
if touches_users {
|
||||||
|
touched_sections.push(AccessSection::Users);
|
||||||
|
}
|
||||||
|
if touches_user_ad_tags {
|
||||||
|
touched_sections.push(AccessSection::UserAdTags);
|
||||||
|
}
|
||||||
|
if touches_user_max_tcp_conns {
|
||||||
|
touched_sections.push(AccessSection::UserMaxTcpConns);
|
||||||
|
}
|
||||||
|
if touches_user_expirations {
|
||||||
|
touched_sections.push(AccessSection::UserExpirations);
|
||||||
|
}
|
||||||
|
if touches_user_data_quota {
|
||||||
|
touched_sections.push(AccessSection::UserDataQuota);
|
||||||
|
}
|
||||||
|
if touches_user_max_unique_ips {
|
||||||
|
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||||
|
}
|
||||||
|
|
||||||
|
let revision = if touched_sections.is_empty() {
|
||||||
|
current_revision(&shared.config_path).await?
|
||||||
|
} else {
|
||||||
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?
|
||||||
|
};
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
match max_unique_ips_change {
|
match max_unique_ips_change {
|
||||||
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
|
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
|
||||||
@@ -438,6 +469,7 @@ pub(super) async fn users_from_config(
|
|||||||
classic: Vec::new(),
|
classic: Vec::new(),
|
||||||
secure: Vec::new(),
|
secure: Vec::new(),
|
||||||
tls: Vec::new(),
|
tls: Vec::new(),
|
||||||
|
tls_domains: Vec::new(),
|
||||||
});
|
});
|
||||||
users.push(UserInfo {
|
users.push(UserInfo {
|
||||||
in_runtime: runtime_cfg
|
in_runtime: runtime_cfg
|
||||||
@@ -492,10 +524,12 @@ fn build_user_links(
|
|||||||
.public_port
|
.public_port
|
||||||
.unwrap_or(resolve_default_link_port(cfg));
|
.unwrap_or(resolve_default_link_port(cfg));
|
||||||
let tls_domains = resolve_tls_domains(cfg);
|
let tls_domains = resolve_tls_domains(cfg);
|
||||||
|
let extra_tls_domains = resolve_extra_tls_domains(cfg);
|
||||||
|
|
||||||
let mut classic = Vec::new();
|
let mut classic = Vec::new();
|
||||||
let mut secure = Vec::new();
|
let mut secure = Vec::new();
|
||||||
let mut tls = Vec::new();
|
let mut tls = Vec::new();
|
||||||
|
let mut tls_domain_links = Vec::new();
|
||||||
|
|
||||||
for host in &hosts {
|
for host in &hosts {
|
||||||
if cfg.general.modes.classic {
|
if cfg.general.modes.classic {
|
||||||
@@ -518,6 +552,17 @@ fn build_user_links(
|
|||||||
host, port, secret, domain_hex
|
host, port, secret, domain_hex
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
for domain in &extra_tls_domains {
|
||||||
|
let domain_hex = hex::encode(domain);
|
||||||
|
let link = format!(
|
||||||
|
"tg://proxy?server={}&port={}&secret=ee{}{}",
|
||||||
|
host, port, secret, domain_hex
|
||||||
|
);
|
||||||
|
tls_domain_links.push(TlsDomainLink {
|
||||||
|
domain: (*domain).to_string(),
|
||||||
|
link,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +570,7 @@ fn build_user_links(
|
|||||||
classic,
|
classic,
|
||||||
secure,
|
secure,
|
||||||
tls,
|
tls,
|
||||||
|
tls_domains: tls_domain_links,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,6 +687,19 @@ fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
|
|||||||
domains
|
domains
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_extra_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
|
||||||
|
let mut domains = Vec::with_capacity(cfg.censorship.tls_domains.len());
|
||||||
|
let primary = cfg.censorship.tls_domain.as_str();
|
||||||
|
for domain in &cfg.censorship.tls_domains {
|
||||||
|
let value = domain.as_str();
|
||||||
|
if value.is_empty() || value == primary || domains.contains(&value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
domains.push(value);
|
||||||
|
}
|
||||||
|
domains
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -730,4 +789,80 @@ mod tests {
|
|||||||
assert!(alice.in_runtime);
|
assert!(alice.in_runtime);
|
||||||
assert!(!bob.in_runtime);
|
assert!(!bob.in_runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_from_config_returns_tls_link_for_each_tls_domain() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.general.modes.classic = false;
|
||||||
|
cfg.general.modes.secure = false;
|
||||||
|
cfg.general.modes.tls = true;
|
||||||
|
cfg.general.links.public_host = Some("proxy.example.net".to_string());
|
||||||
|
cfg.general.links.public_port = Some(443);
|
||||||
|
cfg.censorship.tls_domain = "front-a.example.com".to_string();
|
||||||
|
cfg.censorship.tls_domains = vec![
|
||||||
|
"front-b.example.com".to_string(),
|
||||||
|
"front-c.example.com".to_string(),
|
||||||
|
"front-b.example.com".to_string(),
|
||||||
|
"front-a.example.com".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
|
||||||
|
assert_eq!(alice.links.tls.len(), 3);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.ends_with(&hex::encode("front-a.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.ends_with(&hex::encode("front-b.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.ends_with(&hex::encode("front-c.example.com")))
|
||||||
|
);
|
||||||
|
assert_eq!(alice.links.tls_domains.len(), 2);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls_domains
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.domain == "front-b.example.com"
|
||||||
|
&& entry.link.ends_with(&hex::encode("front-b.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls_domains
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.domain == "front-c.example.com"
|
||||||
|
&& entry.link.ends_with(&hex::encode("front-c.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!alice
|
||||||
|
.links
|
||||||
|
.tls_domains
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.domain == "front-a.example.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ pub(crate) fn default_fake_cert_len() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn default_tls_front_dir() -> String {
|
pub(crate) fn default_tls_front_dir() -> String {
|
||||||
"/etc/telemt/tlsfront".to_string()
|
"tlsfront".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn default_replay_check_len() -> usize {
|
pub(crate) fn default_replay_check_len() -> usize {
|
||||||
@@ -568,7 +568,7 @@ pub(crate) fn default_beobachten_flush_secs() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn default_beobachten_file() -> String {
|
pub(crate) fn default_beobachten_file() -> String {
|
||||||
"/etc/telemt/beobachten.txt".to_string()
|
"beobachten.txt".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn default_tls_new_session_tickets() -> u8 {
|
pub(crate) fn default_tls_new_session_tickets() -> u8 {
|
||||||
|
|||||||
+684
-2
@@ -23,6 +23,667 @@ const MAX_ME_C2ME_CHANNEL_CAPACITY: usize = 8_192;
|
|||||||
const MIN_MAX_CLIENT_FRAME_BYTES: usize = 4 * 1024;
|
const MIN_MAX_CLIENT_FRAME_BYTES: usize = 4 * 1024;
|
||||||
const MAX_MAX_CLIENT_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
const MAX_MAX_CLIENT_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"general",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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_host",
|
||||||
|
"mask_port",
|
||||||
|
"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_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"];
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
] {
|
||||||
|
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,
|
||||||
|
&["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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct LoadedConfig {
|
pub(crate) struct LoadedConfig {
|
||||||
pub(crate) config: ProxyConfig,
|
pub(crate) config: ProxyConfig,
|
||||||
@@ -337,6 +998,7 @@ impl ProxyConfig {
|
|||||||
|
|
||||||
let parsed_toml: toml::Value =
|
let parsed_toml: toml::Value =
|
||||||
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
|
handle_unknown_config_keys(&parsed_toml)?;
|
||||||
let general_table = parsed_toml
|
let general_table = parsed_toml
|
||||||
.get("general")
|
.get("general")
|
||||||
.and_then(|value| value.as_table());
|
.and_then(|value| value.as_table());
|
||||||
@@ -1087,9 +1749,9 @@ impl ProxyConfig {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.general.me_route_blocking_send_timeout_ms > 5000 {
|
if !(1..=5000).contains(&config.general.me_route_blocking_send_timeout_ms) {
|
||||||
return Err(ProxyError::Config(
|
return Err(ProxyError::Config(
|
||||||
"general.me_route_blocking_send_timeout_ms must be within [0, 5000]".to_string(),
|
"general.me_route_blocking_send_timeout_ms must be within [1, 5000]".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2602,6 +3264,26 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_route_blocking_send_timeout_ms_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_route_blocking_send_timeout_ms = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn me_route_no_writer_mode_is_parsed() {
|
fn me_route_no_writer_mode_is_parsed() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
|
|||||||
+32
-1
@@ -26,6 +26,10 @@ pub enum LogLevel {
|
|||||||
Silent,
|
Silent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_quota_state_path() -> PathBuf {
|
||||||
|
PathBuf::from("telemt.limit.json")
|
||||||
|
}
|
||||||
|
|
||||||
impl LogLevel {
|
impl LogLevel {
|
||||||
/// Convert to tracing EnvFilter directive string.
|
/// Convert to tracing EnvFilter directive string.
|
||||||
pub fn to_filter_str(&self) -> &'static str {
|
pub fn to_filter_str(&self) -> &'static str {
|
||||||
@@ -375,6 +379,15 @@ pub struct GeneralConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub data_path: Option<PathBuf>,
|
pub data_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// JSON state file for runtime per-user quota consumption.
|
||||||
|
#[serde(default = "default_quota_state_path")]
|
||||||
|
pub quota_state_path: PathBuf,
|
||||||
|
|
||||||
|
/// Reject unknown TOML config keys during load.
|
||||||
|
/// Startup fails fast; hot-reload rejects the new snapshot and keeps the current config.
|
||||||
|
#[serde(default)]
|
||||||
|
pub config_strict: bool,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modes: ProxyModes,
|
pub modes: ProxyModes,
|
||||||
|
|
||||||
@@ -778,7 +791,7 @@ pub struct GeneralConfig {
|
|||||||
pub me_route_hybrid_max_wait_ms: u64,
|
pub me_route_hybrid_max_wait_ms: u64,
|
||||||
|
|
||||||
/// Maximum wait in milliseconds for blocking ME writer channel send fallback.
|
/// Maximum wait in milliseconds for blocking ME writer channel send fallback.
|
||||||
/// `0` keeps legacy unbounded wait behavior.
|
/// Must be within [1, 5000].
|
||||||
#[serde(default = "default_me_route_blocking_send_timeout_ms")]
|
#[serde(default = "default_me_route_blocking_send_timeout_ms")]
|
||||||
pub me_route_blocking_send_timeout_ms: u64,
|
pub me_route_blocking_send_timeout_ms: u64,
|
||||||
|
|
||||||
@@ -974,6 +987,8 @@ impl Default for GeneralConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
data_path: None,
|
data_path: None,
|
||||||
|
quota_state_path: default_quota_state_path(),
|
||||||
|
config_strict: false,
|
||||||
modes: ProxyModes::default(),
|
modes: ProxyModes::default(),
|
||||||
prefer_ipv6: false,
|
prefer_ipv6: false,
|
||||||
fast_mode: default_true(),
|
fast_mode: default_true(),
|
||||||
@@ -1887,6 +1902,12 @@ pub struct AccessConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cidr_rate_limits: HashMap<IpNetwork, RateLimitBps>,
|
pub cidr_rate_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||||
|
|
||||||
|
/// Per-username client source IP/CIDR deny list. Checked after successful
|
||||||
|
/// authentication; matching IPs get the same rejection path as invalid auth
|
||||||
|
/// (handshake fails closed for that connection).
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_source_deny: HashMap<String, Vec<IpNetwork>>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub user_max_unique_ips: HashMap<String, usize>,
|
pub user_max_unique_ips: HashMap<String, usize>,
|
||||||
|
|
||||||
@@ -1922,6 +1943,7 @@ impl Default for AccessConfig {
|
|||||||
user_data_quota: HashMap::new(),
|
user_data_quota: HashMap::new(),
|
||||||
user_rate_limits: HashMap::new(),
|
user_rate_limits: HashMap::new(),
|
||||||
cidr_rate_limits: HashMap::new(),
|
cidr_rate_limits: HashMap::new(),
|
||||||
|
user_source_deny: HashMap::new(),
|
||||||
user_max_unique_ips: HashMap::new(),
|
user_max_unique_ips: HashMap::new(),
|
||||||
user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(),
|
user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(),
|
||||||
user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(),
|
user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(),
|
||||||
@@ -1933,6 +1955,15 @@ impl Default for AccessConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AccessConfig {
|
||||||
|
/// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`.
|
||||||
|
pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool {
|
||||||
|
self.user_source_deny
|
||||||
|
.get(username)
|
||||||
|
.is_some_and(|nets| nets.iter().any(|n| n.contains(ip)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RateLimitBps {
|
pub struct RateLimitBps {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+234
-41
@@ -9,14 +9,24 @@ use std::sync::Mutex;
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tokio::sync::{Mutex as AsyncMutex, RwLock};
|
use tokio::sync::{Mutex as AsyncMutex, RwLock, RwLockWriteGuard};
|
||||||
|
|
||||||
use crate::config::UserMaxUniqueIpsMode;
|
use crate::config::UserMaxUniqueIpsMode;
|
||||||
|
|
||||||
|
const CLEANUP_DRAIN_BATCH_LIMIT: usize = 1024;
|
||||||
|
const MAX_ACTIVE_IP_ENTRIES: u64 = 131_072;
|
||||||
|
const MAX_RECENT_IP_ENTRIES: u64 = 262_144;
|
||||||
|
|
||||||
|
/// Tracks active and recent client IPs for per-user admission control.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserIpTracker {
|
pub struct UserIpTracker {
|
||||||
active_ips: Arc<RwLock<HashMap<String, HashMap<IpAddr, usize>>>>,
|
active_ips: Arc<RwLock<HashMap<String, HashMap<IpAddr, usize>>>>,
|
||||||
recent_ips: Arc<RwLock<HashMap<String, HashMap<IpAddr, Instant>>>>,
|
recent_ips: Arc<RwLock<HashMap<String, HashMap<IpAddr, Instant>>>>,
|
||||||
|
active_entry_count: Arc<AtomicU64>,
|
||||||
|
recent_entry_count: Arc<AtomicU64>,
|
||||||
|
active_cap_rejects: Arc<AtomicU64>,
|
||||||
|
recent_cap_rejects: Arc<AtomicU64>,
|
||||||
|
cleanup_deferred_releases: Arc<AtomicU64>,
|
||||||
max_ips: Arc<RwLock<HashMap<String, usize>>>,
|
max_ips: Arc<RwLock<HashMap<String, usize>>>,
|
||||||
default_max_ips: Arc<RwLock<usize>>,
|
default_max_ips: Arc<RwLock<usize>>,
|
||||||
limit_mode: Arc<RwLock<UserMaxUniqueIpsMode>>,
|
limit_mode: Arc<RwLock<UserMaxUniqueIpsMode>>,
|
||||||
@@ -26,13 +36,25 @@ pub struct UserIpTracker {
|
|||||||
cleanup_drain_lock: Arc<AsyncMutex<()>>,
|
cleanup_drain_lock: Arc<AsyncMutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point-in-time memory counters for user/IP limiter state.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct UserIpTrackerMemoryStats {
|
pub struct UserIpTrackerMemoryStats {
|
||||||
|
/// Number of users with active IP state.
|
||||||
pub active_users: usize,
|
pub active_users: usize,
|
||||||
|
/// Number of users with recent IP state.
|
||||||
pub recent_users: usize,
|
pub recent_users: usize,
|
||||||
|
/// Number of active `(user, ip)` entries.
|
||||||
pub active_entries: usize,
|
pub active_entries: usize,
|
||||||
|
/// Number of recent-window `(user, ip)` entries.
|
||||||
pub recent_entries: usize,
|
pub recent_entries: usize,
|
||||||
|
/// Number of deferred disconnect cleanups waiting to be drained.
|
||||||
pub cleanup_queue_len: usize,
|
pub cleanup_queue_len: usize,
|
||||||
|
/// Number of new connections rejected by the global active-entry cap.
|
||||||
|
pub active_cap_rejects: u64,
|
||||||
|
/// Number of new connections rejected by the global recent-entry cap.
|
||||||
|
pub recent_cap_rejects: u64,
|
||||||
|
/// Number of release cleanups deferred through the cleanup queue.
|
||||||
|
pub cleanup_deferred_releases: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserIpTracker {
|
impl UserIpTracker {
|
||||||
@@ -40,6 +62,11 @@ impl UserIpTracker {
|
|||||||
Self {
|
Self {
|
||||||
active_ips: Arc::new(RwLock::new(HashMap::new())),
|
active_ips: Arc::new(RwLock::new(HashMap::new())),
|
||||||
recent_ips: Arc::new(RwLock::new(HashMap::new())),
|
recent_ips: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
active_entry_count: Arc::new(AtomicU64::new(0)),
|
||||||
|
recent_entry_count: Arc::new(AtomicU64::new(0)),
|
||||||
|
active_cap_rejects: Arc::new(AtomicU64::new(0)),
|
||||||
|
recent_cap_rejects: Arc::new(AtomicU64::new(0)),
|
||||||
|
cleanup_deferred_releases: Arc::new(AtomicU64::new(0)),
|
||||||
max_ips: Arc::new(RwLock::new(HashMap::new())),
|
max_ips: Arc::new(RwLock::new(HashMap::new())),
|
||||||
default_max_ips: Arc::new(RwLock::new(0)),
|
default_max_ips: Arc::new(RwLock::new(0)),
|
||||||
limit_mode: Arc::new(RwLock::new(UserMaxUniqueIpsMode::ActiveWindow)),
|
limit_mode: Arc::new(RwLock::new(UserMaxUniqueIpsMode::ActiveWindow)),
|
||||||
@@ -50,16 +77,59 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decrement_counter(counter: &AtomicU64, amount: usize) {
|
||||||
|
if amount == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let amount = amount as u64;
|
||||||
|
let _ = counter.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |current| {
|
||||||
|
Some(current.saturating_sub(amount))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_active_cleanup(
|
||||||
|
active_ips: &mut HashMap<String, HashMap<IpAddr, usize>>,
|
||||||
|
user: &str,
|
||||||
|
ip: IpAddr,
|
||||||
|
pending_count: usize,
|
||||||
|
) -> usize {
|
||||||
|
if pending_count == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut remove_user = false;
|
||||||
|
let mut removed_active_entries = 0usize;
|
||||||
|
if let Some(user_ips) = active_ips.get_mut(user) {
|
||||||
|
if let Some(count) = user_ips.get_mut(&ip) {
|
||||||
|
if *count > pending_count {
|
||||||
|
*count -= pending_count;
|
||||||
|
} else if user_ips.remove(&ip).is_some() {
|
||||||
|
removed_active_entries = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove_user = user_ips.is_empty();
|
||||||
|
}
|
||||||
|
if remove_user {
|
||||||
|
active_ips.remove(user);
|
||||||
|
}
|
||||||
|
removed_active_entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a deferred active IP cleanup for a later async drain.
|
||||||
pub fn enqueue_cleanup(&self, user: String, ip: IpAddr) {
|
pub fn enqueue_cleanup(&self, user: String, ip: IpAddr) {
|
||||||
match self.cleanup_queue.lock() {
|
match self.cleanup_queue.lock() {
|
||||||
Ok(mut queue) => {
|
Ok(mut queue) => {
|
||||||
let count = queue.entry((user, ip)).or_insert(0);
|
let count = queue.entry((user, ip)).or_insert(0);
|
||||||
*count = count.saturating_add(1);
|
*count = count.saturating_add(1);
|
||||||
|
self.cleanup_deferred_releases
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
let mut queue = poisoned.into_inner();
|
let mut queue = poisoned.into_inner();
|
||||||
let count = queue.entry((user.clone(), ip)).or_insert(0);
|
let count = queue.entry((user.clone(), ip)).or_insert(0);
|
||||||
*count = count.saturating_add(1);
|
*count = count.saturating_add(1);
|
||||||
|
self.cleanup_deferred_releases
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
self.cleanup_queue.clear_poison();
|
self.cleanup_queue.clear_poison();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"UserIpTracker cleanup_queue lock poisoned; recovered and enqueued IP cleanup for {} ({})",
|
"UserIpTracker cleanup_queue lock poisoned; recovered and enqueued IP cleanup for {} ({})",
|
||||||
@@ -86,16 +156,27 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn drain_cleanup_queue(&self) {
|
pub(crate) async fn drain_cleanup_queue(&self) {
|
||||||
// Serialize queue draining and active-IP mutation so check-and-add cannot
|
let Ok(_drain_guard) = self.cleanup_drain_lock.try_lock() else {
|
||||||
// observe stale active entries that are already queued for removal.
|
return;
|
||||||
let _drain_guard = self.cleanup_drain_lock.lock().await;
|
};
|
||||||
|
|
||||||
let to_remove = {
|
let to_remove = {
|
||||||
match self.cleanup_queue.lock() {
|
match self.cleanup_queue.lock() {
|
||||||
Ok(mut queue) => {
|
Ok(mut queue) => {
|
||||||
if queue.is_empty() {
|
if queue.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::mem::take(&mut *queue)
|
let mut drained =
|
||||||
|
HashMap::with_capacity(queue.len().min(CLEANUP_DRAIN_BATCH_LIMIT));
|
||||||
|
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
||||||
|
let Some(key) = queue.keys().next().cloned() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some(count) = queue.remove(&key) {
|
||||||
|
drained.insert(key, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drained
|
||||||
}
|
}
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
let mut queue = poisoned.into_inner();
|
let mut queue = poisoned.into_inner();
|
||||||
@@ -103,31 +184,33 @@ impl UserIpTracker {
|
|||||||
self.cleanup_queue.clear_poison();
|
self.cleanup_queue.clear_poison();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let drained = std::mem::take(&mut *queue);
|
let mut drained =
|
||||||
|
HashMap::with_capacity(queue.len().min(CLEANUP_DRAIN_BATCH_LIMIT));
|
||||||
|
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
||||||
|
let Some(key) = queue.keys().next().cloned() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some(count) = queue.remove(&key) {
|
||||||
|
drained.insert(key, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.cleanup_queue.clear_poison();
|
self.cleanup_queue.clear_poison();
|
||||||
drained
|
drained
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if to_remove.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mut active_ips = self.active_ips.write().await;
|
let mut active_ips = self.active_ips.write().await;
|
||||||
|
let mut removed_active_entries = 0usize;
|
||||||
for ((user, ip), pending_count) in to_remove {
|
for ((user, ip), pending_count) in to_remove {
|
||||||
if pending_count == 0 {
|
removed_active_entries = removed_active_entries.saturating_add(
|
||||||
continue;
|
Self::apply_active_cleanup(&mut active_ips, &user, ip, pending_count),
|
||||||
}
|
);
|
||||||
if let Some(user_ips) = active_ips.get_mut(&user) {
|
|
||||||
if let Some(count) = user_ips.get_mut(&ip) {
|
|
||||||
if *count > pending_count {
|
|
||||||
*count -= pending_count;
|
|
||||||
} else {
|
|
||||||
user_ips.remove(&ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if user_ips.is_empty() {
|
|
||||||
active_ips.remove(&user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_epoch_secs() -> u64 {
|
fn now_epoch_secs() -> u64 {
|
||||||
@@ -137,6 +220,24 @@ impl UserIpTracker {
|
|||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn active_and_recent_write(
|
||||||
|
&self,
|
||||||
|
) -> (
|
||||||
|
RwLockWriteGuard<'_, HashMap<String, HashMap<IpAddr, usize>>>,
|
||||||
|
RwLockWriteGuard<'_, HashMap<String, HashMap<IpAddr, Instant>>>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
let active_ips = self.active_ips.write().await;
|
||||||
|
match self.recent_ips.try_write() {
|
||||||
|
Ok(recent_ips) => return (active_ips, recent_ips),
|
||||||
|
Err(_) => {
|
||||||
|
drop(active_ips);
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn maybe_compact_empty_users(&self) {
|
async fn maybe_compact_empty_users(&self) {
|
||||||
const COMPACT_INTERVAL_SECS: u64 = 60;
|
const COMPACT_INTERVAL_SECS: u64 = 60;
|
||||||
let now_epoch_secs = Self::now_epoch_secs();
|
let now_epoch_secs = Self::now_epoch_secs();
|
||||||
@@ -157,14 +258,16 @@ impl UserIpTracker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut active_ips = self.active_ips.write().await;
|
|
||||||
let mut recent_ips = self.recent_ips.write().await;
|
|
||||||
let window = *self.limit_window.read().await;
|
let window = *self.limit_window.read().await;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
let (mut active_ips, mut recent_ips) = self.active_and_recent_write().await;
|
||||||
|
|
||||||
|
let mut pruned_recent_entries = 0usize;
|
||||||
for user_recent in recent_ips.values_mut() {
|
for user_recent in recent_ips.values_mut() {
|
||||||
Self::prune_recent(user_recent, now, window);
|
pruned_recent_entries =
|
||||||
|
pruned_recent_entries.saturating_add(Self::prune_recent(user_recent, now, window));
|
||||||
}
|
}
|
||||||
|
Self::decrement_counter(&self.recent_entry_count, pruned_recent_entries);
|
||||||
|
|
||||||
let mut users =
|
let mut users =
|
||||||
Vec::<String>::with_capacity(active_ips.len().saturating_add(recent_ips.len()));
|
Vec::<String>::with_capacity(active_ips.len().saturating_add(recent_ips.len()));
|
||||||
@@ -208,6 +311,9 @@ impl UserIpTracker {
|
|||||||
active_entries,
|
active_entries,
|
||||||
recent_entries,
|
recent_entries,
|
||||||
cleanup_queue_len,
|
cleanup_queue_len,
|
||||||
|
active_cap_rejects: self.active_cap_rejects.load(Ordering::Relaxed),
|
||||||
|
recent_cap_rejects: self.recent_cap_rejects.load(Ordering::Relaxed),
|
||||||
|
cleanup_deferred_releases: self.cleanup_deferred_releases.load(Ordering::Relaxed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,11 +344,17 @@ impl UserIpTracker {
|
|||||||
max_ips.clone_from(limits);
|
max_ips.clone_from(limits);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prune_recent(user_recent: &mut HashMap<IpAddr, Instant>, now: Instant, window: Duration) {
|
fn prune_recent(
|
||||||
|
user_recent: &mut HashMap<IpAddr, Instant>,
|
||||||
|
now: Instant,
|
||||||
|
window: Duration,
|
||||||
|
) -> usize {
|
||||||
if user_recent.is_empty() {
|
if user_recent.is_empty() {
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
let before = user_recent.len();
|
||||||
user_recent.retain(|_, seen_at| now.duration_since(*seen_at) <= window);
|
user_recent.retain(|_, seen_at| now.duration_since(*seen_at) <= window);
|
||||||
|
before.saturating_sub(user_recent.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_and_add(&self, username: &str, ip: IpAddr) -> Result<(), String> {
|
pub async fn check_and_add(&self, username: &str, ip: IpAddr) -> Result<(), String> {
|
||||||
@@ -261,26 +373,40 @@ impl UserIpTracker {
|
|||||||
let window = *self.limit_window.read().await;
|
let window = *self.limit_window.read().await;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
let mut active_ips = self.active_ips.write().await;
|
let (mut active_ips, mut recent_ips) = self.active_and_recent_write().await;
|
||||||
let user_active = active_ips
|
let user_active = active_ips
|
||||||
.entry(username.to_string())
|
.entry(username.to_string())
|
||||||
.or_insert_with(HashMap::new);
|
.or_insert_with(HashMap::new);
|
||||||
|
|
||||||
let mut recent_ips = self.recent_ips.write().await;
|
|
||||||
let user_recent = recent_ips
|
let user_recent = recent_ips
|
||||||
.entry(username.to_string())
|
.entry(username.to_string())
|
||||||
.or_insert_with(HashMap::new);
|
.or_insert_with(HashMap::new);
|
||||||
Self::prune_recent(user_recent, now, window);
|
let pruned_recent_entries = Self::prune_recent(user_recent, now, window);
|
||||||
|
Self::decrement_counter(&self.recent_entry_count, pruned_recent_entries);
|
||||||
|
let recent_contains_ip = user_recent.contains_key(&ip);
|
||||||
|
|
||||||
if let Some(count) = user_active.get_mut(&ip) {
|
if let Some(count) = user_active.get_mut(&ip) {
|
||||||
|
if !recent_contains_ip
|
||||||
|
&& self.recent_entry_count.load(Ordering::Relaxed) >= MAX_RECENT_IP_ENTRIES
|
||||||
|
{
|
||||||
|
self.recent_cap_rejects.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(format!(
|
||||||
|
"IP tracker recent entry cap reached: entries={}/{}",
|
||||||
|
self.recent_entry_count.load(Ordering::Relaxed),
|
||||||
|
MAX_RECENT_IP_ENTRIES
|
||||||
|
));
|
||||||
|
}
|
||||||
*count = count.saturating_add(1);
|
*count = count.saturating_add(1);
|
||||||
user_recent.insert(ip, now);
|
if user_recent.insert(ip, now).is_none() {
|
||||||
|
self.recent_entry_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_new_ip = !recent_contains_ip;
|
||||||
|
|
||||||
if let Some(limit) = limit {
|
if let Some(limit) = limit {
|
||||||
let active_limit_reached = user_active.len() >= limit;
|
let active_limit_reached = user_active.len() >= limit;
|
||||||
let recent_limit_reached = user_recent.len() >= limit;
|
let recent_limit_reached = user_recent.len() >= limit && is_new_ip;
|
||||||
let deny = match mode {
|
let deny = match mode {
|
||||||
UserMaxUniqueIpsMode::ActiveWindow => active_limit_reached,
|
UserMaxUniqueIpsMode::ActiveWindow => active_limit_reached,
|
||||||
UserMaxUniqueIpsMode::TimeWindow => recent_limit_reached,
|
UserMaxUniqueIpsMode::TimeWindow => recent_limit_reached,
|
||||||
@@ -300,30 +426,62 @@ impl UserIpTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user_active.insert(ip, 1);
|
if self.active_entry_count.load(Ordering::Relaxed) >= MAX_ACTIVE_IP_ENTRIES {
|
||||||
user_recent.insert(ip, now);
|
self.active_cap_rejects.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(format!(
|
||||||
|
"IP tracker active entry cap reached: entries={}/{}",
|
||||||
|
self.active_entry_count.load(Ordering::Relaxed),
|
||||||
|
MAX_ACTIVE_IP_ENTRIES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if is_new_ip && self.recent_entry_count.load(Ordering::Relaxed) >= MAX_RECENT_IP_ENTRIES {
|
||||||
|
self.recent_cap_rejects.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(format!(
|
||||||
|
"IP tracker recent entry cap reached: entries={}/{}",
|
||||||
|
self.recent_entry_count.load(Ordering::Relaxed),
|
||||||
|
MAX_RECENT_IP_ENTRIES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if user_active.insert(ip, 1).is_none() {
|
||||||
|
self.active_entry_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if user_recent.insert(ip, now).is_none() {
|
||||||
|
self.recent_entry_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_ip(&self, username: &str, ip: IpAddr) {
|
pub async fn remove_ip(&self, username: &str, ip: IpAddr) {
|
||||||
self.maybe_compact_empty_users().await;
|
self.maybe_compact_empty_users().await;
|
||||||
let mut active_ips = self.active_ips.write().await;
|
let mut active_ips = self.active_ips.write().await;
|
||||||
|
let mut removed_active_entries = 0usize;
|
||||||
if let Some(user_ips) = active_ips.get_mut(username) {
|
if let Some(user_ips) = active_ips.get_mut(username) {
|
||||||
if let Some(count) = user_ips.get_mut(&ip) {
|
if let Some(count) = user_ips.get_mut(&ip) {
|
||||||
if *count > 1 {
|
if *count > 1 {
|
||||||
*count -= 1;
|
*count -= 1;
|
||||||
} else {
|
} else {
|
||||||
user_ips.remove(&ip);
|
if user_ips.remove(&ip).is_some() {
|
||||||
|
removed_active_entries = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if user_ips.is_empty() {
|
if user_ips.is_empty() {
|
||||||
active_ips.remove(username);
|
active_ips.remove(username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_recent_counts_for_users(&self, users: &[String]) -> HashMap<String, usize> {
|
pub async fn get_recent_counts_for_users(&self, users: &[String]) -> HashMap<String, usize> {
|
||||||
self.drain_cleanup_queue().await;
|
self.drain_cleanup_queue().await;
|
||||||
|
self.get_recent_counts_for_users_snapshot(users).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_recent_counts_for_users_snapshot(
|
||||||
|
&self,
|
||||||
|
users: &[String],
|
||||||
|
) -> HashMap<String, usize> {
|
||||||
let window = *self.limit_window.read().await;
|
let window = *self.limit_window.read().await;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let recent_ips = self.recent_ips.read().await;
|
let recent_ips = self.recent_ips.read().await;
|
||||||
@@ -398,19 +556,29 @@ impl UserIpTracker {
|
|||||||
|
|
||||||
pub async fn get_stats(&self) -> Vec<(String, usize, usize)> {
|
pub async fn get_stats(&self) -> Vec<(String, usize, usize)> {
|
||||||
self.drain_cleanup_queue().await;
|
self.drain_cleanup_queue().await;
|
||||||
|
self.get_stats_snapshot().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_stats_snapshot(&self) -> Vec<(String, usize, usize)> {
|
||||||
let active_ips = self.active_ips.read().await;
|
let active_ips = self.active_ips.read().await;
|
||||||
|
let active_counts = active_ips
|
||||||
|
.iter()
|
||||||
|
.map(|(username, user_ips)| (username.clone(), user_ips.len()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
drop(active_ips);
|
||||||
|
|
||||||
let max_ips = self.max_ips.read().await;
|
let max_ips = self.max_ips.read().await;
|
||||||
let default_max_ips = *self.default_max_ips.read().await;
|
let default_max_ips = *self.default_max_ips.read().await;
|
||||||
|
|
||||||
let mut stats = Vec::new();
|
let mut stats = Vec::with_capacity(active_counts.len());
|
||||||
for (username, user_ips) in active_ips.iter() {
|
for (username, active_count) in active_counts {
|
||||||
let limit = max_ips
|
let limit = max_ips
|
||||||
.get(username)
|
.get(&username)
|
||||||
.copied()
|
.copied()
|
||||||
.filter(|limit| *limit > 0)
|
.filter(|limit| *limit > 0)
|
||||||
.or((default_max_ips > 0).then_some(default_max_ips))
|
.or((default_max_ips > 0).then_some(default_max_ips))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stats.push((username.clone(), user_ips.len(), limit));
|
stats.push((username, active_count, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.sort_by(|a, b| a.0.cmp(&b.0));
|
stats.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
@@ -419,20 +587,30 @@ impl UserIpTracker {
|
|||||||
|
|
||||||
pub async fn clear_user_ips(&self, username: &str) {
|
pub async fn clear_user_ips(&self, username: &str) {
|
||||||
let mut active_ips = self.active_ips.write().await;
|
let mut active_ips = self.active_ips.write().await;
|
||||||
active_ips.remove(username);
|
let removed_active_entries = active_ips
|
||||||
|
.remove(username)
|
||||||
|
.map(|ips| ips.len())
|
||||||
|
.unwrap_or(0);
|
||||||
drop(active_ips);
|
drop(active_ips);
|
||||||
|
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||||
|
|
||||||
let mut recent_ips = self.recent_ips.write().await;
|
let mut recent_ips = self.recent_ips.write().await;
|
||||||
recent_ips.remove(username);
|
let removed_recent_entries = recent_ips
|
||||||
|
.remove(username)
|
||||||
|
.map(|ips| ips.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Self::decrement_counter(&self.recent_entry_count, removed_recent_entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_all(&self) {
|
pub async fn clear_all(&self) {
|
||||||
let mut active_ips = self.active_ips.write().await;
|
let mut active_ips = self.active_ips.write().await;
|
||||||
active_ips.clear();
|
active_ips.clear();
|
||||||
drop(active_ips);
|
drop(active_ips);
|
||||||
|
self.active_entry_count.store(0, Ordering::Relaxed);
|
||||||
|
|
||||||
let mut recent_ips = self.recent_ips.write().await;
|
let mut recent_ips = self.recent_ips.write().await;
|
||||||
recent_ips.clear();
|
recent_ips.clear();
|
||||||
|
self.recent_entry_count.store(0, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_ip_active(&self, username: &str, ip: IpAddr) -> bool {
|
pub async fn is_ip_active(&self, username: &str, ip: IpAddr) -> bool {
|
||||||
@@ -860,4 +1038,19 @@ mod tests {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
assert!(!stale_exists);
|
assert!(!stale_exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_time_window_allows_same_ip_reconnect() {
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
tracker.set_user_limit("test_user", 1).await;
|
||||||
|
tracker
|
||||||
|
.set_limit_policy(UserMaxUniqueIpsMode::TimeWindow, 1)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let ip1 = test_ipv4(10, 4, 0, 1);
|
||||||
|
|
||||||
|
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||||
|
tracker.remove_ip("test_user", ip1).await;
|
||||||
|
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) async fn configure_admission_gate(
|
|||||||
route_runtime: Arc<RouteRuntimeController>,
|
route_runtime: Arc<RouteRuntimeController>,
|
||||||
admission_tx: &watch::Sender<bool>,
|
admission_tx: &watch::Sender<bool>,
|
||||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||||
|
me_ready_rx: watch::Receiver<u64>,
|
||||||
) {
|
) {
|
||||||
if config.general.use_middle_proxy {
|
if config.general.use_middle_proxy {
|
||||||
if let Some(pool) = me_pool.as_ref() {
|
if let Some(pool) = me_pool.as_ref() {
|
||||||
@@ -52,6 +53,7 @@ pub(crate) async fn configure_admission_gate(
|
|||||||
let admission_tx_gate = admission_tx.clone();
|
let admission_tx_gate = admission_tx.clone();
|
||||||
let route_runtime_gate = route_runtime.clone();
|
let route_runtime_gate = route_runtime.clone();
|
||||||
let mut config_rx_gate = config_rx.clone();
|
let mut config_rx_gate = config_rx.clone();
|
||||||
|
let mut me_ready_rx_gate = me_ready_rx;
|
||||||
let mut admission_poll_ms = config.general.me_admission_poll_ms.max(1);
|
let mut admission_poll_ms = config.general.me_admission_poll_ms.max(1);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut gate_open = initial_gate_open;
|
let mut gate_open = initial_gate_open;
|
||||||
@@ -74,6 +76,11 @@ pub(crate) async fn configure_admission_gate(
|
|||||||
fast_fallback_enabled = cfg.general.me2dc_fallback && cfg.general.me2dc_fast;
|
fast_fallback_enabled = cfg.general.me2dc_fallback && cfg.general.me2dc_fast;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
changed = me_ready_rx_gate.changed() => {
|
||||||
|
if changed.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = tokio::time::sleep(Duration::from_millis(admission_poll_ms)) => {}
|
_ = tokio::time::sleep(Duration::from_millis(admission_poll_ms)) => {}
|
||||||
}
|
}
|
||||||
let ready = pool_for_gate.admission_ready_conditional_cast().await;
|
let ready = pool_for_gate.admission_ready_conditional_cast().await;
|
||||||
|
|||||||
+123
-3
@@ -1,6 +1,6 @@
|
|||||||
#![allow(clippy::items_after_test_module)]
|
#![allow(clippy::items_after_test_module)]
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
@@ -17,7 +17,7 @@ use crate::transport::middle_proxy::{
|
|||||||
|
|
||||||
pub(crate) fn resolve_runtime_config_path(
|
pub(crate) fn resolve_runtime_config_path(
|
||||||
config_path_cli: &str,
|
config_path_cli: &str,
|
||||||
startup_cwd: &std::path::Path,
|
startup_cwd: &Path,
|
||||||
config_path_explicit: bool,
|
config_path_explicit: bool,
|
||||||
) -> PathBuf {
|
) -> PathBuf {
|
||||||
if config_path_explicit {
|
if config_path_explicit {
|
||||||
@@ -46,6 +46,39 @@ pub(crate) fn resolve_runtime_config_path(
|
|||||||
startup_cwd.join("config.toml")
|
startup_cwd.join("config.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_runtime_base_dir(
|
||||||
|
config_path: &Path,
|
||||||
|
startup_cwd: &Path,
|
||||||
|
config_path_explicit: bool,
|
||||||
|
data_path: Option<&Path>,
|
||||||
|
) -> PathBuf {
|
||||||
|
if let Some(path) = data_path {
|
||||||
|
return normalize_runtime_dir(path, startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if startup_cwd != Path::new("/") {
|
||||||
|
return normalize_runtime_dir(startup_cwd, startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config_path_explicit
|
||||||
|
&& let Some(parent) = config_path.parent()
|
||||||
|
&& !parent.as_os_str().is_empty()
|
||||||
|
{
|
||||||
|
return normalize_runtime_dir(parent, startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from("/etc/telemt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_runtime_dir(path: &Path, startup_cwd: &Path) -> PathBuf {
|
||||||
|
let absolute = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
startup_cwd.join(path)
|
||||||
|
};
|
||||||
|
absolute.canonicalize().unwrap_or(absolute)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parsed CLI arguments.
|
/// Parsed CLI arguments.
|
||||||
pub(crate) struct CliArgs {
|
pub(crate) struct CliArgs {
|
||||||
pub config_path: String,
|
pub config_path: String,
|
||||||
@@ -231,9 +264,11 @@ fn print_help() {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
||||||
resolve_runtime_config_path,
|
resolve_runtime_base_dir, resolve_runtime_config_path,
|
||||||
};
|
};
|
||||||
use crate::error::{ProxyError, StreamError};
|
use crate::error::{ProxyError, StreamError};
|
||||||
|
|
||||||
@@ -304,6 +339,91 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir(&startup_cwd);
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_prefers_cli_data_path() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_cwd_{nonce}"));
|
||||||
|
let data_path = std::env::temp_dir().join(format!("telemt_runtime_base_data_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
std::fs::create_dir_all(&data_path).unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_runtime_base_dir(
|
||||||
|
&startup_cwd.join("config.toml"),
|
||||||
|
&startup_cwd,
|
||||||
|
true,
|
||||||
|
Some(&data_path),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, data_path.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&data_path);
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_uses_working_directory_before_explicit_config_parent() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_start_{nonce}"));
|
||||||
|
let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_cfg_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
std::fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_runtime_base_dir(&config_dir.join("telemt.toml"), &startup_cwd, true, None);
|
||||||
|
assert_eq!(resolved, startup_cwd.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&config_dir);
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_uses_explicit_config_parent_from_root() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let config_dir = std::env::temp_dir().join(format!("telemt_runtime_base_root_cfg_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_runtime_base_dir(&config_dir.join("telemt.toml"), Path::new("/"), true, None);
|
||||||
|
assert_eq!(resolved, config_dir.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&config_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_uses_systemd_working_directory_before_etc() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let startup_cwd = std::env::temp_dir().join(format!("telemt_runtime_base_systemd_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&startup_cwd).unwrap();
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_runtime_base_dir(&startup_cwd.join("config.toml"), &startup_cwd, false, None);
|
||||||
|
assert_eq!(resolved, startup_cwd.canonicalize().unwrap());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir(&startup_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_runtime_base_dir_falls_back_to_etc_from_root() {
|
||||||
|
let resolved = resolve_runtime_base_dir(
|
||||||
|
Path::new("/etc/telemt/config.toml"),
|
||||||
|
Path::new("/"),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, PathBuf::from("/etc/telemt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expected_handshake_eof_matches_connection_reset() {
|
fn expected_handshake_eof_matches_connection_reset() {
|
||||||
let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
|
let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{RwLock, watch};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
@@ -29,6 +29,7 @@ pub(crate) async fn initialize_me_pool(
|
|||||||
rng: Arc<SecureRandom>,
|
rng: Arc<SecureRandom>,
|
||||||
stats: Arc<Stats>,
|
stats: Arc<Stats>,
|
||||||
api_me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
api_me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||||
|
me_ready_tx: watch::Sender<u64>,
|
||||||
) -> Option<Arc<MePool>> {
|
) -> Option<Arc<MePool>> {
|
||||||
if !use_middle_proxy {
|
if !use_middle_proxy {
|
||||||
return None;
|
return None;
|
||||||
@@ -314,6 +315,7 @@ pub(crate) async fn initialize_me_pool(
|
|||||||
let pool_bg = pool.clone();
|
let pool_bg = pool.clone();
|
||||||
let rng_bg = rng.clone();
|
let rng_bg = rng.clone();
|
||||||
let startup_tracker_bg = startup_tracker.clone();
|
let startup_tracker_bg = startup_tracker.clone();
|
||||||
|
let me_ready_tx_bg = me_ready_tx.clone();
|
||||||
let retry_limit = if me_init_retry_attempts == 0 {
|
let retry_limit = if me_init_retry_attempts == 0 {
|
||||||
String::from("unlimited")
|
String::from("unlimited")
|
||||||
} else {
|
} else {
|
||||||
@@ -347,6 +349,9 @@ pub(crate) async fn initialize_me_pool(
|
|||||||
startup_tracker_bg
|
startup_tracker_bg
|
||||||
.set_me_status(StartupMeStatus::Ready, "ready")
|
.set_me_status(StartupMeStatus::Ready, "ready")
|
||||||
.await;
|
.await;
|
||||||
|
me_ready_tx_bg.send_modify(|version| {
|
||||||
|
*version = version.saturating_add(1);
|
||||||
|
});
|
||||||
info!(
|
info!(
|
||||||
attempt = init_attempt,
|
attempt = init_attempt,
|
||||||
"Middle-End pool initialized successfully"
|
"Middle-End pool initialized successfully"
|
||||||
@@ -474,6 +479,9 @@ pub(crate) async fn initialize_me_pool(
|
|||||||
startup_tracker
|
startup_tracker
|
||||||
.set_me_status(StartupMeStatus::Ready, "ready")
|
.set_me_status(StartupMeStatus::Ready, "ready")
|
||||||
.await;
|
.await;
|
||||||
|
me_ready_tx.send_modify(|version| {
|
||||||
|
*version = version.saturating_add(1);
|
||||||
|
});
|
||||||
info!(
|
info!(
|
||||||
attempt = init_attempt,
|
attempt = init_attempt,
|
||||||
"Middle-End pool initialized successfully"
|
"Middle-End pool initialized successfully"
|
||||||
|
|||||||
+65
-13
@@ -47,7 +47,7 @@ use crate::stats::{ReplayChecker, Stats};
|
|||||||
use crate::stream::BufferPool;
|
use crate::stream::BufferPool;
|
||||||
use crate::transport::UpstreamManager;
|
use crate::transport::UpstreamManager;
|
||||||
use crate::transport::middle_proxy::MePool;
|
use crate::transport::middle_proxy::MePool;
|
||||||
use helpers::{parse_cli, resolve_runtime_config_path};
|
use helpers::{parse_cli, resolve_runtime_base_dir, resolve_runtime_config_path};
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::daemon::{DaemonOptions, PidFile, drop_privileges};
|
use crate::daemon::{DaemonOptions, PidFile, drop_privileges};
|
||||||
@@ -112,8 +112,51 @@ async fn run_telemt_core(
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if let Some(ref data_path) = data_path
|
||||||
|
&& !data_path.is_absolute()
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] data_path must be absolute: {}",
|
||||||
|
data_path.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
let mut config_path =
|
let mut config_path =
|
||||||
resolve_runtime_config_path(&config_path_cli, &startup_cwd, config_path_explicit);
|
resolve_runtime_config_path(&config_path_cli, &startup_cwd, config_path_explicit);
|
||||||
|
let runtime_base_dir = resolve_runtime_base_dir(
|
||||||
|
&config_path,
|
||||||
|
&startup_cwd,
|
||||||
|
config_path_explicit,
|
||||||
|
data_path.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !runtime_base_dir.exists()
|
||||||
|
&& let Err(e) = std::fs::create_dir_all(&runtime_base_dir)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't create runtime directory {}: {}",
|
||||||
|
runtime_base_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !runtime_base_dir.is_dir() {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Runtime path exists but is not a directory: {}",
|
||||||
|
runtime_base_dir.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::env::set_current_dir(&runtime_base_dir) {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't use runtime directory {}: {}",
|
||||||
|
runtime_base_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
let mut config = match ProxyConfig::load(&config_path) {
|
let mut config = match ProxyConfig::load(&config_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -156,16 +199,15 @@ async fn run_telemt_core(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let system_dir = std::path::Path::new("/etc/telemt");
|
let runtime_config_path = runtime_base_dir.join("telemt.toml");
|
||||||
let system_config_path = system_dir.join("telemt.toml");
|
let fallback_config_path = runtime_base_dir.join("config.toml");
|
||||||
let startup_config_path = startup_cwd.join("config.toml");
|
|
||||||
let mut persisted = false;
|
let mut persisted = false;
|
||||||
|
|
||||||
if let Some(serialized) = serialized.as_ref() {
|
if let Some(serialized) = serialized.as_ref() {
|
||||||
match std::fs::create_dir_all(system_dir) {
|
match std::fs::create_dir_all(&runtime_base_dir) {
|
||||||
Ok(()) => match std::fs::write(&system_config_path, serialized) {
|
Ok(()) => match std::fs::write(&runtime_config_path, serialized) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
config_path = system_config_path;
|
config_path = runtime_config_path;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Created default config at {}",
|
"[telemt] Created default config at {}",
|
||||||
config_path.display()
|
config_path.display()
|
||||||
@@ -175,7 +217,7 @@ async fn run_telemt_core(
|
|||||||
Err(write_error) => {
|
Err(write_error) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Warning: failed to write default config at {}: {}",
|
"[telemt] Warning: failed to write default config at {}: {}",
|
||||||
system_config_path.display(),
|
runtime_config_path.display(),
|
||||||
write_error
|
write_error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,16 +225,16 @@ async fn run_telemt_core(
|
|||||||
Err(create_error) => {
|
Err(create_error) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Warning: failed to create {}: {}",
|
"[telemt] Warning: failed to create {}: {}",
|
||||||
system_dir.display(),
|
runtime_base_dir.display(),
|
||||||
create_error
|
create_error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !persisted {
|
if !persisted {
|
||||||
match std::fs::write(&startup_config_path, serialized) {
|
match std::fs::write(&fallback_config_path, serialized) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
config_path = startup_config_path;
|
config_path = fallback_config_path;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Created default config at {}",
|
"[telemt] Created default config at {}",
|
||||||
config_path.display()
|
config_path.display()
|
||||||
@@ -202,7 +244,7 @@ async fn run_telemt_core(
|
|||||||
Err(write_error) => {
|
Err(write_error) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[telemt] Warning: failed to write default config at {}: {}",
|
"[telemt] Warning: failed to write default config at {}: {}",
|
||||||
startup_config_path.display(),
|
fallback_config_path.display(),
|
||||||
write_error
|
write_error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -375,6 +417,8 @@ async fn run_telemt_core(
|
|||||||
|
|
||||||
let stats = Arc::new(Stats::new());
|
let stats = Arc::new(Stats::new());
|
||||||
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
||||||
|
let quota_state_path = config.general.quota_state_path.clone();
|
||||||
|
crate::quota_state::load_quota_state("a_state_path, stats.as_ref()).await;
|
||||||
|
|
||||||
let upstream_manager = Arc::new(UpstreamManager::new(
|
let upstream_manager = Arc::new(UpstreamManager::new(
|
||||||
config.upstreams.clone(),
|
config.upstreams.clone(),
|
||||||
@@ -454,6 +498,7 @@ async fn run_telemt_core(
|
|||||||
let config_rx_api = api_config_rx.clone();
|
let config_rx_api = api_config_rx.clone();
|
||||||
let admission_rx_api = admission_rx.clone();
|
let admission_rx_api = admission_rx.clone();
|
||||||
let config_path_api = config_path.clone();
|
let config_path_api = config_path.clone();
|
||||||
|
let quota_state_path_api = quota_state_path.clone();
|
||||||
let startup_tracker_api = startup_tracker.clone();
|
let startup_tracker_api = startup_tracker.clone();
|
||||||
let detected_ips_rx_api = detected_ips_rx.clone();
|
let detected_ips_rx_api = detected_ips_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -467,6 +512,7 @@ async fn run_telemt_core(
|
|||||||
config_rx_api,
|
config_rx_api,
|
||||||
admission_rx_api,
|
admission_rx_api,
|
||||||
config_path_api,
|
config_path_api,
|
||||||
|
quota_state_path_api,
|
||||||
detected_ips_rx_api,
|
detected_ips_rx_api,
|
||||||
process_started_at_epoch_secs,
|
process_started_at_epoch_secs,
|
||||||
startup_tracker_api,
|
startup_tracker_api,
|
||||||
@@ -618,6 +664,8 @@ async fn run_telemt_core(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (me_ready_tx, me_ready_rx) = watch::channel(0_u64);
|
||||||
|
|
||||||
let me_pool: Option<Arc<MePool>> = me_startup::initialize_me_pool(
|
let me_pool: Option<Arc<MePool>> = me_startup::initialize_me_pool(
|
||||||
use_middle_proxy,
|
use_middle_proxy,
|
||||||
&config,
|
&config,
|
||||||
@@ -628,6 +676,7 @@ async fn run_telemt_core(
|
|||||||
rng.clone(),
|
rng.clone(),
|
||||||
stats.clone(),
|
stats.clone(),
|
||||||
api_me_pool.clone(),
|
api_me_pool.clone(),
|
||||||
|
me_ready_tx.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -701,6 +750,7 @@ async fn run_telemt_core(
|
|||||||
api_config_tx.clone(),
|
api_config_tx.clone(),
|
||||||
me_pool.clone(),
|
me_pool.clone(),
|
||||||
shared_state.clone(),
|
shared_state.clone(),
|
||||||
|
me_ready_tx.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let config_rx = runtime_watches.config_rx;
|
let config_rx = runtime_watches.config_rx;
|
||||||
@@ -714,6 +764,7 @@ async fn run_telemt_core(
|
|||||||
route_runtime.clone(),
|
route_runtime.clone(),
|
||||||
&admission_tx,
|
&admission_tx,
|
||||||
config_rx.clone(),
|
config_rx.clone(),
|
||||||
|
me_ready_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _admission_tx_hold = admission_tx;
|
let _admission_tx_hold = admission_tx;
|
||||||
@@ -772,6 +823,7 @@ async fn run_telemt_core(
|
|||||||
beobachten.clone(),
|
beobachten.clone(),
|
||||||
shared_state.clone(),
|
shared_state.clone(),
|
||||||
ip_tracker.clone(),
|
ip_tracker.clone(),
|
||||||
|
tls_cache.clone(),
|
||||||
config_rx.clone(),
|
config_rx.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -799,7 +851,7 @@ async fn run_telemt_core(
|
|||||||
max_connections.clone(),
|
max_connections.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
shutdown::wait_for_shutdown(process_started_at, me_pool, stats).await;
|
shutdown::wait_for_shutdown(process_started_at, me_pool, stats, quota_state_path).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use crate::startup::{
|
|||||||
use crate::stats::beobachten::BeobachtenStore;
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
use crate::stats::telemetry::TelemetryPolicy;
|
use crate::stats::telemetry::TelemetryPolicy;
|
||||||
use crate::stats::{ReplayChecker, Stats};
|
use crate::stats::{ReplayChecker, Stats};
|
||||||
|
use crate::tls_front::TlsFrontCache;
|
||||||
use crate::transport::UpstreamManager;
|
use crate::transport::UpstreamManager;
|
||||||
use crate::transport::middle_proxy::{MePool, MeReinitTrigger};
|
use crate::transport::middle_proxy::{MePool, MeReinitTrigger};
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
api_config_tx: watch::Sender<Arc<ProxyConfig>>,
|
api_config_tx: watch::Sender<Arc<ProxyConfig>>,
|
||||||
me_pool_for_policy: Option<Arc<MePool>>,
|
me_pool_for_policy: Option<Arc<MePool>>,
|
||||||
shared_state: Arc<ProxySharedState>,
|
shared_state: Arc<ProxySharedState>,
|
||||||
|
me_ready_tx: watch::Sender<u64>,
|
||||||
) -> RuntimeWatches {
|
) -> RuntimeWatches {
|
||||||
let um_clone = upstream_manager.clone();
|
let um_clone = upstream_manager.clone();
|
||||||
let dc_overrides_for_health = config.dc_overrides.clone();
|
let dc_overrides_for_health = config.dc_overrides.clone();
|
||||||
@@ -249,12 +251,14 @@ pub(crate) async fn spawn_runtime_tasks(
|
|||||||
let pool_clone_sched = pool.clone();
|
let pool_clone_sched = pool.clone();
|
||||||
let rng_clone_sched = rng.clone();
|
let rng_clone_sched = rng.clone();
|
||||||
let config_rx_clone_sched = config_rx.clone();
|
let config_rx_clone_sched = config_rx.clone();
|
||||||
|
let me_ready_tx_sched = me_ready_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
crate::transport::middle_proxy::me_reinit_scheduler(
|
crate::transport::middle_proxy::me_reinit_scheduler(
|
||||||
pool_clone_sched,
|
pool_clone_sched,
|
||||||
rng_clone_sched,
|
rng_clone_sched,
|
||||||
config_rx_clone_sched,
|
config_rx_clone_sched,
|
||||||
reinit_rx,
|
reinit_rx,
|
||||||
|
me_ready_tx_sched,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
@@ -328,6 +332,7 @@ pub(crate) async fn spawn_metrics_if_configured(
|
|||||||
beobachten: Arc<BeobachtenStore>,
|
beobachten: Arc<BeobachtenStore>,
|
||||||
shared_state: Arc<ProxySharedState>,
|
shared_state: Arc<ProxySharedState>,
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||||
) {
|
) {
|
||||||
// metrics_listen takes precedence; fall back to metrics_port for backward compat.
|
// metrics_listen takes precedence; fall back to metrics_port for backward compat.
|
||||||
@@ -363,6 +368,7 @@ pub(crate) async fn spawn_metrics_if_configured(
|
|||||||
let shared_state = shared_state.clone();
|
let shared_state = shared_state.clone();
|
||||||
let config_rx_metrics = config_rx.clone();
|
let config_rx_metrics = config_rx.clone();
|
||||||
let ip_tracker_metrics = ip_tracker.clone();
|
let ip_tracker_metrics = ip_tracker.clone();
|
||||||
|
let tls_cache_metrics = tls_cache.clone();
|
||||||
let whitelist = config.server.metrics_whitelist.clone();
|
let whitelist = config.server.metrics_whitelist.clone();
|
||||||
let listen_backlog = config.server.listen_backlog;
|
let listen_backlog = config.server.listen_backlog;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -374,6 +380,7 @@ pub(crate) async fn spawn_metrics_if_configured(
|
|||||||
beobachten,
|
beobachten,
|
||||||
shared_state,
|
shared_state,
|
||||||
ip_tracker_metrics,
|
ip_tracker_metrics,
|
||||||
|
tls_cache_metrics,
|
||||||
config_rx_metrics,
|
config_rx_metrics,
|
||||||
whitelist,
|
whitelist,
|
||||||
)
|
)
|
||||||
|
|||||||
+20
-1
@@ -9,6 +9,7 @@
|
|||||||
//! SIGHUP is handled separately in config/hot_reload.rs for config reload.
|
//! SIGHUP is handled separately in config/hot_reload.rs for config reload.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
@@ -48,9 +49,10 @@ pub(crate) async fn wait_for_shutdown(
|
|||||||
process_started_at: Instant,
|
process_started_at: Instant,
|
||||||
me_pool: Option<Arc<MePool>>,
|
me_pool: Option<Arc<MePool>>,
|
||||||
stats: Arc<Stats>,
|
stats: Arc<Stats>,
|
||||||
|
quota_state_path: PathBuf,
|
||||||
) {
|
) {
|
||||||
let signal = wait_for_shutdown_signal().await;
|
let signal = wait_for_shutdown_signal().await;
|
||||||
perform_shutdown(signal, process_started_at, me_pool, &stats).await;
|
perform_shutdown(signal, process_started_at, me_pool, &stats, quota_state_path).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for any shutdown signal (SIGINT, SIGTERM, SIGQUIT).
|
/// Waits for any shutdown signal (SIGINT, SIGTERM, SIGQUIT).
|
||||||
@@ -79,6 +81,7 @@ async fn perform_shutdown(
|
|||||||
process_started_at: Instant,
|
process_started_at: Instant,
|
||||||
me_pool: Option<Arc<MePool>>,
|
me_pool: Option<Arc<MePool>>,
|
||||||
stats: &Stats,
|
stats: &Stats,
|
||||||
|
quota_state_path: PathBuf,
|
||||||
) {
|
) {
|
||||||
let shutdown_started_at = Instant::now();
|
let shutdown_started_at = Instant::now();
|
||||||
info!(signal = %signal, "Received shutdown signal");
|
info!(signal = %signal, "Received shutdown signal");
|
||||||
@@ -109,6 +112,22 @@ async fn perform_shutdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match crate::quota_state::save_quota_state("a_state_path, stats).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(
|
||||||
|
path = %quota_state_path.display(),
|
||||||
|
"Persisted per-user quota state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
error = %error,
|
||||||
|
path = %quota_state_path.display(),
|
||||||
|
"Failed to persist per-user quota state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let shutdown_secs = shutdown_started_at.elapsed().as_secs();
|
let shutdown_secs = shutdown_started_at.elapsed().as_secs();
|
||||||
info!(
|
info!(
|
||||||
"Shutdown completed successfully in {} {}.",
|
"Shutdown completed successfully in {} {}.",
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ use crate::tls_front::TlsFrontCache;
|
|||||||
use crate::tls_front::fetcher::TlsFetchStrategy;
|
use crate::tls_front::fetcher::TlsFetchStrategy;
|
||||||
use crate::transport::UpstreamManager;
|
use crate::transport::UpstreamManager;
|
||||||
|
|
||||||
|
fn tls_fetch_host_for_domain(mask_host: &str, primary_tls_domain: &str, domain: &str) -> String {
|
||||||
|
if mask_host.eq_ignore_ascii_case(primary_tls_domain) {
|
||||||
|
domain.to_string()
|
||||||
|
} else {
|
||||||
|
mask_host.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn bootstrap_tls_front(
|
pub(crate) async fn bootstrap_tls_front(
|
||||||
config: &ProxyConfig,
|
config: &ProxyConfig,
|
||||||
tls_domains: &[String],
|
tls_domains: &[String],
|
||||||
@@ -56,6 +64,7 @@ pub(crate) async fn bootstrap_tls_front(
|
|||||||
let cache_initial = cache.clone();
|
let cache_initial = cache.clone();
|
||||||
let domains_initial = tls_domains.to_vec();
|
let domains_initial = tls_domains.to_vec();
|
||||||
let host_initial = mask_host.clone();
|
let host_initial = mask_host.clone();
|
||||||
|
let primary_initial = config.censorship.tls_domain.clone();
|
||||||
let unix_sock_initial = mask_unix_sock.clone();
|
let unix_sock_initial = mask_unix_sock.clone();
|
||||||
let scope_initial = tls_fetch_scope.clone();
|
let scope_initial = tls_fetch_scope.clone();
|
||||||
let upstream_initial = upstream_manager.clone();
|
let upstream_initial = upstream_manager.clone();
|
||||||
@@ -64,7 +73,8 @@ pub(crate) async fn bootstrap_tls_front(
|
|||||||
let mut join = tokio::task::JoinSet::new();
|
let mut join = tokio::task::JoinSet::new();
|
||||||
for domain in domains_initial {
|
for domain in domains_initial {
|
||||||
let cache_domain = cache_initial.clone();
|
let cache_domain = cache_initial.clone();
|
||||||
let host_domain = host_initial.clone();
|
let host_domain =
|
||||||
|
tls_fetch_host_for_domain(&host_initial, &primary_initial, &domain);
|
||||||
let unix_sock_domain = unix_sock_initial.clone();
|
let unix_sock_domain = unix_sock_initial.clone();
|
||||||
let scope_domain = scope_initial.clone();
|
let scope_domain = scope_initial.clone();
|
||||||
let upstream_domain = upstream_initial.clone();
|
let upstream_domain = upstream_initial.clone();
|
||||||
@@ -117,6 +127,7 @@ pub(crate) async fn bootstrap_tls_front(
|
|||||||
let cache_refresh = cache.clone();
|
let cache_refresh = cache.clone();
|
||||||
let domains_refresh = tls_domains.to_vec();
|
let domains_refresh = tls_domains.to_vec();
|
||||||
let host_refresh = mask_host.clone();
|
let host_refresh = mask_host.clone();
|
||||||
|
let primary_refresh = config.censorship.tls_domain.clone();
|
||||||
let unix_sock_refresh = mask_unix_sock.clone();
|
let unix_sock_refresh = mask_unix_sock.clone();
|
||||||
let scope_refresh = tls_fetch_scope.clone();
|
let scope_refresh = tls_fetch_scope.clone();
|
||||||
let upstream_refresh = upstream_manager.clone();
|
let upstream_refresh = upstream_manager.clone();
|
||||||
@@ -130,7 +141,8 @@ pub(crate) async fn bootstrap_tls_front(
|
|||||||
let mut join = tokio::task::JoinSet::new();
|
let mut join = tokio::task::JoinSet::new();
|
||||||
for domain in domains_refresh.clone() {
|
for domain in domains_refresh.clone() {
|
||||||
let cache_domain = cache_refresh.clone();
|
let cache_domain = cache_refresh.clone();
|
||||||
let host_domain = host_refresh.clone();
|
let host_domain =
|
||||||
|
tls_fetch_host_for_domain(&host_refresh, &primary_refresh, &domain);
|
||||||
let unix_sock_domain = unix_sock_refresh.clone();
|
let unix_sock_domain = unix_sock_refresh.clone();
|
||||||
let scope_domain = scope_refresh.clone();
|
let scope_domain = scope_refresh.clone();
|
||||||
let upstream_domain = upstream_refresh.clone();
|
let upstream_domain = upstream_refresh.clone();
|
||||||
@@ -186,3 +198,24 @@ pub(crate) async fn bootstrap_tls_front(
|
|||||||
|
|
||||||
tls_cache
|
tls_cache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::tls_fetch_host_for_domain;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_host_uses_each_domain_when_mask_host_is_primary_default() {
|
||||||
|
assert_eq!(
|
||||||
|
tls_fetch_host_for_domain("a.com", "a.com", "b.com"),
|
||||||
|
"b.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_host_preserves_explicit_non_primary_mask_host() {
|
||||||
|
assert_eq!(
|
||||||
|
tls_fetch_host_for_domain("origin.example", "a.com", "b.com"),
|
||||||
|
"origin.example"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ mod metrics;
|
|||||||
mod network;
|
mod network;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
mod quota_state;
|
||||||
mod service;
|
mod service;
|
||||||
mod startup;
|
mod startup;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|||||||
+440
-19
@@ -18,8 +18,16 @@ use crate::ip_tracker::UserIpTracker;
|
|||||||
use crate::proxy::shared_state::ProxySharedState;
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
use crate::stats::beobachten::BeobachtenStore;
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
|
use crate::tls_front::TlsFrontCache;
|
||||||
|
use crate::tls_front::cache;
|
||||||
|
use crate::tls_front::fetcher;
|
||||||
use crate::transport::{ListenOptions, create_listener};
|
use crate::transport::{ListenOptions, create_listener};
|
||||||
|
|
||||||
|
// Keeps `/metrics` response size bounded when per-user telemetry is enabled.
|
||||||
|
const USER_LABELED_METRICS_MAX_USERS: usize = 4096;
|
||||||
|
// Keeps TLS-front per-domain health series bounded for large generated configs.
|
||||||
|
const TLS_FRONT_PROFILE_HEALTH_MAX_DOMAINS: usize = 256;
|
||||||
|
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
port: u16,
|
port: u16,
|
||||||
listen: Option<String>,
|
listen: Option<String>,
|
||||||
@@ -28,6 +36,7 @@ pub async fn serve(
|
|||||||
beobachten: Arc<BeobachtenStore>,
|
beobachten: Arc<BeobachtenStore>,
|
||||||
shared_state: Arc<ProxySharedState>,
|
shared_state: Arc<ProxySharedState>,
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
config_rx: tokio::sync::watch::Receiver<Arc<ProxyConfig>>,
|
config_rx: tokio::sync::watch::Receiver<Arc<ProxyConfig>>,
|
||||||
whitelist: Vec<IpNetwork>,
|
whitelist: Vec<IpNetwork>,
|
||||||
) {
|
) {
|
||||||
@@ -52,6 +61,7 @@ pub async fn serve(
|
|||||||
beobachten,
|
beobachten,
|
||||||
shared_state,
|
shared_state,
|
||||||
ip_tracker,
|
ip_tracker,
|
||||||
|
tls_cache,
|
||||||
config_rx,
|
config_rx,
|
||||||
whitelist,
|
whitelist,
|
||||||
)
|
)
|
||||||
@@ -107,6 +117,7 @@ pub async fn serve(
|
|||||||
beobachten,
|
beobachten,
|
||||||
shared_state,
|
shared_state,
|
||||||
ip_tracker,
|
ip_tracker,
|
||||||
|
tls_cache,
|
||||||
config_rx,
|
config_rx,
|
||||||
whitelist,
|
whitelist,
|
||||||
)
|
)
|
||||||
@@ -117,6 +128,7 @@ pub async fn serve(
|
|||||||
let beobachten_v6 = beobachten.clone();
|
let beobachten_v6 = beobachten.clone();
|
||||||
let shared_state_v6 = shared_state.clone();
|
let shared_state_v6 = shared_state.clone();
|
||||||
let ip_tracker_v6 = ip_tracker.clone();
|
let ip_tracker_v6 = ip_tracker.clone();
|
||||||
|
let tls_cache_v6 = tls_cache.clone();
|
||||||
let config_rx_v6 = config_rx.clone();
|
let config_rx_v6 = config_rx.clone();
|
||||||
let whitelist_v6 = whitelist.clone();
|
let whitelist_v6 = whitelist.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -126,6 +138,7 @@ pub async fn serve(
|
|||||||
beobachten_v6,
|
beobachten_v6,
|
||||||
shared_state_v6,
|
shared_state_v6,
|
||||||
ip_tracker_v6,
|
ip_tracker_v6,
|
||||||
|
tls_cache_v6,
|
||||||
config_rx_v6,
|
config_rx_v6,
|
||||||
whitelist_v6,
|
whitelist_v6,
|
||||||
)
|
)
|
||||||
@@ -137,6 +150,7 @@ pub async fn serve(
|
|||||||
beobachten,
|
beobachten,
|
||||||
shared_state,
|
shared_state,
|
||||||
ip_tracker,
|
ip_tracker,
|
||||||
|
tls_cache,
|
||||||
config_rx,
|
config_rx,
|
||||||
whitelist,
|
whitelist,
|
||||||
)
|
)
|
||||||
@@ -166,6 +180,7 @@ async fn serve_listener(
|
|||||||
beobachten: Arc<BeobachtenStore>,
|
beobachten: Arc<BeobachtenStore>,
|
||||||
shared_state: Arc<ProxySharedState>,
|
shared_state: Arc<ProxySharedState>,
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
config_rx: tokio::sync::watch::Receiver<Arc<ProxyConfig>>,
|
config_rx: tokio::sync::watch::Receiver<Arc<ProxyConfig>>,
|
||||||
whitelist: Arc<Vec<IpNetwork>>,
|
whitelist: Arc<Vec<IpNetwork>>,
|
||||||
) {
|
) {
|
||||||
@@ -187,6 +202,7 @@ async fn serve_listener(
|
|||||||
let beobachten = beobachten.clone();
|
let beobachten = beobachten.clone();
|
||||||
let shared_state = shared_state.clone();
|
let shared_state = shared_state.clone();
|
||||||
let ip_tracker = ip_tracker.clone();
|
let ip_tracker = ip_tracker.clone();
|
||||||
|
let tls_cache = tls_cache.clone();
|
||||||
let config_rx_conn = config_rx.clone();
|
let config_rx_conn = config_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let svc = service_fn(move |req| {
|
let svc = service_fn(move |req| {
|
||||||
@@ -194,6 +210,7 @@ async fn serve_listener(
|
|||||||
let beobachten = beobachten.clone();
|
let beobachten = beobachten.clone();
|
||||||
let shared_state = shared_state.clone();
|
let shared_state = shared_state.clone();
|
||||||
let ip_tracker = ip_tracker.clone();
|
let ip_tracker = ip_tracker.clone();
|
||||||
|
let tls_cache = tls_cache.clone();
|
||||||
let config = config_rx_conn.borrow().clone();
|
let config = config_rx_conn.borrow().clone();
|
||||||
async move {
|
async move {
|
||||||
handle(
|
handle(
|
||||||
@@ -202,6 +219,7 @@ async fn serve_listener(
|
|||||||
&beobachten,
|
&beobachten,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
&ip_tracker,
|
&ip_tracker,
|
||||||
|
tls_cache.as_deref(),
|
||||||
&config,
|
&config,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -223,10 +241,11 @@ async fn handle<B>(
|
|||||||
beobachten: &BeobachtenStore,
|
beobachten: &BeobachtenStore,
|
||||||
shared_state: &ProxySharedState,
|
shared_state: &ProxySharedState,
|
||||||
ip_tracker: &UserIpTracker,
|
ip_tracker: &UserIpTracker,
|
||||||
|
tls_cache: Option<&TlsFrontCache>,
|
||||||
config: &ProxyConfig,
|
config: &ProxyConfig,
|
||||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||||
if req.uri().path() == "/metrics" {
|
if req.uri().path() == "/metrics" {
|
||||||
let body = render_metrics(stats, shared_state, config, ip_tracker).await;
|
let body = render_metrics(stats, shared_state, config, ip_tracker, tls_cache).await;
|
||||||
let resp = Response::builder()
|
let resp = Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("content-type", "text/plain; version=0.0.4; charset=utf-8")
|
.header("content-type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
@@ -261,11 +280,151 @@ fn render_beobachten(beobachten: &BeobachtenStore, config: &ProxyConfig) -> Stri
|
|||||||
beobachten.snapshot_text(ttl)
|
beobachten.snapshot_text(ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tls_front_domains(config: &ProxyConfig) -> Vec<String> {
|
||||||
|
let mut domains = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
||||||
|
if !config.censorship.tls_domain.is_empty() {
|
||||||
|
domains.push(config.censorship.tls_domain.clone());
|
||||||
|
}
|
||||||
|
for domain in &config.censorship.tls_domains {
|
||||||
|
if !domain.is_empty() && !domains.contains(domain) {
|
||||||
|
domains.push(domain.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domains
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prometheus_label_value(value: &str) -> String {
|
||||||
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_tls_front_profile_health(
|
||||||
|
out: &mut String,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
tls_cache: Option<&TlsFrontCache>,
|
||||||
|
) {
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
let domains = tls_front_domains(config);
|
||||||
|
let (health, suppressed) = match (config.censorship.tls_emulation, tls_cache) {
|
||||||
|
(true, Some(cache)) => {
|
||||||
|
cache
|
||||||
|
.profile_health_snapshot(&domains, TLS_FRONT_PROFILE_HEALTH_MAX_DOMAINS)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => (Vec::new(), domains.len()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_domains TLS front configured profile domains by export status"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_domains gauge");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_domains{{status=\"configured\"}} {}",
|
||||||
|
domains.len()
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_domains{{status=\"emitted\"}} {}",
|
||||||
|
health.len()
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_domains{{status=\"suppressed\"}} {}",
|
||||||
|
suppressed
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_info TLS front profile source and feature flags per configured domain"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_info gauge");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_age_seconds Age of cached TLS front profile data per configured domain"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_front_profile_age_seconds gauge"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_app_data_records TLS front cached app-data record count per configured domain"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_front_profile_app_data_records gauge"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_ticket_records TLS front cached ticket-like tail record count per configured domain"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_front_profile_ticket_records gauge"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_change_cipher_spec_records TLS front cached ChangeCipherSpec record count per configured domain"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_front_profile_change_cipher_spec_records gauge"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_profile_app_data_bytes TLS front cached total app-data bytes per configured domain"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_front_profile_app_data_bytes gauge"
|
||||||
|
);
|
||||||
|
|
||||||
|
for item in health {
|
||||||
|
let domain = prometheus_label_value(&item.domain);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_info{{domain=\"{}\",source=\"{}\",is_default=\"{}\",has_cert_info=\"{}\",has_cert_payload=\"{}\"}} 1",
|
||||||
|
domain,
|
||||||
|
item.source,
|
||||||
|
item.is_default,
|
||||||
|
item.has_cert_info,
|
||||||
|
item.has_cert_payload
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_age_seconds{{domain=\"{}\"}} {}",
|
||||||
|
domain, item.age_seconds
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_app_data_records{{domain=\"{}\"}} {}",
|
||||||
|
domain, item.app_data_records
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_ticket_records{{domain=\"{}\"}} {}",
|
||||||
|
domain, item.ticket_records
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_change_cipher_spec_records{{domain=\"{}\"}} {}",
|
||||||
|
domain, item.change_cipher_spec_count
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_profile_app_data_bytes{{domain=\"{}\"}} {}",
|
||||||
|
domain, item.total_app_data_len
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn render_metrics(
|
async fn render_metrics(
|
||||||
stats: &Stats,
|
stats: &Stats,
|
||||||
shared_state: &ProxySharedState,
|
shared_state: &ProxySharedState,
|
||||||
config: &ProxyConfig,
|
config: &ProxyConfig,
|
||||||
ip_tracker: &UserIpTracker,
|
ip_tracker: &UserIpTracker,
|
||||||
|
tls_cache: Option<&TlsFrontCache>,
|
||||||
) -> String {
|
) -> String {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
let mut out = String::with_capacity(4096);
|
let mut out = String::with_capacity(4096);
|
||||||
@@ -311,6 +470,12 @@ async fn render_metrics(
|
|||||||
"telemt_telemetry_user_enabled {}",
|
"telemt_telemetry_user_enabled {}",
|
||||||
if user_enabled { 1 } else { 0 }
|
if user_enabled { 1 } else { 0 }
|
||||||
);
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_stats_user_entries Retained per-user stats entries"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_stats_user_entries gauge");
|
||||||
|
let _ = writeln!(out, "telemt_stats_user_entries {}", stats.user_stats_len());
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
@@ -366,6 +531,54 @@ async fn render_metrics(
|
|||||||
stats.get_buffer_pool_in_use_gauge()
|
stats.get_buffer_pool_in_use_gauge()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_fetch_profile_cache_entries Current adaptive TLS fetch profile-cache entries"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_tls_fetch_profile_cache_entries gauge");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_fetch_profile_cache_entries {}",
|
||||||
|
fetcher::profile_cache_entries_for_metrics()
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_fetch_profile_cache_cap_drops_total Profile-cache winner inserts skipped because the cache cap was reached"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_fetch_profile_cache_cap_drops_total counter"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_fetch_profile_cache_cap_drops_total {}",
|
||||||
|
fetcher::profile_cache_cap_drops_for_metrics()
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_full_cert_budget_ips Current IP entries tracked by TLS full-cert budget"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_tls_front_full_cert_budget_ips gauge");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_full_cert_budget_ips {}",
|
||||||
|
cache::full_cert_sent_ips_for_metrics()
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_tls_front_full_cert_budget_cap_drops_total New IPs denied full-cert budget tracking because the cap was reached"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_tls_front_full_cert_budget_cap_drops_total counter"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_tls_front_full_cert_budget_cap_drops_total {}",
|
||||||
|
cache::full_cert_sent_cap_drops_for_metrics()
|
||||||
|
);
|
||||||
|
render_tls_front_profile_health(&mut out, config, tls_cache).await;
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"# HELP telemt_connections_total Total accepted connections"
|
"# HELP telemt_connections_total Total accepted connections"
|
||||||
@@ -396,6 +609,21 @@ async fn render_metrics(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_connections_bad_by_class_total Bad/rejected connections by class"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_connections_bad_by_class_total counter");
|
||||||
|
if core_enabled {
|
||||||
|
for (class, total) in stats.get_connects_bad_class_counts() {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_connections_bad_by_class_total{{class=\"{}\"}} {}",
|
||||||
|
class, total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"# HELP telemt_handshake_timeouts_total Handshake timeouts"
|
"# HELP telemt_handshake_timeouts_total Handshake timeouts"
|
||||||
@@ -411,6 +639,24 @@ async fn render_metrics(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_handshake_failures_by_class_total Handshake failures by class"
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# TYPE telemt_handshake_failures_by_class_total counter"
|
||||||
|
);
|
||||||
|
if core_enabled {
|
||||||
|
for (class, total) in stats.get_handshake_failure_class_counts() {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_handshake_failures_by_class_total{{class=\"{}\"}} {}",
|
||||||
|
class, total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"# HELP telemt_auth_expensive_checks_total Expensive authentication candidate checks executed during handshake validation"
|
"# HELP telemt_auth_expensive_checks_total Expensive authentication candidate checks executed during handshake validation"
|
||||||
@@ -3019,17 +3265,6 @@ async fn render_metrics(
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"# HELP telemt_telemetry_user_series_suppressed User-labeled metric series suppression flag"
|
|
||||||
);
|
|
||||||
let _ = writeln!(out, "# TYPE telemt_telemetry_user_series_suppressed gauge");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"telemt_telemetry_user_series_suppressed {}",
|
|
||||||
if user_enabled { 0 } else { 1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
let ip_memory = ip_tracker.memory_stats().await;
|
let ip_memory = ip_tracker.memory_stats().await;
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
@@ -3071,11 +3306,46 @@ async fn render_metrics(
|
|||||||
"telemt_ip_tracker_cleanup_queue_len {}",
|
"telemt_ip_tracker_cleanup_queue_len {}",
|
||||||
ip_memory.cleanup_queue_len
|
ip_memory.cleanup_queue_len
|
||||||
);
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_ip_tracker_cleanup_total Release cleanups deferred through the cleanup queue"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_ip_tracker_cleanup_total counter");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_ip_tracker_cleanup_total{{path=\"deferred\"}} {}",
|
||||||
|
ip_memory.cleanup_deferred_releases
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_ip_tracker_cap_rejects_total New connection rejects caused by global IP tracker caps"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_ip_tracker_cap_rejects_total counter");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_ip_tracker_cap_rejects_total{{scope=\"active\"}} {}",
|
||||||
|
ip_memory.active_cap_rejects
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_ip_tracker_cap_rejects_total{{scope=\"recent\"}} {}",
|
||||||
|
ip_memory.recent_cap_rejects
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut user_stats_emitted = 0usize;
|
||||||
|
let mut user_stats_suppressed = 0usize;
|
||||||
|
let mut unique_ip_emitted = 0usize;
|
||||||
|
let mut unique_ip_suppressed = 0usize;
|
||||||
|
|
||||||
if user_enabled {
|
if user_enabled {
|
||||||
for entry in stats.iter_user_stats() {
|
for entry in stats.iter_user_stats() {
|
||||||
|
if user_stats_emitted >= USER_LABELED_METRICS_MAX_USERS {
|
||||||
|
user_stats_suppressed = user_stats_suppressed.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let user = entry.key();
|
let user = entry.key();
|
||||||
let s = entry.value();
|
let s = entry.value();
|
||||||
|
user_stats_emitted = user_stats_emitted.saturating_add(1);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"telemt_user_connections_total{{user=\"{}\"}} {}",
|
"telemt_user_connections_total{{user=\"{}\"}} {}",
|
||||||
@@ -3117,7 +3387,7 @@ async fn render_metrics(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ip_stats = ip_tracker.get_stats().await;
|
let ip_stats = ip_tracker.get_stats_snapshot().await;
|
||||||
let ip_counts: HashMap<String, usize> = ip_stats
|
let ip_counts: HashMap<String, usize> = ip_stats
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(user, count, _)| (user, count))
|
.map(|(user, count, _)| (user, count))
|
||||||
@@ -3129,7 +3399,7 @@ async fn render_metrics(
|
|||||||
unique_users.extend(ip_counts.keys().cloned());
|
unique_users.extend(ip_counts.keys().cloned());
|
||||||
let unique_users_vec: Vec<String> = unique_users.iter().cloned().collect();
|
let unique_users_vec: Vec<String> = unique_users.iter().cloned().collect();
|
||||||
let recent_counts = ip_tracker
|
let recent_counts = ip_tracker
|
||||||
.get_recent_counts_for_users(&unique_users_vec)
|
.get_recent_counts_for_users_snapshot(&unique_users_vec)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
@@ -3154,6 +3424,11 @@ async fn render_metrics(
|
|||||||
let _ = writeln!(out, "# TYPE telemt_user_unique_ips_utilization gauge");
|
let _ = writeln!(out, "# TYPE telemt_user_unique_ips_utilization gauge");
|
||||||
|
|
||||||
for user in unique_users {
|
for user in unique_users {
|
||||||
|
if unique_ip_emitted >= USER_LABELED_METRICS_MAX_USERS {
|
||||||
|
unique_ip_suppressed = unique_ip_suppressed.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unique_ip_emitted = unique_ip_emitted.saturating_add(1);
|
||||||
let current = ip_counts.get(&user).copied().unwrap_or(0);
|
let current = ip_counts.get(&user).copied().unwrap_or(0);
|
||||||
let limit = config
|
let limit = config
|
||||||
.access
|
.access
|
||||||
@@ -3193,6 +3468,46 @@ async fn render_metrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_telemetry_user_series_suppressed User-labeled metric series suppression flag"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_telemetry_user_series_suppressed gauge");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_telemetry_user_series_suppressed {}",
|
||||||
|
if user_enabled && user_stats_suppressed == 0 && unique_ip_suppressed == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"# HELP telemt_telemetry_user_series_users User-labeled metric users by export status"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "# TYPE telemt_telemetry_user_series_users gauge");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_telemetry_user_series_users{{family=\"stats\",status=\"emitted\"}} {}",
|
||||||
|
user_stats_emitted
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_telemetry_user_series_users{{family=\"stats\",status=\"suppressed\"}} {}",
|
||||||
|
user_stats_suppressed
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_telemetry_user_series_users{{family=\"unique_ip\",status=\"emitted\"}} {}",
|
||||||
|
unique_ip_emitted
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"telemt_telemetry_user_series_users{{family=\"unique_ip\",status=\"suppressed\"}} {}",
|
||||||
|
unique_ip_suppressed
|
||||||
|
);
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3201,6 +3516,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::tls_front::types::{
|
||||||
|
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_render_metrics_format() {
|
async fn test_render_metrics_format() {
|
||||||
@@ -3215,8 +3535,9 @@ mod tests {
|
|||||||
|
|
||||||
stats.increment_connects_all();
|
stats.increment_connects_all();
|
||||||
stats.increment_connects_all();
|
stats.increment_connects_all();
|
||||||
stats.increment_connects_bad();
|
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
||||||
stats.increment_handshake_timeouts();
|
stats.increment_handshake_timeouts();
|
||||||
|
stats.increment_handshake_failure_class("timeout");
|
||||||
shared_state
|
shared_state
|
||||||
.handshake
|
.handshake
|
||||||
.auth_expensive_checks_total
|
.auth_expensive_checks_total
|
||||||
@@ -3268,7 +3589,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let output = render_metrics(&stats, shared_state.as_ref(), &config, &tracker).await;
|
let output = render_metrics(&stats, shared_state.as_ref(), &config, &tracker, None).await;
|
||||||
|
|
||||||
assert!(output.contains(&format!(
|
assert!(output.contains(&format!(
|
||||||
"telemt_build_info{{version=\"{}\"}} 1",
|
"telemt_build_info{{version=\"{}\"}} 1",
|
||||||
@@ -3276,7 +3597,10 @@ mod tests {
|
|||||||
)));
|
)));
|
||||||
assert!(output.contains("telemt_connections_total 2"));
|
assert!(output.contains("telemt_connections_total 2"));
|
||||||
assert!(output.contains("telemt_connections_bad_total 1"));
|
assert!(output.contains("telemt_connections_bad_total 1"));
|
||||||
|
assert!(output
|
||||||
|
.contains("telemt_connections_bad_by_class_total{class=\"tls_handshake_bad_client\"} 1"));
|
||||||
assert!(output.contains("telemt_handshake_timeouts_total 1"));
|
assert!(output.contains("telemt_handshake_timeouts_total 1"));
|
||||||
|
assert!(output.contains("telemt_handshake_failures_by_class_total{class=\"timeout\"} 1"));
|
||||||
assert!(output.contains("telemt_auth_expensive_checks_total 9"));
|
assert!(output.contains("telemt_auth_expensive_checks_total 9"));
|
||||||
assert!(output.contains("telemt_auth_budget_exhausted_total 2"));
|
assert!(output.contains("telemt_auth_budget_exhausted_total 2"));
|
||||||
assert!(output.contains("telemt_upstream_connect_attempt_total 2"));
|
assert!(output.contains("telemt_upstream_connect_attempt_total 2"));
|
||||||
@@ -3330,13 +3654,86 @@ mod tests {
|
|||||||
assert!(output.contains("telemt_ip_tracker_cleanup_queue_len 0"));
|
assert!(output.contains("telemt_ip_tracker_cleanup_queue_len 0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_render_tls_front_profile_health() {
|
||||||
|
let stats = Stats::new();
|
||||||
|
let shared_state = ProxySharedState::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config.censorship.tls_domain = "primary.example".to_string();
|
||||||
|
config.censorship.tls_domains = vec!["fallback.example".to_string()];
|
||||||
|
|
||||||
|
let cache = TlsFrontCache::new(
|
||||||
|
&[
|
||||||
|
"primary.example".to_string(),
|
||||||
|
"fallback.example".to_string(),
|
||||||
|
],
|
||||||
|
1024,
|
||||||
|
"tlsfront-profile-health-test",
|
||||||
|
);
|
||||||
|
cache
|
||||||
|
.set(
|
||||||
|
"primary.example",
|
||||||
|
CachedTlsData {
|
||||||
|
server_hello_template: ParsedServerHello {
|
||||||
|
version: [0x03, 0x03],
|
||||||
|
random: [0u8; 32],
|
||||||
|
session_id: Vec::new(),
|
||||||
|
cipher_suite: [0x13, 0x01],
|
||||||
|
compression: 0,
|
||||||
|
extensions: Vec::new(),
|
||||||
|
},
|
||||||
|
cert_info: None,
|
||||||
|
cert_payload: Some(TlsCertPayload {
|
||||||
|
cert_chain_der: vec![vec![0x30, 0x01]],
|
||||||
|
certificate_message: vec![0x0b, 0x00, 0x00, 0x00],
|
||||||
|
}),
|
||||||
|
app_data_records_sizes: vec![1024, 512],
|
||||||
|
total_app_data_len: 1536,
|
||||||
|
behavior_profile: TlsBehaviorProfile {
|
||||||
|
change_cipher_spec_count: 1,
|
||||||
|
app_data_record_sizes: vec![1024, 512],
|
||||||
|
ticket_record_sizes: vec![69],
|
||||||
|
source: TlsProfileSource::Merged,
|
||||||
|
},
|
||||||
|
fetched_at: SystemTime::now(),
|
||||||
|
domain: "primary.example".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let output = render_metrics(&stats, &shared_state, &config, &tracker, Some(&cache)).await;
|
||||||
|
|
||||||
|
assert!(output.contains("telemt_tls_front_profile_domains{status=\"configured\"} 2"));
|
||||||
|
assert!(output.contains("telemt_tls_front_profile_domains{status=\"emitted\"} 2"));
|
||||||
|
assert!(output.contains("telemt_tls_front_profile_domains{status=\"suppressed\"} 0"));
|
||||||
|
assert!(
|
||||||
|
output.contains("telemt_tls_front_profile_info{domain=\"primary.example\",source=\"merged\",is_default=\"false\",has_cert_info=\"false\",has_cert_payload=\"true\"} 1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("telemt_tls_front_profile_info{domain=\"fallback.example\",source=\"default\",is_default=\"true\",has_cert_info=\"false\",has_cert_payload=\"false\"} 1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("telemt_tls_front_profile_app_data_records{domain=\"primary.example\"} 2")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("telemt_tls_front_profile_ticket_records{domain=\"primary.example\"} 1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("telemt_tls_front_profile_change_cipher_spec_records{domain=\"primary.example\"} 1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("telemt_tls_front_profile_app_data_bytes{domain=\"primary.example\"} 1536")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_render_empty_stats() {
|
async fn test_render_empty_stats() {
|
||||||
let stats = Stats::new();
|
let stats = Stats::new();
|
||||||
let shared_state = ProxySharedState::new();
|
let shared_state = ProxySharedState::new();
|
||||||
let tracker = UserIpTracker::new();
|
let tracker = UserIpTracker::new();
|
||||||
let config = ProxyConfig::default();
|
let config = ProxyConfig::default();
|
||||||
let output = render_metrics(&stats, &shared_state, &config, &tracker).await;
|
let output = render_metrics(&stats, &shared_state, &config, &tracker, None).await;
|
||||||
assert!(output.contains("telemt_connections_total 0"));
|
assert!(output.contains("telemt_connections_total 0"));
|
||||||
assert!(output.contains("telemt_connections_bad_total 0"));
|
assert!(output.contains("telemt_connections_bad_total 0"));
|
||||||
assert!(output.contains("telemt_handshake_timeouts_total 0"));
|
assert!(output.contains("telemt_handshake_timeouts_total 0"));
|
||||||
@@ -3360,7 +3757,7 @@ mod tests {
|
|||||||
let mut config = ProxyConfig::default();
|
let mut config = ProxyConfig::default();
|
||||||
config.access.user_max_unique_ips_global_each = 2;
|
config.access.user_max_unique_ips_global_each = 2;
|
||||||
|
|
||||||
let output = render_metrics(&stats, &shared_state, &config, &tracker).await;
|
let output = render_metrics(&stats, &shared_state, &config, &tracker, None).await;
|
||||||
|
|
||||||
assert!(output.contains("telemt_user_unique_ips_limit{user=\"alice\"} 2"));
|
assert!(output.contains("telemt_user_unique_ips_limit{user=\"alice\"} 2"));
|
||||||
assert!(output.contains("telemt_user_unique_ips_utilization{user=\"alice\"} 0.500000"));
|
assert!(output.contains("telemt_user_unique_ips_utilization{user=\"alice\"} 0.500000"));
|
||||||
@@ -3372,11 +3769,13 @@ mod tests {
|
|||||||
let shared_state = ProxySharedState::new();
|
let shared_state = ProxySharedState::new();
|
||||||
let tracker = UserIpTracker::new();
|
let tracker = UserIpTracker::new();
|
||||||
let config = ProxyConfig::default();
|
let config = ProxyConfig::default();
|
||||||
let output = render_metrics(&stats, &shared_state, &config, &tracker).await;
|
let output = render_metrics(&stats, &shared_state, &config, &tracker, None).await;
|
||||||
assert!(output.contains("# TYPE telemt_uptime_seconds gauge"));
|
assert!(output.contains("# TYPE telemt_uptime_seconds gauge"));
|
||||||
assert!(output.contains("# TYPE telemt_connections_total counter"));
|
assert!(output.contains("# TYPE telemt_connections_total counter"));
|
||||||
assert!(output.contains("# TYPE telemt_connections_bad_total counter"));
|
assert!(output.contains("# TYPE telemt_connections_bad_total counter"));
|
||||||
|
assert!(output.contains("# TYPE telemt_connections_bad_by_class_total counter"));
|
||||||
assert!(output.contains("# TYPE telemt_handshake_timeouts_total counter"));
|
assert!(output.contains("# TYPE telemt_handshake_timeouts_total counter"));
|
||||||
|
assert!(output.contains("# TYPE telemt_handshake_failures_by_class_total counter"));
|
||||||
assert!(output.contains("# TYPE telemt_auth_expensive_checks_total counter"));
|
assert!(output.contains("# TYPE telemt_auth_expensive_checks_total counter"));
|
||||||
assert!(output.contains("# TYPE telemt_auth_budget_exhausted_total counter"));
|
assert!(output.contains("# TYPE telemt_auth_budget_exhausted_total counter"));
|
||||||
assert!(output.contains("# TYPE telemt_upstream_connect_attempt_total counter"));
|
assert!(output.contains("# TYPE telemt_upstream_connect_attempt_total counter"));
|
||||||
@@ -3406,9 +3805,28 @@ mod tests {
|
|||||||
assert!(output.contains("# TYPE telemt_user_unique_ips_recent_window gauge"));
|
assert!(output.contains("# TYPE telemt_user_unique_ips_recent_window gauge"));
|
||||||
assert!(output.contains("# TYPE telemt_user_unique_ips_limit gauge"));
|
assert!(output.contains("# TYPE telemt_user_unique_ips_limit gauge"));
|
||||||
assert!(output.contains("# TYPE telemt_user_unique_ips_utilization gauge"));
|
assert!(output.contains("# TYPE telemt_user_unique_ips_utilization gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_stats_user_entries gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_telemetry_user_series_users gauge"));
|
||||||
assert!(output.contains("# TYPE telemt_ip_tracker_users gauge"));
|
assert!(output.contains("# TYPE telemt_ip_tracker_users gauge"));
|
||||||
assert!(output.contains("# TYPE telemt_ip_tracker_entries gauge"));
|
assert!(output.contains("# TYPE telemt_ip_tracker_entries gauge"));
|
||||||
assert!(output.contains("# TYPE telemt_ip_tracker_cleanup_queue_len gauge"));
|
assert!(output.contains("# TYPE telemt_ip_tracker_cleanup_queue_len gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_ip_tracker_cleanup_total counter"));
|
||||||
|
assert!(output.contains("# TYPE telemt_ip_tracker_cap_rejects_total counter"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_fetch_profile_cache_entries gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_fetch_profile_cache_cap_drops_total counter"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_full_cert_budget_ips gauge"));
|
||||||
|
assert!(
|
||||||
|
output.contains("# TYPE telemt_tls_front_full_cert_budget_cap_drops_total counter")
|
||||||
|
);
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_profile_domains gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_profile_info gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_profile_age_seconds gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_profile_app_data_records gauge"));
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_profile_ticket_records gauge"));
|
||||||
|
assert!(
|
||||||
|
output.contains("# TYPE telemt_tls_front_profile_change_cipher_spec_records gauge")
|
||||||
|
);
|
||||||
|
assert!(output.contains("# TYPE telemt_tls_front_profile_app_data_bytes gauge"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3429,6 +3847,7 @@ mod tests {
|
|||||||
&beobachten,
|
&beobachten,
|
||||||
shared_state.as_ref(),
|
shared_state.as_ref(),
|
||||||
&tracker,
|
&tracker,
|
||||||
|
None,
|
||||||
&config,
|
&config,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -3463,6 +3882,7 @@ mod tests {
|
|||||||
&beobachten,
|
&beobachten,
|
||||||
shared_state.as_ref(),
|
shared_state.as_ref(),
|
||||||
&tracker,
|
&tracker,
|
||||||
|
None,
|
||||||
&config,
|
&config,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -3480,6 +3900,7 @@ mod tests {
|
|||||||
&beobachten,
|
&beobachten,
|
||||||
shared_state.as_ref(),
|
shared_state.as_ref(),
|
||||||
&tracker,
|
&tracker,
|
||||||
|
None,
|
||||||
&config,
|
&config,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+30
-35
@@ -1432,7 +1432,7 @@ impl RunningClientHandler {
|
|||||||
/// Main dispatch after successful handshake.
|
/// Main dispatch after successful handshake.
|
||||||
/// Two modes:
|
/// Two modes:
|
||||||
/// - Direct: TCP relay to TG DC (existing behavior)
|
/// - Direct: TCP relay to TG DC (existing behavior)
|
||||||
/// - Middle Proxy: RPC multiplex through ME pool (new — supports CDN DCs)
|
/// - Middle Proxy: RPC multiplex through ME pool (supports CDN DCs)
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
async fn handle_authenticated_static<R, W>(
|
async fn handle_authenticated_static<R, W>(
|
||||||
client_reader: CryptoReader<R>,
|
client_reader: CryptoReader<R>,
|
||||||
@@ -1612,22 +1612,19 @@ impl RunningClientHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks_ip = ip_tracker.get_user_limit(user).await.is_some();
|
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
||||||
if tracks_ip {
|
Ok(()) => {}
|
||||||
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
Err(reason) => {
|
||||||
Ok(()) => {}
|
stats.decrement_user_curr_connects(user);
|
||||||
Err(reason) => {
|
warn!(
|
||||||
stats.decrement_user_curr_connects(user);
|
user = %user,
|
||||||
warn!(
|
ip = %peer_addr.ip(),
|
||||||
user = %user,
|
reason = %reason,
|
||||||
ip = %peer_addr.ip(),
|
"IP limit exceeded"
|
||||||
reason = %reason,
|
);
|
||||||
"IP limit exceeded"
|
return Err(ProxyError::ConnectionLimitExceeded {
|
||||||
);
|
user: user.to_string(),
|
||||||
return Err(ProxyError::ConnectionLimitExceeded {
|
});
|
||||||
user: user.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,7 +1633,7 @@ impl RunningClientHandler {
|
|||||||
ip_tracker,
|
ip_tracker,
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
peer_addr.ip(),
|
peer_addr.ip(),
|
||||||
tracks_ip,
|
true,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1679,23 +1676,21 @@ impl RunningClientHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ip_tracker.get_user_limit(user).await.is_some() {
|
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
||||||
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
Ok(()) => {
|
||||||
Ok(()) => {
|
ip_tracker.remove_ip(user, peer_addr.ip()).await;
|
||||||
ip_tracker.remove_ip(user, peer_addr.ip()).await;
|
}
|
||||||
}
|
Err(reason) => {
|
||||||
Err(reason) => {
|
stats.decrement_user_curr_connects(user);
|
||||||
stats.decrement_user_curr_connects(user);
|
warn!(
|
||||||
warn!(
|
user = %user,
|
||||||
user = %user,
|
ip = %peer_addr.ip(),
|
||||||
ip = %peer_addr.ip(),
|
reason = %reason,
|
||||||
reason = %reason,
|
"IP limit exceeded"
|
||||||
"IP limit exceeded"
|
);
|
||||||
);
|
return Err(ProxyError::ConnectionLimitExceeded {
|
||||||
return Err(ProxyError::ConnectionLimitExceeded {
|
user: user.to_string(),
|
||||||
user: user.to_string(),
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1450,6 +1450,20 @@ where
|
|||||||
validated_secret.copy_from_slice(secret);
|
validated_secret.copy_from_slice(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config
|
||||||
|
.access
|
||||||
|
.is_user_source_ip_denied(validated_user.as_str(), peer.ip())
|
||||||
|
{
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %validated_user,
|
||||||
|
"TLS handshake rejected: client source IP on per-user deny list (access.user_source_deny)"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
// Reject known replay digests before expensive cache/domain/ALPN policy work.
|
// Reject known replay digests before expensive cache/domain/ALPN policy work.
|
||||||
let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN];
|
let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN];
|
||||||
if replay_checker.check_tls_digest(digest_half) {
|
if replay_checker.check_tls_digest(digest_half) {
|
||||||
@@ -1795,6 +1809,20 @@ where
|
|||||||
|
|
||||||
let validation = matched_validation.expect("validation must exist when matched");
|
let validation = matched_validation.expect("validation must exist when matched");
|
||||||
|
|
||||||
|
if config
|
||||||
|
.access
|
||||||
|
.is_user_source_ip_denied(matched_user.as_str(), peer.ip())
|
||||||
|
{
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %matched_user,
|
||||||
|
"MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
// Apply replay tracking only after successful authentication.
|
// Apply replay tracking only after successful authentication.
|
||||||
//
|
//
|
||||||
// This ordering prevents an attacker from producing invalid handshakes that
|
// This ordering prevents an attacker from producing invalid handshakes that
|
||||||
@@ -1873,6 +1901,17 @@ where
|
|||||||
.auth_expensive_checks_total
|
.auth_expensive_checks_total
|
||||||
.fetch_add(validation_checks as u64, Ordering::Relaxed);
|
.fetch_add(validation_checks as u64, Ordering::Relaxed);
|
||||||
|
|
||||||
|
if config.access.is_user_source_ip_denied(user.as_str(), peer.ip()) {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %user,
|
||||||
|
"MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
// Apply replay tracking only after successful authentication.
|
// Apply replay tracking only after successful authentication.
|
||||||
//
|
//
|
||||||
// This ordering prevents an attacker from producing invalid handshakes that
|
// This ordering prevents an attacker from producing invalid handshakes that
|
||||||
|
|||||||
+116
-5
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
use crate::network::dns_overrides::resolve_socket_addr;
|
use crate::network::dns_overrides::resolve_socket_addr;
|
||||||
|
use crate::protocol::tls;
|
||||||
use crate::stats::beobachten::BeobachtenStore;
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
|
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -328,6 +329,89 @@ async fn wait_mask_outcome_budget(started: Instant, config: &ProxyConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tls_domain_mask_host_tests {
|
||||||
|
use super::{mask_host_for_initial_data, matching_tls_domain_for_sni};
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
|
|
||||||
|
fn client_hello_with_sni(sni_host: &str) -> Vec<u8> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
body.extend_from_slice(&[0x03, 0x03]);
|
||||||
|
body.extend_from_slice(&[0u8; 32]);
|
||||||
|
body.push(32);
|
||||||
|
body.extend_from_slice(&[0x42u8; 32]);
|
||||||
|
body.extend_from_slice(&2u16.to_be_bytes());
|
||||||
|
body.extend_from_slice(&[0x13, 0x01]);
|
||||||
|
body.push(1);
|
||||||
|
body.push(0);
|
||||||
|
|
||||||
|
let host_bytes = sni_host.as_bytes();
|
||||||
|
let mut sni_payload = Vec::new();
|
||||||
|
sni_payload.extend_from_slice(&((host_bytes.len() + 3) as u16).to_be_bytes());
|
||||||
|
sni_payload.push(0);
|
||||||
|
sni_payload.extend_from_slice(&(host_bytes.len() as u16).to_be_bytes());
|
||||||
|
sni_payload.extend_from_slice(host_bytes);
|
||||||
|
|
||||||
|
let mut extensions = Vec::new();
|
||||||
|
extensions.extend_from_slice(&0x0000u16.to_be_bytes());
|
||||||
|
extensions.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
|
||||||
|
extensions.extend_from_slice(&sni_payload);
|
||||||
|
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
|
||||||
|
body.extend_from_slice(&extensions);
|
||||||
|
|
||||||
|
let mut handshake = Vec::new();
|
||||||
|
handshake.push(0x01);
|
||||||
|
let body_len = (body.len() as u32).to_be_bytes();
|
||||||
|
handshake.extend_from_slice(&body_len[1..4]);
|
||||||
|
handshake.extend_from_slice(&body);
|
||||||
|
|
||||||
|
let mut record = Vec::new();
|
||||||
|
record.push(0x16);
|
||||||
|
record.extend_from_slice(&[0x03, 0x01]);
|
||||||
|
record.extend_from_slice(&(handshake.len() as u16).to_be_bytes());
|
||||||
|
record.extend_from_slice(&handshake);
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_with_tls_domains() -> ProxyConfig {
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config.censorship.tls_domain = "a.com".to_string();
|
||||||
|
config.censorship.tls_domains = vec!["b.com".to_string(), "c.com".to_string()];
|
||||||
|
config.censorship.mask_host = Some("a.com".to_string());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_tls_domain_accepts_primary_and_extra_domains_case_insensitively() {
|
||||||
|
let config = config_with_tls_domains();
|
||||||
|
|
||||||
|
assert_eq!(matching_tls_domain_for_sni(&config, "A.COM"), Some("a.com"));
|
||||||
|
assert_eq!(matching_tls_domain_for_sni(&config, "B.COM"), Some("b.com"));
|
||||||
|
assert_eq!(matching_tls_domain_for_sni(&config, "unknown.com"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_host_preserves_explicit_non_primary_origin() {
|
||||||
|
let mut config = config_with_tls_domains();
|
||||||
|
config.censorship.mask_host = Some("origin.example".to_string());
|
||||||
|
|
||||||
|
let initial_data = client_hello_with_sni("b.com");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mask_host_for_initial_data(&config, &initial_data),
|
||||||
|
"origin.example"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_host_uses_matching_tls_domain_when_mask_host_is_primary_default() {
|
||||||
|
let config = config_with_tls_domains();
|
||||||
|
let initial_data = client_hello_with_sni("b.com");
|
||||||
|
|
||||||
|
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect client type based on initial data
|
/// Detect client type based on initial data
|
||||||
fn detect_client_type(data: &[u8]) -> &'static str {
|
fn detect_client_type(data: &[u8]) -> &'static str {
|
||||||
// Check for HTTP request
|
// Check for HTTP request
|
||||||
@@ -360,6 +444,37 @@ fn parse_mask_host_ip_literal(host: &str) -> Option<IpAddr> {
|
|||||||
host.parse::<IpAddr>().ok()
|
host.parse::<IpAddr>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> {
|
||||||
|
if config.censorship.tls_domain.eq_ignore_ascii_case(sni) {
|
||||||
|
return Some(config.censorship.tls_domain.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
for domain in &config.censorship.tls_domains {
|
||||||
|
if domain.eq_ignore_ascii_case(sni) {
|
||||||
|
return Some(domain.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_host_for_initial_data<'a>(config: &'a ProxyConfig, initial_data: &[u8]) -> &'a str {
|
||||||
|
let configured_mask_host = config
|
||||||
|
.censorship
|
||||||
|
.mask_host
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&config.censorship.tls_domain);
|
||||||
|
|
||||||
|
if !configured_mask_host.eq_ignore_ascii_case(&config.censorship.tls_domain) {
|
||||||
|
return configured_mask_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
tls::extract_sni_from_client_hello(initial_data)
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
|
||||||
|
.unwrap_or(configured_mask_host)
|
||||||
|
}
|
||||||
|
|
||||||
fn canonical_ip(ip: IpAddr) -> IpAddr {
|
fn canonical_ip(ip: IpAddr) -> IpAddr {
|
||||||
match ip {
|
match ip {
|
||||||
IpAddr::V6(v6) => v6
|
IpAddr::V6(v6) => v6
|
||||||
@@ -734,11 +849,7 @@ pub async fn handle_bad_client<R, W>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mask_host = config
|
let mask_host = mask_host_for_initial_data(config, initial_data);
|
||||||
.censorship
|
|
||||||
.mask_host
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(&config.censorship.tls_domain);
|
|
||||||
let mask_port = config.censorship.mask_port;
|
let mask_port = config.censorship.mask_port;
|
||||||
|
|
||||||
// Fail closed when fallback points at our own listener endpoint.
|
// Fail closed when fallback points at our own listener endpoint.
|
||||||
|
|||||||
@@ -960,6 +960,36 @@ async fn reservation_limit_failure_does_not_leak_curr_connects_counter() {
|
|||||||
assert_eq!(ip_tracker.get_active_ip_count(user).await, 0);
|
assert_eq!(ip_tracker.get_active_ip_count(user).await, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unlimited_unique_ip_user_is_still_visible_in_active_ip_tracker() {
|
||||||
|
let user = "active-ip-observed-user";
|
||||||
|
let config = crate::config::ProxyConfig::default();
|
||||||
|
let stats = Arc::new(crate::stats::Stats::new());
|
||||||
|
let ip_tracker = Arc::new(crate::ip_tracker::UserIpTracker::new());
|
||||||
|
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 200, 17)), 50017);
|
||||||
|
|
||||||
|
let reservation = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||||
|
user,
|
||||||
|
&config,
|
||||||
|
stats.clone(),
|
||||||
|
peer,
|
||||||
|
ip_tracker.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("reservation without unique-IP limit must succeed");
|
||||||
|
|
||||||
|
assert_eq!(stats.get_user_curr_connects(user), 1);
|
||||||
|
assert_eq!(
|
||||||
|
ip_tracker.get_active_ip_count(user).await,
|
||||||
|
1,
|
||||||
|
"active IP observability must not depend on unique-IP limit enforcement"
|
||||||
|
);
|
||||||
|
|
||||||
|
reservation.release().await;
|
||||||
|
assert_eq!(stats.get_user_curr_connects(user), 0);
|
||||||
|
assert_eq!(ip_tracker.get_active_ip_count(user).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn short_tls_probe_is_masked_through_client_pipeline() {
|
async fn short_tls_probe_is_masked_through_client_pipeline() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|||||||
@@ -669,6 +669,13 @@ fn adversarial_check_then_symlink_flip_is_blocked_by_nofollow_open() {
|
|||||||
"telemt-unknown-dc-check-open-race-{}",
|
"telemt-unknown-dc-check-open-race-{}",
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
|
if let Ok(meta) = fs::symlink_metadata(&parent) {
|
||||||
|
if meta.file_type().is_symlink() || meta.is_file() {
|
||||||
|
fs::remove_file(&parent).expect("stale check-open-race path must be removable");
|
||||||
|
} else {
|
||||||
|
fs::remove_dir_all(&parent).expect("stale check-open-race parent must be removable");
|
||||||
|
}
|
||||||
|
}
|
||||||
fs::create_dir_all(&parent).expect("check-open-race parent must be creatable");
|
fs::create_dir_all(&parent).expect("check-open-race parent must be creatable");
|
||||||
|
|
||||||
let target = parent.join("unknown-dc.log");
|
let target = parent.join("unknown-dc.log");
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::stats::{Stats, UserQuotaSnapshot};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct QuotaStateFile {
|
||||||
|
pub(crate) last_reset_epoch_secs: u64,
|
||||||
|
pub(crate) users: BTreeMap<String, QuotaUserState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct QuotaUserState {
|
||||||
|
pub(crate) used_bytes: u64,
|
||||||
|
pub(crate) last_reset_epoch_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_epoch_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_quota_state(path: &Path, stats: &Stats) {
|
||||||
|
let bytes = match tokio::fs::read(path).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
error = %error,
|
||||||
|
path = %path.display(),
|
||||||
|
"Failed to read quota state file"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = match serde_json::from_slice::<QuotaStateFile>(&bytes) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
error = %error,
|
||||||
|
path = %path.display(),
|
||||||
|
"Failed to parse quota state file"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let loaded_users = state.users.len();
|
||||||
|
for (user, quota) in state.users {
|
||||||
|
stats.load_user_quota_state(&user, quota.used_bytes, quota.last_reset_epoch_secs);
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
path = %path.display(),
|
||||||
|
loaded_users,
|
||||||
|
"Loaded per-user quota state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn save_quota_state(path: &Path, stats: &Stats) -> std::io::Result<()> {
|
||||||
|
let mut users = BTreeMap::new();
|
||||||
|
let mut last_reset_epoch_secs = 0;
|
||||||
|
for (user, quota) in stats.user_quota_snapshot() {
|
||||||
|
last_reset_epoch_secs = last_reset_epoch_secs.max(quota.last_reset_epoch_secs);
|
||||||
|
users.insert(user, quota_user_state(quota));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = QuotaStateFile {
|
||||||
|
last_reset_epoch_secs,
|
||||||
|
users,
|
||||||
|
};
|
||||||
|
write_state_file(path, &state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn reset_user_quota(
|
||||||
|
path: &Path,
|
||||||
|
stats: &Stats,
|
||||||
|
user: &str,
|
||||||
|
) -> std::io::Result<UserQuotaSnapshot> {
|
||||||
|
let snapshot = stats.reset_user_quota(user);
|
||||||
|
save_quota_state(path, stats).await?;
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_state_file(path: &Path, state: &QuotaStateFile) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent()
|
||||||
|
&& !parent.as_os_str().is_empty()
|
||||||
|
{
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp_path = path.with_extension(format!("tmp.{}", now_epoch_secs()));
|
||||||
|
let payload = serde_json::to_vec_pretty(state)?;
|
||||||
|
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||||
|
file.write_all(&payload).await?;
|
||||||
|
file.write_all(b"\n").await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
drop(file);
|
||||||
|
tokio::fs::rename(&tmp_path, path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_user_state(quota: UserQuotaSnapshot) -> QuotaUserState {
|
||||||
|
QuotaUserState {
|
||||||
|
used_bytes: quota.used_bytes,
|
||||||
|
last_reset_epoch_secs: quota.last_reset_epoch_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-8
@@ -74,16 +74,21 @@ impl BeobachtenStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut guard = self.inner.lock();
|
let entries = {
|
||||||
Self::cleanup(&mut guard, now, ttl);
|
let mut guard = self.inner.lock();
|
||||||
guard.last_cleanup = Some(now);
|
Self::cleanup(&mut guard, now, ttl);
|
||||||
|
guard.last_cleanup = Some(now);
|
||||||
|
|
||||||
|
guard
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|((class, ip), entry)| (class.clone(), *ip, entry.tries))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
let mut grouped = BTreeMap::<String, Vec<(IpAddr, u64)>>::new();
|
let mut grouped = BTreeMap::<String, Vec<(IpAddr, u64)>>::new();
|
||||||
for ((class, ip), entry) in &guard.entries {
|
for (class, ip, tries) in entries {
|
||||||
grouped
|
grouped.entry(class).or_default().push((ip, tries));
|
||||||
.entry(class.clone())
|
|
||||||
.or_default()
|
|
||||||
.push((*ip, entry.tries));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if grouped.is_empty() {
|
if grouped.is_empty() {
|
||||||
|
|||||||
+59
-1
@@ -8,7 +8,7 @@ pub mod telemetry;
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::VecDeque;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
@@ -297,9 +297,16 @@ pub struct UserStats {
|
|||||||
/// This counter is the single source of truth for quota enforcement and
|
/// This counter is the single source of truth for quota enforcement and
|
||||||
/// intentionally tracks attempted traffic, not guaranteed delivery.
|
/// intentionally tracks attempted traffic, not guaranteed delivery.
|
||||||
pub quota_used: AtomicU64,
|
pub quota_used: AtomicU64,
|
||||||
|
pub quota_last_reset_epoch_secs: AtomicU64,
|
||||||
pub last_seen_epoch_secs: AtomicU64,
|
pub last_seen_epoch_secs: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UserQuotaSnapshot {
|
||||||
|
pub used_bytes: u64,
|
||||||
|
pub last_reset_epoch_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum QuotaReserveError {
|
pub enum QuotaReserveError {
|
||||||
LimitExceeded,
|
LimitExceeded,
|
||||||
@@ -2408,6 +2415,52 @@ impl Stats {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_user_quota_state(
|
||||||
|
&self,
|
||||||
|
user: &str,
|
||||||
|
used_bytes: u64,
|
||||||
|
last_reset_epoch_secs: u64,
|
||||||
|
) {
|
||||||
|
let stats = self.get_or_create_user_stats_handle(user);
|
||||||
|
stats.quota_used.store(used_bytes, Ordering::Relaxed);
|
||||||
|
stats
|
||||||
|
.quota_last_reset_epoch_secs
|
||||||
|
.store(last_reset_epoch_secs, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_user_quota(&self, user: &str) -> UserQuotaSnapshot {
|
||||||
|
let stats = self.get_or_create_user_stats_handle(user);
|
||||||
|
let last_reset_epoch_secs = Self::now_epoch_secs();
|
||||||
|
stats.quota_used.store(0, Ordering::Relaxed);
|
||||||
|
stats
|
||||||
|
.quota_last_reset_epoch_secs
|
||||||
|
.store(last_reset_epoch_secs, Ordering::Relaxed);
|
||||||
|
UserQuotaSnapshot {
|
||||||
|
used_bytes: 0,
|
||||||
|
last_reset_epoch_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_quota_snapshot(&self) -> HashMap<String, UserQuotaSnapshot> {
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
for entry in self.user_stats.iter() {
|
||||||
|
let stats = entry.value();
|
||||||
|
let used_bytes = stats.quota_used.load(Ordering::Relaxed);
|
||||||
|
let last_reset_epoch_secs = stats.quota_last_reset_epoch_secs.load(Ordering::Relaxed);
|
||||||
|
if used_bytes == 0 && last_reset_epoch_secs == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.insert(
|
||||||
|
entry.key().clone(),
|
||||||
|
UserQuotaSnapshot {
|
||||||
|
used_bytes,
|
||||||
|
last_reset_epoch_secs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_handshake_timeouts(&self) -> u64 {
|
pub fn get_handshake_timeouts(&self) -> u64 {
|
||||||
self.handshake_timeouts.load(Ordering::Relaxed)
|
self.handshake_timeouts.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
@@ -2477,6 +2530,11 @@ impl Stats {
|
|||||||
self.user_stats.iter()
|
self.user_stats.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current number of retained per-user stats entries.
|
||||||
|
pub fn user_stats_len(&self) -> usize {
|
||||||
|
self.user_stats.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn uptime_secs(&self) -> f64 {
|
pub fn uptime_secs(&self) -> f64 {
|
||||||
self.start_time
|
self.start_time
|
||||||
.read()
|
.read()
|
||||||
|
|||||||
+34
-24
@@ -277,6 +277,7 @@ impl StreamState for TlsReaderState {
|
|||||||
pub struct FakeTlsReader<R> {
|
pub struct FakeTlsReader<R> {
|
||||||
upstream: R,
|
upstream: R,
|
||||||
state: TlsReaderState,
|
state: TlsReaderState,
|
||||||
|
body_scratch: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> FakeTlsReader<R> {
|
impl<R> FakeTlsReader<R> {
|
||||||
@@ -284,6 +285,7 @@ impl<R> FakeTlsReader<R> {
|
|||||||
Self {
|
Self {
|
||||||
upstream,
|
upstream,
|
||||||
state: TlsReaderState::Idle,
|
state: TlsReaderState::Idle,
|
||||||
|
body_scratch: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +441,13 @@ impl<R: AsyncRead + Unpin> AsyncRead for FakeTlsReader<R> {
|
|||||||
length,
|
length,
|
||||||
mut buffer,
|
mut buffer,
|
||||||
} => {
|
} => {
|
||||||
let result = poll_read_body(&mut this.upstream, cx, &mut buffer, length);
|
let result = poll_read_body(
|
||||||
|
&mut this.upstream,
|
||||||
|
cx,
|
||||||
|
&mut buffer,
|
||||||
|
length,
|
||||||
|
&mut this.body_scratch,
|
||||||
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
BodyPollResult::Pending => {
|
BodyPollResult::Pending => {
|
||||||
@@ -558,34 +566,36 @@ fn poll_read_body<R: AsyncRead + Unpin>(
|
|||||||
cx: &mut Context<'_>,
|
cx: &mut Context<'_>,
|
||||||
buffer: &mut BytesMut,
|
buffer: &mut BytesMut,
|
||||||
target_len: usize,
|
target_len: usize,
|
||||||
|
scratch: &mut Vec<u8>,
|
||||||
) -> BodyPollResult {
|
) -> BodyPollResult {
|
||||||
// NOTE: This implementation uses a temporary Vec to avoid tricky borrow/lifetime
|
|
||||||
// issues with BytesMut spare capacity and ReadBuf across polls.
|
|
||||||
// It's safe and correct; optimization is possible if needed.
|
|
||||||
while buffer.len() < target_len {
|
while buffer.len() < target_len {
|
||||||
let remaining = target_len - buffer.len();
|
let remaining = target_len - buffer.len();
|
||||||
|
let chunk_len = remaining.min(8192);
|
||||||
|
|
||||||
let mut temp = vec![0u8; remaining.min(8192)];
|
if scratch.len() < chunk_len {
|
||||||
let mut read_buf = ReadBuf::new(&mut temp);
|
scratch.resize(chunk_len, 0);
|
||||||
|
|
||||||
match Pin::new(&mut *upstream).poll_read(cx, &mut read_buf) {
|
|
||||||
Poll::Pending => return BodyPollResult::Pending,
|
|
||||||
Poll::Ready(Err(e)) => return BodyPollResult::Error(e),
|
|
||||||
Poll::Ready(Ok(())) => {
|
|
||||||
let n = read_buf.filled().len();
|
|
||||||
if n == 0 {
|
|
||||||
return BodyPollResult::Error(Error::new(
|
|
||||||
ErrorKind::UnexpectedEof,
|
|
||||||
format!(
|
|
||||||
"unexpected EOF in TLS body (got {} of {} bytes)",
|
|
||||||
buffer.len(),
|
|
||||||
target_len
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
buffer.extend_from_slice(&temp[..n]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let n = {
|
||||||
|
let mut read_buf = ReadBuf::new(&mut scratch[..chunk_len]);
|
||||||
|
match Pin::new(&mut *upstream).poll_read(cx, &mut read_buf) {
|
||||||
|
Poll::Pending => return BodyPollResult::Pending,
|
||||||
|
Poll::Ready(Err(e)) => return BodyPollResult::Error(e),
|
||||||
|
Poll::Ready(Ok(())) => read_buf.filled().len(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
return BodyPollResult::Error(Error::new(
|
||||||
|
ErrorKind::UnexpectedEof,
|
||||||
|
format!(
|
||||||
|
"unexpected EOF in TLS body (got {} of {} bytes)",
|
||||||
|
buffer.len(),
|
||||||
|
target_len
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
buffer.extend_from_slice(&scratch[..n]);
|
||||||
}
|
}
|
||||||
|
|
||||||
BodyPollResult::Complete(buffer.split().freeze())
|
BodyPollResult::Complete(buffer.split().freeze())
|
||||||
|
|||||||
@@ -559,9 +559,7 @@ async fn mass_reconnect_sync_cleanup_prevents_temporary_reservation_bloat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn adversarial_drain_cleanup_queue_race_does_not_cause_false_rejections() {
|
async fn adversarial_drain_cleanup_queue_race_does_not_deadlock_or_exceed_limit() {
|
||||||
// Regression guard: concurrent cleanup draining must not produce false
|
|
||||||
// limit denials for a new IP when the previous IP is already queued.
|
|
||||||
let tracker = Arc::new(UserIpTracker::new());
|
let tracker = Arc::new(UserIpTracker::new());
|
||||||
tracker.set_user_limit("racer", 1).await;
|
tracker.set_user_limit("racer", 1).await;
|
||||||
let ip1 = ip_from_idx(1);
|
let ip1 = ip_from_idx(1);
|
||||||
@@ -573,7 +571,6 @@ async fn adversarial_drain_cleanup_queue_race_does_not_cause_false_rejections()
|
|||||||
// User disconnects from ip1, queuing it
|
// User disconnects from ip1, queuing it
|
||||||
tracker.enqueue_cleanup("racer".to_string(), ip1);
|
tracker.enqueue_cleanup("racer".to_string(), ip1);
|
||||||
|
|
||||||
let mut saw_false_rejection = false;
|
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
// Queue cleanup then race explicit drain and check-and-add on the alternative IP.
|
// Queue cleanup then race explicit drain and check-and-add on the alternative IP.
|
||||||
tracker.enqueue_cleanup("racer".to_string(), ip1);
|
tracker.enqueue_cleanup("racer".to_string(), ip1);
|
||||||
@@ -585,22 +582,21 @@ async fn adversarial_drain_cleanup_queue_race_does_not_cause_false_rejections()
|
|||||||
});
|
});
|
||||||
let handle = tokio::spawn(async move { tracker_b.check_and_add("racer", ip2).await });
|
let handle = tokio::spawn(async move { tracker_b.check_and_add("racer", ip2).await });
|
||||||
|
|
||||||
drain_handle.await.unwrap();
|
tokio::time::timeout(Duration::from_secs(1), drain_handle)
|
||||||
let res = handle.await.unwrap();
|
.await
|
||||||
if res.is_err() {
|
.expect("cleanup drain must not deadlock")
|
||||||
saw_false_rejection = true;
|
.unwrap();
|
||||||
break;
|
let _ = tokio::time::timeout(Duration::from_secs(1), handle)
|
||||||
}
|
.await
|
||||||
|
.expect("admission must not deadlock")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Restore baseline for next iteration.
|
assert!(tracker.get_active_ip_count("racer").await <= 1);
|
||||||
|
tracker.drain_cleanup_queue().await;
|
||||||
tracker.remove_ip("racer", ip2).await;
|
tracker.remove_ip("racer", ip2).await;
|
||||||
|
tracker.remove_ip("racer", ip1).await;
|
||||||
tracker.check_and_add("racer", ip1).await.unwrap();
|
tracker.check_and_add("racer", ip1).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
|
||||||
!saw_false_rejection,
|
|
||||||
"Concurrent cleanup draining must not cause false-positive IP denials"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+355
-14
@@ -1,26 +1,71 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant, SystemTime};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::tls_front::types::{
|
use crate::tls_front::types::{
|
||||||
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult,
|
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FULL_CERT_SENT_SWEEP_INTERVAL_SECS: u64 = 30;
|
||||||
|
const FULL_CERT_SENT_MAX_IPS: usize = 65_536;
|
||||||
|
const FULL_CERT_SENT_SHARDS: usize = 64;
|
||||||
|
|
||||||
|
static FULL_CERT_SENT_IPS_GAUGE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static FULL_CERT_SENT_CAP_DROPS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Current number of IPs tracked by the TLS full-cert budget gate.
|
||||||
|
pub(crate) fn full_cert_sent_ips_for_metrics() -> u64 {
|
||||||
|
FULL_CERT_SENT_IPS_GAUGE.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of new IPs denied a full-cert budget slot because the cap was reached.
|
||||||
|
pub(crate) fn full_cert_sent_cap_drops_for_metrics() -> u64 {
|
||||||
|
FULL_CERT_SENT_CAP_DROPS.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Lightweight in-memory + optional on-disk cache for TLS fronting data.
|
/// Lightweight in-memory + optional on-disk cache for TLS fronting data.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TlsFrontCache {
|
pub struct TlsFrontCache {
|
||||||
memory: RwLock<HashMap<String, Arc<CachedTlsData>>>,
|
memory: RwLock<HashMap<String, Arc<CachedTlsData>>>,
|
||||||
default: Arc<CachedTlsData>,
|
default: Arc<CachedTlsData>,
|
||||||
full_cert_sent: RwLock<HashMap<IpAddr, Instant>>,
|
full_cert_sent_shards: Vec<RwLock<HashMap<IpAddr, Instant>>>,
|
||||||
|
full_cert_sent_last_sweep_epoch_secs: AtomicU64,
|
||||||
disk_path: PathBuf,
|
disk_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only health view for one configured TLS front domain.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct TlsFrontProfileHealth {
|
||||||
|
pub(crate) domain: String,
|
||||||
|
pub(crate) source: &'static str,
|
||||||
|
pub(crate) age_seconds: u64,
|
||||||
|
pub(crate) is_default: bool,
|
||||||
|
pub(crate) has_cert_info: bool,
|
||||||
|
pub(crate) has_cert_payload: bool,
|
||||||
|
pub(crate) app_data_records: usize,
|
||||||
|
pub(crate) ticket_records: usize,
|
||||||
|
pub(crate) change_cipher_spec_count: u8,
|
||||||
|
pub(crate) total_app_data_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_source_label(source: TlsProfileSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
TlsProfileSource::Default => "default",
|
||||||
|
TlsProfileSource::Raw => "raw",
|
||||||
|
TlsProfileSource::Rustls => "rustls",
|
||||||
|
TlsProfileSource::Merged => "merged",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
impl TlsFrontCache {
|
impl TlsFrontCache {
|
||||||
pub fn new(domains: &[String], default_len: usize, disk_path: impl AsRef<Path>) -> Self {
|
pub fn new(domains: &[String], default_len: usize, disk_path: impl AsRef<Path>) -> Self {
|
||||||
@@ -52,7 +97,10 @@ impl TlsFrontCache {
|
|||||||
Self {
|
Self {
|
||||||
memory: RwLock::new(map),
|
memory: RwLock::new(map),
|
||||||
default,
|
default,
|
||||||
full_cert_sent: RwLock::new(HashMap::new()),
|
full_cert_sent_shards: (0..FULL_CERT_SENT_SHARDS)
|
||||||
|
.map(|_| RwLock::new(HashMap::new()))
|
||||||
|
.collect(),
|
||||||
|
full_cert_sent_last_sweep_epoch_secs: AtomicU64::new(0),
|
||||||
disk_path: disk_path.as_ref().to_path_buf(),
|
disk_path: disk_path.as_ref().to_path_buf(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,22 +117,128 @@ impl TlsFrontCache {
|
|||||||
self.memory.read().await.contains_key(domain)
|
self.memory.read().await.contains_key(domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn profile_health_snapshot(
|
||||||
|
&self,
|
||||||
|
domains: &[String],
|
||||||
|
max_domains: usize,
|
||||||
|
) -> (Vec<TlsFrontProfileHealth>, usize) {
|
||||||
|
let guard = self.memory.read().await;
|
||||||
|
let now = SystemTime::now();
|
||||||
|
let mut snapshot = Vec::with_capacity(domains.len().min(max_domains));
|
||||||
|
let mut suppressed = 0usize;
|
||||||
|
|
||||||
|
for domain in domains {
|
||||||
|
if snapshot.len() >= max_domains {
|
||||||
|
suppressed = suppressed.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached = guard
|
||||||
|
.get(domain)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| self.default.clone());
|
||||||
|
let behavior = &cached.behavior_profile;
|
||||||
|
let age_seconds = now
|
||||||
|
.duration_since(cached.fetched_at)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
snapshot.push(TlsFrontProfileHealth {
|
||||||
|
domain: domain.clone(),
|
||||||
|
source: profile_source_label(behavior.source),
|
||||||
|
age_seconds,
|
||||||
|
is_default: cached.domain == "default",
|
||||||
|
has_cert_info: cached.cert_info.is_some(),
|
||||||
|
has_cert_payload: cached.cert_payload.is_some(),
|
||||||
|
app_data_records: cached.app_data_records_sizes.len().max(
|
||||||
|
behavior.app_data_record_sizes.len(),
|
||||||
|
),
|
||||||
|
ticket_records: behavior.ticket_record_sizes.len(),
|
||||||
|
change_cipher_spec_count: behavior.change_cipher_spec_count,
|
||||||
|
total_app_data_len: cached.total_app_data_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(snapshot, suppressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_cert_sent_shard_index(client_ip: IpAddr) -> usize {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
client_ip.hash(&mut hasher);
|
||||||
|
(hasher.finish() as usize) % FULL_CERT_SENT_SHARDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_cert_sent_shard(&self, client_ip: IpAddr) -> &RwLock<HashMap<IpAddr, Instant>> {
|
||||||
|
&self.full_cert_sent_shards[Self::full_cert_sent_shard_index(client_ip)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_full_cert_sent_entries(amount: usize) {
|
||||||
|
if amount == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let amount = amount as u64;
|
||||||
|
let _ =
|
||||||
|
FULL_CERT_SENT_IPS_GAUGE.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |current| {
|
||||||
|
Some(current.saturating_sub(amount))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_reserve_full_cert_sent_entry() -> bool {
|
||||||
|
let mut current = FULL_CERT_SENT_IPS_GAUGE.load(Ordering::Relaxed);
|
||||||
|
loop {
|
||||||
|
if current >= FULL_CERT_SENT_MAX_IPS as u64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match FULL_CERT_SENT_IPS_GAUGE.compare_exchange_weak(
|
||||||
|
current,
|
||||||
|
current.saturating_add(1),
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
) {
|
||||||
|
Ok(_) => return true,
|
||||||
|
Err(actual) => current = actual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sweep_full_cert_sent_shards(&self, now: Instant, ttl: Duration) {
|
||||||
|
for shard in &self.full_cert_sent_shards {
|
||||||
|
let mut guard = shard.write().await;
|
||||||
|
let before = guard.len();
|
||||||
|
guard.retain(|_, seen_at| now.duration_since(*seen_at) < ttl);
|
||||||
|
Self::decrement_full_cert_sent_entries(before.saturating_sub(guard.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true when full cert payload should be sent for client_ip
|
/// Returns true when full cert payload should be sent for client_ip
|
||||||
/// according to TTL policy.
|
/// according to TTL policy.
|
||||||
pub async fn take_full_cert_budget_for_ip(&self, client_ip: IpAddr, ttl: Duration) -> bool {
|
pub async fn take_full_cert_budget_for_ip(&self, client_ip: IpAddr, ttl: Duration) -> bool {
|
||||||
if ttl.is_zero() {
|
if ttl.is_zero() {
|
||||||
self.full_cert_sent
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(client_ip, Instant::now());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut guard = self.full_cert_sent.write().await;
|
let now_epoch_secs = SystemTime::now()
|
||||||
guard.retain(|_, seen_at| now.duration_since(*seen_at) < ttl);
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let should_sweep = self
|
||||||
|
.full_cert_sent_last_sweep_epoch_secs
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |last_sweep| {
|
||||||
|
if now_epoch_secs.saturating_sub(last_sweep) >= FULL_CERT_SENT_SWEEP_INTERVAL_SECS {
|
||||||
|
Some(now_epoch_secs)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
match guard.get_mut(&client_ip) {
|
if should_sweep {
|
||||||
|
self.sweep_full_cert_sent_shards(now, ttl).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut guard = self.full_cert_sent_shard(client_ip).write().await;
|
||||||
|
let allowed = match guard.get_mut(&client_ip) {
|
||||||
Some(seen_at) => {
|
Some(seen_at) => {
|
||||||
if now.duration_since(*seen_at) >= ttl {
|
if now.duration_since(*seen_at) >= ttl {
|
||||||
*seen_at = now;
|
*seen_at = now;
|
||||||
@@ -94,12 +248,43 @@ impl TlsFrontCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
if !Self::try_reserve_full_cert_sent_entry() {
|
||||||
|
FULL_CERT_SENT_CAP_DROPS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
guard.insert(client_ip, now);
|
guard.insert(client_ip, now);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn insert_full_cert_sent_for_tests(&self, client_ip: IpAddr, seen_at: Instant) {
|
||||||
|
let mut guard = self.full_cert_sent_shard(client_ip).write().await;
|
||||||
|
if guard.insert(client_ip, seen_at).is_none() {
|
||||||
|
FULL_CERT_SENT_IPS_GAUGE.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn full_cert_sent_is_empty_for_tests(&self) -> bool {
|
||||||
|
for shard in &self.full_cert_sent_shards {
|
||||||
|
if !shard.read().await.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn full_cert_sent_contains_for_tests(&self, client_ip: IpAddr) -> bool {
|
||||||
|
self.full_cert_sent_shard(client_ip)
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.contains_key(&client_ip)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set(&self, domain: &str, data: CachedTlsData) {
|
pub async fn set(&self, domain: &str, data: CachedTlsData) {
|
||||||
let mut guard = self.memory.write().await;
|
let mut guard = self.memory.write().await;
|
||||||
guard.insert(domain.to_string(), Arc::new(data));
|
guard.insert(domain.to_string(), Arc::new(data));
|
||||||
@@ -130,6 +315,14 @@ impl TlsFrontCache {
|
|||||||
warn!(file = %name, "Skipping TLS cache entry with invalid domain");
|
warn!(file = %name, "Skipping TLS cache entry with invalid domain");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !cert_info_matches_domain(&cached) {
|
||||||
|
warn!(
|
||||||
|
file = %name,
|
||||||
|
domain = %cached.domain,
|
||||||
|
"Skipping TLS cache entry with mismatched certificate metadata"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// fetched_at is skipped during deserialization; approximate with file mtime if available.
|
// fetched_at is skipped during deserialization; approximate with file mtime if available.
|
||||||
if let Ok(meta) = entry.metadata().await
|
if let Ok(meta) = entry.metadata().await
|
||||||
&& let Ok(modified) = meta.modified()
|
&& let Ok(modified) = meta.modified()
|
||||||
@@ -209,10 +402,100 @@ impl TlsFrontCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cert_info_matches_domain(cached: &CachedTlsData) -> bool {
|
||||||
|
let Some(cert_info) = cached.cert_info.as_ref() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !cert_info.san_names.is_empty() {
|
||||||
|
return cert_info
|
||||||
|
.san_names
|
||||||
|
.iter()
|
||||||
|
.any(|name| dns_name_matches_domain(name, &cached.domain));
|
||||||
|
}
|
||||||
|
cert_info
|
||||||
|
.subject_cn
|
||||||
|
.as_deref()
|
||||||
|
.map_or(true, |name| dns_name_matches_domain(name, &cached.domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dns_name_matches_domain(pattern: &str, domain: &str) -> bool {
|
||||||
|
let pattern = normalize_dns_name(pattern);
|
||||||
|
let domain = normalize_dns_name(domain);
|
||||||
|
if pattern == domain {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(suffix) = pattern.strip_prefix("*.") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(prefix) = domain.strip_suffix(suffix) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
prefix.ends_with('.') && !prefix[..prefix.len() - 1].contains('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_dns_name(value: &str) -> String {
|
||||||
|
value.trim().trim_end_matches('.').to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn cached_with_cert_info(
|
||||||
|
domain: &str,
|
||||||
|
subject_cn: Option<&str>,
|
||||||
|
san_names: Vec<&str>,
|
||||||
|
) -> CachedTlsData {
|
||||||
|
CachedTlsData {
|
||||||
|
server_hello_template: ParsedServerHello {
|
||||||
|
version: [0x03, 0x03],
|
||||||
|
random: [0u8; 32],
|
||||||
|
session_id: Vec::new(),
|
||||||
|
cipher_suite: [0x13, 0x01],
|
||||||
|
compression: 0,
|
||||||
|
extensions: Vec::new(),
|
||||||
|
},
|
||||||
|
cert_info: Some(crate::tls_front::types::ParsedCertificateInfo {
|
||||||
|
not_after_unix: None,
|
||||||
|
not_before_unix: None,
|
||||||
|
issuer_cn: None,
|
||||||
|
subject_cn: subject_cn.map(str::to_string),
|
||||||
|
san_names: san_names.into_iter().map(str::to_string).collect(),
|
||||||
|
}),
|
||||||
|
cert_payload: None,
|
||||||
|
app_data_records_sizes: vec![1024],
|
||||||
|
total_app_data_len: 1024,
|
||||||
|
behavior_profile: TlsBehaviorProfile::default(),
|
||||||
|
fetched_at: SystemTime::now(),
|
||||||
|
domain: domain.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_info_domain_match_accepts_exact_san() {
|
||||||
|
let cached = cached_with_cert_info("b.com", Some("a.com"), vec!["b.com"]);
|
||||||
|
assert!(cert_info_matches_domain(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_info_domain_match_rejects_wrong_san() {
|
||||||
|
let cached = cached_with_cert_info("b.com", Some("b.com"), vec!["a.com"]);
|
||||||
|
assert!(!cert_info_matches_domain(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_info_domain_match_accepts_single_label_wildcard_san() {
|
||||||
|
let cached = cached_with_cert_info("api.b.com", None, vec!["*.b.com"]);
|
||||||
|
assert!(cert_info_matches_domain(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_info_domain_match_rejects_multi_label_wildcard_san() {
|
||||||
|
let cached = cached_with_cert_info("deep.api.b.com", None, vec!["*.b.com"]);
|
||||||
|
assert!(!cert_info_matches_domain(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_take_full_cert_budget_for_ip_uses_ttl() {
|
async fn test_take_full_cert_budget_for_ip_uses_ttl() {
|
||||||
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");
|
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");
|
||||||
@@ -230,10 +513,68 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_take_full_cert_budget_for_ip_zero_ttl_always_allows_full_payload() {
|
async fn test_take_full_cert_budget_for_ip_zero_ttl_always_allows_full_payload() {
|
||||||
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");
|
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");
|
||||||
let ip: IpAddr = "127.0.0.1".parse().expect("ip");
|
|
||||||
let ttl = Duration::ZERO;
|
let ttl = Duration::ZERO;
|
||||||
|
|
||||||
assert!(cache.take_full_cert_budget_for_ip(ip, ttl).await);
|
for idx in 0..100_000u32 {
|
||||||
assert!(cache.take_full_cert_budget_for_ip(ip, ttl).await);
|
let ip = IpAddr::V4(std::net::Ipv4Addr::new(
|
||||||
|
10,
|
||||||
|
((idx >> 16) & 0xff) as u8,
|
||||||
|
((idx >> 8) & 0xff) as u8,
|
||||||
|
(idx & 0xff) as u8,
|
||||||
|
));
|
||||||
|
assert!(cache.take_full_cert_budget_for_ip(ip, ttl).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(cache.full_cert_sent_is_empty_for_tests().await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_take_full_cert_budget_for_ip_sweeps_expired_entries_when_due() {
|
||||||
|
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");
|
||||||
|
let stale_ip: IpAddr = "127.0.0.1".parse().expect("ip");
|
||||||
|
let new_ip: IpAddr = "127.0.0.2".parse().expect("ip");
|
||||||
|
let ttl = Duration::from_secs(1);
|
||||||
|
let stale_seen_at = Instant::now()
|
||||||
|
.checked_sub(Duration::from_secs(10))
|
||||||
|
.unwrap_or_else(Instant::now);
|
||||||
|
|
||||||
|
cache
|
||||||
|
.insert_full_cert_sent_for_tests(stale_ip, stale_seen_at)
|
||||||
|
.await;
|
||||||
|
cache
|
||||||
|
.full_cert_sent_last_sweep_epoch_secs
|
||||||
|
.store(0, Ordering::Relaxed);
|
||||||
|
|
||||||
|
assert!(cache.take_full_cert_budget_for_ip(new_ip, ttl).await);
|
||||||
|
|
||||||
|
assert!(!cache.full_cert_sent_contains_for_tests(stale_ip).await);
|
||||||
|
assert!(cache.full_cert_sent_contains_for_tests(new_ip).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_take_full_cert_budget_for_ip_does_not_sweep_every_call() {
|
||||||
|
let cache = TlsFrontCache::new(&["example.com".to_string()], 1024, "tlsfront-test-cache");
|
||||||
|
let stale_ip: IpAddr = "127.0.0.1".parse().expect("ip");
|
||||||
|
let new_ip: IpAddr = "127.0.0.2".parse().expect("ip");
|
||||||
|
let ttl = Duration::from_secs(1);
|
||||||
|
let stale_seen_at = Instant::now()
|
||||||
|
.checked_sub(Duration::from_secs(10))
|
||||||
|
.unwrap_or_else(Instant::now);
|
||||||
|
let now_epoch_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
cache
|
||||||
|
.insert_full_cert_sent_for_tests(stale_ip, stale_seen_at)
|
||||||
|
.await;
|
||||||
|
cache
|
||||||
|
.full_cert_sent_last_sweep_epoch_secs
|
||||||
|
.store(now_epoch_secs, Ordering::Relaxed);
|
||||||
|
|
||||||
|
assert!(cache.take_full_cert_budget_for_ip(new_ip, ttl).await);
|
||||||
|
|
||||||
|
assert!(cache.full_cert_sent_contains_for_tests(stale_ip).await);
|
||||||
|
assert!(cache.full_cert_sent_contains_for_tests(new_ip).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
@@ -144,12 +146,37 @@ enum FetchErrorKind {
|
|||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROFILE_CACHE_MAX_ENTRIES: usize = 4096;
|
||||||
|
|
||||||
static PROFILE_CACHE: OnceLock<DashMap<ProfileCacheKey, ProfileCacheValue>> = OnceLock::new();
|
static PROFILE_CACHE: OnceLock<DashMap<ProfileCacheKey, ProfileCacheValue>> = OnceLock::new();
|
||||||
|
static PROFILE_CACHE_INSERT_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
static PROFILE_CACHE_CAP_DROPS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
fn profile_cache() -> &'static DashMap<ProfileCacheKey, ProfileCacheValue> {
|
fn profile_cache() -> &'static DashMap<ProfileCacheKey, ProfileCacheValue> {
|
||||||
PROFILE_CACHE.get_or_init(DashMap::new)
|
PROFILE_CACHE.get_or_init(DashMap::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn profile_cache_insert_guard() -> &'static Mutex<()> {
|
||||||
|
PROFILE_CACHE_INSERT_GUARD.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sweep_expired_profile_cache(ttl: Duration, now: Instant) {
|
||||||
|
if ttl.is_zero() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profile_cache().retain(|_, value| now.saturating_duration_since(value.updated_at) <= ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current number of adaptive TLS fetch profile-cache entries.
|
||||||
|
pub(crate) fn profile_cache_entries_for_metrics() -> usize {
|
||||||
|
profile_cache().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of fresh profile-cache winners skipped because the cache was full.
|
||||||
|
pub(crate) fn profile_cache_cap_drops_for_metrics() -> u64 {
|
||||||
|
PROFILE_CACHE_CAP_DROPS.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
fn route_hint(
|
fn route_hint(
|
||||||
upstream: Option<&std::sync::Arc<crate::transport::UpstreamManager>>,
|
upstream: Option<&std::sync::Arc<crate::transport::UpstreamManager>>,
|
||||||
unix_sock: Option<&str>,
|
unix_sock: Option<&str>,
|
||||||
@@ -267,6 +294,43 @@ fn remember_profile_success(
|
|||||||
let Some(key) = cache_key else {
|
let Some(key) = cache_key else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
remember_profile_success_with_cap(strategy, key, profile, now, PROFILE_CACHE_MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_profile_success_with_cap(
|
||||||
|
strategy: &TlsFetchStrategy,
|
||||||
|
key: ProfileCacheKey,
|
||||||
|
profile: TlsFetchProfile,
|
||||||
|
now: Instant,
|
||||||
|
max_entries: usize,
|
||||||
|
) {
|
||||||
|
let Ok(_guard) = profile_cache_insert_guard().lock() else {
|
||||||
|
PROFILE_CACHE_CAP_DROPS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if max_entries == 0 {
|
||||||
|
PROFILE_CACHE_CAP_DROPS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if profile_cache().contains_key(&key) {
|
||||||
|
profile_cache().insert(
|
||||||
|
key,
|
||||||
|
ProfileCacheValue {
|
||||||
|
profile,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if profile_cache().len() >= max_entries {
|
||||||
|
// TLS fetch is control-plane work; sweeping under a tiny mutex keeps
|
||||||
|
// profile-cache cardinality hard-bounded without touching relay hot paths.
|
||||||
|
sweep_expired_profile_cache(strategy.profile_cache_ttl, now);
|
||||||
|
}
|
||||||
|
if profile_cache().len() >= max_entries {
|
||||||
|
PROFILE_CACHE_CAP_DROPS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
profile_cache().insert(
|
profile_cache().insert(
|
||||||
key,
|
key,
|
||||||
ProfileCacheValue {
|
ProfileCacheValue {
|
||||||
|
|||||||
@@ -618,13 +618,9 @@ impl MePool {
|
|||||||
me_route_hybrid_max_wait: Duration::from_millis(
|
me_route_hybrid_max_wait: Duration::from_millis(
|
||||||
me_route_hybrid_max_wait_ms.max(50),
|
me_route_hybrid_max_wait_ms.max(50),
|
||||||
),
|
),
|
||||||
me_route_blocking_send_timeout: if me_route_blocking_send_timeout_ms == 0 {
|
me_route_blocking_send_timeout: Some(Duration::from_millis(
|
||||||
None
|
me_route_blocking_send_timeout_ms.clamp(1, 5_000),
|
||||||
} else {
|
)),
|
||||||
Some(Duration::from_millis(
|
|
||||||
me_route_blocking_send_timeout_ms.min(5_000),
|
|
||||||
))
|
|
||||||
},
|
|
||||||
me_route_last_success_epoch_ms: AtomicU64::new(0),
|
me_route_last_success_epoch_ms: AtomicU64::new(0),
|
||||||
me_route_hybrid_timeout_warn_epoch_ms: AtomicU64::new(0),
|
me_route_hybrid_timeout_warn_epoch_ms: AtomicU64::new(0),
|
||||||
me_async_recovery_last_trigger_epoch_ms: AtomicU64::new(0),
|
me_async_recovery_last_trigger_epoch_ms: AtomicU64::new(0),
|
||||||
|
|||||||
@@ -365,7 +365,10 @@ impl MePool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn zero_downtime_reinit_after_map_change(self: &Arc<Self>, rng: &SecureRandom) {
|
pub async fn zero_downtime_reinit_after_map_change(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
) -> bool {
|
||||||
let desired_by_dc = self.desired_dc_endpoints().await;
|
let desired_by_dc = self.desired_dc_endpoints().await;
|
||||||
let now_epoch_secs = Self::now_epoch_secs();
|
let now_epoch_secs = Self::now_epoch_secs();
|
||||||
let v4_suppressed = self.is_family_temporarily_suppressed(IpFamily::V4, now_epoch_secs);
|
let v4_suppressed = self.is_family_temporarily_suppressed(IpFamily::V4, now_epoch_secs);
|
||||||
@@ -380,7 +383,7 @@ impl MePool {
|
|||||||
MeDrainGateReason::CoverageQuorum
|
MeDrainGateReason::CoverageQuorum
|
||||||
};
|
};
|
||||||
self.set_last_drain_gate(false, false, reason, now_epoch_secs);
|
self.set_last_drain_gate(false, false, reason, now_epoch_secs);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let desired_map_hash = Self::desired_map_hash(&desired_by_dc);
|
let desired_map_hash = Self::desired_map_hash(&desired_by_dc);
|
||||||
@@ -490,7 +493,7 @@ impl MePool {
|
|||||||
missing_dc = ?missing_dc,
|
missing_dc = ?missing_dc,
|
||||||
"ME reinit coverage below threshold; keeping stale writers"
|
"ME reinit coverage below threshold; keeping stale writers"
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if hardswap {
|
if hardswap {
|
||||||
@@ -520,7 +523,7 @@ impl MePool {
|
|||||||
missing_dc = ?fresh_missing_dc,
|
missing_dc = ?fresh_missing_dc,
|
||||||
"ME hardswap pending: fresh generation DC coverage incomplete"
|
"ME hardswap pending: fresh generation DC coverage incomplete"
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +570,7 @@ impl MePool {
|
|||||||
self.clear_pending_hardswap_state();
|
self.clear_pending_hardswap_state();
|
||||||
}
|
}
|
||||||
debug!("ME reinit cycle completed with no stale writers");
|
debug!("ME reinit cycle completed with no stale writers");
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let drain_timeout = self.force_close_timeout();
|
let drain_timeout = self.force_close_timeout();
|
||||||
@@ -606,10 +609,11 @@ impl MePool {
|
|||||||
if hardswap {
|
if hardswap {
|
||||||
self.clear_pending_hardswap_state();
|
self.clear_pending_hardswap_state();
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn zero_downtime_reinit_periodic(self: &Arc<Self>, rng: &SecureRandom) {
|
pub async fn zero_downtime_reinit_periodic(self: &Arc<Self>, rng: &SecureRandom) -> bool {
|
||||||
self.zero_downtime_reinit_after_map_change(rng).await;
|
self.zero_downtime_reinit_after_map_change(rng).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub async fn me_reinit_scheduler(
|
|||||||
rng: Arc<SecureRandom>,
|
rng: Arc<SecureRandom>,
|
||||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||||
mut trigger_rx: mpsc::Receiver<MeReinitTrigger>,
|
mut trigger_rx: mpsc::Receiver<MeReinitTrigger>,
|
||||||
|
me_ready_tx: watch::Sender<u64>,
|
||||||
) {
|
) {
|
||||||
info!("ME reinit scheduler started");
|
info!("ME reinit scheduler started");
|
||||||
loop {
|
loop {
|
||||||
@@ -90,15 +91,25 @@ pub async fn me_reinit_scheduler(
|
|||||||
|
|
||||||
if cfg.general.me_reinit_singleflight {
|
if cfg.general.me_reinit_singleflight {
|
||||||
debug!(reason, "ME reinit scheduled (single-flight)");
|
debug!(reason, "ME reinit scheduled (single-flight)");
|
||||||
pool.zero_downtime_reinit_periodic(rng.as_ref()).await;
|
if pool.zero_downtime_reinit_periodic(rng.as_ref()).await {
|
||||||
|
me_ready_tx.send_modify(|version| {
|
||||||
|
*version = version.saturating_add(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!(reason, "ME reinit scheduled (concurrent mode)");
|
debug!(reason, "ME reinit scheduled (concurrent mode)");
|
||||||
let pool_clone = pool.clone();
|
let pool_clone = pool.clone();
|
||||||
let rng_clone = rng.clone();
|
let rng_clone = rng.clone();
|
||||||
|
let me_ready_tx_clone = me_ready_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
pool_clone
|
if pool_clone
|
||||||
.zero_downtime_reinit_periodic(rng_clone.as_ref())
|
.zero_downtime_reinit_periodic(rng_clone.as_ref())
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
me_ready_tx_clone.send_modify(|version| {
|
||||||
|
*version = version.saturating_add(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6611
-4925
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user