Compare commits

..

114 Commits

Author SHA1 Message Date
Alexey 9dc67727b0 Merge pull request #840 from telemt/flow
Restore single-record TLS-F primary application flight + Fix SYN limiter lifecycle and default burst
2026-06-12 15:23:23 +03:00
Alexey 2d02fbe548 Bump 2026-06-12 15:06:14 +03:00
Alexey 2675779915 Fix SYN limiter lifecycle and default burst
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 14:40:26 +03:00
Alexey c4954f745f Restore single-record TLS-F primary application flight
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 12:44:22 +03:00
Alexey f33abfb09e Merge pull request #838 from telemt/flow
SYN limiter for Netfilter control + Syntactic key shares for TLS-F
2026-06-12 10:08:25 +03:00
Alexey 9904da737a Rustfmt 2026-06-12 01:28:41 +03:00
Alexey 9a3ff726b2 Use token-bucket SYN limiter backends
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 01:27:03 +03:00
Alexey 942882f9de SYN Limiter interval and hitcount in Config
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 00:29:23 +03:00
Alexey eeff16c3fd Rustfmt 2026-06-12 00:01:01 +03:00
Alexey c86dc2f65e Docs for SYN Limiter
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:59:47 +03:00
Alexey 1cbde70a14 Add per-listener SYN limiter for Netfilter control
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:58:48 +03:00
Alexey 26cd4734de Update tls.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:29:10 +03:00
Alexey 52a1b66ad7 Syntactic key shares for TLS-F
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:13:21 +03:00
Alexey 9ff48c2028 Merge pull request #836 from telemt/flow
API + TLS-F Advanced tuning
2026-06-11 21:08:11 +03:00
Alexey b43c683615 Rustfmt 2026-06-11 19:59:48 +03:00
Alexey e41470fb4c Update fetcher.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 19:52:23 +03:00
Alexey 09dc0cb76c Update handshake_security_tests.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 19:44:39 +03:00
Alexey c36eb81808 Fix for TLS-F, ALPN и SNI/ALPN helpers
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 19:17:06 +03:00
Alexey 0f8aca56d9 Fix fallback test record iterator lifetime
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 17:56:21 +03:00
Alexey 4e66933a35 Fix TLS masking test ClientHello fixtures and tail write ordering 2026-06-11 17:51:05 +03:00
Alexey 7cf00db242 Update client_masking_budget_security_tests.rs 2026-06-11 17:32:26 +03:00
Alexey 8bc1ac06d6 Update client_masking_budget_security_tests.rs 2026-06-11 17:31:23 +03:00
Alexey 59cfcf05d3 Update client_masking_blackhat_campaign_tests.rs 2026-06-11 17:23:35 +03:00
Alexey fcbedf66ea Update client_masking_blackhat_campaign_tests.rs 2026-06-11 17:21:54 +03:00
Alexey f5c402d9fc Update metrics.rs 2026-06-11 16:43:24 +03:00
Alexey 118d53239a Merge pull request #835 from telemt/flow-ey
TLS Fixes escalating
2026-06-11 16:38:10 +03:00
Alexey 607f5442ad Merge pull request #834 from telemt/flow-11ec
TLS Fixes
2026-06-11 16:37:15 +03:00
Alexey 1edd63bfb1 Rustfmt + Bump 2026-06-11 16:36:33 +03:00
Alexey a808dc2815 Fix TLS fetch test constants scope
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 16:34:58 +03:00
Alexey 6dc9f8c27a Replay-safe TLS-F ServerHello profile consistency
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 16:11:41 +03:00
Alexey 409b0ef5ee Expose TLS Fetcher Profile Quality for ServerHello fidelity
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 14:53:21 +03:00
Alexey 3d0560d583 Select ServerHello key share from TLS Fetcher Profile
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 14:43:03 +03:00
Alexey 62af515504 Generate Valid X25519MLKEM768 ServerHello key shares
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 14:14:09 +03:00
Alexey eba55e755d Preserve TLS-F Origin Record Choreography
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 13:51:58 +03:00
Alexey c4b58ad374 Hardened TLS-F ServerHello selection
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 13:07:40 +03:00
Alexey db7ff8737c Add dynamic SNI mask target mode
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 10:36:37 +03:00
Alexey cd2bb9c8cd Alles muss man selber machen
Co-Authored-By: Mikhail I. Izmestev <355023+izmmisha@users.noreply.github.com>
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
Co-Authored-By: Dietmar Schreiber <376736+dginorg@users.noreply.github.com>
2026-06-11 10:13:17 +03:00
Alexey 8d3f8a8215 Merge pull request #828 from amirotin/feat/config-edit-api
Add config-edit HTTP API: PATCH/GET /v1/config
2026-06-10 10:30:52 +03:00
Mirotin Artem ff7a12d5f8 fix(api): GET /v1/config returns only editable sections; tolerate commented TOML headers; doc fixes 2026-06-09 12:13:32 +03:00
Mirotin Artem 27ee634f4a docs(api): document PATCH/GET /v1/config 2026-06-09 12:03:35 +03:00
Mirotin Artem d7e16f5b26 feat(api): config-edit endpoints PATCH/GET /v1/config 2026-06-09 12:03:28 +03:00
Mirotin Artem e39aaeb5c5 feat(config): classify_config_changes (hot vs restart) via overlay_hot_fields 2026-06-09 12:03:10 +03:00
Mirotin Artem 1628a7d822 feat(api): generic config section writer + array-table bounds 2026-06-09 12:03:01 +03:00
Alexey e9c62b6d8d Merge pull request #827 from Rightarion/fix-rate-limits-document-bits-per-second
Document rate limits as bits per second
2026-06-08 20:04:10 +03:00
Alexey 36cf3b035c Merge pull request #825 from groozchique/main
[docs] change fingerprint for xray double hop instruction
2026-06-08 20:01:20 +03:00
Samat Gilmanov 8491f5183c Document rate limits as bits per second 2026-06-08 12:39:32 -04:00
Nick Parfyonov 357852cc59 [docs] change fingerprint for xray double hop 2026-06-08 11:14:15 +03:00
Alexey 504cafb129 Merge pull request #824 from telemt/flow
MSS Tuning
2026-06-06 12:25:33 +03:00
Alexey 1096e38854 Docs for MSS Tuning
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-06 12:24:27 +03:00
Alexey 9bbdf796d8 Rustfmt 2026-06-06 12:17:19 +03:00
Alexey 27a5f5a4ec MSS Tuning with config
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-06 12:11:05 +03:00
Alexey a8adc9fe54 API hardening + Dual-stack fixes + JA3/JA4 observability + Test Stabilization: merge pull request #822 from telemt/flow
API hardening + Dual-stack fixes + JA3/JA4 observability + Test Stabilization
2026-06-05 14:36:00 +03:00
Alexey 44be585ee3 Update Cargo.toml 2026-06-05 14:24:27 +03:00
Alexey cb89d3f4fe Merge branch 'flow' of https://github.com/telemt/telemt into flow 2026-06-05 14:21:34 +03:00
Alexey c4e522a16d Bump -> 3.4.14
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 14:21:29 +03:00
Alexey 8e5f73a86b Merge branch 'main' into flow 2026-06-05 13:01:05 +03:00
Alexey 7d543aeb67 Fixes for Adversarial Timing Profile Latency-flake by #761
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 12:59:50 +03:00
Alexey 89a885c25f Reset Interface Cache in Masking timing test
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 12:51:54 +03:00
Alexey 54e40fd073 Fixes for Load mask shape security test
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 12:43:30 +03:00
Alexey 1934c1279c Update README.md 2026-06-05 06:54:53 +03:00
Alexey 0bc99b9f74 Merge pull request #820 from groozchique/main
[docs] README updates
2026-06-04 18:45:01 +03:00
Alexey 1d8e8890a4 Update README.md 2026-06-04 18:43:04 +03:00
Alexey d1680a7a80 Update README.md 2026-06-04 18:42:27 +03:00
Alexey b027608282 JA3 + JA4 Docs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-03 15:32:32 +03:00
Nick Parfyonov 2f2c9b336c [docs] make dashes great again 2026-06-03 15:11:52 +03:00
Nick Parfyonov b9ebfdcd7b [docs] update RU README to match EN README 2026-06-03 15:10:17 +03:00
Alexey 34b48325fd JA3+JA4 Pitfall in API + Beobachten
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-02 08:17:56 +03:00
Alexey 5c573a926b Update Docs after Dualstack + Disable User adding
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-01 20:03:56 +03:00
Alexey 462215b53c Dual-stack fixes for Upstreams by #798
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-01 19:50:26 +03:00
Alexey 2264980926 User Disabler in API by #814 + Consistent Listeners in API by #800 2026-05-31 11:17:18 +03:00
Alexey 3d0d575b94 Normalize rlimit type on 32-bit targets in Conntrack Control #815 2026-05-30 18:13:54 +03:00
Alexey b720906fbc Merge pull request #813 from telemt/flow
Flow
2026-05-29 16:50:37 +03:00
Alexey ac244962ed Merge branch 'main' into flow 2026-05-29 16:07:29 +03:00
Aleksei K 752a2f5012 Bump -> 3.4.13 2026-05-29 14:05:19 +03:00
Aleksei K a77aedfd7a Atomically claim pressure eviction budget in MR 2026-05-29 13:17:47 +03:00
Alexey 8575d0ee5d Merge pull request #809 from Dimasssss/install.sh
Add interactive prompt for server port during installation
2026-05-29 10:38:00 +03:00
Alexey 213aba5dc9 Update README.md 2026-05-29 08:54:03 +03:00
Aleksei K a79aaee166 Merge branch 'flow' of https://github.com/telemt/telemt into flow 2026-05-28 16:11:27 +03:00
Aleksei K 2a0fcd6e35 Align ServerHello cipher and opaque ALPN behavior in TLS-F 2026-05-28 16:11:25 +03:00
Dimasssss 54a53e9ff0 Update install.sh 2026-05-28 12:26:46 +03:00
Alexey 63bcd7b3d0 Merge pull request #780 from temandroid/main
fix(docker): mount config as directory to allow atomic API writes
2026-05-27 08:33:43 +03:00
Alexey b68b10790c Merge pull request #799 from groozchique/main
[docs] more CONFIG_PARAMS.md fixes
2026-05-27 08:30:33 +03:00
Alexey 383d4318fe Add logging configuration to docker-compose: merge pull request #804 from Iv/main
Add logging configuration to docker-compose
2026-05-27 08:30:00 +03:00
Kravchenko Ivan d293861351 Add logging configuration to docker-compose
There was a disk full problem.
2026-05-26 20:43:26 +03:00
Alexey 31da0a1356 Fixes for Disable Colors 2026-05-26 12:20:28 +03:00
Nick Parfyonov 34bc1d943a [docs] add Hot-Reload column back to [general]
Re-adding Hot-Reload column to the [general] which was removed by mistake in my previous changes
2026-05-25 10:19:01 +03:00
Nick Parfyonov 50dee40dd2 [docs] remove duplicated parameters in [censorship] 2026-05-25 10:16:11 +03:00
Alexey d4adf0ef9a ME: Bound writer queue waits under backpressure 2026-05-25 00:28:29 +03:00
Alexey dc8951eae8 Reduce MR + ME Routing hot-path contention 2026-05-22 20:19:09 +03:00
Alexey 77a7f89075 Reuse ME reader scratch buffer across read loop iterations 2026-05-22 19:56:38 +03:00
Alexey 31b9504464 Merge pull request #797 from groozchique/main
Remove duplicated config params in [general] + some clarifications in FAQ + fix grammar mistake in RU FAQ
2026-05-22 19:23:40 +03:00
Nick Parfyonov 54cb4d0f29 [docs] some clarification in client-to-DC section in FAQ
Made some clarification about MTProxy requiremnt to reach all DCs to work properly for every client
2026-05-22 18:20:37 +03:00
Nick Parfyonov d449fc080c [docs] fix grammar mistake in FAQ.ru.md 2026-05-22 18:15:49 +03:00
Nick Parfyonov 3b8d16bee5 [docs] remove duplicated parameters in CONFIG_PARAMS.md 2026-05-22 18:14:10 +03:00
Alexey 9abaf9006c Prioritize Cancellation in MP select paths 2026-05-22 16:47:54 +03:00
Alexey 231f04a810 Bump 2026-05-22 11:00:41 +03:00
Alexey b32daf79bc Update Cargo.lock 2026-05-22 11:00:18 +03:00
Alexey f668759c05 API Fixes + Exclusive Mask + Startup Speed-up + IDN + Decomposing hot-path modules: merge pull request #796 from telemt/flow
API Fixes + Exclusive Mask + Startup Speed-up + IDN + Decomposing hot-path modules
2026-05-22 10:55:26 +03:00
Alexey 4d9e835fa2 Merge branch 'main' into flow 2026-05-21 23:45:54 +03:00
Alexey 885258b85e Prioritize Relay Flow Cancellation over Buffered Writes 2026-05-21 20:35:25 +03:00
Alexey b4c33eff39 Update CONTRIBUTING.md 2026-05-19 11:10:00 +03:00
TEMAndroid 855c5eef8b Merge branch 'main' into main 2026-05-18 10:41:24 +03:00
Alexey 01b0c5c6ce Merge pull request #786 from Dimasssss/patch-1
Update install.sh
2026-05-16 14:12:14 +03:00
Dimasssss ad1bb5cc1a Update install.sh 2026-05-15 01:32:37 +03:00
Dimasssss 08cde1a255 Update install.sh 2026-05-15 01:29:13 +03:00
Dimasssss faf1f28f9d Update install.sh 2026-05-15 01:23:45 +03:00
Dimasssss 32613c8e68 Update install.sh 2026-05-15 01:12:47 +03:00
TEMAndroid b175927324 fix(docker): mount config as directory to allow atomic API writes 2026-05-11 20:50:31 +00:00
Alexey 1fe621f743 Update CONFIG_PARAMS.en.md 2026-05-10 17:37:41 +03:00
Alexey 3b0ebf3c9e Update CONFIG_PARAMS.ru.md 2026-05-10 17:37:31 +03:00
Alexey b41f6bc21e Update CONFIG_PARAMS.en.md 2026-05-10 17:37:15 +03:00
Alexey 0a9f599611 Update CONFIG_PARAMS.en.md 2026-05-10 17:37:03 +03:00
Alexey cdb021fc71 Update CONFIG_PARAMS.ru.md 2026-05-10 17:22:39 +03:00
Alexey 6b61183b9d Update CONFIG_PARAMS.en.md 2026-05-10 17:22:21 +03:00
107 changed files with 9871 additions and 1961 deletions
+5 -1
View File
@@ -52,6 +52,10 @@ By submitting a PR, you confirm that:
AI-generated code is treated as **draft** and must be validated like any other external contribution.
The problem isnt AI as a tool, but the dilution of responsibility. If the commit history says "Claude/GPT authored this", then who is accountable for the bug? Claude? GPT? Anthropic? OpenAI? Samuel Altman?
The user who didnt read the diff? No one? But, in a sensitive system, *"no one"* is an unacceptable maintainer model.
PRs that look like unverified AI dumps WILL be closed
---
@@ -79,4 +83,4 @@ This includes (but is not limited to):
- unverified or low-effort changes
- inability to explain the change
These actions follow the Code of Conduct and are intended to preserve signal, quality, and Telemt's integrity
These actions follow the Code of Conduct and are intended to preserve signal, quality, and Telemt's integrity
Generated
+313 -250
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.4.11"
version = "3.4.18"
edition = "2024"
[features]
@@ -27,6 +27,7 @@ crc32c = "0.6"
zeroize = { version = "1.8", features = ["derive"] }
subtle = "2.6"
static_assertions = "1.1"
ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] }
# Network
socket2 = { version = "0.6", features = ["all"] }
@@ -90,6 +91,7 @@ tokio-test = "0.4"
criterion = "0.8"
proptest = "1.4"
futures = "0.3"
tempfile = "3.27.0"
[[bench]]
name = "crypto_bench"
+4 -4
View File
@@ -4,13 +4,13 @@
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
***Löst Probleme, bevor andere überhaupt wissen, dass sie existieren*** / ***It solves problems before others even realize they exist***
> [!NOTE]
>
> Fixed TLS ClientHello is now available in official clients for Desktop / Android / iOS
> From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions"
>
> Telegram Clients TLS ClientHello has been banned by JA3 Fingerprint: we are already looking for ways to solve this problem
>
> To work with EE-MTProxy, please update your client!
> You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf)
<p align="center">
<a href="https://t.me/telemtrs">
+28 -38
View File
@@ -1,57 +1,52 @@
# Telemt — MTProxy на Rust + Tokio
[![Latest Release](https://img.shields.io/github/v/release/telemt/telemt?color=neon)](https://github.com/telemt/telemt/releases/latest) [![Stars](https://img.shields.io/github/stars/telemt/telemt?style=social)](https://github.com/telemt/telemt/stargazers) [![Forks](https://img.shields.io/github/forks/telemt/telemt?style=social)](https://github.com/telemt/telemt/network/members) [![Telegram](https://img.shields.io/badge/Telegram-Chat-24a1de?logo=telegram&logoColor=24a1de)](https://t.me/telemtrs)
***Решает проблемы раньше, чем другие узнают об их существовании***
[![Latest Release](https://img.shields.io/github/v/release/telemt/telemt?color=neon)](https://github.com/telemt/telemt/releases/latest) [![Stars](https://img.shields.io/github/stars/telemt/telemt?style=social)](https://github.com/telemt/telemt/stargazers) [![Forks](https://img.shields.io/github/forks/telemt/telemt?style=social)](https://github.com/telemt/telemt/network/members)
> [!NOTE]
>
> Исправленный TLS ClientHello доступен в Telegram для настольных ПК, Android и iOS.
> Клиенты Telegram подвергаются блокировке по JA3-отпечатку; мы ищем варианты решения этой проблемы
>
> Пожалуйста, обновите клиентское приложение для работы с EE-MTProxy.
> Вы можете попробовать собрать свой клиент с нашей Telegram Devlibrary — [tdlib-obf](https://github.com/telemt/tdlib-obf)
<p align="center">
<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>
</p>
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust. Он полностью реализует официальный алгоритм прокси Telegram и добавляет множество улучшений для продакшена:
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust: он полностью реализует официальный алгоритм Telegram прокси и добавляет множество различных улучшений
## Установка и обновление одной командой
```bash
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
```
- [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md)
- [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md)
Реализация **TLS-fronting** максимально приближена к поведению реального HTTPS-трафика (подробнее - [FAQ](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров)).
## Функционал
Наша реализация **TLS-fronting** одна из наиболее глубоко отлаженных, продвинутых и почти поведенчески неотличима от настоящего: мы уверены, что сделали это правильно - [см. доказательства в нашей проверке](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров).
***Middle-End Pool*** оптимизирован для высокой производительности.
Наша архитектура ***Middle-End Pool*** в стандартных сценариях самая производительная, по сравнению с другими реализациями подключения к Middle-End прокси: не кардинально, но достаточно
- Поддержка всех режимов MTProto proxy:
- Полная поддержа всех официальных режимов MTProto proxy:
- Classic;
- Secure (префикс `dd`);
- Fake TLS (префикс `ee` + SNI fronting);
- Secure с префиксом `dd`;
- Fake TLS с префиксом `ee` + SNI fronting;
- Защита от replay-атак;
- Маскировка трафика (перенаправление неизвестных подключений на реальные сайты);
- Настраиваемые keepalive, таймауты, IPv6 и «быстрый режим»;
- Опциональная маскировка трафика: перенаправление неизвестных подключений на реальные сайты;
- Настраиваемые keepalive, таймауты, IPv6 и "быстрый режим";
- Корректное завершение работы (Ctrl+C);
- Подробное логирование через `trace` и `debug`.
- Подробное логирование через `trace` и `debug` с помощью `RUST_LOG`.
# Подробнее о Telemt
- [FAQ](#faq)
- [Архитектура](docs/Architecture)
- [Параметры конфигурационного файла](docs/Config_params)
- [Сборка](#build)
- [Установка на BSD](#%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D0%BD%D0%B0-bsd)
- [Почему Rust?](#why-rust)
## ЧаВо
- [Часто задаваемые вопросы](docs/FAQ.ru.md)
## FAQ
- [FAQ RU](docs/FAQ.ru.md)
- [FAQ EN](docs/FAQ.en.md)
# Узнайте больше о Telemt
- [Наша архитектура](docs/Architecture)
- [Все конфигурационные параметры](docs/Config_params)
- [Как собрать Telemt самостоятельно?](#сборка)
- [Установка на BSD](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md)
- [Почему Rust?](#почему-rust)
## Сборка
```bash
@@ -63,7 +58,7 @@ cd telemt
cargo build --release
# В текущем release-профиле используется lto = "fat" для максимальной оптимизации (см. Cargo.toml).
# На системах с малым объёмом RAM (~1 ГБ) можно переопределить это значение на "thin".
# На системах с малым объёмом ОЗУ (~1 ГБ) можно переопределить это значение на "thin".
# Перейдите в каталог /bin
mv ./target/release/telemt /bin
@@ -73,24 +68,19 @@ chmod +x /bin/telemt
telemt config.toml
```
## Установка на BSD
- Руководство по сборке и настройке на английском языке [OpenBSD Guide (EN)](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md);
- Пример rc.d скрипта: [contrib/openbsd/telemt.rcd](contrib/openbsd/telemt.rcd);
- Поддержка sandbox с `pledge(2)` и `unveil(2)` пока не реализована.
## Почему Rust?
- Надёжность для долгоживущих процессов;
- Детерминированное управление ресурсами (RAII);
- Надёжность при длительной работе и идемпотентное поведение;
- Детерминированное управление ресурсами RAII;
- Отсутствие сборщика мусора;
- Безопасность памяти;
- Безопасность памяти и меньше поверхность атаки;
- Асинхронная архитектура Tokio.
## Поддержать Telemt
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разработанное в свободное время.
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разрабатываемое в свободное время.
Если оно оказалось вам полезным, вы можете поддержать дальнейшую разработку.
Принимаемые криптовалюты (BTC, ETH, USDT, 350+ и другие):
Любая криптовалюта (BTC, ETH, USDT и 350+ других):
<p align="center">
<a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener">
+12 -6
View File
@@ -10,12 +10,15 @@ services:
- "443:443"
- "127.0.0.1:9090:9090"
- "127.0.0.1:9091:9091"
# Allow caching 'proxy-secret' in read-only container
working_dir: /etc/telemt
# Working dir uses tmpfs for caching 'proxy-secret' at runtime.
# Config is mounted as a directory (not a single file) so the API can
# atomically update config.toml via write-temp → rename within the same FS.
working_dir: /run/telemt
command: ["/etc/telemt/config.toml"]
volumes:
- ./config.toml:/etc/telemt/config.toml:ro
- ./config:/etc/telemt:rw
tmpfs:
- /etc/telemt:rw,mode=1777,size=4m
- /run/telemt:rw,mode=1777,size=4m
environment:
- RUST_LOG=info
healthcheck:
@@ -24,8 +27,6 @@ services:
timeout: 5s
retries: 3
start_period: 20s
# Uncomment this line if you want to use host network for IPv6, but bridge is default and usually better
# network_mode: host
cap_drop:
- ALL
cap_add:
@@ -37,3 +38,8 @@ services:
nofile:
soft: 65536
hard: 262144
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
+3
View File
@@ -86,6 +86,9 @@ Die unten angegebenen `Default`-Werte sind Code-Defaults (bei fehlendem Schlüss
| `[[upstreams]].weight` | alle Upstreams | `u16` | nein | `1` | Basisgewicht für weighted-random Auswahl. |
| `[[upstreams]].enabled` | alle Upstreams | `bool` | nein | `true` | Deaktivierte Einträge werden beim Start ignoriert. |
| `[[upstreams]].scopes` | alle Upstreams | `String` | nein | `""` | Komma-separierte Scope-Tags für Request-Routing. |
| `[[upstreams]].ipv4` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv4-DC-Ziele für diesen Upstream. |
| `[[upstreams]].ipv6` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv6-DC-Ziele für diesen Upstream, inklusive Proxy-Egress unabhängig vom Host-IPv6. |
| `[[upstreams]].prefer` | alle Upstreams | `Option<4 \| 6>` | nein | effective `[network].prefer` | Pro-Upstream-Präferenz für die DC-Ziel-Adressfamilie. |
| `interface` | `direct` | `Option<String>` | nein | `null` | Interface-Name (z. B. `eth0`) oder lokale Literal-IP. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | nein | `null` | Explizite Source-IP-Kandidaten (strikter Vorrang vor `interface`). |
| `address` | `socks4` | `String` | ja | n/a | SOCKS4-Server (`ip:port` oder `host:port`). |
+3
View File
@@ -86,6 +86,9 @@ Defaults below are code defaults (used when a key is omitted), not necessarily v
| `[[upstreams]].weight` | all upstreams | `u16` | no | `1` | Base weight for weighted-random selection. |
| `[[upstreams]].enabled` | all upstreams | `bool` | no | `true` | Disabled entries are ignored at startup. |
| `[[upstreams]].scopes` | all upstreams | `String` | no | `""` | Comma-separated scope tags for request-level routing. |
| `[[upstreams]].ipv4` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv4 DC targets for this upstream. |
| `[[upstreams]].ipv6` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv6 DC targets for this upstream, including proxy egress independent of host IPv6. |
| `[[upstreams]].prefer` | all upstreams | `Option<4 \| 6>` | no | effective `[network].prefer` | Per-upstream DC target family preference. |
| `interface` | `direct` | `Option<String>` | no | `null` | Interface name (e.g. `eth0`) or literal local IP for bind selection. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | no | `null` | Explicit local source IP candidates (strict priority over `interface`). |
| `address` | `socks4` | `String` | yes | n/a | SOCKS4 server endpoint (`ip:port` or `host:port`). |
+3
View File
@@ -86,6 +86,9 @@
| `[[upstreams]].weight` | все upstream | `u16` | нет | `1` | Базовый вес в weighted-random выборе. |
| `[[upstreams]].enabled` | все upstream | `bool` | нет | `true` | Выключенные записи игнорируются на старте. |
| `[[upstreams]].scopes` | все upstream | `String` | нет | `""` | Список scope-токенов через запятую для маршрутизации. |
| `[[upstreams]].ipv4` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv4 DC-targets для этого upstream. |
| `[[upstreams]].ipv6` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv6 DC-targets для этого upstream, включая proxy egress независимо от IPv6 на хосте. |
| `[[upstreams]].prefer` | все upstream | `Option<4 \| 6>` | нет | эффективный `[network].prefer` | Предпочтительное семейство DC-target для конкретного upstream. |
| `interface` | `direct` | `Option<String>` | нет | `null` | Имя интерфейса (например `eth0`) или literal локальный IP. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | нет | `null` | Явные кандидаты source IP (имеют приоритет над `interface`). |
| `address` | `socks4` | `String` | да | n/a | Адрес SOCKS4 сервера (`ip:port` или `host:port`). |
+193 -6
View File
@@ -103,14 +103,19 @@ Notes:
| `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` |
| `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` |
| `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` |
| `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` |
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
| `GET` | `/v1/config` | none | `200` | `ConfigData` |
| `PATCH` | `/v1/config` | sparse JSON object | `200` | `PatchConfigResponse` |
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` or `202` | `UserInfo` |
| `DELETE` | `/v1/users/{username}` | none | `200` or `202` | `DeleteUserResponse` |
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `200` or `202` | `CreateUserResponse` |
| `POST` | `/v1/users/{username}/enable` | empty body | `200` or `202` | `UserInfo` |
| `POST` | `/v1/users/{username}/disable` | empty body | `200` or `202` | `UserInfo` |
| `POST` | `/v1/users/{username}/reset-quota` | empty body | `200` | `ResetUserQuotaResponse` |
## Endpoint Behavior
@@ -140,12 +145,16 @@ Notes:
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
| `GET /v1/config` | Returns the current editable config sections as JSON (no `access.*`) plus the revision. |
| `PATCH /v1/config` | Applies a sparse patch to editable config sections; validates, writes, and reports restart impact. |
| `GET /v1/users` | Returns disk-first user views sorted by username. |
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
| `PATCH /v1/users/{username}` | Updates selected per-user fields with JSON Merge Patch semantics. |
| `DELETE /v1/users/{username}` | Deletes one user and related per-user access-map entries. |
| `POST /v1/users/{username}/rotate-secret` | Rotates one user's secret and returns the effective secret. |
| `POST /v1/users/{username}/enable` | Enables one user, removing any disabled override from config. |
| `POST /v1/users/{username}/disable` | Disables one user and closes active runtime sessions for that user. |
| `POST /v1/users/{username}/reset-quota` | Resets one user's runtime quota counter and persists quota state. |
## Common Error Codes
@@ -153,6 +162,8 @@ Notes:
| HTTP | `error.code` | Trigger |
| --- | --- | --- |
| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. |
| `400` | `access_not_editable` | `PATCH /v1/config` body contains an `access` key (managed via users API). |
| `400` | `section_not_editable` | `PATCH /v1/config` body contains `server`, `network`, or an unknown top-level key. |
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
@@ -172,9 +183,12 @@ Notes:
| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. |
| Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. |
| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. |
| `DELETE /v1/config` (or any method not in `GET`, `PATCH`) | `405 method_not_allowed` with `Allow: GET, PATCH`. |
| `PUT /v1/users/{username}` | `405 method_not_allowed`. |
| `POST /v1/users/{username}` | `404 not_found`. |
| `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. |
| `POST /v1/users/{username}/enable/` | Trailing slash is trimmed and the route matches `enable`. |
| `POST /v1/users/{username}/disable/` | Trailing slash is trimmed and the route matches `disable`. |
| `POST /v1/users/{username}/reset-quota/` | Trailing slash is trimmed and the route matches `reset-quota`. |
## Body and JSON Semantics
@@ -205,9 +219,10 @@ Notes:
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
| `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bytes per second. |
| `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bytes per second. |
| `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bits per second. |
| `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bits per second. |
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
| `enabled` | `bool` | no | User enable flag. Missing means enabled. `false` persists a disabled override. |
### `PatchUserRequest`
| Field | Type | Required | Description |
@@ -217,9 +232,10 @@ Notes:
| `max_tcp_conns` | `usize|null` | no | Per-user concurrent TCP limit; `null` removes the per-user override. |
| `expiration_rfc3339` | `string|null` | no | RFC3339 expiration timestamp; `null` removes the expiration. |
| `data_quota_bytes` | `u64|null` | no | Per-user traffic quota; `null` removes the per-user quota. |
| `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bytes per second; `null` removes the upload direction limit. |
| `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bytes per second; `null` removes the download direction limit. |
| `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bits per second; `null` removes the upload direction limit. |
| `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bits per second; `null` removes the download direction limit. |
| `max_unique_ips` | `usize|null` | no | Per-user unique source IP limit; `null` removes the per-user override. |
| `enabled` | `bool|null` | no | `false` disables the user. `true` or `null` removes the disabled override, so the user is enabled. |
### `access.user_source_deny` via API
- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`.
@@ -236,6 +252,20 @@ alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
bob = ["198.51.100.42/32"]
```
### `PatchConfigRequest`
A sparse JSON object containing only the top-level config sections to modify. Each key must be one of the editable sections (`general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`). Tables within a section are deep-merged field-by-field into the existing config; arrays and scalar values replace the existing value wholesale. Untouched sections and file comments are preserved.
**Rejected keys:**
- `access``400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`).
- `server`, `network`, or any unknown top-level key → `400 section_not_editable`.
- An object with no editable keys → `400 bad_request` (empty patch).
Example — patch only the SNI domain:
```json
{"censorship": {"tls_domain": "front.example.com"}}
```
### `RotateSecretRequest`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
@@ -245,6 +275,31 @@ An empty request body is accepted and generates a new secret automatically.
## Response Data Contracts
### `ConfigData`
Returned by `GET /v1/config` as the envelope `data`. The fields are exactly the editable TOML sections. The current revision is returned in the envelope `revision` field (same value as `config_hash` in `SystemInfoData`), **not** inside `data`.
| Field | Type | Description |
| --- | --- | --- |
| `general` | `object?` | `[general]` section, if present in config. |
| `timeouts` | `object?` | `[timeouts]` section, if present. |
| `censorship` | `object?` | `[censorship]` section, if present. |
| `upstreams` | `object?` | `[upstreams]` section, if present. |
| `show_link` | `object?` | `[show_link]` section, if present. |
| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. |
Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets), `server` (carries the API `auth_header` and per-node identity), and `network` (per-node addresses) are always excluded.
### `PatchConfigResponse`
Returned by `PATCH /v1/config` on success (`200`).
| Field | Type | Description |
| --- | --- | --- |
| `revision` | `string` | SHA-256 hex of the config file after the patch was written. |
| `restart_required` | `bool` | `true` when one or more changed fields require a process restart to take effect. Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher; restart-required fields (e.g. any `censorship.*`, `timeouts.*`, `upstreams`, or `general.modes` change) are written to disk but only take effect after the Telemt process is restarted. The caller is responsible for triggering a restart when this flag is `true`. |
| `changed` | `string[]` | Top-level section names that differed between the old and new config (e.g. `["censorship"]`). |
### `HealthData`
| Field | Type | Description |
| --- | --- | --- |
@@ -807,6 +862,43 @@ An empty request body is accepted and generates a new secret automatically.
| `event_type` | `string` | Event kind identifier. |
| `context` | `string` | Context text (truncated to implementation-defined max length). |
### `RuntimeEdgeTlsFingerprintsData`
| Field | Type | Description |
| --- | --- | --- |
| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. |
| `reason` | `string?` | `feature_disabled` when endpoint is disabled. |
| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. |
| `data` | `RuntimeEdgeTlsFingerprintsPayload?` | Null when unavailable. |
#### `RuntimeEdgeTlsFingerprintsPayload`
| Field | Type | Description |
| --- | --- | --- |
| `limit` | `usize` | Effective Top-N row count. |
| `retention_secs` | `u64` | In-memory retention window, derived from `general.beobachten_minutes`. |
| `capacity` | `usize` | Maximum retained fingerprint buckets. |
| `dropped_total` | `u64` | Buckets dropped because the collector was full. |
| `parse_error_total` | `u64` | Complete ClientHello records that could not be fingerprinted. |
| `by_fingerprint` | `RuntimeEdgeTlsFingerprintRow[]` | Global JA3/JA4 leaderboard. |
| `by_ip` | `RuntimeEdgeTlsFingerprintRow[]` | Source-IP scoped leaderboard. |
| `by_cidr` | `RuntimeEdgeTlsFingerprintRow[]` | Source CIDR scoped leaderboard (`/24` for IPv4, `/56` for IPv6). |
| `by_user` | `RuntimeEdgeTlsFingerprintRow[]` | Authenticated user scoped leaderboard. |
#### `RuntimeEdgeTlsFingerprintRow`
| Field | Type | Description |
| --- | --- | --- |
| `scope` | `string?` | IP, CIDR, or username; absent in `by_fingerprint`. |
| `ja3` | `string` | JA3 MD5 hash. |
| `ja3_raw` | `string` | Raw JA3 field string. |
| `ja4` | `string` | JA4 TLS client fingerprint. |
| `ja4_raw` | `string` | Raw JA4 material used for the hashed parts. |
| `total` | `u64` | Complete ClientHello observations for this bucket. |
| `auth_success` | `u64` | TLS-authenticated observations for this bucket. |
| `bad_or_probe` | `u64` | Complete ClientHello observations later classified as bad/probe. |
| `first_seen_epoch_secs` | `u64` | First observation timestamp. |
| `last_seen_epoch_secs` | `u64` | Last observation timestamp. |
JA3 follows the Salesforce ClientHello field order. JA4 follows the FoxIO TLS-client `a_b_c` format; GREASE values are excluded and no high-cardinality Prometheus labels are emitted for fingerprints.
### `ZeroAllData`
| Field | Type | Description |
| --- | --- | --- |
@@ -1165,13 +1257,14 @@ An empty request body is accepted and generates a new secret automatically.
| Field | Type | Description |
| --- | --- | --- |
| `username` | `string` | Username. |
| `enabled` | `bool` | Effective user enable flag. Missing config entry is reported as `true`. |
| `in_runtime` | `bool` | Whether current runtime config already contains this user. |
| `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). |
| `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. |
| `expiration_rfc3339` | `string?` | Optional expiration timestamp. |
| `data_quota_bytes` | `u64?` | Optional data quota. |
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bytes per second. |
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bytes per second. |
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bits per second. |
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bits per second. |
| `max_unique_ips` | `usize?` | Optional unique IP limit. |
| `current_connections` | `u64` | Current live connections. |
| `active_unique_ips` | `usize` | Current active unique source IPs. |
@@ -1232,13 +1325,106 @@ Link generation uses active config and enabled modes:
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
| `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. |
## Config Endpoints
### `GET /v1/config`
Returns the current editable config sections as TOML-shaped JSON, plus the current revision. The `access` section (users and secrets) is always stripped and never appears in the response.
**Auth:** requires `Authorization` header when `auth_header` is configured (same as all other endpoints).
**Success `200` response body** (`data` field of the standard envelope):
```json
{
"revision": "<sha256-hex>",
"censorship": {"tls_domain": "front.example.com"},
"general": {"log_level": "normal"}
}
```
Top-level sections absent from the config file are absent from the response. Only `GET` and `PATCH` are accepted; any other method returns `405 Method Not Allowed` with `Allow: GET, PATCH`.
---
### `PATCH /v1/config`
Applies a sparse patch to the editable config sections. The merged config is fully validated before writing; if validation fails the file is not modified.
**Auth:** requires `Authorization` header when `auth_header` is configured.
**Headers:**
| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | when configured | Same token as all other endpoints. |
| `Content-Type: application/json` | recommended | Not enforced, but body must be valid JSON. |
| `If-Match: <revision>` | no | Optimistic concurrency. `<revision>` is the `revision` value from `GET /v1/config` or `config_hash` from `GET /v1/system/info`. If supplied and it does not match the current on-disk revision, returns `409 revision_conflict`. If omitted, the patch applies unconditionally. |
**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`.
**Rejected keys and their error codes:**
| Key | HTTP | `error.code` |
| --- | --- | --- |
| `access` | `400` | `access_not_editable` |
| `server`, `network`, or any unknown key | `400` | `section_not_editable` |
| Object with no editable key | `400` | `bad_request` |
**Merge semantics:** tables are deep-merged field-by-field; arrays and scalar values replace the existing value wholesale. File comments and untouched sections are preserved.
**Validation:** the merged config is deserialized into the full `ProxyConfig` type and validated before writing. Failures return `400` with a descriptive message; the file is not modified.
**Read-only mode:** returns `403 read_only` when the API runs with `read_only = true`.
**Success `200` response body** (`data` field of the standard envelope):
```json
{
"revision": "<new-sha256-hex>",
"restart_required": true,
"changed": ["censorship"]
}
```
- `revision` — SHA-256 hex of the config file after the write.
- `restart_required``true` when the change affects a field that Telemt cannot hot-reload (e.g. `censorship.*`, `timeouts.*`, `upstreams`, `general.modes`). Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher. Restart-required fields are written to disk but only take effect after the Telemt process is restarted; the caller is responsible for triggering the restart.
- `changed` — list of top-level section names that differed.
**Status codes:**
| HTTP | `error.code` | Condition |
| --- | --- | --- |
| `200` | — | Patch applied successfully. |
| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. |
| `400` | `access_not_editable` | Patch contains an `access` key. |
| `400` | `section_not_editable` | Patch contains `server`, `network`, or an unknown top-level key. |
| `401` | `unauthorized` | Missing or invalid `Authorization` header. |
| `403` | `read_only` | API is in read-only mode. |
| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. |
| `409` | `revision_conflict` | `If-Match` header supplied but does not match current revision. |
| `500` | `internal_error` | I/O or serialization failure. |
**curl example:**
```bash
# get current revision
curl -s -H "Authorization: <token>" http://127.0.0.1:<api>/v1/system/info | jq -r .config_hash
# patch the SNI domain with optimistic concurrency
curl -s -X PATCH -H "Authorization: <token>" -H "If-Match: <revision>" \
-H "Content-Type: application/json" \
-d '{"censorship":{"tls_domain":"front.example.com"}}' \
http://127.0.0.1:<api>/v1/config
```
## Mutation Semantics
| Endpoint | Notes |
| --- | --- |
| `PATCH /v1/config` | Deep-merges the patch into editable config sections (tables merged per-field; arrays/scalars replaced wholesale). Validates the merged result before writing. Writes only the touched sections via atomic `tmp + rename`. Returns the new revision and which sections changed. |
| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). |
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. |
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |
| `POST /v1/users/{username}/enable` | Enables the user idempotently by removing the `access.user_enabled[username]` override and updating the runtime admission state immediately. |
| `POST /v1/users/{username}/disable` | Disables the user idempotently by writing `access.user_enabled[username] = false`, updating runtime admission immediately, and cancelling active sessions for that username. |
| `POST /v1/users/{username}/reset-quota` | Resets the runtime quota counter for the route username, persists quota state to `general.quota_state_path`, and does not modify user config. |
| `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. |
@@ -1282,6 +1468,7 @@ Additional runtime endpoint behavior:
| `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload |
| `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload |
| `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
| `/v1/runtime/tls-fingerprints` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
## ME Fallback Behavior Exposed Via API
@@ -0,0 +1,507 @@
# JA3 и JA4 анализ в Telemt
Этот документ описывает, как использовать JA3/JA4 telemetry в Telemt для диагностики блокировок, которые происходят на основе TLS ClientHello, особенно JA4 TLS client fingerprint.
Цель документа практическая: помочь оператору понять, какой клиентский TLS-отпечаток реально доходит до Telemt, как он распределён по IP/CIDR/пользователям, и как отделить JA4-based фильтрацию от блокировки по IP, SNI, домену, server flight или активному сканированию.
## Коротко
JA3 и JA4 описывают форму TLS ClientHello. ClientHello отправляет клиент, поэтому JA3/JA4 в этом контексте являются fingerprint'ами клиентской TLS-реализации, а не Telemt как сервера.
Telemt собирает JA3/JA4 только из уже прочитанного полного ClientHello:
- без packet capture;
- без MITM;
- без расшифровки TLS;
- без дополнительных сетевых чтений;
- без Prometheus labels с высокой кардинальностью;
- с ограниченным in-memory TTL/cap collector.
Собранные данные доступны:
- через API: `GET /v1/runtime/tls-fingerprints`;
- через `/beobachten`, если `general.beobachten=true`.
Основная польза:
- увидеть, какие JA4 реально используют клиенты;
- понять, один ли fingerprint страдает у всех пользователей;
- отделить проблему клиента от проблемы IP/ASN/домена;
- увидеть, доходят ли проблемные соединения до Telemt вообще;
- сравнить successful TLS-auth и bad/probe поток для одного fingerprint;
- собрать evidence для последующего изменения клиента, маршрута или deployment-профиля.
## Что такое JA3
JA3 - старый и широко совместимый способ получить hash от TLS ClientHello.
JA3 строится из ClientHello fields:
```text
SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat
```
Значения внутри полей записываются в порядке, в котором они пришли в ClientHello. GREASE values исключаются. Итоговая строка хэшируется MD5, поэтому в API есть два поля:
- `ja3` - MD5 hash;
- `ja3_raw` - исходная строка, из которой получен hash.
Практическое значение JA3 в 2026 году ограничено тем, что современные TLS-клиенты и браузерные стеки могут менять порядок extensions. Поэтому JA3 полезен как совместимый исторический сигнал, но для диагностики современных блокировок обычно важнее JA4.
## Что такое JA4
JA4 TLS client fingerprint - более структурированный fingerprint ClientHello.
JA4 в Telemt считается для TLS-over-TCP ClientHello и имеет форму:
```text
t<version><sni_marker><cipher_count><extension_count><alpn_marker>_<cipher_hash>_<extension_hash>
```
Пример:
```text
t13d1516h2_8daaf6152771_e5627efa2ab1
```
Части JA4:
| Часть | Смысл |
| --- | --- |
| `t` | TLS over TCP. Telemt сейчас не считает JA4 для QUIC/DTLS. |
| `13`, `12`, `11`, `10` | TLS version, предпочтительно из `supported_versions`. |
| `d` / `i` | Есть SNI domain (`d`) или SNI отсутствует (`i`). |
| `15` | Количество cipher suites без GREASE, capped до `99`. |
| `16` | Количество extensions без GREASE, capped до `99`. |
| `h2`, `h1`, `00` | ALPN marker: первый и последний символ первого ALPN value или `00`. |
| `cipher_hash` | SHA256 от отсортированного списка ciphers, первые 12 hex chars. |
| `extension_hash` | SHA256 от отсортированных extensions плюс signature algorithms, первые 12 hex chars. |
Важное отличие JA4 от JA3: JA4 нормализует часть полей, поэтому он устойчивее к простому изменению порядка extensions. Это делает JA4 удобным для фильтров и одновременно полезным для диагностики таких фильтров.
## Где Telemt видит ClientHello
В TLS/FakeTLS режиме Telemt получает первые bytes соединения и определяет, похоже ли оно на TLS handshake. Если record является полным ClientHello и проходит bounds checks, Telemt один раз парсит его для JA3/JA4.
Дальше возможны три исхода:
1. **Успешный MTProxy/FakeTLS клиент**
- Telemt принимает TLS-auth;
- fingerprint записывается в global/IP/CIDR scopes;
- после успешной TLS-auth Telemt добавляет user scope.
2. **Bad client или probe**
- ClientHello полный, но auth не проходит;
- fingerprint записывается в global/IP/CIDR scopes;
- user scope не записывается;
- `bad_or_probe` увеличивается.
3. **Неполный или обрезанный ClientHello**
- fingerprint не считается;
- такие случаи остаются в существующих bad-class counters.
Если фильтр режет трафик до того, как TCP connection или ClientHello дошли до процесса Telemt, Telemt не увидит этот fingerprint. Это важнейшее диагностическое отличие: отсутствие fingerprint'а во время жалобы пользователя часто означает блокировку до приложения, а не проблему внутри Telemt.
## Включение сбора
Collector включается, когда включён хотя бы один потребитель:
```toml
[general]
beobachten = true
beobachten_minutes = 10
```
или:
```toml
[server.api]
runtime_edge_enabled = true
runtime_edge_top_n = 50
```
Практически:
- для файлового/metrics endpoint анализа достаточно `general.beobachten=true`;
- для API snapshot нужен `server.api.runtime_edge_enabled=true`;
- `general.beobachten_minutes` задаёт retention window для fingerprint buckets;
- `server.api.runtime_edge_top_n` задаёт default Top-N размер API snapshot.
## API snapshot
Endpoint:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints
```
С явным лимитом:
```bash
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100'
```
Если API защищён header'ом:
```bash
curl -s \
-H 'Authorization: Bearer YOUR_TOKEN' \
'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100'
```
Если `runtime_edge_enabled=false`, endpoint возвращает payload с:
```json
{
"enabled": false,
"reason": "feature_disabled"
}
```
### Структура payload
Основные поля:
| Поле | Смысл |
| --- | --- |
| `retention_secs` | Текущее TTL окно collector'а. |
| `capacity` | Максимум retained buckets. |
| `dropped_total` | Сколько новых buckets отброшено из-за cap. |
| `parse_error_total` | Сколько полных ClientHello не удалось распарсить. |
| `by_fingerprint` | Top fingerprints глобально. |
| `by_ip` | Top fingerprints по exact source IP. |
| `by_cidr` | Top fingerprints по source prefix: IPv4 `/24`, IPv6 `/56`. |
| `by_user` | Top fingerprints по authenticated user. |
Строка snapshot:
| Поле | Смысл |
| --- | --- |
| `scope` | IP, CIDR или username. В `by_fingerprint` отсутствует. |
| `ja3` | JA3 hash. |
| `ja3_raw` | Raw JA3 string. |
| `ja4` | JA4 TLS client fingerprint. |
| `ja4_raw` | Raw JA4 material. |
| `total` | Сколько полных ClientHello попало в этот bucket. |
| `auth_success` | Сколько из них успешно прошли TLS-auth. |
| `bad_or_probe` | Сколько были bad/probe после полного ClientHello. |
| `first_seen_epoch_secs` | Первый timestamp bucket'а. |
| `last_seen_epoch_secs` | Последний timestamp bucket'а. |
### Быстрый просмотр через jq
Top JA4 глобально:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints \
| jq -r '.data.data.by_fingerprint[] | [.ja4, .total, .auth_success, .bad_or_probe] | @tsv'
```
Top JA4 по пользователям:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100 \
| jq -r '.data.data.by_user[] | [.scope, .ja4, .total, .auth_success] | @tsv'
```
Top JA4 по CIDR:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100 \
| jq -r '.data.data.by_cidr[] | [.scope, .ja4, .total, .auth_success, .bad_or_probe] | @tsv'
```
Ошибки парсинга и drops:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints \
| jq '.data.data | {retention_secs, capacity, dropped_total, parse_error_total}'
```
## Beobachten output
Если включён endpoint metrics, `/beobachten` содержит обычные forensic buckets и, когда есть данные, append-only секцию TLS fingerprints:
```bash
curl -s http://127.0.0.1:9090/beobachten
```
Фрагмент:
```text
[tls_fingerprints]
retention_secs=600 capacity=65536 dropped_total=0 parse_error_total=0
[tls_fingerprints.by_fingerprint]
ja4=t13d1516h2_8daaf6152771_e5627efa2ab1 ja3=... total=42 auth_success=41 bad_or_probe=1 first_seen=... last_seen=...
[tls_fingerprints.by_cidr]
scope=203.0.113.0/24 ja4=t13d1516h2_8daaf6152771_e5627efa2ab1 ja3=... total=10 auth_success=10 bad_or_probe=0 first_seen=... last_seen=...
```
`/beobachten` удобен для быстрой операторской диагностики без API client. API удобнее для автоматической корреляции.
## Как анализировать JA4-based блокировку
### 1. Зафиксировать симптом
Перед анализом нужно записать:
- какие пользователи жалуются;
- какая версия Telegram client используется;
- какая платформа: Desktop, Android, iOS;
- какой источник сети: mobile ISP, home ISP, corporate network, country/region;
- работает ли тот же пользователь через другой network path;
- работает ли другой пользователь с того же IP/CIDR;
- видит ли Telemt новые ClientHello от проблемного пользователя в момент попытки.
JA4 без контекста почти всегда недостаточен. Фильтры часто используют сочетание:
- JA4;
- destination IP;
- SNI;
- порт;
- ASN/source network;
- rate или connection pattern;
- reputation домена/IP;
- active probing result.
### 2. Проверить, доходит ли ClientHello до Telemt
Во время попытки подключения проблемного пользователя смотрите:
```bash
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=200' \
| jq '.data.data.by_user, .data.data.by_ip, .data.data.by_cidr'
```
Интерпретация:
| Наблюдение | Вероятный вывод |
| --- | --- |
| Нет новых rows для IP/CIDR пользователя | Блокировка до Telemt: routing, firewall, ISP/DPI drop, IP block, SYN/TCP reset, UDP/TCP path issue. |
| Есть `by_ip`/`by_cidr`, но нет `by_user` | ClientHello дошёл, но TLS-auth/MTProxy layer не дошёл до успешного пользователя. Возможны bad key, probe, wrong client, active scanner, обрыв после ClientHello. |
| Есть `by_user.auth_success` | Клиентский JA4 дошёл и был принят Telemt. Если пользователь всё равно видит проблему, искать нужно дальше: relay path, Telegram upstream, quota, route mode, session cancellation, ME/direct routing. |
| Резко растёт `bad_or_probe` для одного JA4 | Вероятны сканеры или неправильные клиенты с тем же fingerprint family. |
### 3. Сравнить working и blocked случаи
Снимите snapshot во время working case и blocked case:
```bash
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=500' > tls-fp-working.json
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=500' > tls-fp-blocked.json
```
Сравните:
- появился ли тот же `ja4` в blocked сети;
- меняется ли `ja4` между версиями клиента;
- меняется ли только IP/CIDR при том же `ja4`;
- есть ли `auth_success` для того же `ja4` из других сетей;
- отличается ли `bad_or_probe` между сетями.
Ключевая матрица:
| Working JA4 | Blocked JA4 | Вывод |
| --- | --- | --- |
| Same | Same, но blocked network не доходит до Telemt | Вероятна фильтрация по JA4 + destination/IP/SNI/network до приложения. |
| Same | Same, доходит и `auth_success>0` | JA4 ClientHello не является точкой отказа; искать post-auth проблему. |
| Different | Blocked только один JA4 | Вероятен client-version/platform-specific fingerprint block. |
| Same | `bad_or_probe` растёт, `auth_success=0` | Возможно, доходит не тот клиент/secret или фильтр/прокси ломает поток после ClientHello. |
### 4. Разделить client JA4 и server fingerprint
JA4 ClientHello - это клиентская сторона. Настройки Telemt вроде TLS-front server flight, `mask_host`, ticket-tail или CCS replay не меняют ClientHello, который отправляет Telegram client.
Если фильтр принимает решение строго после ClientHello, то серверные улучшения могут не помочь. В этом случае полезные действия:
- проверить обновление Telegram client;
- сравнить платформы и версии клиента;
- проверить, меняется ли JA4 на другой версии;
- проверить, блокируется ли тот же JA4 к другому destination;
- проверить, блокируется ли другой JA4 к тому же Telemt IP/SNI;
- собрать evidence для client-side fingerprint fix.
Если ClientHello проходит, а блокировка возникает после server response, тогда уже важны:
- форма FakeTLS server flight;
- TLS front profile fidelity;
- `mask_host` поведение для non-auth clients;
- certificate/provenance fallback для сканеров;
- TCP relay behavior;
- upstream route к Telegram.
### 5. Коррелировать с packet capture
Telemt collector показывает только то, что процесс увидел. Для подтверждения фильтрации до Telemt нужен внешний capture.
На сервере:
```bash
sudo tcpdump -i any -w telemt-clienthello.pcap host CLIENT_IP and port 443
```
Быстрый tshark вывод ClientHello fields:
```bash
tshark -r telemt-clienthello.pcap -Y "tls.handshake.type == 1" -T fields \
-e frame.time_epoch \
-e ip.src \
-e ip.dst \
-e tcp.srcport \
-e tcp.dstport \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extensions_alpn_str
```
Если на клиентской стороне capture видит ClientHello, а серверный capture не видит, проблема в сети между клиентом и сервером. Если серверный capture видит ClientHello, но Telemt API не видит fingerprint, проверьте порт, listener, PROXY protocol, TLS record fragmentation и bounds/errors.
## Практические сценарии
### Сценарий A: один JA4 перестал работать у многих пользователей
Признаки:
- один `ja4` доминирует в жалобах;
- у разных source CIDR нет `auth_success`;
- working пользователи используют другой JA4;
- обновление клиента меняет поведение.
Вероятный вывод: фильтр на стороне сети научился распознавать конкретный ClientHello family.
Действия:
- сравнить Telegram client versions;
- проверить, не используют ли пользователи старые клиенты;
- собрать `ja4`, `ja4_raw`, platform/version, source network;
- проверить тот же client через другую сеть;
- проверить другой client version через ту же сеть.
### Сценарий B: один CIDR не работает, JA4 обычный
Признаки:
- тот же `ja4` успешно работает из других сетей;
- проблемный `/24` или `/56` не доходит до Telemt или не получает `auth_success`;
- нет общей корреляции по версии клиента.
Вероятный вывод: проблема не в JA4 alone, а в source network policy или destination reputation.
Действия:
- сменить route/VPS/IP;
- проверить port;
- проверить SNI/domain reputation;
- сравнить с другим Telemt endpoint;
- смотреть server-side packet capture.
### Сценарий C: много `bad_or_probe` на одном JA4
Признаки:
- `bad_or_probe` высокий;
- `by_user` пустой или слабый;
- source IP/CIDR разнообразные;
- попытки не соответствуют реальным пользователям.
Вероятный вывод: активное сканирование или нерелевантный TLS traffic с похожим ClientHello.
Действия:
- смотреть `/beobachten` по IP classes;
- проверить `unknown_tls_sni` и bad-client counters;
- убедиться, что fallback `mask_host` отвечает правдоподобно;
- не делать вывод о блокировке пользователей только по global `bad_or_probe`.
### Сценарий D: `auth_success` есть, но пользователь жалуется
Признаки:
- fingerprint присутствует в `by_user`;
- `auth_success` растёт;
- соединение проходит TLS-auth.
Вероятный вывод: JA4 ClientHello не является причиной отказа в этом случае.
Действия:
- проверить user enabled/disabled status;
- проверить quota;
- проверить direct/ME route;
- проверить upstream health;
- проверить runtime events;
- смотреть relay/session logs.
## Что нельзя вывести из JA3/JA4
JA3/JA4 не говорят:
- почему сеть приняла решение о блокировке;
- какой именно vendor DPI используется;
- был ли block только по JA4 или по связке JA4+IP+SNI;
- что произошло с соединением после TLS-auth;
- как выглядит server-side TLS fingerprint;
- как ведёт себя HTTP layer после TLS.
JA3/JA4 также не являются уникальной идентичностью человека. Это fingerprint клиентской TLS-реализации и её настроек. Один fingerprint может быть у большого числа пользователей.
## Ограничения collector'а Telemt
- Считается только TLS ClientHello, который полностью дошёл до Telemt.
- QUIC/DTLS/HTTP JA4 variants не собираются.
- Truncated ClientHello не fingerprint'ится.
- User scope появляется только после успешной TLS-auth.
- `by_ip` и `by_cidr` отражают source address после нормализации/PROXY protocol path, если он используется.
- Collector bounded: при большом количестве уникальных buckets возможен рост `dropped_total`.
- Retention зависит от `general.beobachten_minutes`.
- Данные runtime in-memory; это snapshot для диагностики, а не долговременное хранилище.
## Рекомендованный workflow расследования
1. Включить `runtime_edge_enabled=true` и разумный `runtime_edge_top_n`, например `100`.
2. Зафиксировать baseline в период нормальной работы.
3. Во время жалобы снять API snapshot и `/beobachten`.
4. Сравнить `by_user`, `by_ip`, `by_cidr`, `by_fingerprint`.
5. Проверить, появляется ли problematic source в Telemt вообще.
6. Если не появляется, снять packet capture на сервере и клиенте.
7. Если появляется без `auth_success`, проверить secret/client/proxy link и bad/probe counters.
8. Если появляется с `auth_success`, исключить JA4 ClientHello как primary cause и перейти к relay/upstream/runtime диагностике.
9. Если один JA4 стабильно коррелирует с block, собрать client version/platform evidence.
10. Проверить, меняет ли обновление клиента JA4 и результат подключения.
## Минимальный incident report
Для полезного отчёта по JA4-based блокировке соберите:
```text
time_window:
telemt_version:
server_ip:
server_port:
tls_domain:
mask_host:
client_platform:
client_version:
source_network:
source_ip_or_cidr:
ja4:
ja4_raw:
ja3:
total:
auth_success:
bad_or_probe:
seen_in_by_user: yes/no
seen_in_by_ip: yes/no
seen_in_by_cidr: yes/no
server_tcpdump_seen_clienthello: yes/no
client_tcpdump_sent_clienthello: yes/no
works_from_other_network: yes/no
works_with_other_client_version: yes/no
```
Этот набор обычно достаточен, чтобы отличить client fingerprint block от IP/SNI/reputation block и от post-auth проблем Telemt.
## Источники форматов
- JA3 reference: https://github.com/salesforce/ja3
- JA4 technical details: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -172,7 +172,7 @@ Those cross-DC requests are normal and happen constantly.
> If your home DC is DC2 and DC2 goes down, you **cannot** reach DC5 even though DC5 itself is perfectly healthy.
> The client has no valid session to route the request through.
This is also why an MTProxy only needs to reach Telegram's DC infrastructure as a whole.
This is also why it is required for MTProxy to reach Telegram's DC infrastructure as a whole.
The proxy itself doesn't care which DC your account lives on. The client negotiates the correct DC through the proxy after connecting.
### How many people can use one link
+4 -2
View File
@@ -40,6 +40,8 @@ hello2 = "ad_tag2"
> Проблема с TLS отпечатком исправлена в последних версиях клиентов Telegram для Desktop / Android / iOS.
> Обновите свой клиент для корректной работы с MTProxy Fake-TLS!
- Для расследования блокировок на базе JA4 ClientHello используйте отдельную инструкцию: [`JA3 и JA4 анализ в Telemt`](Architecture/Fronting-splitting/TLS_JA3_JA4_ANALYSIS.ru.md).
- Мы считаем это прорывом, которому на сегодняшний день нет стабильных аналогов;
- Исходя из этого: если `telemt` настроен правильно, **режим TLS полностью идентичен реальному «рукопожатию» + обмену данными** с указанным хостом;
- Вот наши доказательства:
@@ -157,7 +159,7 @@ https://github.com/telemt/telemt/discussions/167
## Как клиенты взаимодействуют с дата-центрами Telegram
При регистрации аккаунта Telegram он навсегда привязывается к одному из дата-центров (DC).
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относиться номер телефона.
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относится номер телефона.
Этот DC становится вашим **домашним**: именно там хранится весь контент, который вы загружаете (фото, видео, файлы, сообщения).
И именно на нем клиент авторизуется при каждом подключении.
@@ -170,7 +172,7 @@ Telegram заранее определяет к какому DC привязат
> Если ваш домашний DC — DC2, и DC2 лежит, вы **не сможете** достучаться и до DC5, даже если сам DC5 полностью исправен.
> У клиента просто нет валидной сессии, через которую можно было бы направить запрос.
По той же причине MTProxy достаточно иметь доступ к инфраструктуре Telegram в целом.
По той же причине MTProxy необходимо иметь доступ к инфраструктуре Telegram целиком, а не частично.
Cамому MTProxy всё равно, на каком DC живёт ваш аккаунт. Клиент cам договаривается о нужном DC через прокси уже после подключения.
## Что такое dd и ee в контексте MTProxy?
+5 -2
View File
@@ -235,7 +235,10 @@ curl -s http://127.0.0.1:9091/v1/users | jq -r '.data[] | "[\(.username)]", (.li
# Telemt через Docker Compose
**1. Отредактируйте `config.toml` в корневом каталоге репозитория (как минимум: порт, пользовательские секреты, tls_domain)**
**1. Создайте директорию `config/` и поместите в неё отрдеактированный `config.toml` (указав как минимум: порт, пользовательские секреты, tls_domain):**
```bash
mkdir config && mv config.toml config/
```
**2. Запустите контейнер:**
```bash
docker compose up -d --build
@@ -249,7 +252,7 @@ docker compose logs -f telemt
docker compose down
```
> [!NOTE]
> - В `docker-compose.yml` файл `./config.toml` монтируется в `/app/config.toml` (доступно только для чтения)
> - Директория `./config/` монтируется в `/etc/telemt/` (read-write), что позволяет API атомарно обновлять config.toml
> - По умолчанию публикуются порты 443:443, а контейнер запускается со сброшенными привилегиями (добавлена только `NET_BIND_SERVICE`)
> - Если вам действительно нужна сеть хоста (обычно это требуется только для некоторых конфигураций IPv6), раскомментируйте `network_mode: host`
+1 -1
View File
@@ -206,7 +206,7 @@ File content:
"publicKey": "<SERVER_B_PUBLIC_KEY>",
"shortId": "<SHORT_ID>",
"spiderX": "/",
"fingerprint": "chrome"
"fingerprint": "firefox"
},
"xhttpSettings": {
"path": "/<YOUR_RANDOM_PATH>"
+1 -1
View File
@@ -206,7 +206,7 @@ nano /usr/local/etc/xray/config.json
"publicKey": "<SERVER_B_PUBLIC_KEY>",
"shortId": "<SHORT_ID>",
"spiderX": "/",
"fingerprint": "chrome"
"fingerprint": "firefox"
},
"xhttpSettings": {
"path": "/<YOUR_RANDOM_PATH>"
+91 -47
View File
@@ -84,27 +84,29 @@ set_language() {
L_INFO_KEEP_CONF="Примечание: Конфигурация сохранена. Используйте 'purge' для очистки."
L_INFO_I_START="Начинается установка"
L_I_STAGE_1=">>> Этап 1: Проверка окружения и зависимостей"
L_I_STAGE_1_5=">>> Этап 1.5: Интерактивная настройка"
L_I_STAGE_2=">>> Этап 2: Интерактивная настройка"
L_I_PROMPT_DOM="\nПожалуйста, укажите домен TLS\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
L_I_PROMPT_PORT="\nПожалуйста, укажите порт сервера\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
L_WARN_NO_TTY="Интерактивный режим недоступен (нет TTY). Используется:"
L_I_STAGE_2=">>> Этап 2: Загрузка архива"
L_I_STAGE_3=">>> Этап 3: Загрузка архива"
L_ERR_TMP_DIR="Не удалось создать временную директорию"
L_ERR_TMP_INV="Временная директория недействительна"
L_INFO_FALLBACK="Сборка x86_64-v3 не найдена, откат к стандартной x86_64..."
L_ERR_DL_FAIL="Ошибка загрузки архива"
L_I_STAGE_3=">>> Этап 3: Распаковка архива"
L_I_STAGE_4=">>> Этап 4: Распаковка архива"
L_ERR_EXTRACT="Ошибка распаковки архива."
L_ERR_BIN_NOT_FOUND="Бинарный файл не найден в архиве"
L_I_STAGE_4=">>> Этап 4: Настройка окружения (Юзер, Группа, Папки)"
L_I_STAGE_5=">>> Этап 5: Установка бинарного файла"
L_I_STAGE_6=">>> Этап 6: Генерация/Обновление конфигурации"
L_I_STAGE_7=">>> Этап 7: Установка и запуск службы"
L_I_STAGE_5=">>> Этап 5: Настройка окружения (Юзер, Группа, Папки)"
L_I_STAGE_6=">>> Этап 6: Установка бинарного файла"
L_I_STAGE_7=">>> Этап 7: Генерация/Обновление конфигурации"
L_I_STAGE_8=">>> Этап 8: Установка и запуск службы"
L_OUT_WARN_H="УСТАНОВКА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ"
L_OUT_WARN_D="Служба установлена, но не запустилась.\nПожалуйста, проверьте логи.\n"
L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА"
L_OUT_UNINST_H="УДАЛЕНИЕ ЗАВЕРШЕНО"
L_OUT_LINK="Ваша ссылка для подключения к Telegram Proxy:\n"
L_ERR_INCORR_ROOT_LOGIN="Используйте 'su -' или 'sudo -i' для входа под пользователем root"
L_OUT_LOGS="Чтобы посмотреть логи (в случае проблем), используйте команду:"
;;
*)
L_ERR_DOMAIN_REQ="requires a domain argument."
@@ -159,27 +161,29 @@ set_language() {
L_INFO_KEEP_CONF="Note: Configuration kept. Run with 'purge' to remove completely."
L_INFO_I_START="Starting installation of"
L_I_STAGE_1=">>> Stage 1: Verifying environment and dependencies"
L_I_STAGE_1_5=">>> Stage 1.5: Interactive Setup"
L_I_STAGE_2=">>> Stage 2: Interactive Setup"
L_I_PROMPT_DOM="\nPlease specify the TLS Domain\nPress Enter to keep default [%s]: "
L_I_PROMPT_PORT="\nPlease specify the Server Port\nPress Enter to keep default [%s]: "
L_WARN_NO_TTY="Interactive mode unavailable (no TTY). Using:"
L_I_STAGE_2=">>> Stage 2: Downloading archive"
L_I_STAGE_3=">>> Stage 3: Downloading archive"
L_ERR_TMP_DIR="Temp directory creation failed"
L_ERR_TMP_INV="Temp directory is invalid or was not created"
L_INFO_FALLBACK="x86_64-v3 build not found, falling back to standard x86_64..."
L_ERR_DL_FAIL="Download failed"
L_I_STAGE_3=">>> Stage 3: Extracting archive"
L_I_STAGE_4=">>> Stage 4: Extracting archive"
L_ERR_EXTRACT="Extraction failed."
L_ERR_BIN_NOT_FOUND="Binary not found in archive"
L_I_STAGE_4=">>> Stage 4: Setting up environment (User, Group, Directories)"
L_I_STAGE_5=">>> Stage 5: Installing binary"
L_I_STAGE_6=">>> Stage 6: Generating/Updating configuration"
L_I_STAGE_7=">>> Stage 7: Installing and starting service"
L_I_STAGE_5=">>> Stage 5: Setting up environment (User, Group, Directories)"
L_I_STAGE_6=">>> Stage 6: Installing binary"
L_I_STAGE_7=">>> Stage 7: Generating/Updating configuration"
L_I_STAGE_8=">>> Stage 8: Installing and starting service"
L_OUT_WARN_H="INSTALLATION COMPLETED WITH WARNINGS"
L_OUT_WARN_D="The service was installed but failed to start.\nPlease check the logs to determine the issue.\n"
L_OUT_SUCC_H="INSTALLATION SUCCESS"
L_OUT_UNINST_H="UNINSTALLATION COMPLETE"
L_OUT_LINK="Your Telegram Proxy connection link:\n"
L_ERR_INCORR_ROOT_LOGIN="Use 'su -' or 'sudo -i' to login under root"
L_OUT_LOGS="To view logs (in case of issues), use the following command:"
;;
esac
}
@@ -267,7 +271,10 @@ say() {
if [ "$#" -eq 0 ] || [ -z "${1:-}" ]; then
printf '\n'
else
printf '[INFO] %s\n' "$*"
case "$*" in
\[*\]*) printf '%s\n' "$*" ;;
*) printf '[INFO] %s\n' "$*" ;;
esac
fi
}
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
@@ -392,7 +399,7 @@ verify_common() {
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if [ "${USER:-}" != "root" ] && [ "${LOGNAME:-}" != "root" ]; then
die "$L_ERR_INCORR_ROOT_LOGIN"
fi
else
@@ -525,9 +532,9 @@ setup_dirs() {
stop_service() {
svc="$(get_svc_mgr)"
if [ "$svc" = "systemd" ] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
if [ "$svc" = "systemd" ] && $SUDO systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
$SUDO systemctl stop "$SERVICE_NAME" 2>/dev/null || true
elif [ "$svc" = "openrc" ] && rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
elif [ "$svc" = "openrc" ] && $SUDO rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
$SUDO rc-service "$SERVICE_NAME" stop 2>/dev/null || true
fi
}
@@ -539,7 +546,7 @@ install_binary() {
fi
$SUDO mkdir -p "$INSTALL_DIR" || die "$L_ERR_MKDIR"
$SUDO rm -f "$bin_dst" 2>/dev/null || true
if command -v install >/dev/null 2>&1; then
@@ -609,33 +616,33 @@ install_config() {
tmp_conf="${TEMP_DIR}/config.tmp"
$SUDO cat "$CONFIG_FILE" > "$tmp_conf"
escaped_domain="$(printf '%s\n' "$TLS_DOMAIN" | tr -d '[:cntrl:]' | sed 's/\\/\\\\/g; s/"/\\"/g')"
awk -v port="$SERVER_PORT" -v secret="$USER_SECRET" -v domain="$escaped_domain" -v ad_tag="$AD_TAG" \
-v flag_p="$PORT_PROVIDED" -v flag_s="$SECRET_PROVIDED" -v flag_d="$DOMAIN_PROVIDED" -v flag_a="$AD_TAG_PROVIDED" '
BEGIN { ad_tag_handled = 0 }
flag_p == "1" && /^[ \t]*port[ \t]*=/ { print "port = " port; next }
flag_s == "1" && /^[ \t]*hello[ \t]*=/ { print "hello = \"" secret "\""; next }
flag_d == "1" && /^[ \t]*tls_domain[ \t]*=/ { print "tls_domain = \"" domain "\""; next }
flag_a == "1" && /^[ \t]*ad_tag[ \t]*=/ {
if (!ad_tag_handled) {
print "ad_tag = \"" ad_tag "\"";
ad_tag_handled = 1;
}
next
flag_a == "1" && /^[ \t]*ad_tag[ \t]*=/ {
if (!ad_tag_handled) {
print "ad_tag = \"" ad_tag "\"";
ad_tag_handled = 1;
}
next
}
flag_a == "1" && /^\[general\]/ {
print;
if (!ad_tag_handled) {
print "ad_tag = \"" ad_tag "\"";
ad_tag_handled = 1;
}
next
flag_a == "1" && /^\[general\]/ {
print;
if (!ad_tag_handled) {
print "ad_tag = \"" ad_tag "\"";
ad_tag_handled = 1;
}
next
}
{ print }
' "$tmp_conf" > "${tmp_conf}.new" && mv "${tmp_conf}.new" "$tmp_conf"
@@ -785,11 +792,11 @@ uninstall() {
say "$L_U_STAGE_5"
$SUDO rm -rf "$CONFIG_DIR" "$WORK_DIR"
$SUDO rm -f "$CONFIG_FILE"
if check_os_entity passwd telemt; then
$SUDO userdel telemt 2>/dev/null || $SUDO deluser telemt 2>/dev/null || true
fi
if check_os_entity group telemt; then
$SUDO groupdel telemt 2>/dev/null || $SUDO delgroup telemt 2>/dev/null || true
fi
@@ -830,10 +837,36 @@ case "$ACTION" in
fi
fi
check_port_availability
if [ "$PORT_PROVIDED" -eq 0 ] || [ "$DOMAIN_PROVIDED" -eq 0 ]; then
say "$L_I_STAGE_2"
fi
if [ "$PORT_PROVIDED" -eq 0 ]; then
if [ -t 0 ] || [ -c /dev/tty ]; then
while true; do
printf "$L_I_PROMPT_PORT" "$SERVER_PORT"
read -r input_port </dev/tty || input_port=""
if [ -z "$input_port" ]; then
break
fi
case "$input_port" in
*[!0-9]*) printf '[ERROR] %s\n' "$L_ERR_PORT_NUM" >&2; continue ;;
esac
port_num="$(printf '%s\n' "$input_port" | sed 's/^0*//')"
[ -z "$port_num" ] && port_num="0"
if [ "${#port_num}" -gt 5 ] || [ "$port_num" -lt 1 ] || [ "$port_num" -gt 65535 ]; then
printf '[ERROR] %s\n' "$L_ERR_PORT_RANGE" >&2; continue
fi
SERVER_PORT="$port_num"
break
done
else
say "[WARNING] $L_WARN_NO_TTY $SERVER_PORT"
fi
PORT_PROVIDED=1
fi
if [ "$DOMAIN_PROVIDED" -eq 0 ]; then
say "$L_I_STAGE_1_5"
if [ -t 0 ] || [ -c /dev/tty ]; then
printf "$L_I_PROMPT_DOM" "$TLS_DOMAIN"
read -r input_domain </dev/tty || input_domain=""
@@ -846,6 +879,8 @@ case "$ACTION" in
DOMAIN_PROVIDED=1
fi
check_port_availability
if [ "$TARGET_VERSION" != "latest" ]; then
TARGET_VERSION="${TARGET_VERSION#v}"
fi
@@ -859,7 +894,7 @@ case "$ACTION" in
DL_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}/${FILE_NAME}"
fi
say "$L_I_STAGE_2"
say "$L_I_STAGE_3"
TEMP_DIR="$(mktemp -d)" || die "$L_ERR_TMP_DIR"
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
die "$L_ERR_TMP_INV"
@@ -881,7 +916,7 @@ case "$ACTION" in
fi
fi
say "$L_I_STAGE_3"
say "$L_I_STAGE_4"
if ! gzip -dc "${TEMP_DIR}/${FILE_NAME}" | tar -xf - -C "$TEMP_DIR" 2>/dev/null; then
die "$L_ERR_EXTRACT"
fi
@@ -889,16 +924,16 @@ case "$ACTION" in
EXTRACTED_BIN="$(find "$TEMP_DIR" -type f -name "$BIN_NAME" -print 2>/dev/null | head -n 1 || true)"
[ -n "$EXTRACTED_BIN" ] || die "$L_ERR_BIN_NOT_FOUND"
say "$L_I_STAGE_4"
say "$L_I_STAGE_5"
ensure_user_group; setup_dirs; stop_service
say "$L_I_STAGE_5"
say "$L_I_STAGE_6"
install_binary "$EXTRACTED_BIN" "${INSTALL_DIR}/${BIN_NAME}"
say "$L_I_STAGE_6"
say "$L_I_STAGE_7"
install_config
say "$L_I_STAGE_7"
say "$L_I_STAGE_8"
install_service
if [ "${SERVICE_START_FAILED:-0}" -eq 1 ]; then
@@ -916,7 +951,7 @@ case "$ACTION" in
if command -v curl >/dev/null 2>&1; then SERVER_IP="$(curl -s4 -m 3 ifconfig.me 2>/dev/null || curl -s4 -m 3 api.ipify.org 2>/dev/null || true)"
elif command -v wget >/dev/null 2>&1; then SERVER_IP="$(wget -qO- -T 3 ifconfig.me 2>/dev/null || wget -qO- -T 3 api.ipify.org 2>/dev/null || true)"; fi
[ -z "$SERVER_IP" ] && SERVER_IP="<YOUR_SERVER_IP>"
if command -v xxd >/dev/null 2>&1; then HEX_DOMAIN="$(printf '%s' "$TLS_DOMAIN" | xxd -p | tr -d '\n')"
elif command -v hexdump >/dev/null 2>&1; then HEX_DOMAIN="$(printf '%s' "$TLS_DOMAIN" | hexdump -v -e '/1 "%02x"')"
elif command -v od >/dev/null 2>&1; then HEX_DOMAIN="$(printf '%s' "$TLS_DOMAIN" | od -A n -t x1 | tr -d ' \n')"
@@ -927,6 +962,15 @@ case "$ACTION" in
printf '%b\n' "$L_OUT_LINK"
printf ' tg://proxy?server=%s&port=%s&secret=%s\n\n' "$SERVER_IP" "$SERVER_PORT" "$CLIENT_SECRET"
svc="$(get_svc_mgr)"
if [ "$svc" = "systemd" ]; then
printf '%s\n' "$L_OUT_LOGS"
printf ' sudo journalctl -u %s -f\n\n' "$SERVICE_NAME"
elif [ "$svc" = "openrc" ]; then
printf '%s\n' "$L_OUT_LOGS"
printf ' sudo tail -f /var/log/messages /var/log/syslog 2>/dev/null | grep -i %s\n\n' "$SERVICE_NAME"
fi
printf '====================================================================\n'
;;
esac
+334
View File
@@ -0,0 +1,334 @@
//! Config-editing API: read managed sections and apply sparse field patches.
//! `access.*` is intentionally not editable here (owned by the users API).
use serde_json::Value as Json;
use toml::Value as Toml;
use super::ApiShared;
use super::config_store::{
EDITABLE_SECTIONS, compute_revision, current_revision, save_sections_to_disk,
};
use super::model::ApiFailure;
use crate::config::ProxyConfig;
use crate::config::hot_reload::classify_config_changes;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
pub(super) struct PatchConfigResponse {
pub revision: String,
pub restart_required: bool,
pub changed: Vec<String>,
}
/// Shared-state wrapper around [`apply_patch_to_path`]: serializes config
/// mutations behind `mutation_lock`, then records a runtime event. The route
/// handler calls this; the core logic stays decoupled for unit tests.
pub(super) async fn patch_config(
patch_json: Json,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<PatchConfigResponse, ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
drop(_guard);
shared
.runtime_events
.record("api.config.patch.ok", format!("changed={:?}", resp.changed));
Ok(resp)
}
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
/// against a temp file. The route handler holds `mutation_lock` while calling this.
pub(super) async fn apply_patch_to_path(
config_path: &Path,
patch_json: &Json,
expected_revision: Option<String>,
) -> Result<PatchConfigResponse, ApiFailure> {
// 1. optimistic concurrency
let current = current_revision(config_path).await?;
if expected_revision.is_some_and(|expected| expected != current) {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"revision_conflict",
"Config revision mismatch",
));
}
// 2. convert + reject access / unknown sections
let patch_toml = json_to_toml(patch_json)
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
let patch_table = patch_toml
.as_table()
.ok_or_else(|| ApiFailure::bad_request("patch must be a JSON object"))?;
if patch_table.contains_key("access") {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"access_not_editable",
"access.* is managed via the users API, not editable here",
));
}
for key in patch_table.keys() {
if !EDITABLE_SECTIONS.contains(&key.as_str()) {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"section_not_editable",
format!("section not editable: {}", key),
));
}
}
let touched: Vec<&str> = patch_table
.keys()
.map(|k| k.as_str())
.filter(|k| EDITABLE_SECTIONS.contains(k))
.collect();
if touched.is_empty() {
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
}
// 3. Parse old + merged from the SAME deserialize path so the classifier
// sees only the delta this patch introduces. `ProxyConfig::load` applies
// include-expansion / legacy-compat / normalization that a bare
// `try_into` does not; mixing the two paths would make unrelated fields
// compare unequal and spuriously force `restart_required`.
let original = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
let original_toml: Toml = toml::from_str(&original)
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
let old_cfg: ProxyConfig = original_toml
.clone()
.try_into()
.map_err(|e| ApiFailure::internal(format!("config does not deserialize: {}", e)))?;
let mut merged = original_toml;
deep_merge(&mut merged, &patch_toml);
let new_cfg: ProxyConfig = merged
.clone()
.try_into()
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
new_cfg
.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
// 4. classify changes (Telemt's own hot/restart rule)
let class = classify_config_changes(&old_cfg, &new_cfg);
// 5. write only the touched top-level sections
let revision = save_sections_to_disk(config_path, &new_cfg, &touched).await?;
Ok(PatchConfigResponse {
revision,
restart_required: class.restart_required,
changed: class.changed,
})
}
/// Return only the editable config sections + current revision.
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
let original = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
let parsed: Toml = toml::from_str(&original)
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
let parsed_table = parsed
.as_table()
.cloned()
.unwrap_or_else(toml::value::Table::new);
// Whitelist: return ONLY the editable sections. A blacklist (just removing
// `access`) would leak `server` (carries the API `auth_header` + per-node
// identity) and `network` (per-node addresses). Mirror the PATCH contract.
let mut table = toml::value::Table::new();
for section in EDITABLE_SECTIONS {
if let Some(value) = parsed_table.get(*section) {
table.insert((*section).to_string(), value.clone());
}
}
let revision = compute_revision(&original);
Ok((Toml::Table(table), revision))
}
/// Convert a serde_json value to a toml value. `null` is dropped from objects
/// (a patch never sets a key to TOML-null). Numbers become integers when exact,
/// otherwise floats.
fn json_to_toml(j: &Json) -> Result<Toml, String> {
Ok(match j {
Json::Null => return Err("null is not representable in TOML".into()),
Json::Bool(b) => Toml::Boolean(*b),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
Toml::Integer(i)
} else if let Some(f) = n.as_f64() {
Toml::Float(f)
} else {
return Err(format!("unrepresentable number: {}", n));
}
}
Json::String(s) => Toml::String(s.clone()),
Json::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(json_to_toml(item)?);
}
Toml::Array(out)
}
Json::Object(map) => {
let mut table = toml::value::Table::new();
for (k, v) in map {
if v.is_null() {
continue; // skip nulls instead of erroring at object level
}
table.insert(k.clone(), json_to_toml(v)?);
}
Toml::Table(table)
}
})
}
/// Recursively overlay `patch` onto `base`. Tables merge key-by-key; every
/// other value type (scalars, arrays) replaces wholesale.
fn deep_merge(base: &mut Toml, patch: &Toml) {
match (base, patch) {
(Toml::Table(b), Toml::Table(p)) => {
for (k, pv) in p {
match b.get_mut(k) {
Some(bv) => deep_merge(bv, pv),
None => {
b.insert(k.clone(), pv.clone());
}
}
}
}
(b, p) => *b = p.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_object_converts_to_toml_table() {
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
let t = json_to_toml(&j).expect("convertible");
let table = t.as_table().unwrap();
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
assert_eq!(table["default_dc"].as_integer(), Some(2));
}
#[test]
fn deep_merge_overlays_tables_and_replaces_scalars() {
let mut base: Toml =
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
deep_merge(&mut base, &patch);
let cens = base["censorship"].as_table().unwrap();
assert_eq!(cens["tls_domain"].as_str(), Some("new")); // overlaid
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100)); // preserved
}
use std::path::PathBuf;
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, body).unwrap();
(path, dir)
}
#[tokio::test]
async fn patch_rejects_access_section() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "access_not_editable");
}
#[tokio::test]
async fn patch_revision_conflict() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
.await
.unwrap_err();
assert_eq!(err.code, "revision_conflict");
}
#[tokio::test]
async fn patch_sni_reports_restart_required() {
let (path, _d) =
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(resp.restart_required);
assert!(resp.changed.iter().any(|c| c == "censorship"));
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("tls_domain = \"b.com\""));
assert_eq!(
resp.revision,
crate::api::config_store::compute_revision(&written)
);
}
#[tokio::test]
async fn read_managed_config_strips_access() {
let (path, _d) = temp_config(
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"deadbeef\"\n",
);
let (value, revision) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
assert!(!table.contains_key("access")); // secrets never leave the box here
assert_eq!(revision, current_revision(&path).await.unwrap());
}
#[tokio::test]
async fn read_managed_config_returns_only_editable_sections() {
// server carries the API auth_header + per-node identity; network carries
// per-node addresses. Neither must be exposed by GET /v1/config.
let (path, _d) = temp_config(concat!(
"[censorship]\ntls_domain = \"a\"\n",
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
"[network]\nipv4 = \"1.2.3.4\"\n",
"[access.users]\nbob = \"deadbeef\"\n",
));
let (value, _rev) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
assert!(!table.contains_key("server")); // no API auth_header / identity leak
assert!(!table.contains_key("network")); // no per-node identity leak
assert!(!table.contains_key("access")); // no users/secrets
}
#[tokio::test]
async fn patch_rejects_server_section() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"server": {"port": 1}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "section_not_editable");
}
#[tokio::test]
async fn patch_empty_is_rejected() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({});
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
}
#[tokio::test]
async fn patch_log_level_is_hot() {
// general.log_level is hot-reloadable -> a patch changing only it must
// report restart_required = false (exercises the full apply path, not
// just the classifier). Default LogLevel is Normal; patch to "debug".
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(!resp.restart_required);
assert!(resp.changed.iter().any(|c| c == "general"));
}
}
+147 -4
View File
@@ -14,6 +14,7 @@ use super::model::ApiFailure;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AccessSection {
Users,
UserEnabled,
UserAdTags,
UserMaxTcpConns,
UserExpirations,
@@ -26,6 +27,7 @@ impl AccessSection {
fn table_name(self) -> &'static str {
match self {
Self::Users => "access.users",
Self::UserEnabled => "access.user_enabled",
Self::UserAdTags => "access.user_ad_tags",
Self::UserMaxTcpConns => "access.user_max_tcp_conns",
Self::UserExpirations => "access.user_expirations",
@@ -95,6 +97,81 @@ pub(super) async fn save_config_to_disk(
Ok(compute_revision(&serialized))
}
/// Top-level config tables that may be edited via the config API.
///
/// Intentionally excluded (defense-in-depth, enforces the spec's per-node
/// identity invariant at the Telemt layer too):
///
/// - `access` : owned by the users API.
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
///
/// A future field-level allowlist can re-admit specific safe fields
/// (e.g. `network.dns_overrides`) without opening the whole section.
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
"general",
"timeouts",
"censorship",
"upstreams",
"show_link",
"dc_overrides",
];
/// Re-render the given top-level tables from `cfg` and upsert each into the
/// on-disk file, preserving every untouched section (and its comments).
pub(super) async fn save_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
sections: &[&str],
) -> Result<String, ApiFailure> {
let mut content = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
for section in sections {
let rendered = render_top_level_section(cfg, section)?;
content = upsert_toml_table(&content, section, &rendered);
}
write_atomic(config_path.to_path_buf(), content.clone()).await?;
Ok(compute_revision(&content))
}
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
/// output matches the canonical format Telemt parses.
fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String, ApiFailure> {
let value = toml::Value::try_from(cfg)
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
let table = value
.get(section)
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
if let toml::Value::Array(items) = table {
let mut out = String::new();
for item in items {
out.push_str(&format!("[[{}]]\n", section));
out.push_str(&toml::to_string(item).map_err(|e| {
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
})?);
if !out.ends_with('\n') {
out.push('\n');
}
}
return Ok(out);
}
let body = toml::to_string(table)
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
let mut out = format!("[{}]\n", section);
out.push_str(&body);
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
pub(super) async fn save_access_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
@@ -135,6 +212,15 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserEnabled => {
let rows: BTreeMap<String, bool> = cfg
.access
.user_enabled
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserAdTags => {
let rows: BTreeMap<String, String> = cfg
.access
@@ -204,6 +290,7 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
match section {
AccessSection::Users => cfg.access.users.is_empty(),
AccessSection::UserEnabled => cfg.access.user_enabled.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(),
@@ -261,17 +348,22 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin
}
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
let target = format!("[{}]", table_name);
let single = format!("[{}]", table_name);
let array = format!("[[{}]]", table_name);
let mut offset = 0usize;
let mut start = None;
for line in source.split_inclusive('\n') {
let trimmed = line.trim();
// Drop any inline comment so a hand-edited header like
// `[censorship] # note` still matches. Section names never contain `#`.
let header = line.trim().split('#').next().unwrap_or("").trim();
if let Some(start_offset) = start {
if trimmed.starts_with('[') {
let is_same_array = header == array;
let is_new_header = header.starts_with('[');
if is_new_header && !is_same_array {
return Some((start_offset, offset));
}
} else if trimmed == target {
} else if header == single || header == array {
start = Some(offset);
}
offset = offset.saturating_add(line.len());
@@ -324,6 +416,57 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
mod tests {
use super::*;
#[tokio::test]
async fn save_sections_preserves_other_tables_and_comments() {
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(
&path,
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
)
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.censorship.tls_domain = "new.example".to_string();
cfg.server.port = 443;
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
.await
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("tls_domain = \"new.example\""));
assert!(written.contains("# top comment")); // untouched comment kept
assert!(written.contains("[server]\nport = 443")); // untouched table kept
assert_eq!(rev, compute_revision(&written));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn find_bounds_matches_array_of_tables() {
let src =
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
let bounds = find_toml_table_bounds(src, "upstreams");
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[[upstreams]]"));
assert!(slice.contains("kind = \"b\"")); // spans through the last upstream block
}
#[test]
fn find_bounds_matches_header_with_inline_comment() {
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
let bounds = find_toml_table_bounds(src, "censorship");
assert!(bounds.is_some(), "commented header must still match");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[censorship] # notes"));
assert!(slice.contains("tls_domain"));
assert!(!slice.contains("[server]")); // terminates at the next header
}
#[test]
fn render_user_rate_limits_section() {
let mut cfg = ProxyConfig::default();
+10 -5
View File
@@ -1,6 +1,7 @@
use http_body_util::{BodyExt, Full};
use hyper::StatusCode;
use hyper::body::{Bytes, Incoming};
use hyper::header::ALLOW;
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -25,6 +26,8 @@ pub(super) fn success_response<T: Serialize>(
}
pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Response<Full<Bytes>> {
let status = failure.status;
let allow = failure.allow;
let payload = ErrorResponse {
ok: false,
error: ErrorBody {
@@ -40,11 +43,13 @@ pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Res
)
.into_bytes()
});
hyper::Response::builder()
.status(failure.status)
.header("content-type", "application/json; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.unwrap()
let mut builder = hyper::Response::builder()
.status(status)
.header("content-type", "application/json; charset=utf-8");
if let Some(allow) = allow {
builder = builder.header(ALLOW, allow);
}
builder.body(Full::new(Bytes::from(body))).unwrap()
}
pub(super) async fn read_json<T: DeserializeOwned>(
+275 -17
View File
@@ -22,11 +22,13 @@ use tracing::{debug, info, warn};
use crate::config::{ApiGrayAction, ProxyConfig};
use crate::ip_tracker::UserIpTracker;
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::startup::StartupTracker;
use crate::stats::Stats;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
mod config_edit;
mod config_store;
mod events;
mod http_utils;
@@ -41,7 +43,9 @@ mod runtime_watch;
mod runtime_zero;
mod users;
use config_store::{current_revision, load_config_from_disk, parse_if_match};
use config_store::{
current_revision, ensure_expected_revision, load_config_from_disk, parse_if_match,
};
use events::ApiEventStore;
use http_utils::{error_response, read_json, read_optional_json, success_response};
use model::{
@@ -49,9 +53,10 @@ use model::{
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
is_valid_username,
};
use patch::Patch;
use runtime_edge::{
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
build_runtime_events_recent_data,
build_runtime_events_recent_data, build_runtime_tls_fingerprints_data,
};
use runtime_init::build_runtime_initialization_data;
use runtime_min::{
@@ -69,12 +74,18 @@ use runtime_zero::{
build_system_info_data,
};
use users::{
build_user_quota_list, create_user, delete_user, patch_user, rotate_secret, users_from_config,
build_user_quota_list, create_user, delete_user, patch_user, rotate_secret, set_user_enabled,
users_from_config,
};
const API_MAX_CONTROL_CONNECTIONS: usize = 1024;
const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
const ROUTE_USERNAME_ERROR: &str = "username must match [A-Za-z0-9_.-] and be 1..64 chars";
const ALLOW_GET: &str = "GET";
const ALLOW_POST: &str = "POST";
const ALLOW_GET_POST: &str = "GET, POST";
const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE";
const ALLOW_GET_PATCH: &str = "GET, PATCH";
pub(super) struct ApiRuntimeState {
pub(super) process_started_at_epoch_secs: u64,
@@ -101,6 +112,7 @@ pub(super) struct ApiShared {
pub(super) runtime_state: Arc<ApiRuntimeState>,
pub(super) startup_tracker: Arc<StartupTracker>,
pub(super) route_runtime: Arc<RouteRuntimeController>,
pub(super) proxy_shared: Arc<ProxySharedState>,
}
impl ApiShared {
@@ -125,12 +137,68 @@ fn parse_route_username(user: &str) -> Result<&str, ApiFailure> {
}
}
fn user_action_route_matches(path: &str, suffix: &str) -> bool {
path.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix(suffix))
.map(|user| !user.is_empty() && !user.contains('/'))
.unwrap_or(false)
}
fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
match path {
"/v1/health"
| "/v1/health/ready"
| "/v1/system/info"
| "/v1/runtime/gates"
| "/v1/runtime/initialization"
| "/v1/limits/effective"
| "/v1/security/posture"
| "/v1/security/whitelist"
| "/v1/stats/summary"
| "/v1/stats/zero/all"
| "/v1/stats/upstreams"
| "/v1/stats/minimal/all"
| "/v1/stats/me-writers"
| "/v1/stats/dcs"
| "/v1/runtime/me-pool-state"
| "/v1/runtime/me_pool_state"
| "/v1/runtime/me-quality"
| "/v1/runtime/me_quality"
| "/v1/runtime/upstream-quality"
| "/v1/runtime/upstream_quality"
| "/v1/runtime/nat-stun"
| "/v1/runtime/nat_stun"
| "/v1/runtime/me-selftest"
| "/v1/runtime/connections/summary"
| "/v1/runtime/events/recent"
| "/v1/runtime/tls-fingerprints"
| "/v1/stats/users/active-ips"
| "/v1/stats/users/quota"
| "/v1/stats/users" => Some(ALLOW_GET),
"/v1/users" => Some(ALLOW_GET_POST),
"/v1/config" => Some(ALLOW_GET_PATCH),
_ if user_action_route_matches(path, "/reset-quota") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/rotate-secret") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/enable") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/disable") => Some(ALLOW_POST),
_ if path
.strip_prefix("/v1/users/")
.map(|user| !user.is_empty() && !user.contains('/'))
.unwrap_or(false) =>
{
Some(ALLOW_GET_PATCH_DELETE)
}
_ => None,
}
}
pub async fn serve(
listen: SocketAddr,
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
route_runtime: Arc<RouteRuntimeController>,
proxy_shared: Arc<ProxySharedState>,
upstream_manager: Arc<UpstreamManager>,
config_rx: watch::Receiver<Arc<ProxyConfig>>,
admission_rx: watch::Receiver<bool>,
@@ -180,6 +248,7 @@ pub async fn serve(
runtime_state: runtime_state.clone(),
startup_tracker,
route_runtime,
proxy_shared,
});
spawn_runtime_watchers(
@@ -435,22 +504,22 @@ async fn handle(
let data = build_dcs_data(shared.as_ref(), api_cfg).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/me_pool_state") => {
("GET", "/v1/runtime/me-pool-state") | ("GET", "/v1/runtime/me_pool_state") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_me_pool_state_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/me_quality") => {
("GET", "/v1/runtime/me-quality") | ("GET", "/v1/runtime/me_quality") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_me_quality_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/upstream_quality") => {
("GET", "/v1/runtime/upstream-quality") | ("GET", "/v1/runtime/upstream_quality") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_upstream_quality_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/nat_stun") => {
("GET", "/v1/runtime/nat-stun") | ("GET", "/v1/runtime/nat_stun") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_nat_stun_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
@@ -475,6 +544,15 @@ async fn handle(
);
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/tls-fingerprints") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_tls_fingerprints_data(
shared.as_ref(),
cfg.as_ref(),
query.as_deref(),
);
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/stats/users/active-ips") => {
let revision = current_revision(&shared.config_path).await?;
let usernames: Vec<_> = cfg.access.users.keys().cloned().collect();
@@ -506,7 +584,7 @@ async fn handle(
.await;
Ok(success_response(StatusCode::OK, users, revision))
}
("GET", "/v1/users/quota") => {
("GET", "/v1/stats/users/quota") => {
let revision = current_revision(&shared.config_path).await?;
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
let data = build_user_quota_list(&disk_cfg, shared.stats.as_ref());
@@ -525,6 +603,7 @@ async fn handle(
}
let expected_revision = parse_if_match(req.headers());
let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?;
let requested_enabled = body.enabled;
let result = create_user(body, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
@@ -537,6 +616,25 @@ async fn handle(
};
let runtime_cfg = config_rx.borrow().clone();
data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username);
if let Some(enabled) = requested_enabled {
shared
.proxy_shared
.set_user_enabled(&data.user.username, enabled);
if !enabled {
let cancelled = shared
.proxy_shared
.cancel_user_sessions(&data.user.username);
if cancelled > 0 {
shared.runtime_events.record(
"api.user.disable.runtime",
format!(
"username={} cancelled_sessions={}",
data.user.username, cancelled
),
);
}
}
}
shared.runtime_events.record(
"api.user.create.ok",
format!("username={}", data.user.username),
@@ -548,7 +646,131 @@ async fn handle(
};
Ok(success_response(status, data, revision))
}
("GET", "/v1/config") => {
let (value, revision) =
config_edit::read_managed_config(&shared.config_path).await?;
Ok(success_response(StatusCode::OK, value, revision))
}
("PATCH", "/v1/config") => {
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let body = read_json::<serde_json::Value>(req.into_body(), body_limit).await?;
match config_edit::patch_config(body, expected_revision, &shared).await {
Ok(resp) => {
let revision = resp.revision.clone();
Ok(success_response(StatusCode::OK, resp, revision))
}
Err(error) => {
shared
.runtime_events
.record("api.config.patch.failed", error.code);
Err(error)
}
}
}
_ => {
if method == Method::POST
&& let Some(base_user) = normalized_path
.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix("/enable"))
&& !base_user.is_empty()
&& !base_user.contains('/')
{
let base_user = parse_route_username(base_user)?;
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let result =
set_user_enabled(base_user, true, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
Err(error) => {
shared.runtime_events.record(
"api.user.enable.failed",
format!("username={} code={}", base_user, error.code),
);
return Err(error);
}
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
shared.proxy_shared.set_user_enabled(base_user, true);
shared
.runtime_events
.record("api.user.enable.ok", format!("username={}", base_user));
let status = if data.in_runtime {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok(success_response(status, data, revision));
}
if method == Method::POST
&& let Some(base_user) = normalized_path
.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix("/disable"))
&& !base_user.is_empty()
&& !base_user.contains('/')
{
let base_user = parse_route_username(base_user)?;
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let result =
set_user_enabled(base_user, false, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
Err(error) => {
shared.runtime_events.record(
"api.user.disable.failed",
format!("username={} code={}", base_user, error.code),
);
return Err(error);
}
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
let newly_disabled = shared.proxy_shared.set_user_enabled(base_user, false);
let cancelled = shared.proxy_shared.cancel_user_sessions(base_user);
shared.runtime_events.record(
"api.user.disable.ok",
format!(
"username={} newly_disabled={} cancelled_sessions={}",
base_user, newly_disabled, cancelled
),
);
let status = if data.in_runtime {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok(success_response(status, data, revision));
}
if method == Method::POST
&& let Some(user) = normalized_path
.strip_prefix("/v1/users/")
@@ -567,6 +789,16 @@ async fn handle(
),
));
}
let expected_revision = parse_if_match(req.headers());
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref())
.await?;
if !disk_cfg.access.users.contains_key(user) {
return Ok(error_response(
request_id,
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "User not found"),
));
}
let snapshot = match crate::quota_state::reset_user_quota(
&shared.quota_state_path,
shared.stats.as_ref(),
@@ -696,6 +928,11 @@ async fn handle(
let expected_revision = parse_if_match(req.headers());
let body =
read_json::<PatchUserRequest>(req.into_body(), body_limit).await?;
let enabled_update = match &body.enabled {
Patch::Unchanged => None,
Patch::Remove => Some(true),
Patch::Set(enabled) => Some(*enabled),
};
let result = patch_user(user, body, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
@@ -709,6 +946,22 @@ async fn handle(
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
if let Some(enabled) = enabled_update {
shared
.proxy_shared
.set_user_enabled(&data.username, enabled);
if !enabled {
let cancelled =
shared.proxy_shared.cancel_user_sessions(&data.username);
shared.runtime_events.record(
"api.user.disable.runtime",
format!(
"username={} cancelled_sessions={}",
data.username, cancelled
),
);
}
}
shared
.runtime_events
.record("api.user.patch.ok", format!("username={}", data.username));
@@ -742,9 +995,12 @@ async fn handle(
return Err(error);
}
};
shared
.runtime_events
.record("api.user.delete.ok", format!("username={}", deleted_user));
shared.proxy_shared.set_user_enabled(&deleted_user, true);
let cancelled = shared.proxy_shared.cancel_user_sessions(&deleted_user);
shared.runtime_events.record(
"api.user.delete.ok",
format!("username={} cancelled_sessions={}", deleted_user, cancelled),
);
let runtime_cfg = config_rx.borrow().clone();
let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user);
let response = DeleteUserResponse {
@@ -761,16 +1017,18 @@ async fn handle(
if method == Method::POST {
return Ok(error_response(
request_id,
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "Route not found"),
ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
));
}
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::METHOD_NOT_ALLOWED,
"method_not_allowed",
"Unsupported HTTP method for this route",
),
ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
));
}
if let Some(allow) = allowed_methods_for_path(normalized_path) {
return Ok(error_response(
request_id,
ApiFailure::method_not_allowed(allow),
));
}
debug!(
+15
View File
@@ -15,6 +15,7 @@ pub(super) struct ApiFailure {
pub(super) status: StatusCode,
pub(super) code: &'static str,
pub(super) message: String,
pub(super) allow: Option<&'static str>,
}
impl ApiFailure {
@@ -23,6 +24,7 @@ impl ApiFailure {
status,
code,
message: message.into(),
allow: None,
}
}
@@ -33,6 +35,15 @@ impl ApiFailure {
pub(super) fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
pub(super) fn method_not_allowed(allow: &'static str) -> Self {
Self {
status: StatusCode::METHOD_NOT_ALLOWED,
code: "method_not_allowed",
message: "Unsupported HTTP method for this route".to_string(),
allow: Some(allow),
}
}
}
#[derive(Serialize)]
@@ -468,6 +479,7 @@ pub(super) struct TlsDomainLink {
#[derive(Serialize)]
pub(super) struct UserInfo {
pub(super) username: String,
pub(super) enabled: bool,
pub(super) in_runtime: bool,
pub(super) user_ad_tag: Option<String>,
pub(super) max_tcp_conns: Option<usize>,
@@ -534,6 +546,7 @@ pub(super) struct CreateUserRequest {
pub(super) rate_limit_up_bps: Option<u64>,
pub(super) rate_limit_down_bps: Option<u64>,
pub(super) max_unique_ips: Option<usize>,
pub(super) enabled: Option<bool>,
}
#[derive(Deserialize)]
@@ -553,6 +566,8 @@ pub(super) struct PatchUserRequest {
pub(super) rate_limit_down_bps: Patch<u64>,
#[serde(default, deserialize_with = "patch_field")]
pub(super) max_unique_ips: Patch<usize>,
#[serde(default, deserialize_with = "patch_field")]
pub(super) enabled: Patch<bool>,
}
#[derive(Default, Deserialize)]
+128
View File
@@ -12,6 +12,8 @@ const FEATURE_DISABLED_REASON: &str = "feature_disabled";
const SOURCE_UNAVAILABLE_REASON: &str = "source_unavailable";
const EVENTS_DEFAULT_LIMIT: usize = 50;
const EVENTS_MAX_LIMIT: usize = 1000;
const TLS_FINGERPRINTS_MAX_LIMIT: usize = 1000;
const RUNTIME_EDGE_RETENTION_MAX_MINUTES: u64 = 24 * 60;
#[derive(Clone, Serialize)]
pub(super) struct RuntimeEdgeConnectionUserData {
@@ -90,6 +92,44 @@ pub(super) struct RuntimeEdgeEventsData {
pub(super) data: Option<RuntimeEdgeEventsPayload>,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintRow {
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) scope: Option<String>,
pub(super) ja3: String,
pub(super) ja3_raw: String,
pub(super) ja4: String,
pub(super) ja4_raw: String,
pub(super) total: u64,
pub(super) auth_success: u64,
pub(super) bad_or_probe: u64,
pub(super) first_seen_epoch_secs: u64,
pub(super) last_seen_epoch_secs: u64,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintsPayload {
pub(super) limit: usize,
pub(super) retention_secs: u64,
pub(super) capacity: usize,
pub(super) dropped_total: u64,
pub(super) parse_error_total: u64,
pub(super) by_fingerprint: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_ip: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_cidr: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_user: Vec<RuntimeEdgeTlsFingerprintRow>,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintsData {
pub(super) enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) reason: Option<&'static str>,
pub(super) generated_at_epoch_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) data: Option<RuntimeEdgeTlsFingerprintsPayload>,
}
pub(super) async fn build_runtime_connections_summary_data(
shared: &ApiShared,
cfg: &ProxyConfig,
@@ -162,6 +202,65 @@ pub(super) fn build_runtime_events_recent_data(
}
}
pub(super) fn build_runtime_tls_fingerprints_data(
shared: &ApiShared,
cfg: &ProxyConfig,
query: Option<&str>,
) -> RuntimeEdgeTlsFingerprintsData {
let now_epoch_secs = now_epoch_secs();
let api_cfg = &cfg.server.api;
if !api_cfg.runtime_edge_enabled {
return RuntimeEdgeTlsFingerprintsData {
enabled: false,
reason: Some(FEATURE_DISABLED_REASON),
generated_at_epoch_secs: now_epoch_secs,
data: None,
};
}
let limit = parse_recent_events_limit(
query,
api_cfg.runtime_edge_top_n.max(1),
TLS_FINGERPRINTS_MAX_LIMIT,
);
let snapshot = shared
.stats
.tls_fingerprint_snapshot(runtime_edge_retention(cfg), limit);
RuntimeEdgeTlsFingerprintsData {
enabled: true,
reason: None,
generated_at_epoch_secs: now_epoch_secs,
data: Some(RuntimeEdgeTlsFingerprintsPayload {
limit,
retention_secs: snapshot.retention_secs,
capacity: snapshot.capacity,
dropped_total: snapshot.dropped_total,
parse_error_total: snapshot.parse_error_total,
by_fingerprint: snapshot
.by_fingerprint
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_ip: snapshot
.by_ip
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_cidr: snapshot
.by_cidr
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_user: snapshot
.by_user
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
}),
}
}
async fn get_connections_payload_cached(
shared: &ApiShared,
cache_ttl_ms: u64,
@@ -286,6 +385,35 @@ fn parse_recent_events_limit(query: Option<&str>, default_limit: usize, max_limi
default_limit
}
fn runtime_edge_retention(cfg: &ProxyConfig) -> Duration {
let minutes = cfg
.general
.beobachten_minutes
.clamp(1, RUNTIME_EDGE_RETENTION_MAX_MINUTES);
Duration::from_secs(minutes.saturating_mul(60))
}
fn runtime_tls_fingerprint_row(
row: crate::stats::TlsFingerprintSnapshotRow,
) -> RuntimeEdgeTlsFingerprintRow {
RuntimeEdgeTlsFingerprintRow {
scope: if row.scope_key.is_empty() {
None
} else {
Some(row.scope_key)
},
ja3: row.ja3,
ja3_raw: row.ja3_raw,
ja4: row.ja4,
ja4_raw: row.ja4_raw,
total: row.total,
auth_success: row.auth_success,
bad_or_probe: row.bad_or_probe,
first_seen_epoch_secs: row.first_seen_epoch_secs,
last_seen_epoch_secs: row.last_seen_epoch_secs,
}
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
+111
View File
@@ -32,6 +32,7 @@ pub(super) async fn create_user(
let touches_user_rate_limits =
body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some();
let touches_user_max_unique_ips = body.max_unique_ips.is_some();
let touches_user_enabled = matches!(body.enabled, Some(false));
if !is_valid_username(&body.username) {
return Err(ApiFailure::bad_request(
@@ -111,6 +112,9 @@ pub(super) async fn create_user(
.user_max_unique_ips
.insert(body.username.clone(), limit);
}
if matches!(body.enabled, Some(false)) {
cfg.access.user_enabled.insert(body.username.clone(), false);
}
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
@@ -134,6 +138,9 @@ pub(super) async fn create_user(
if touches_user_max_unique_ips {
touched_sections.push(AccessSection::UserMaxUniqueIps);
}
if touches_user_enabled {
touched_sections.push(AccessSection::UserEnabled);
}
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
@@ -161,6 +168,7 @@ pub(super) async fn create_user(
.find(|entry| entry.username == body.username)
.unwrap_or(UserInfo {
username: body.username.clone(),
enabled: cfg.access.is_user_enabled(&body.username),
in_runtime: false,
user_ad_tag: None,
max_tcp_conns: cfg
@@ -202,6 +210,7 @@ pub(super) async fn patch_user(
let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged)
|| !matches!(&body.rate_limit_down_bps, Patch::Unchanged);
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged);
if let Some(secret) = body.secret.as_ref()
&& !is_valid_user_secret(secret)
@@ -313,6 +322,15 @@ pub(super) async fn patch_user(
Some(Some(limit))
}
};
match body.enabled {
Patch::Unchanged => {}
Patch::Remove | Patch::Set(true) => {
cfg.access.user_enabled.remove(user);
}
Patch::Set(false) => {
cfg.access.user_enabled.insert(user.to_string(), false);
}
}
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
@@ -339,6 +357,9 @@ pub(super) async fn patch_user(
if touches_user_max_unique_ips {
touched_sections.push(AccessSection::UserMaxUniqueIps);
}
if touches_user_enabled {
touched_sections.push(AccessSection::UserEnabled);
}
let revision = if touched_sections.is_empty() {
current_revision(&shared.config_path).await?
@@ -399,6 +420,7 @@ pub(super) async fn rotate_secret(
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let touched_sections = [
AccessSection::Users,
AccessSection::UserEnabled,
AccessSection::UserAdTags,
AccessSection::UserMaxTcpConns,
AccessSection::UserExpirations,
@@ -434,6 +456,55 @@ pub(super) async fn rotate_secret(
))
}
pub(super) async fn set_user_enabled(
user: &str,
enabled: bool,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<(UserInfo, String), ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
if !cfg.access.users.contains_key(user) {
return Err(ApiFailure::new(
StatusCode::NOT_FOUND,
"not_found",
"User not found",
));
}
if enabled {
cfg.access.user_enabled.remove(user);
} else {
cfg.access.user_enabled.insert(user.to_string(), false);
}
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
.await?;
drop(_guard);
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
let users = users_from_config(
&cfg,
&shared.stats,
&shared.ip_tracker,
detected_ip_v4,
detected_ip_v6,
None,
)
.await;
let user_info = users
.into_iter()
.find(|entry| entry.username == user)
.ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?;
Ok((user_info, revision))
}
pub(super) async fn delete_user(
user: &str,
expected_revision: Option<String>,
@@ -459,6 +530,7 @@ pub(super) async fn delete_user(
}
cfg.access.users.remove(user);
cfg.access.user_enabled.remove(user);
cfg.access.user_ad_tags.remove(user);
cfg.access.user_max_tcp_conns.remove(user);
cfg.access.user_expirations.remove(user);
@@ -470,6 +542,7 @@ pub(super) async fn delete_user(
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let touched_sections = [
AccessSection::Users,
AccessSection::UserEnabled,
AccessSection::UserAdTags,
AccessSection::UserMaxTcpConns,
AccessSection::UserExpirations,
@@ -518,6 +591,7 @@ pub(super) async fn users_from_config(
})
.unwrap_or_else(empty_user_links);
users.push(UserInfo {
enabled: cfg.access.is_user_enabled(&username),
in_runtime: runtime_cfg
.map(|runtime| runtime.access.users.contains_key(&username))
.unwrap_or(false),
@@ -876,6 +950,43 @@ mod tests {
assert_eq!(alice.rate_limit_down_bps, None);
}
#[tokio::test]
async fn users_from_config_reports_user_enabled_default_and_override() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.users.insert(
"bob".to_string(),
"fedcba9876543210fedcba9876543210".to_string(),
);
cfg.access.user_enabled.insert("bob".to_string(), false);
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");
let bob = users
.iter()
.find(|entry| entry.username == "bob")
.expect("bob must be present");
assert!(alice.enabled);
assert!(!bob.enabled);
cfg.access.user_enabled.insert("bob".to_string(), true);
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let bob = users
.iter()
.find(|entry| entry.username == "bob")
.expect("bob must be present");
assert!(bob.enabled);
}
#[tokio::test]
async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() {
let mut disk_cfg = ProxyConfig::default();
+3
View File
@@ -705,6 +705,9 @@ ignore_time_skew = false
type = "direct"
enabled = true
weight = 10
# Optional per-upstream DC family policy:
# ipv6 = true
# prefer = 6
"#,
username = username,
secret = secret,
+15
View File
@@ -54,6 +54,9 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true;
const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85;
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1;
const DEFAULT_SYNLIMIT_BURST: u32 = 2;
const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
@@ -243,6 +246,18 @@ pub(crate) fn default_conntrack_delete_budget_per_sec() -> u64 {
DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC
}
pub(crate) fn default_synlimit_seconds() -> u32 {
DEFAULT_SYNLIMIT_SECONDS
}
pub(crate) fn default_synlimit_hitcount() -> u32 {
DEFAULT_SYNLIMIT_HITCOUNT
}
pub(crate) fn default_synlimit_burst() -> u32 {
DEFAULT_SYNLIMIT_BURST
}
pub(crate) fn default_prefer_4() -> u8 {
4
}
+151 -2
View File
@@ -16,10 +16,12 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately |
//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately |
//!
//! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted.
//! applied, except for SYN limiter fields on unchanged listener endpoints; a
//! warning is emitted.
//! Non-hot changes are never mixed into the runtime config snapshot.
use std::collections::BTreeSet;
@@ -34,7 +36,8 @@ use tracing::{error, info, warn};
use super::load::{LoadedConfig, ProxyConfig};
use crate::config::{
LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode,
ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
MeWriterPickMode, SynLimitMode,
};
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
@@ -118,6 +121,7 @@ pub struct HotFields {
pub me_admission_poll_ms: u64,
pub me_warn_rate_limit_ms: u64,
pub users: std::collections::HashMap<String, String>,
pub user_enabled: std::collections::HashMap<String, bool>,
pub user_ad_tags: std::collections::HashMap<String, String>,
pub user_max_tcp_conns: std::collections::HashMap<String, usize>,
pub user_max_tcp_conns_global_each: usize,
@@ -130,6 +134,17 @@ pub struct HotFields {
pub user_max_unique_ips_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64,
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerSynLimitHotFields {
pub ip: IpAddr,
pub port: Option<u16>,
pub synlimit: SynLimitMode,
pub synlimit_seconds: u32,
pub synlimit_hitcount: u32,
pub synlimit_burst: u32,
}
impl HotFields {
@@ -247,6 +262,7 @@ impl HotFields {
me_admission_poll_ms: cfg.general.me_admission_poll_ms,
me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms,
users: cfg.access.users.clone(),
user_enabled: cfg.access.user_enabled.clone(),
user_ad_tags: cfg.access.user_ad_tags.clone(),
user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(),
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
@@ -258,6 +274,25 @@ impl HotFields {
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
listener_synlimit: cfg
.server
.listeners
.iter()
.map(ListenerSynLimitHotFields::from_listener)
.collect(),
}
}
}
impl ListenerSynLimitHotFields {
fn from_listener(listener: &ListenerConfig) -> Self {
Self {
ip: listener.ip,
port: listener.port,
synlimit: listener.synlimit,
synlimit_seconds: listener.synlimit_seconds,
synlimit_hitcount: listener.synlimit_hitcount,
synlimit_burst: listener.synlimit_burst,
}
}
}
@@ -310,6 +345,7 @@ fn listeners_equal(
lhs.iter().zip(rhs.iter()).all(|(a, b)| {
a.ip == b.ip
&& a.port == b.port
&& a.client_mss == b.client_mss
&& a.announce == b.announce
&& a.announce_ip == b.announce_ip
&& a.proxy_protocol == b.proxy_protocol
@@ -551,6 +587,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms;
cfg.access.users = new.access.users.clone();
cfg.access.user_enabled = new.access.user_enabled.clone();
cfg.access.user_ad_tags = new.access.user_ad_tags.clone();
cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone();
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
@@ -562,6 +599,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners);
if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None;
@@ -570,6 +608,21 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg
}
fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) {
if old.len() != new.len() {
return;
}
for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) {
if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port {
continue;
}
old_listener.synlimit = new_listener.synlimit;
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
old_listener.synlimit_burst = new_listener.synlimit_burst;
}
}
/// Warn if any non-hot fields changed (require restart).
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
let mut warned = false;
@@ -605,6 +658,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4
|| old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6
|| old.server.listen_tcp != new.server.listen_tcp
|| old.server.client_mss != new.server.client_mss
|| old.server.listen_unix_sock != new.server.listen_unix_sock
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
{
@@ -615,6 +669,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.censorship.tls_domains != new.censorship.tls_domains
|| old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope
|| old.censorship.mask != new.censorship.mask
|| old.censorship.mask_dynamic != new.censorship.mask_dynamic
|| old.censorship.mask_host != new.censorship.mask_host
|| old.censorship.mask_port != new.censorship.mask_port
|| old.censorship.exclusive_mask != new.censorship.exclusive_mask
@@ -844,6 +899,13 @@ fn log_changes(
);
}
if old_hot.listener_synlimit != new_hot.listener_synlimit {
info!(
"config reload: server.listeners SYN limiter updated ({} listeners)",
new_hot.listener_synlimit.len()
);
}
if old_hot.desync_all_full != new_hot.desync_all_full {
info!(
"config reload: desync_all_full: {} → {}",
@@ -1178,6 +1240,16 @@ fn log_changes(
}
}
if old_hot.user_enabled != new_hot.user_enabled {
info!(
"config reload: user_enabled updated ({} disabled overrides)",
new_hot
.user_enabled
.values()
.filter(|enabled| !**enabled)
.count()
);
}
if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns {
info!(
"config reload: user_max_tcp_conns updated ({} entries)",
@@ -1474,6 +1546,48 @@ pub fn spawn_config_watcher(
(config_rx, log_rx)
}
// ── Change classification ─────────────────────────────────────────────────────
/// Which top-level config sections changed and whether any require a restart.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct ChangeClassification {
pub changed: Vec<String>,
pub restart_required: bool,
}
/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and
/// see if anything non-hot remains different. This guarantees `restart_required`
/// matches actual runtime behavior and never drifts as new fields are added.
pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification {
let applied = overlay_hot_fields(old, new);
let restart_required = !config_equal(&applied, new);
ChangeClassification {
changed: changed_sections(old, new),
restart_required,
}
}
/// Top-level config sections whose canonical serialized form differs between
/// old and new. Uses the same serialize+canonicalize path as `config_equal`.
fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null);
let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null);
canonicalize_json(&mut lhs);
canonicalize_json(&mut rhs);
let mut out = Vec::new();
if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) {
let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect();
keys.extend(ro.keys());
for key in keys {
if lo.get(key) != ro.get(key) {
out.push(key.clone());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1646,6 +1760,41 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn classify_sni_change_requires_restart() {
// censorship.* is not in overlay_hot_fields -> restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.censorship.tls_domain = "front.example".to_string();
let class = classify_config_changes(&old, &new);
assert!(class.restart_required);
assert!(class.changed.iter().any(|c| c == "censorship"));
}
#[test]
fn classify_dns_overrides_change_is_hot() {
// network.dns_overrides IS in overlay_hot_fields -> no restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.network.dns_overrides.push("1.1.1.1".to_string());
let class = classify_config_changes(&old, &new);
assert!(!class.restart_required);
assert!(class.changed.iter().any(|c| c == "network"));
}
#[test]
fn classify_timeouts_change_requires_restart() {
// timeouts.* is NOT in overlay_hot_fields -> restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.timeouts.client_handshake = old.timeouts.client_handshake + 1;
let class = classify_config_changes(&old, &new);
assert!(class.restart_required);
}
#[test]
fn reload_recovers_after_parse_error_on_next_attempt() {
let initial_tag = "cccccccccccccccccccccccccccccccc";
+225 -5
View File
@@ -299,6 +299,7 @@ const SERVER_CONFIG_KEYS: &[&str] = &[
"listen_unix_sock",
"listen_unix_sock_perm",
"listen_tcp",
"client_mss",
"proxy_protocol",
"proxy_protocol_header_timeout_ms",
"proxy_protocol_trusted_cidrs",
@@ -344,6 +345,11 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip",
"port",
"client_mss",
"synlimit",
"synlimit_seconds",
"synlimit_hitcount",
"synlimit_burst",
"announce",
"announce_ip",
"proxy_protocol",
@@ -370,6 +376,7 @@ const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
"tls_fetch_scope",
"tls_fetch",
"mask",
"mask_dynamic",
"mask_host",
"mask_port",
"exclusive_mask",
@@ -411,6 +418,7 @@ const TLS_FETCH_CONFIG_KEYS: &[&str] = &[
const ACCESS_CONFIG_KEYS: &[&str] = &[
"users",
"user_enabled",
"user_ad_tags",
"user_max_tcp_conns",
"user_max_tcp_conns_global_each",
@@ -1006,6 +1014,14 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
"upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(),
));
}
if let Some(prefer) = upstream.prefer
&& prefer != 4
&& prefer != 6
{
return Err(ProxyError::Config(
"upstream.prefer must be 4 or 6".to_string(),
));
}
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
let parsed = ShadowsocksServerConfig::from_url(url)
@@ -1021,6 +1037,26 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
Ok(())
}
fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
warn!(
upstream = idx,
"upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6"
);
upstream.prefer = Some(6);
}
if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) {
warn!(
upstream = idx,
"upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4"
);
upstream.prefer = Some(4);
}
}
}
// ============= Main Config =============
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -1904,6 +1940,35 @@ impl ProxyConfig {
));
}
config
.server
.client_mss_value()
.map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?;
for (idx, listener) in config.server.listeners.iter().enumerate() {
if listener.client_mss.is_some() {
listener
.effective_client_mss(&config.server)
.map_err(|error| {
ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}"))
})?;
}
if listener.synlimit_seconds == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_seconds must be > 0"
)));
}
if listener.synlimit_hitcount == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_hitcount must be > 0"
)));
}
if listener.synlimit_burst == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_burst must be > 0"
)));
}
}
if config.server.accept_permit_timeout_ms > 60_000 {
return Err(ProxyError::Config(
"server.accept_permit_timeout_ms must be within [0, 60000]".to_string(),
@@ -2002,11 +2067,6 @@ impl ProxyConfig {
*mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?;
}
// Default mask_host to tls_domain if not set and no unix socket configured.
if config.censorship.mask_host.is_none() && config.censorship.mask_unix_sock.is_none() {
config.censorship.mask_host = Some(config.censorship.tls_domain.clone());
}
for (domain, target) in &config.censorship.exclusive_mask {
if !is_valid_tls_domain_name(domain) {
return Err(ProxyError::Config(format!(
@@ -2144,6 +2204,11 @@ impl ProxyConfig {
config.server.listeners.push(ListenerConfig {
ip: ipv4,
port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
synlimit_seconds: default_synlimit_seconds(),
synlimit_hitcount: default_synlimit_hitcount(),
synlimit_burst: default_synlimit_burst(),
announce: None,
announce_ip: None,
proxy_protocol: None,
@@ -2156,6 +2221,11 @@ impl ProxyConfig {
config.server.listeners.push(ListenerConfig {
ip: ipv6,
port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
synlimit_seconds: default_synlimit_seconds(),
synlimit_hitcount: default_synlimit_hitcount(),
synlimit_burst: default_synlimit_burst(),
announce: None,
announce_ip: None,
proxy_protocol: None,
@@ -2199,8 +2269,10 @@ impl ProxyConfig {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
});
}
normalize_upstream_family_policy(&mut config);
// Ensure default DC203 override is present.
config
@@ -2429,6 +2501,7 @@ mod tests {
assert_eq!(cfg.general.update_every, default_update_every());
assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4());
assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt());
assert_eq!(cfg.server.client_mss_value(), Ok(None));
assert_eq!(
cfg.server.proxy_protocol_trusted_cidrs,
default_proxy_protocol_trusted_cidrs()
@@ -3756,6 +3829,153 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_presets_and_listener_override_are_resolved() {
let toml = r#"
[server]
client_mss = "tspu"
[[server.listeners]]
ip = "127.0.0.1"
port = 1443
[[server.listeners]]
ip = "127.0.0.2"
port = 1444
client_mss = "2in8"
[[server.listeners]]
ip = "127.0.0.3"
port = 1445
client_mss = ""
[[server.listeners]]
ip = "127.0.0.4"
port = 1446
client_mss = "extreme-low"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(92)));
assert_eq!(
cfg.server.listeners[0].effective_client_mss(&cfg.server),
Ok(Some(92))
);
assert_eq!(
cfg.server.listeners[1].effective_client_mss(&cfg.server),
Ok(Some(256))
);
assert_eq!(
cfg.server.listeners[2].effective_client_mss(&cfg.server),
Ok(None)
);
assert_eq!(
cfg.server.listeners[3].effective_client_mss(&cfg.server),
Ok(Some(88))
);
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_custom_value_is_accepted() {
let toml = r#"
[server]
client_mss = "4096"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_custom_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096)));
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_out_of_range_is_rejected() {
for value in ["87", "4097"] {
let toml = format!(
r#"
[server]
client_mss = "{value}"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#
);
let dir = std::env::temp_dir();
let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml"));
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.client_mss custom value must be within [88, 4096]"));
let _ = std::fs::remove_file(path);
}
}
#[test]
fn client_mss_unquoted_number_is_rejected() {
let toml = r#"
[server]
client_mss = 256
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_unquoted_number_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("client_mss"));
let _ = std::fs::remove_file(path);
}
#[test]
fn listener_client_mss_invalid_preset_is_rejected() {
let toml = r#"
[[server.listeners]]
ip = "127.0.0.1"
port = 1443
client_mss = "tiny"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_listener_client_mss_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.listeners[0].client_mss"));
assert!(err.contains("must be \"\", extreme-low, tspu, 2in8"));
let _ = std::fs::remove_file(path);
}
#[test]
fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() {
let toml = r#"
@@ -1,14 +1,21 @@
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_CONFIG_COUNTER: AtomicU64 = AtomicU64::new(0);
fn write_temp_config(contents: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time must be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("telemt-load-mask-shape-security-{nonce}.toml"));
let seq = TEMP_CONFIG_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!(
"telemt-load-mask-shape-security-{pid}-{seq}-{nonce}.toml"
));
fs::write(&path, contents).expect("temp config write must succeed");
path
}
+178
View File
@@ -1369,6 +1369,77 @@ impl ConntrackPressureProfile {
}
}
/// Per-listener SYN limiter mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SynLimitMode {
/// Disable SYN limiting for this listener.
#[default]
Off,
/// Use iptables/ip6tables filter rules with the hashlimit match.
Iptables,
/// Use nftables rules with per-source token-bucket meters.
Nftables,
}
impl Serialize for SynLimitMode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Off => serializer.serialize_bool(false),
Self::Iptables => serializer.serialize_str("iptables"),
Self::Nftables => serializer.serialize_str("nftables"),
}
}
}
impl<'de> Deserialize<'de> for SynLimitMode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SynLimitModeVisitor;
impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor {
type Value = SynLimitMode;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("false, iptables, or nftables")
}
fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
if value {
Err(E::custom(
"synlimit=true is ambiguous; use \"iptables\" or \"nftables\"",
))
} else {
Ok(SynLimitMode::Off)
}
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
match value.trim().to_ascii_lowercase().as_str() {
"false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off),
"iptables" => Ok(SynLimitMode::Iptables),
"nftables" => Ok(SynLimitMode::Nftables),
_ => Err(E::custom(
"synlimit must be false, \"iptables\", or \"nftables\"",
)),
}
}
}
deserializer.deserialize_any(SynLimitModeVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConntrackControlConfig {
/// Enables runtime conntrack-control worker for pressure mitigation.
@@ -1451,6 +1522,11 @@ pub struct ServerConfig {
#[serde(default)]
pub listen_tcp: Option<bool>,
/// Client-facing TCP MSS preset or custom value for all TCP listeners.
/// Empty string or omitted value keeps the kernel default.
#[serde(default)]
pub client_mss: Option<String>,
/// Accept HAProxy PROXY protocol headers on incoming connections.
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
#[serde(default)]
@@ -1517,6 +1593,7 @@ impl Default for ServerConfig {
listen_unix_sock: None,
listen_unix_sock_perm: None,
listen_tcp: None,
client_mss: None,
proxy_protocol: false,
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
@@ -1720,6 +1797,10 @@ pub struct AntiCensorshipConfig {
#[serde(default = "default_true")]
pub mask: bool,
/// Use the ClientHello SNI as the mask TCP target for configured TLS domains.
#[serde(default = "default_true")]
pub mask_dynamic: bool,
#[serde(default)]
pub mask_host: Option<String>,
@@ -1855,6 +1936,7 @@ impl Default for AntiCensorshipConfig {
tls_fetch_scope: default_tls_fetch_scope(),
tls_fetch: TlsFetchConfig::default(),
mask: default_true(),
mask_dynamic: default_true(),
mask_host: None,
mask_port: default_mask_port(),
exclusive_mask: HashMap::new(),
@@ -1892,6 +1974,9 @@ pub struct AccessConfig {
#[serde(default = "default_access_users")]
pub users: HashMap<String, String>,
#[serde(default)]
pub user_enabled: HashMap<String, bool>,
/// Per-user ad_tag (32 hex chars from @MTProxybot).
#[serde(default)]
pub user_ad_tags: HashMap<String, String>,
@@ -1963,6 +2048,7 @@ impl Default for AccessConfig {
fn default() -> Self {
Self {
users: default_access_users(),
user_enabled: HashMap::new(),
user_ad_tags: HashMap::new(),
user_max_tcp_conns: HashMap::new(),
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
@@ -1983,6 +2069,10 @@ impl Default for AccessConfig {
}
impl AccessConfig {
pub fn is_user_enabled(&self, username: &str) -> bool {
self.user_enabled.get(username).copied().unwrap_or(true)
}
/// 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
@@ -2057,6 +2147,20 @@ pub struct UpstreamConfig {
/// `None` means auto-detect from runtime connectivity state.
#[serde(default)]
pub ipv6: Option<bool>,
/// Per-upstream IP family preference for Telegram DC targets.
/// `None` inherits the effective global `[network].prefer` decision.
#[serde(default)]
pub prefer: Option<u8>,
}
impl UpstreamConfig {
pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool {
match self.prefer {
Some(6) => true,
Some(4) => false,
_ => default_prefer_ipv6,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -2065,6 +2169,22 @@ pub struct ListenerConfig {
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
#[serde(default)]
pub port: Option<u16>,
/// Per-listener client-facing TCP MSS preset or custom value.
/// Empty string disables MSS shaping for this listener.
#[serde(default)]
pub client_mss: Option<String>,
/// Per-listener SYN limiter mode.
#[serde(default)]
pub synlimit: SynLimitMode,
/// Token-bucket rate interval for the per-listener SYN limiter.
#[serde(default = "default_synlimit_seconds")]
pub synlimit_seconds: u32,
/// Token-bucket rate amount for the per-listener SYN limiter.
#[serde(default = "default_synlimit_hitcount")]
pub synlimit_hitcount: u32,
/// Token-bucket burst size for the per-listener SYN limiter.
#[serde(default = "default_synlimit_burst")]
pub synlimit_burst: u32,
/// IP address or hostname to announce in proxy links.
/// Takes precedence over `announce_ip` if both are set.
#[serde(default)]
@@ -2082,6 +2202,64 @@ pub struct ListenerConfig {
pub reuse_allow: bool,
}
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
pub const CLIENT_MSS_EXTREME_LOW: u16 = 88;
/// Client-facing TCP MSS preset matching TSPU-oriented deployments.
pub const CLIENT_MSS_TSPU: u16 = 92;
/// Client-facing TCP MSS preset for 2-in-8 segment shaping.
pub const CLIENT_MSS_2IN8: u16 = 256;
/// Minimum accepted custom client-facing TCP MSS value.
pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW;
/// Maximum accepted custom client-facing TCP MSS value.
pub const CLIENT_MSS_MAX: u16 = 4096;
impl ServerConfig {
/// Resolves the global client-facing TCP MSS setting.
pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> {
parse_client_mss(self.client_mss.as_deref())
}
}
impl ListenerConfig {
/// Resolves the listener MSS override, falling back to the global server value.
pub fn effective_client_mss(
&self,
server: &ServerConfig,
) -> std::result::Result<Option<u16>, String> {
match self.client_mss.as_deref() {
Some(value) => parse_client_mss(Some(value)),
None => server.client_mss_value(),
}
}
}
fn parse_client_mss(raw: Option<&str>) -> std::result::Result<Option<u16>, String> {
let Some(raw) = raw else {
return Ok(None);
};
let value = raw.trim();
if value.is_empty() {
return Ok(None);
}
match value.to_ascii_lowercase().as_str() {
"extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)),
"tspu" => return Ok(Some(CLIENT_MSS_TSPU)),
"2in8" => return Ok(Some(CLIENT_MSS_2IN8)),
_ => {}
}
let parsed = value
.parse::<u16>()
.map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?;
if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) {
return Err(format!(
"custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]"
));
}
Ok(Some(parsed))
}
// ============= ShowLink =============
/// Controls which users' proxy links are displayed at startup.
+1 -1
View File
@@ -705,7 +705,7 @@ fn nofile_soft_limit() -> Option<u64> {
if rc != 0 {
return None;
}
return Some(lim.rlim_cur);
return Some(lim.rlim_cur.into());
}
#[cfg(not(target_os = "linux"))]
{
+3
View File
@@ -245,6 +245,9 @@ pub enum ProxyError {
InvalidSecret { user: String, reason: String },
// ============= User Errors =============
#[error("User {user} disabled")]
UserDisabled { user: String },
#[error("User {user} expired")]
UserExpired { user: String },
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) async fn run_startup_connectivity(
.any(|r| r.rtt_ms.is_some());
if upstream_result.both_available {
if prefer_ipv6 {
if upstream_result.prefer_ipv6 {
info!(" IPv6 in use / IPv4 is fallback");
} else {
info!(" IPv4 in use / IPv6 is fallback");
+36 -3
View File
@@ -1,6 +1,7 @@
#![allow(clippy::items_after_test_module)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::watch;
@@ -18,8 +19,27 @@ use crate::transport::middle_proxy::{
const MAESTRO_COLOR: &str = "\x1b[92m";
const COLOR_RESET: &str = "\x1b[0m";
static MAESTRO_COLORS_ENABLED: AtomicBool = AtomicBool::new(true);
/// Enables or disables ANSI color in direct MAESTRO status lines.
pub(crate) fn set_maestro_colors_enabled(enabled: bool) {
MAESTRO_COLORS_ENABLED.store(enabled, Ordering::Relaxed);
}
fn format_maestro_line(message: impl AsRef<str>, colors_enabled: bool) -> String {
if colors_enabled {
format!("{MAESTRO_COLOR}MAESTRO{COLOR_RESET}: {}", message.as_ref())
} else {
format!("MAESTRO: {}", message.as_ref())
}
}
/// Prints a direct MAESTRO status line outside the tracing subscriber.
pub(crate) fn print_maestro_line(message: impl AsRef<str>) {
eprintln!("{MAESTRO_COLOR}MAESTRO{COLOR_RESET}: {}", message.as_ref());
eprintln!(
"{}",
format_maestro_line(message, MAESTRO_COLORS_ENABLED.load(Ordering::Relaxed))
);
}
pub(crate) fn resolve_runtime_config_path(
@@ -274,11 +294,24 @@ mod tests {
use std::path::{Path, PathBuf};
use super::{
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
resolve_runtime_base_dir, resolve_runtime_config_path,
expected_handshake_close_description, format_maestro_line, is_expected_handshake_eof,
peer_close_description, resolve_runtime_base_dir, resolve_runtime_config_path,
};
use crate::error::{ProxyError, StreamError};
#[test]
fn maestro_line_formatter_respects_disabled_colors() {
let plain = format_maestro_line("boot", false);
assert_eq!(plain, "MAESTRO: boot");
assert!(!plain.contains('\x1b'));
}
#[test]
fn maestro_line_formatter_keeps_color_when_enabled() {
let colored = format_maestro_line("boot", true);
assert!(colored.contains("\x1b[92mMAESTRO\x1b[0m"));
}
#[test]
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
let nonce = std::time::SystemTime::now()
+24
View File
@@ -47,6 +47,10 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
.unwrap_or(config.server.port)
}
fn mss_segment_multiplier(client_mss: u16) -> u16 {
1460u16.div_ceil(client_mss)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn bind_listeners(
config: &Arc<ProxyConfig>,
@@ -90,10 +94,22 @@ pub(crate) async fn bind_listeners(
warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]");
continue;
}
let client_mss = match listener_conf.effective_client_mss(&config.server) {
Ok(value) => value,
Err(error) => {
warn!(
%addr,
error = %error,
"Invalid listener client MSS after config validation; using kernel default"
);
None
}
};
let options = ListenOptions {
reuse_port: listener_conf.reuse_allow,
ipv6_only: listener_conf.ip.is_ipv6(),
backlog: config.server.listen_backlog,
client_mss,
..Default::default()
};
@@ -101,6 +117,14 @@ pub(crate) async fn bind_listeners(
Ok(socket) => {
let listener = TcpListener::from_std(socket.into())?;
info!("Listening on {}", addr);
if let Some(client_mss) = client_mss {
info!(
%addr,
client_mss,
segment_multiplier = mss_segment_multiplier(client_mss),
"Client-facing TCP MSS configured"
);
}
let listener_proxy_protocol = listener_conf
.proxy_protocol
.unwrap_or(config.server.proxy_protocol);
+14 -5
View File
@@ -45,10 +45,12 @@ use crate::stats::beobachten::BeobachtenStore;
use crate::stats::telemetry::TelemetryPolicy;
use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool;
use crate::synlimit_control;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
use helpers::{
parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path,
set_maestro_colors_enabled,
};
#[cfg(unix)]
@@ -314,6 +316,7 @@ async fn run_telemt_core(
eprintln!("[telemt] Invalid network.dns_overrides: {}", e);
std::process::exit(1);
}
set_maestro_colors_enabled(!config.general.disable_colors);
startup_tracker
.complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string()))
.await;
@@ -462,6 +465,12 @@ async fn run_telemt_core(
config.network.dns_overrides.len()
);
}
let shared_state = ProxySharedState::new();
shared_state.apply_user_enabled_config(&config.access.user_enabled);
shared_state.traffic_limiter.apply_policy(
config.access.user_rate_limits.clone(),
config.access.cidr_rate_limits.clone(),
);
let (api_config_tx, api_config_rx) = watch::channel(Arc::new(config.clone()));
let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>));
@@ -500,6 +509,7 @@ async fn run_telemt_core(
let me_pool_api = api_me_pool.clone();
let upstream_manager_api = upstream_manager.clone();
let route_runtime_api = route_runtime.clone();
let proxy_shared_api = shared_state.clone();
let config_rx_api = api_config_rx.clone();
let admission_rx_api = admission_rx.clone();
let config_path_api = config_path.clone();
@@ -513,6 +523,7 @@ async fn run_telemt_core(
ip_tracker_api,
me_pool_api,
route_runtime_api,
proxy_shared_api,
upstream_manager_api,
config_rx_api,
admission_rx_api,
@@ -730,11 +741,6 @@ async fn run_telemt_core(
));
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
let shared_state = ProxySharedState::new();
shared_state.traffic_limiter.apply_policy(
config.access.user_rate_limits.clone(),
config.access.cidr_rate_limits.clone(),
);
if direct_first_startup {
startup_tracker
@@ -904,6 +910,9 @@ async fn run_telemt_core(
// On Unix, caller supplies privilege drop after bind (may require root for port < 1024).
drop_after_bind();
synlimit_control::reconcile_synlimit_rules(&config).await;
synlimit_control::spawn_synlimit_controller(config_rx.clone());
runtime_tasks::apply_runtime_log_filter(
has_rust_log,
&effective_log_level,
+22 -1
View File
@@ -3,7 +3,7 @@ use std::path::Path;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tracing::{debug, warn};
use tracing::{debug, info, warn};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::reload;
@@ -234,6 +234,27 @@ pub(crate) async fn spawn_runtime_tasks(
}
});
let shared_user_enabled = shared_state.clone();
let mut config_rx_user_enabled = config_rx.clone();
tokio::spawn(async move {
loop {
if config_rx_user_enabled.changed().await.is_err() {
break;
}
let cfg = config_rx_user_enabled.borrow_and_update().clone();
for user in shared_user_enabled.apply_user_enabled_config(&cfg.access.user_enabled) {
let cancelled = shared_user_enabled.cancel_user_sessions(&user);
if cancelled > 0 {
info!(
user = %user,
cancelled,
"Disabled user sessions cancelled after config reload"
);
}
}
}
});
let beobachten_writer = beobachten.clone();
let config_rx_beobachten = config_rx.clone();
tokio::spawn(async move {
+5
View File
@@ -19,6 +19,7 @@ use tokio::signal::unix::{SignalKind, signal};
use tracing::{info, warn};
use crate::stats::Stats;
use crate::synlimit_control;
use crate::transport::middle_proxy::MePool;
use super::helpers::{format_uptime, unit_label};
@@ -102,6 +103,10 @@ async fn perform_shutdown(
let uptime_secs = process_started_at.elapsed().as_secs();
info!("Uptime: {}", format_uptime(uptime_secs));
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
}
// Graceful ME pool shutdown
if let Some(pool) = &me_pool {
match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all())
+1
View File
@@ -30,6 +30,7 @@ mod service;
mod startup;
mod stats;
mod stream;
mod synlimit_control;
mod tls_front;
mod transport;
mod util;
+82 -6
View File
@@ -55,8 +55,10 @@ pub async fn serve(
return;
}
};
let is_ipv6 = addr.is_ipv6();
match bind_metrics_listener(addr, is_ipv6, listen_backlog) {
// Match `server.api.listen`: `[::]:port` is a dual-stack wildcard
// on Linux when `net.ipv6.bindv6only=0`.
let ipv6_only = addr.is_ipv6() && !addr.ip().is_unspecified();
match bind_metrics_listener(addr, ipv6_only, listen_backlog) {
Ok(listener) => {
info!("Metrics endpoint: http://{}/metrics and /beobachten", addr);
serve_listener(
@@ -286,7 +288,7 @@ async fn handle<B>(
}
if req.uri().path() == "/beobachten" {
let body = render_beobachten(beobachten, config);
let body = render_beobachten(stats, beobachten, config);
let resp = Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/plain; charset=utf-8")
@@ -302,13 +304,22 @@ async fn handle<B>(
Ok(resp)
}
fn render_beobachten(beobachten: &BeobachtenStore, config: &ProxyConfig) -> String {
fn render_beobachten(stats: &Stats, beobachten: &BeobachtenStore, config: &ProxyConfig) -> String {
if !config.general.beobachten {
return "beobachten disabled\n".to_string();
}
let ttl = Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60));
beobachten.snapshot_text(ttl)
let mut body = beobachten.snapshot_text(ttl);
let tls_text = stats.tls_fingerprint_snapshot_text(ttl, 20);
if !tls_text.is_empty() {
if !body.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str(&tls_text);
}
body
}
fn tls_front_domains(config: &ProxyConfig) -> Vec<String> {
@@ -370,11 +381,32 @@ async fn render_tls_front_profile_health(
"# 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_quality_info TLS front profile quality and key-share group per configured domain"
);
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_quality_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_server_hello_bytes TLS front cached ServerHello record body bytes per configured domain"
);
let _ = writeln!(
out,
"# TYPE telemt_tls_front_profile_server_hello_bytes gauge"
);
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_server_hello_extensions TLS front cached visible ServerHello extension count per configured domain"
);
let _ = writeln!(
out,
"# TYPE telemt_tls_front_profile_server_hello_extensions gauge"
);
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_app_data_records TLS front cached app-data record count per configured domain"
@@ -409,11 +441,26 @@ async fn render_tls_front_profile_health(
"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_quality_info{{domain=\"{}\",quality=\"{}\",key_share_group=\"{}\"}} 1",
domain, item.quality, item.key_share_group
);
let _ = writeln!(
out,
"telemt_tls_front_profile_age_seconds{{domain=\"{}\"}} {}",
domain, item.age_seconds
);
let _ = writeln!(
out,
"telemt_tls_front_profile_server_hello_bytes{{domain=\"{}\"}} {}",
domain, item.server_hello_record_len
);
let _ = writeln!(
out,
"telemt_tls_front_profile_server_hello_extensions{{domain=\"{}\"}} {}",
domain, item.server_hello_extensions
);
let _ = writeln!(
out,
"telemt_tls_front_profile_app_data_records{{domain=\"{}\"}} {}",
@@ -3890,7 +3937,20 @@ mod tests {
session_id: Vec::new(),
cipher_suite: [0x13, 0x01],
compression: 0,
extensions: Vec::new(),
extensions: {
let mut key_share = vec![0x00, 0x1d, 0x00, 0x20];
key_share.resize(36, 0x42);
vec![
crate::tls_front::types::TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
crate::tls_front::types::TlsExtension {
ext_type: 0x0033,
data: key_share,
},
]
},
},
cert_info: None,
cert_payload: Some(TlsCertPayload {
@@ -3904,6 +3964,7 @@ mod tests {
app_data_record_sizes: vec![1024, 512],
ticket_record_sizes: vec![69],
source: TlsProfileSource::Merged,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "primary.example".to_string(),
@@ -3922,6 +3983,18 @@ mod tests {
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_quality_info{domain=\"primary.example\",quality=\"raw_strict\",key_share_group=\"x25519\"} 1")
);
assert!(
output.contains("telemt_tls_front_profile_quality_info{domain=\"fallback.example\",quality=\"fallback\",key_share_group=\"none\"} 1")
);
assert!(output.contains(
"telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 90"
));
assert!(output.contains(
"telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 2"
));
assert!(
output.contains(
"telemt_tls_front_profile_app_data_records{domain=\"primary.example\"} 2"
@@ -4034,7 +4107,10 @@ mod tests {
);
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_quality_info gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_age_seconds gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_bytes gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_extensions 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!(
+3
View File
@@ -4,6 +4,7 @@ pub mod constants;
pub mod frame;
pub mod obfuscation;
pub mod tls;
pub mod tls_fingerprint;
#[allow(unused_imports)]
pub use constants::*;
@@ -13,3 +14,5 @@ pub use frame::*;
pub use obfuscation::*;
#[allow(unused_imports)]
pub use tls::*;
#[allow(unused_imports)]
pub use tls_fingerprint::*;
+418 -14
View File
@@ -1239,6 +1239,18 @@ fn test_gen_fake_x25519_key() {
assert_ne!(key1, key2);
}
#[test]
fn test_gen_fake_x25519mlkem768_server_key_share_shape() {
let rng = crate::crypto::SecureRandom::new();
let key_share = gen_fake_x25519mlkem768_server_key_share(&rng);
assert_eq!(key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN);
assert!(
key_share.iter().any(|byte| *byte != 0),
"hybrid ServerHello key_share must not collapse to all-zero bytes"
);
}
#[test]
fn test_fake_x25519_key_is_nonzero_and_varies() {
let rng = crate::crypto::SecureRandom::new();
@@ -1325,6 +1337,69 @@ fn server_hello_extension_types(record: &[u8]) -> Vec<u16> {
out
}
fn server_hello_key_share(record: &[u8]) -> Option<(u16, usize)> {
if record.len() < 9 || record[0] != TLS_RECORD_HANDSHAKE || record[5] != 0x02 {
return None;
}
let record_len = u16::from_be_bytes([record[3], record[4]]) as usize;
if record.len() < 5 + record_len {
return None;
}
let hs_len = u32::from_be_bytes([0, record[6], record[7], record[8]]) as usize;
let hs_start = 5;
let hs_end = hs_start + 4 + hs_len;
if hs_end > record.len() {
return None;
}
let mut pos = hs_start + 4 + 2 + 32;
if pos >= hs_end {
return None;
}
let sid_len = record[pos] as usize;
pos += 1 + sid_len;
if pos + 2 + 1 + 2 > hs_end {
return None;
}
pos += 2 + 1;
let ext_len = u16::from_be_bytes([record[pos], record[pos + 1]]) as usize;
pos += 2;
let ext_end = pos + ext_len;
if ext_end > hs_end {
return None;
}
while pos + 4 <= ext_end {
let etype = u16::from_be_bytes([record[pos], record[pos + 1]]);
let elen = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize;
pos += 4;
if pos + elen > ext_end {
return None;
}
if etype == extension_type::KEY_SHARE {
if elen < 4 {
return None;
}
let group = u16::from_be_bytes([record[pos], record[pos + 1]]);
let key_exchange_len = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize;
if 4 + key_exchange_len != elen {
return None;
}
return Some((group, key_exchange_len));
}
pos += elen;
}
None
}
fn test_server_key_share(group: u16, len: usize) -> ServerHelloKeyShare {
ServerHelloKeyShare::new(group, vec![0x42; len])
}
#[test]
fn build_server_hello_never_places_alpn_in_server_hello_extensions() {
let secret = b"alpn_sh_forbidden";
@@ -1372,6 +1447,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
app_data_record_sizes: vec![1024],
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),
@@ -1385,6 +1461,11 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN,
),
&rng,
Some(b"h2".to_vec()),
0,
@@ -1394,14 +1475,21 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
!exts.contains(&0x0010),
"ALPN extension must not appear in emulated ServerHello"
);
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
}
#[test]
fn test_tls_extension_builder() {
let key = [0x42u8; 32];
let key = vec![0x42u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let mut builder = TlsExtensionBuilder::new();
builder.add_key_share(&key);
builder.add_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key);
builder.add_supported_versions(0x0304);
let result = builder.build();
@@ -1414,10 +1502,10 @@ fn test_tls_extension_builder() {
#[test]
fn test_server_hello_builder() {
let session_id = vec![0x01, 0x02, 0x03, 0x04];
let key = [0x55u8; 32];
let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let builder = ServerHelloBuilder::new(session_id.clone())
.with_x25519_key(&key)
.with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key)
.with_tls13_version();
let record = builder.build_record();
@@ -1451,6 +1539,41 @@ fn test_build_server_hello_structure() {
let app_start = ccs_start + ccs_len;
assert!(response.len() > app_start + 5);
assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
}
#[test]
fn test_build_server_hello_with_cipher_uses_selected_key_share_group() {
let secret = b"test secret";
let client_digest = [0x42u8; 32];
let session_id = vec![0xAA; 32];
let key_share =
ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519, vec![0x55u8; X25519_KEY_SHARE_LEN]);
let rng = crate::crypto::SecureRandom::new();
let response = build_server_hello_with_cipher(
secret,
&client_digest,
&session_id,
2048,
&rng,
[0x13, 0x01],
&key_share,
None,
0,
);
assert_eq!(
server_hello_key_share(&response),
Some((TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN))
);
}
#[test]
@@ -1473,10 +1596,10 @@ fn test_build_server_hello_digest() {
#[test]
fn test_server_hello_extensions_length() {
let session_id = vec![0x01; 32];
let key = [0x55u8; 32];
let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let builder = ServerHelloBuilder::new(session_id)
.with_x25519_key(&key)
.with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key)
.with_tls13_version();
let record = builder.build_record();
@@ -1509,12 +1632,55 @@ fn test_validate_tls_handshake_format() {
}
fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8> {
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x01]], exts, host)
}
fn client_key_share_extension(entries: &[(u16, usize)]) -> Vec<u8> {
let mut shares = Vec::new();
for (group, key_exchange_len) in entries {
assert!(*key_exchange_len <= u16::MAX as usize);
shares.extend_from_slice(&group.to_be_bytes());
shares.extend_from_slice(&(*key_exchange_len as u16).to_be_bytes());
let start = shares.len();
shares.resize(start + *key_exchange_len, 0x42);
}
assert!(shares.len() <= u16::MAX as usize);
let mut extension = Vec::new();
extension.extend_from_slice(&(shares.len() as u16).to_be_bytes());
extension.extend_from_slice(&shares);
extension
}
fn client_key_share_extension_with_payloads(entries: &[(u16, &[u8])]) -> Vec<u8> {
let mut shares = Vec::new();
for (group, key_exchange) in entries {
assert!(key_exchange.len() <= u16::MAX as usize);
shares.extend_from_slice(&group.to_be_bytes());
shares.extend_from_slice(&(key_exchange.len() as u16).to_be_bytes());
shares.extend_from_slice(key_exchange);
}
assert!(shares.len() <= u16::MAX as usize);
let mut extension = Vec::new();
extension.extend_from_slice(&(shares.len() as u16).to_be_bytes());
extension.extend_from_slice(&shares);
extension
}
fn build_client_hello_with_ciphers_and_exts(
cipher_suites: &[[u8; 2]],
exts: Vec<(u16, Vec<u8>)>,
host: &str,
) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
body.push(0);
body.extend_from_slice(&2u16.to_be_bytes());
body.extend_from_slice(&[0x13, 0x01]);
body.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes());
for suite in cipher_suites {
body.extend_from_slice(suite);
}
body.push(1);
body.push(0);
@@ -1654,6 +1820,244 @@ fn detect_client_hello_tls_version_rejects_malformed_supported_versions() {
assert!(detect_client_hello_tls_version(&ch).is_none());
}
#[test]
fn select_server_hello_cipher_suite_keeps_profile_cipher_when_offered() {
let ch = build_client_hello_with_ciphers_and_exts(
&[[0x13, 0x01], [0x13, 0x03]],
Vec::new(),
"example.com",
);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x03]),
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() {
let ch = build_client_hello_with_ciphers_and_exts(
&[[0xc0, 0x2f], [0x13, 0x03]],
Vec::new(),
"example.com",
);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0xc0, 0x2f]),
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_rejects_without_offered_tls13_suite() {
let ch = build_client_hello_with_ciphers_and_exts(&[[0xc0, 0x2f]], Vec::new(), "example.com");
assert_eq!(select_server_hello_cipher_suite(&ch, [0x13, 0x01]), None);
}
#[test]
fn select_server_hello_cipher_suite_falls_back_to_offered_tls13_suite() {
let ch = build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_rejects_malformed_clienthello() {
let mut ch =
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
ch.truncate(12);
assert_eq!(select_server_hello_cipher_suite(&ch, [0x13, 0x01]), None);
}
#[test]
fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offered() {
let key_share = client_key_share_extension(&[
(0x0a0a, 1),
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group(&ch),
Some(TLS_NAMED_GROUP_X25519MLKEM768)
);
}
#[test]
fn select_server_hello_key_share_group_prefers_profiled_x25519_when_valid_share_is_offered() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group_with_preference(&ch, Some(TLS_NAMED_GROUP_X25519)),
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn build_x25519mlkem768_server_key_share_accepts_tdesktop_canonical_share() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_x25519mlkem768_server_key_share(&ch, &rng)
.expect("tdesktop-like canonical share must build a ServerHello share");
assert_eq!(server_key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN);
assert!(
server_key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN]
.iter()
.any(|byte| *byte != 0),
"ML-KEM ciphertext must not be all zero"
);
assert!(
server_key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..]
.iter()
.any(|byte| *byte != 0),
"X25519 server share must not be all zero"
);
}
#[test]
fn build_x25519_server_key_share_accepts_tdesktop_fallback_share() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_x25519_server_key_share(&ch, &rng)
.expect("tdesktop-like X25519 share must build a ServerHello share");
assert_eq!(server_key_share.len(), X25519_KEY_SHARE_LEN);
assert!(
server_key_share.iter().any(|byte| *byte != 0),
"X25519 server share must not be all zero"
);
}
#[test]
fn build_server_hello_key_share_prefers_profiled_x25519() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng)
.expect("profiled X25519 share must be selected when client offers it");
assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519);
assert_eq!(server_key_share.key_exchange().len(), X25519_KEY_SHARE_LEN);
}
#[test]
fn build_server_hello_key_share_falls_back_from_bad_profiled_x25519_to_hybrid() {
let key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng)
.expect("hybrid share must be selected when profiled X25519 is unavailable");
assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519MLKEM768);
assert_eq!(
server_key_share.key_exchange().len(),
X25519MLKEM768_SERVER_KEY_SHARE_LEN
);
}
#[test]
fn build_x25519mlkem768_server_key_share_rejects_noncanonical_mlkem_key() {
let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN];
key_exchange[..3].copy_from_slice(&[0xff, 0xff, 0xff]);
let key_share = client_key_share_extension_with_payloads(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
&key_exchange,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
assert!(build_x25519mlkem768_server_key_share(&ch, &rng).is_none());
}
#[test]
fn build_x25519mlkem768_server_key_share_rejects_all_zero_x25519_share() {
let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN];
key_exchange[MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN..].fill(0);
let key_share = client_key_share_extension_with_payloads(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
&key_exchange,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
assert!(build_x25519mlkem768_server_key_share(&ch, &rng).is_none());
}
#[test]
fn select_server_hello_key_share_group_accepts_x25519_when_hybrid_is_absent() {
let key_share = client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group(&ch),
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn select_server_hello_key_share_group_rejects_malformed_hybrid_len() {
let key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN - 1,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test]
fn select_server_hello_key_share_group_rejects_malformed_key_share_tail() {
let mut key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)]);
let shares_len = u16::from_be_bytes([key_share[0], key_share[1]]) + 1;
key_share[0..2].copy_from_slice(&shares_len.to_be_bytes());
key_share.push(0);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test]
fn extract_sni_rejects_zero_length_host_name() {
let mut sni_ext = Vec::new();
@@ -2179,7 +2583,7 @@ fn light_fuzz_boot_time_timestamp_matrix_with_short_replay_window_obeys_boot_cap
}
#[test]
fn server_hello_application_data_contains_alpn_marker_when_selected() {
fn server_hello_application_data_omits_alpn_marker_when_selected() {
let secret = b"alpn_marker_test";
let client_digest = [0x55u8; TLS_DIGEST_LEN];
let session_id = vec![0xAB; 32];
@@ -2206,8 +2610,8 @@ fn server_hello_application_data_contains_alpn_marker_when_selected() {
assert!(
app_payload
.windows(expected.len())
.any(|window| window == expected),
"first application payload must carry ALPN marker for selected protocol"
.all(|window| window != expected),
"first application payload must not expose plaintext ALPN marker bytes"
);
}
@@ -2303,14 +2707,14 @@ fn server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
}
#[test]
fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() {
fn server_hello_omits_alpn_marker_even_when_it_would_fit_fake_cert_len() {
let secret = b"alpn_exact_fit_test";
let client_digest = [0x58u8; TLS_DIGEST_LEN];
let session_id = vec![0xA5; 32];
let rng = crate::crypto::SecureRandom::new();
let proto = vec![b'z'; 57];
// marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64
// marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64.
let response = build_server_hello(
secret,
&client_digest,
@@ -2336,7 +2740,7 @@ fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() {
expected_marker.extend_from_slice(&proto);
assert_eq!(app_payload.len(), expected_marker.len());
assert_eq!(app_payload, expected_marker.as_slice());
assert_ne!(app_payload, expected_marker.as_slice());
}
#[test]
+519 -41
View File
@@ -65,6 +65,7 @@ use super::constants::*;
use crate::crypto::{SecureRandom, sha256_hmac};
#[cfg(test)]
use crate::error::ProxyError;
use ml_kem::{B32, EncapsulationKey as MlKemEncapsulationKey, Key as MlKemKey, MlKem768};
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
use x25519_dalek::{X25519_BASEPOINT_BYTES, x25519};
@@ -105,11 +106,49 @@ mod extension_type {
/// TLS Cipher Suites
mod cipher_suite {
pub const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
pub const TLS_AES_256_GCM_SHA384: [u8; 2] = [0x13, 0x02];
pub const TLS_CHACHA20_POLY1305_SHA256: [u8; 2] = [0x13, 0x03];
}
/// TLS Named Curves
/// TLS named groups used in KeyShare extensions.
mod named_curve {
pub const X25519: u16 = 0x001d;
pub const X25519MLKEM768: u16 = 0x11ec;
}
/// TLS X25519 named group.
pub(crate) const TLS_NAMED_GROUP_X25519: u16 = named_curve::X25519;
/// TLS X25519MLKEM768 named group.
pub(crate) const TLS_NAMED_GROUP_X25519MLKEM768: u16 = named_curve::X25519MLKEM768;
const X25519_KEY_SHARE_LEN: usize = 32;
const X25519MLKEM768_CLIENT_KEY_SHARE_LEN: usize = 1216;
const X25519MLKEM768_SERVER_KEY_SHARE_LEN: usize = 1120;
const MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN: usize = 1184;
const MLKEM768_SERVER_CIPHERTEXT_LEN: usize = 1088;
/// ServerHello key_share selected for the authenticated ClientHello.
#[derive(Clone, Debug)]
pub(crate) struct ServerHelloKeyShare {
group: u16,
key_exchange: Vec<u8>,
}
impl ServerHelloKeyShare {
pub(crate) fn new(group: u16, key_exchange: Vec<u8>) -> Self {
Self {
group,
key_exchange,
}
}
pub(crate) fn group(&self) -> u16 {
self.group
}
pub(crate) fn key_exchange(&self) -> &[u8] {
&self.key_exchange
}
}
// ============= TLS Validation Result =============
@@ -142,26 +181,28 @@ impl TlsExtensionBuilder {
}
}
/// Add Key Share extension with X25519 key
fn add_key_share(&mut self, public_key: &[u8; 32]) -> &mut Self {
/// Add KeyShare extension with the selected named group.
fn add_key_share(&mut self, group: u16, key_exchange: &[u8]) -> &mut Self {
let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else {
return self;
};
let Some(entry_len) = key_exchange.len().checked_add(4) else {
return self;
};
let Ok(entry_len) = u16::try_from(entry_len) else {
return self;
};
// Extension type: key_share (0x0033)
self.extensions
.extend_from_slice(&extension_type::KEY_SHARE.to_be_bytes());
// Key share entry: curve (2) + key_len (2) + key (32) = 36 bytes
// Extension data length
let entry_len: u16 = 2 + 2 + 32; // curve + length + key
// ServerHello key_share data is exactly one KeyShareEntry.
self.extensions.extend_from_slice(&entry_len.to_be_bytes());
// Named curve: x25519
self.extensions.extend_from_slice(&group.to_be_bytes());
self.extensions
.extend_from_slice(&named_curve::X25519.to_be_bytes());
// Key length
self.extensions.extend_from_slice(&(32u16).to_be_bytes());
// Key data
self.extensions.extend_from_slice(public_key);
.extend_from_slice(&key_exchange_len.to_be_bytes());
self.extensions.extend_from_slice(key_exchange);
self
}
@@ -230,8 +271,8 @@ impl ServerHelloBuilder {
}
}
fn with_x25519_key(mut self, key: &[u8; 32]) -> Self {
self.extensions.add_key_share(key);
fn with_key_share(mut self, group: u16, key_exchange: &[u8]) -> Self {
self.extensions.add_key_share(group, key_exchange);
self
}
@@ -241,6 +282,13 @@ impl ServerHelloBuilder {
self
}
fn with_cipher_suite(mut self, cipher_suite: [u8; 2]) -> Self {
if cipher_suite != [0, 0] {
self.cipher_suite = cipher_suite;
}
self
}
/// Build ServerHello message (without record header)
fn build_message(&self) -> Vec<u8> {
let Ok(session_id_len) = u8::try_from(self.session_id.len()) else {
@@ -499,9 +547,137 @@ fn validate_tls_handshake_at_time_with_boot_cap(
/// Uses RFC 7748 X25519 scalar multiplication over the canonical basepoint,
/// yielding distribution-consistent public keys for anti-fingerprinting.
pub fn gen_fake_x25519_key(rng: &SecureRandom) -> [u8; 32] {
let mut scalar = [0u8; 32];
scalar.copy_from_slice(&rng.bytes(32));
x25519(scalar, X25519_BASEPOINT_BYTES)
let (_scalar, public_key) = gen_x25519_key_pair(rng);
public_key
}
fn gen_x25519_key_pair(rng: &SecureRandom) -> ([u8; 32], [u8; 32]) {
let mut scalar = [0u8; X25519_KEY_SHARE_LEN];
rng.fill(&mut scalar);
let public_key = x25519(scalar, X25519_BASEPOINT_BYTES);
(scalar, public_key)
}
/// Generate a fake X25519MLKEM768 ServerHello key_share payload.
pub(crate) fn gen_fake_x25519mlkem768_server_key_share(rng: &SecureRandom) -> Vec<u8> {
let mut key_share = vec![0u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
// FakeTLS never derives TLS traffic secrets from this payload; only the
// externally visible named group and vector lengths are protocol-facing.
rng.fill(&mut key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN]);
let x25519_key = gen_fake_x25519_key(rng);
key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..].copy_from_slice(&x25519_key);
key_share
}
fn mlkem768_encapsulate_to_client(client_key: &[u8], rng: &SecureRandom) -> Option<Vec<u8>> {
let key_bytes = MlKemKey::<MlKemEncapsulationKey<MlKem768>>::try_from(client_key).ok()?;
let encapsulation_key = MlKemEncapsulationKey::<MlKem768>::new(&key_bytes).ok()?;
let mut randomness = [0u8; 32];
rng.fill(&mut randomness);
let randomness = B32::try_from(randomness.as_slice()).ok()?;
let (ciphertext, _shared_key) = encapsulation_key.encapsulate_deterministic(&randomness);
let ciphertext = ciphertext.as_slice().to_vec();
if ciphertext.len() == MLKEM768_SERVER_CIPHERTEXT_LEN {
Some(ciphertext)
} else {
None
}
}
/// Build a valid X25519MLKEM768 ServerHello key_share for the authenticated ClientHello.
pub(crate) fn build_x25519mlkem768_server_key_share(
handshake: &[u8],
rng: &SecureRandom,
) -> Option<Vec<u8>> {
let client_key_exchange = client_hello_key_share_group_entry(
handshake,
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)?;
let client_mlkem_key = client_key_exchange.get(..MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN)?;
let client_x25519_key = client_key_exchange.get(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN..)?;
let mlkem_ciphertext = mlkem768_encapsulate_to_client(client_mlkem_key, rng)?;
let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN];
client_x25519.copy_from_slice(client_x25519_key);
let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng);
let x25519_shared = x25519(server_x25519_scalar, client_x25519);
if bool::from(x25519_shared.ct_eq(&[0u8; X25519_KEY_SHARE_LEN])) {
return None;
}
let mut key_share = Vec::with_capacity(X25519MLKEM768_SERVER_KEY_SHARE_LEN);
key_share.extend_from_slice(&mlkem_ciphertext);
key_share.extend_from_slice(&server_x25519_key);
Some(key_share)
}
/// Build a valid X25519 ServerHello key_share for the authenticated ClientHello.
pub(crate) fn build_x25519_server_key_share(
handshake: &[u8],
rng: &SecureRandom,
) -> Option<Vec<u8>> {
let client_key_exchange = client_hello_key_share_group_entry(
handshake,
TLS_NAMED_GROUP_X25519,
X25519_KEY_SHARE_LEN,
)?;
let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN];
client_x25519.copy_from_slice(client_key_exchange);
let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng);
let x25519_shared = x25519(server_x25519_scalar, client_x25519);
if bool::from(x25519_shared.ct_eq(&[0u8; X25519_KEY_SHARE_LEN])) {
return None;
}
Some(server_x25519_key.to_vec())
}
fn build_server_hello_key_share_for_group(
handshake: &[u8],
group: u16,
rng: &SecureRandom,
) -> Option<ServerHelloKeyShare> {
let expected_key_exchange_len = client_hello_key_share_group_len(group)?;
client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len)?;
// FakeTLS clients validate ServerHello shape and digest, not TLS traffic
// secrets, so the response must mirror the offered group without binding to
// the camouflage key bytes embedded in ClientHello.
match group {
TLS_NAMED_GROUP_X25519MLKEM768 => Some(ServerHelloKeyShare::new(
group,
gen_fake_x25519mlkem768_server_key_share(rng),
)),
TLS_NAMED_GROUP_X25519 => Some(ServerHelloKeyShare::new(
group,
gen_fake_x25519_key(rng).to_vec(),
)),
_ => None,
}
}
fn server_hello_key_share_candidate_order(preferred_group: Option<u16>) -> [u16; 2] {
if preferred_group == Some(TLS_NAMED_GROUP_X25519) {
[TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768]
} else {
[TLS_NAMED_GROUP_X25519MLKEM768, TLS_NAMED_GROUP_X25519]
}
}
/// Build a ServerHello key_share using a profile-preferred group when possible.
pub(crate) fn build_server_hello_key_share(
handshake: &[u8],
preferred_group: Option<u16>,
rng: &SecureRandom,
) -> Option<ServerHelloKeyShare> {
for group in server_hello_key_share_candidate_order(preferred_group) {
if let Some(key_share) = build_server_hello_key_share_for_group(handshake, group, rng) {
return Some(key_share);
}
}
None
}
/// Build TLS ServerHello response
@@ -520,15 +696,48 @@ pub fn build_server_hello(
rng: &SecureRandom,
alpn: Option<Vec<u8>>,
new_session_tickets: u8,
) -> Vec<u8> {
let server_key_share = ServerHelloKeyShare::new(
TLS_NAMED_GROUP_X25519MLKEM768,
gen_fake_x25519mlkem768_server_key_share(rng),
);
build_server_hello_with_cipher(
secret,
client_digest,
session_id,
fake_cert_len,
rng,
cipher_suite::TLS_AES_128_GCM_SHA256,
&server_key_share,
alpn,
new_session_tickets,
)
}
/// Build TLS ServerHello response with a caller-selected cipher suite.
///
/// The caller is responsible for selecting a suite that is compatible with the
/// already-authenticated ClientHello. Keeping the selection outside this
/// builder avoids extra ClientHello parsing in the response construction path.
pub(crate) fn build_server_hello_with_cipher(
secret: &[u8],
client_digest: &[u8; TLS_DIGEST_LEN],
session_id: &[u8],
fake_cert_len: usize,
rng: &SecureRandom,
selected_cipher_suite: [u8; 2],
server_key_share: &ServerHelloKeyShare,
alpn: Option<Vec<u8>>,
new_session_tickets: u8,
) -> Vec<u8> {
const MIN_APP_DATA: usize = 64;
const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE;
let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA);
let x25519_key = gen_fake_x25519_key(rng);
// Build ServerHello
let server_hello = ServerHelloBuilder::new(session_id.to_vec())
.with_x25519_key(&x25519_key)
.with_cipher_suite(selected_cipher_suite)
.with_key_share(server_key_share.group(), server_key_share.key_exchange())
.with_tls13_version()
.build_record();
@@ -538,28 +747,14 @@ pub fn build_server_hello(
TLS_VERSION[0],
TLS_VERSION[1],
0x00,
0x01, // length = 1
0x01, // CCS byte
0x01,
0x01,
];
// Build first encrypted flight mimic as opaque ApplicationData bytes.
// Embed a compact EncryptedExtensions-like ALPN block when selected.
// ALPN belongs inside encrypted EncryptedExtensions in real TLS 1.3.
let mut fake_cert = Vec::with_capacity(fake_cert_len);
if let Some(proto) = alpn
.as_ref()
.filter(|p| !p.is_empty() && p.len() <= u8::MAX as usize)
{
let proto_list_len = 1usize + proto.len();
let ext_data_len = 2usize + proto_list_len;
let marker_len = 4usize + ext_data_len;
if marker_len <= fake_cert_len {
fake_cert.extend_from_slice(&0x0010u16.to_be_bytes());
fake_cert.extend_from_slice(&(ext_data_len as u16).to_be_bytes());
fake_cert.extend_from_slice(&(proto_list_len as u16).to_be_bytes());
fake_cert.push(proto.len() as u8);
fake_cert.extend_from_slice(proto);
}
}
let _ = alpn;
if fake_cert.len() < fake_cert_len {
fake_cert.extend_from_slice(&rng.bytes(fake_cert_len - fake_cert.len()));
} else if fake_cert.len() > fake_cert_len {
@@ -580,7 +775,7 @@ pub fn build_server_hello(
let ticket_count = new_session_tickets.min(4);
if ticket_count > 0 {
for _ in 0..ticket_count {
let ticket_len: usize = rng.range(48) + 48; // 48-95 bytes
let ticket_len: usize = rng.range(48) + 48;
let mut record = Vec::with_capacity(5 + ticket_len);
record.push(TLS_RECORD_APPLICATION);
record.extend_from_slice(&TLS_VERSION);
@@ -927,6 +1122,289 @@ pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option<ClientHelloTl
}
}
fn client_hello_cipher_suites_range(handshake: &[u8]) -> Option<(usize, usize)> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5;
if handshake.get(pos) != Some(&0x01) {
return None;
}
pos += 1;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((handshake[pos] as usize) << 16)
| ((handshake[pos + 1] as usize) << 8)
| handshake[pos + 2] as usize;
pos += 3;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
pos += 2 + 32;
let session_id_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
if cipher_len == 0 || cipher_len % 2 != 0 {
return None;
}
pos += 2;
let cipher_end = pos.checked_add(cipher_len)?;
if cipher_end > handshake_end {
return None;
}
Some((pos, cipher_end))
}
fn client_hello_extensions_range(handshake: &[u8]) -> Option<(usize, usize)> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5;
if handshake.get(pos) != Some(&0x01) {
return None;
}
pos += 1;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((handshake[pos] as usize) << 16)
| ((handshake[pos + 1] as usize) << 8)
| handshake[pos + 2] as usize;
pos += 3;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
pos += 2 + 32;
let session_id_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
if cipher_len == 0 || cipher_len % 2 != 0 {
return None;
}
pos += 2;
pos = pos.checked_add(cipher_len)?;
if pos + 1 > handshake_end {
return None;
}
let compression_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(compression_len)?;
if pos == handshake_end {
return Some((handshake_end, handshake_end));
}
if pos + 2 > handshake_end {
return None;
}
let extensions_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
pos += 2;
let extensions_end = pos.checked_add(extensions_len)?;
if extensions_end > handshake_end {
return None;
}
Some((pos, extensions_end))
}
fn key_share_extension_group_entry<'a>(
data: &'a [u8],
group: u16,
expected_key_exchange_len: usize,
) -> Option<&'a [u8]> {
if data.len() < 2 {
return None;
}
let shares_len = u16::from_be_bytes([data[0], data[1]]) as usize;
if shares_len != data.len().saturating_sub(2) {
return None;
}
let mut pos = 2usize;
let shares_end = 2 + shares_len;
let mut found_group = None;
while pos + 4 <= shares_end {
let entry_group = u16::from_be_bytes([data[pos], data[pos + 1]]);
let key_exchange_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
let Some(key_exchange_end) = pos.checked_add(key_exchange_len) else {
return None;
};
if key_exchange_end > shares_end {
return None;
}
if entry_group == group {
if key_exchange_len != expected_key_exchange_len || found_group.is_some() {
return None;
}
found_group = Some(&data[pos..key_exchange_end]);
}
pos = key_exchange_end;
}
if pos == shares_end { found_group } else { None }
}
fn client_hello_key_share_group_entry<'a>(
handshake: &'a [u8],
group: u16,
expected_key_exchange_len: usize,
) -> Option<&'a [u8]> {
let Some((mut pos, extensions_end)) = client_hello_extensions_range(handshake) else {
return None;
};
while pos + 4 <= extensions_end {
let ext_type = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
let ext_len = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize;
pos += 4;
let Some(ext_end) = pos.checked_add(ext_len) else {
return None;
};
if ext_end > extensions_end {
return None;
}
if ext_type == extension_type::KEY_SHARE {
return key_share_extension_group_entry(
&handshake[pos..ext_end],
group,
expected_key_exchange_len,
);
}
pos = ext_end;
}
None
}
fn client_hello_offers_cipher_suite(
handshake: &[u8],
range: (usize, usize),
suite: [u8; 2],
) -> bool {
let mut pos = range.0;
while pos + 1 < range.1 {
if handshake[pos] == suite[0] && handshake[pos + 1] == suite[1] {
return true;
}
pos += 2;
}
false
}
fn is_tls13_cipher_suite(suite: [u8; 2]) -> bool {
suite == cipher_suite::TLS_AES_128_GCM_SHA256
|| suite == cipher_suite::TLS_AES_256_GCM_SHA384
|| suite == cipher_suite::TLS_CHACHA20_POLY1305_SHA256
}
/// Select the ServerHello cipher suite from the already-received ClientHello.
///
/// This is intentionally a borrowed, zero-allocation scan. It runs only for an
/// authenticated success response and fails closed for malformed or unsupported
/// ClientHello shapes that cannot produce a DPI-consistent ServerHello.
pub(crate) fn select_server_hello_cipher_suite(
handshake: &[u8],
preferred: [u8; 2],
) -> Option<[u8; 2]> {
let preferred = if is_tls13_cipher_suite(preferred) {
preferred
} else {
cipher_suite::TLS_AES_128_GCM_SHA256
};
let Some(range) = client_hello_cipher_suites_range(handshake) else {
return None;
};
if client_hello_offers_cipher_suite(handshake, range, preferred) {
return Some(preferred);
}
for fallback in [
cipher_suite::TLS_AES_128_GCM_SHA256,
cipher_suite::TLS_CHACHA20_POLY1305_SHA256,
cipher_suite::TLS_AES_256_GCM_SHA384,
] {
if client_hello_offers_cipher_suite(handshake, range, fallback) {
return Some(fallback);
}
}
None
}
fn client_hello_key_share_group_len(group: u16) -> Option<usize> {
match group {
TLS_NAMED_GROUP_X25519MLKEM768 => Some(X25519MLKEM768_CLIENT_KEY_SHARE_LEN),
TLS_NAMED_GROUP_X25519 => Some(X25519_KEY_SHARE_LEN),
_ => None,
}
}
/// Select the ServerHello key_share named group from the authenticated ClientHello.
///
/// Malformed key_share structures fail closed so authenticated but
/// DPI-inconsistent ClientHellos take the ordinary masking fallback path.
pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option<u16> {
select_server_hello_key_share_group_with_preference(handshake, None)
}
/// Select the ServerHello key_share named group with an origin-profile preference.
pub(crate) fn select_server_hello_key_share_group_with_preference(
handshake: &[u8],
preferred_group: Option<u16>,
) -> Option<u16> {
for group in server_hello_key_share_candidate_order(preferred_group) {
let expected_key_exchange_len = client_hello_key_share_group_len(group)?;
if client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len).is_some()
{
return Some(group);
}
}
None
}
/// Check if bytes look like a TLS ClientHello
pub fn is_tls_handshake(first_bytes: &[u8]) -> bool {
if first_bytes.len() < 3 {
+450
View File
@@ -0,0 +1,450 @@
//! Passive JA3 / JA4 TLS ClientHello fingerprinting.
use crate::crypto::hash::md5;
use crate::crypto::sha256;
use crate::protocol::constants::TLS_RECORD_HANDSHAKE;
const EXT_SNI: u16 = 0x0000;
const EXT_SUPPORTED_GROUPS: u16 = 0x000a;
const EXT_EC_POINT_FORMATS: u16 = 0x000b;
const EXT_SIGNATURE_ALGORITHMS: u16 = 0x000d;
const EXT_ALPN: u16 = 0x0010;
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TlsClientFingerprint {
pub ja3: String,
pub ja3_raw: String,
pub ja4: String,
pub ja4_raw: String,
}
#[derive(Default)]
struct ParsedClientHello {
legacy_version: u16,
ciphers: Vec<u16>,
extensions: Vec<u16>,
supported_groups: Vec<u16>,
ec_point_formats: Vec<u8>,
signature_algorithms: Vec<u16>,
supported_versions: Vec<u16>,
alpn_first: Option<Vec<u8>>,
sni_present: bool,
}
pub fn fingerprint_client_hello(handshake: &[u8]) -> Option<TlsClientFingerprint> {
let parsed = parse_client_hello(handshake)?;
let ja3_raw = ja3_raw(&parsed);
let ja3 = hex::encode(md5(ja3_raw.as_bytes()));
let (ja4, ja4_raw) = ja4(&parsed);
Some(TlsClientFingerprint {
ja3,
ja3_raw,
ja4,
ja4_raw,
})
}
fn parse_client_hello(handshake: &[u8]) -> Option<ParsedClientHello> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = read_u16_at(handshake, 3)? as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5usize;
if *handshake.get(pos)? != 0x01 {
return None;
}
pos = pos.checked_add(1)?;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((usize::from(handshake[pos])) << 16)
| ((usize::from(handshake[pos + 1])) << 8)
| usize::from(handshake[pos + 2]);
pos = pos.checked_add(3)?;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
let legacy_version = read_u16_at(handshake, pos)?;
pos = pos.checked_add(2 + 32)?;
let session_id_len = usize::from(*handshake.get(pos)?);
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = read_u16_at(handshake, pos)? as usize;
pos = pos.checked_add(2)?;
let cipher_end = pos.checked_add(cipher_len)?;
if cipher_end > handshake_end || cipher_len % 2 != 0 {
return None;
}
let mut ciphers = Vec::with_capacity(cipher_len / 2);
while pos + 1 < cipher_end {
let value = read_u16_at(handshake, pos)?;
if !is_grease(value) {
ciphers.push(value);
}
pos = pos.checked_add(2)?;
}
let comp_len = usize::from(*handshake.get(pos)?);
pos = pos.checked_add(1)?.checked_add(comp_len)?;
if pos > handshake_end {
return None;
}
let mut parsed = ParsedClientHello {
legacy_version,
ciphers,
..ParsedClientHello::default()
};
if pos == handshake_end {
return Some(parsed);
}
if pos + 2 > handshake_end {
return None;
}
let ext_len = read_u16_at(handshake, pos)? as usize;
pos = pos.checked_add(2)?;
let ext_end = pos.checked_add(ext_len)?;
if ext_end > handshake_end {
return None;
}
while pos + 4 <= ext_end {
let etype = read_u16_at(handshake, pos)?;
let elen = read_u16_at(handshake, pos + 2)? as usize;
pos = pos.checked_add(4)?;
let data_end = pos.checked_add(elen)?;
if data_end > ext_end {
return None;
}
let data = handshake.get(pos..data_end)?;
if !is_grease(etype) {
parsed.extensions.push(etype);
match etype {
EXT_SNI => parsed.sni_present = true,
EXT_SUPPORTED_GROUPS => {
parsed.supported_groups = parse_u16_vector(data, 2)?;
}
EXT_EC_POINT_FORMATS => {
parsed.ec_point_formats = parse_u8_vector(data)?;
}
EXT_SIGNATURE_ALGORITHMS => {
parsed.signature_algorithms = parse_u16_vector(data, 2)?;
}
EXT_ALPN => {
parsed.alpn_first = parse_alpn_first(data)?;
}
EXT_SUPPORTED_VERSIONS => {
parsed.supported_versions = parse_u16_vector(data, 1)?;
}
_ => {}
}
}
pos = data_end;
}
if pos != ext_end {
return None;
}
Some(parsed)
}
fn parse_u16_vector(data: &[u8], len_prefix_len: usize) -> Option<Vec<u16>> {
let (list_len, mut pos) = match len_prefix_len {
1 => (usize::from(*data.first()?), 1usize),
2 => (read_u16_at(data, 0)? as usize, 2usize),
_ => return None,
};
let list_end = pos.checked_add(list_len)?;
if list_end > data.len() || list_len % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(list_len / 2);
while pos + 1 < list_end {
let value = read_u16_at(data, pos)?;
if !is_grease(value) {
out.push(value);
}
pos = pos.checked_add(2)?;
}
Some(out)
}
fn parse_u8_vector(data: &[u8]) -> Option<Vec<u8>> {
let list_len = usize::from(*data.first()?);
let list_start = 1usize;
let list_end = list_start.checked_add(list_len)?;
if list_end > data.len() {
return None;
}
Some(data.get(list_start..list_end)?.to_vec())
}
fn parse_alpn_first(data: &[u8]) -> Option<Option<Vec<u8>>> {
if data.len() < 2 {
return None;
}
let list_len = read_u16_at(data, 0)? as usize;
let mut pos = 2usize;
let list_end = pos.checked_add(list_len)?;
if list_end > data.len() {
return None;
}
if pos == list_end {
return Some(None);
}
let protocol_len = usize::from(*data.get(pos)?);
pos = pos.checked_add(1)?;
let protocol_end = pos.checked_add(protocol_len)?;
if protocol_end > list_end {
return None;
}
if protocol_len == 0 {
return Some(None);
}
Some(Some(data.get(pos..protocol_end)?.to_vec()))
}
fn ja3_raw(parsed: &ParsedClientHello) -> String {
format!(
"{},{},{},{},{}",
parsed.legacy_version,
join_decimal_u16(&parsed.ciphers),
join_decimal_u16(&parsed.extensions),
join_decimal_u16(&parsed.supported_groups),
join_decimal_u8(&parsed.ec_point_formats)
)
}
fn ja4(parsed: &ParsedClientHello) -> (String, String) {
let a = format!(
"t{}{}{:02}{:02}{}",
ja4_version_code(parsed),
if parsed.sni_present { "d" } else { "i" },
count_ja4(parsed.ciphers.len()),
count_ja4(parsed.extensions.len()),
ja4_alpn_marker(parsed.alpn_first.as_deref())
);
let mut ciphers = parsed.ciphers.clone();
ciphers.sort_unstable();
let cipher_raw = join_hex_u16(&ciphers);
let cipher_hash = if ciphers.is_empty() {
"000000000000".to_string()
} else {
sha256_truncated_12(&cipher_raw)
};
let mut extensions_for_hash = parsed
.extensions
.iter()
.copied()
.filter(|value| *value != EXT_SNI && *value != EXT_ALPN)
.collect::<Vec<_>>();
extensions_for_hash.sort_unstable();
let extension_raw = join_hex_u16(&extensions_for_hash);
let signature_raw = join_hex_u16(&parsed.signature_algorithms);
let extension_hash_input = if signature_raw.is_empty() {
extension_raw.clone()
} else {
format!("{extension_raw}_{signature_raw}")
};
let extension_hash = if extensions_for_hash.is_empty() {
"000000000000".to_string()
} else {
sha256_truncated_12(&extension_hash_input)
};
(
format!("{a}_{cipher_hash}_{extension_hash}"),
format!("{a}_{cipher_raw}_{extension_hash_input}"),
)
}
fn ja4_version_code(parsed: &ParsedClientHello) -> &'static str {
let version = parsed
.supported_versions
.iter()
.copied()
.max()
.unwrap_or(parsed.legacy_version);
match version {
0x0304 => "13",
0x0303 => "12",
0x0302 => "11",
0x0301 => "10",
0x0300 => "s3",
0x0002 => "s2",
0xfeff => "d1",
0xfefd => "d2",
0xfefc => "d3",
_ => "00",
}
}
fn ja4_alpn_marker(alpn_first: Option<&[u8]>) -> String {
let Some(value) = alpn_first else {
return "00".to_string();
};
let Some(first) = value.first().copied() else {
return "00".to_string();
};
let last = value.last().copied().unwrap_or(first);
if first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric() {
return format!("{}{}", first as char, last as char);
}
let encoded = hex::encode(value);
if encoded.is_empty() {
return "00".to_string();
}
let first_hex = encoded.as_bytes()[0] as char;
let last_hex = encoded.as_bytes()[encoded.len().saturating_sub(1)] as char;
format!("{first_hex}{last_hex}")
}
fn count_ja4(count: usize) -> usize {
count.min(99)
}
fn sha256_truncated_12(input: &str) -> String {
let mut encoded = hex::encode(sha256(input.as_bytes()));
encoded.truncate(12);
encoded
}
fn join_decimal_u16(values: &[u16]) -> String {
values
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join("-")
}
fn join_decimal_u8(values: &[u8]) -> String {
values
.iter()
.map(u8::to_string)
.collect::<Vec<_>>()
.join("-")
}
fn join_hex_u16(values: &[u16]) -> String {
values
.iter()
.map(|value| format!("{value:04x}"))
.collect::<Vec<_>>()
.join(",")
}
fn read_u16_at(buf: &[u8], pos: usize) -> Option<u16> {
Some(u16::from_be_bytes([
*buf.get(pos)?,
*buf.get(pos.checked_add(1)?)?,
]))
}
fn is_grease(value: u16) -> bool {
let high = (value >> 8) as u8;
let low = value as u8;
high == low && (high & 0x0f) == 0x0a
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_client_hello() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&[0x03, 0x03]);
body.extend_from_slice(&[0x11; 32]);
body.push(0);
body.extend_from_slice(&10u16.to_be_bytes());
body.extend_from_slice(&[0x0a, 0x0a, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f, 0x00, 0xff]);
body.push(1);
body.push(0);
let mut extensions = Vec::new();
append_ext(&mut extensions, EXT_SNI, &[0, 0]);
append_ext(&mut extensions, EXT_ALPN, &[0, 3, 2, b'h', b'2']);
append_ext(
&mut extensions,
EXT_SUPPORTED_GROUPS,
&[0, 6, 0x0a, 0x0a, 0x00, 0x17, 0x00, 0x1d],
);
append_ext(&mut extensions, EXT_EC_POINT_FORMATS, &[1, 0]);
append_ext(
&mut extensions,
EXT_SIGNATURE_ALGORITHMS,
&[0, 4, 0x04, 0x03, 0x08, 0x04],
);
append_ext(
&mut extensions,
EXT_SUPPORTED_VERSIONS,
&[4, 0x03, 0x04, 0x03, 0x03],
);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
let mut record = Vec::new();
record.push(TLS_RECORD_HANDSHAKE);
record.extend_from_slice(&[0x03, 0x01]);
record.extend_from_slice(&((body.len() + 4) as u16).to_be_bytes());
record.push(0x01);
record.extend_from_slice(&[
((body.len() >> 16) & 0xff) as u8,
((body.len() >> 8) & 0xff) as u8,
(body.len() & 0xff) as u8,
]);
record.extend_from_slice(&body);
record
}
fn append_ext(out: &mut Vec<u8>, etype: u16, data: &[u8]) {
out.extend_from_slice(&etype.to_be_bytes());
out.extend_from_slice(&(data.len() as u16).to_be_bytes());
out.extend_from_slice(data);
}
#[test]
fn ja3_and_ja4_ignore_grease_and_remain_stable() {
let fp = fingerprint_client_hello(&sample_client_hello())
.expect("sample ClientHello must fingerprint");
assert_eq!(
fp.ja3_raw,
"771,4865-4866-49199-255,0-16-10-11-13-43,23-29,0"
);
assert!(fp.ja4.starts_with("t13d0406h2_"));
}
#[test]
fn malformed_client_hello_returns_none() {
let mut hello = sample_client_hello();
hello.truncate(12);
assert!(fingerprint_client_hello(&hello).is_none());
}
}
+113
View File
@@ -98,6 +98,7 @@ use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
use crate::ip_tracker::UserIpTracker;
use crate::protocol::constants::*;
use crate::protocol::tls;
use crate::protocol::tls_fingerprint::{self, TlsClientFingerprint};
use crate::stats::beobachten::BeobachtenStore;
use crate::stats::{ReplayChecker, Stats};
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
@@ -350,6 +351,60 @@ fn record_beobachten_class(
beobachten.record(class, peer_ip, beobachten_ttl(config));
}
fn tls_fingerprint_collection_enabled(config: &ProxyConfig) -> bool {
config.general.beobachten || config.server.api.runtime_edge_enabled
}
fn observe_tls_client_fingerprint(
stats: &Stats,
config: &ProxyConfig,
peer_ip: IpAddr,
handshake: &[u8],
) -> Option<TlsClientFingerprint> {
if !tls_fingerprint_collection_enabled(config) {
return None;
}
match tls_fingerprint::fingerprint_client_hello(handshake) {
Some(fingerprint) => {
stats.record_tls_fingerprint_observed(&fingerprint, peer_ip, beobachten_ttl(config));
Some(fingerprint)
}
None => {
stats.increment_tls_fingerprint_parse_error();
None
}
}
}
fn record_tls_fingerprint_auth_success(
stats: &Stats,
config: &ProxyConfig,
peer_ip: IpAddr,
fingerprint: Option<&TlsClientFingerprint>,
user: &str,
) {
if let Some(fingerprint) = fingerprint {
stats.record_tls_fingerprint_auth_success(
fingerprint,
peer_ip,
user,
beobachten_ttl(config),
);
}
}
fn record_tls_fingerprint_bad_or_probe(
stats: &Stats,
config: &ProxyConfig,
peer_ip: IpAddr,
fingerprint: Option<&TlsClientFingerprint>,
) {
if let Some(fingerprint) = fingerprint {
stats.record_tls_fingerprint_bad_or_probe(fingerprint, peer_ip, beobachten_ttl(config));
}
}
fn classify_expected_64_got_0(kind: std::io::ErrorKind) -> Option<&'static str> {
match kind {
std::io::ErrorKind::UnexpectedEof => Some("expected_64_got_0_unexpected_eof"),
@@ -705,6 +760,9 @@ where
));
}
let tls_fingerprint =
observe_tls_client_fingerprint(stats.as_ref(), &config, real_peer.ip(), &handshake);
let (read_half, write_half) = tokio::io::split(stream);
let (mut tls_reader, tls_writer, tls_user) = match handle_tls_handshake_with_shared(
@@ -715,6 +773,12 @@ where
HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
real_peer.ip(),
tls_fingerprint.as_ref(),
);
return Ok(masking_outcome(
reader,
writer,
@@ -726,10 +790,23 @@ where
));
}
HandshakeResult::Error(e) => {
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
real_peer.ip(),
tls_fingerprint.as_ref(),
);
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
return Err(e);
}
};
record_tls_fingerprint_auth_success(
stats.as_ref(),
&config,
real_peer.ip(),
tls_fingerprint.as_ref(),
tls_user.as_str(),
);
debug!(peer = %peer, "Reading MTProto handshake through TLS");
let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?;
@@ -1295,6 +1372,13 @@ impl RunningClientHandler {
));
}
let tls_fingerprint = observe_tls_client_fingerprint(
self.stats.as_ref(),
&self.config,
peer.ip(),
&handshake,
);
let config = self.config.clone();
let replay_checker = self.replay_checker.clone();
let stats = self.stats.clone();
@@ -1318,6 +1402,12 @@ impl RunningClientHandler {
HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
return Ok(masking_outcome(
reader,
writer,
@@ -1329,10 +1419,23 @@ impl RunningClientHandler {
));
}
HandshakeResult::Error(e) => {
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
return Err(e);
}
};
record_tls_fingerprint_auth_success(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
tls_user.as_str(),
);
debug!(peer = %peer, "Reading MTProto handshake through TLS");
let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?;
@@ -1558,6 +1661,11 @@ impl RunningClientHandler {
{
let user = success.user.clone();
if !shared.is_user_enabled(&user) {
warn!(user = %user, "Disabled user rejected");
return Err(ProxyError::UserDisabled { user });
}
let user_limit_reservation = match Self::acquire_user_connection_reservation_static(
&user,
&config,
@@ -1576,6 +1684,8 @@ impl RunningClientHandler {
let route_snapshot = route_runtime.snapshot();
let session_id = rng.u64();
let _user_session = shared.register_user_session(&user, session_id);
let session_cancel = _user_session.token();
let selected_me_pool = if config.general.use_middle_proxy
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
{
@@ -1607,6 +1717,7 @@ impl RunningClientHandler {
route_runtime.subscribe(),
route_snapshot,
session_id,
session_cancel.clone(),
shared.clone(),
)
.await
@@ -1625,6 +1736,7 @@ impl RunningClientHandler {
route_snapshot,
session_id,
local_addr,
session_cancel.clone(),
shared.clone(),
)
.await
@@ -1644,6 +1756,7 @@ impl RunningClientHandler {
route_snapshot,
session_id,
local_addr,
session_cancel,
shared.clone(),
)
.await
+40 -19
View File
@@ -10,6 +10,7 @@ use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadHalf, WriteHalf, split};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::config::ProxyConfig;
@@ -258,6 +259,7 @@ where
route_snapshot,
session_id,
SocketAddr::from(([0, 0, 0, 0], config.server.port)),
CancellationToken::new(),
ProxySharedState::new(),
)
.await
@@ -276,6 +278,7 @@ pub(crate) async fn handle_via_direct_with_shared<R, W>(
route_snapshot: RouteCutoverState,
session_id: u64,
local_addr: SocketAddr,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>,
) -> Result<()>
where
@@ -302,14 +305,25 @@ where
"Ignoring invalid scope hint and falling back to default upstream selection"
);
}
let tg_stream = upstream_manager
.connect(dc_addr, Some(success.dc_idx), scope_hint)
.await?;
let tg_stream = tokio::select! {
result = upstream_manager.connect(dc_addr, Some(success.dc_idx), scope_hint) => result?,
_ = session_cancel.cancelled() => {
return Err(ProxyError::UserDisabled {
user: user.to_string(),
});
}
};
debug!(peer = %success.peer, dc_addr = %dc_addr, "Connected, performing TG handshake");
let (tg_reader, tg_writer) =
do_tg_handshake_static(tg_stream, &success, &config, rng.as_ref()).await?;
let (tg_reader, tg_writer) = tokio::select! {
result = do_tg_handshake_static(tg_stream, &success, &config, rng.as_ref()) => result?,
_ = session_cancel.cancelled() => {
return Err(ProxyError::UserDisabled {
user: user.to_string(),
});
}
};
debug!(peer = %success.peer, "TG handshake complete, starting relay");
@@ -331,20 +345,22 @@ where
} else {
Duration::from_secs(1800)
};
let relay_result = crate::proxy::relay::relay_bidirectional_with_activity_timeout_and_lease(
client_reader,
client_writer,
tg_reader,
tg_writer,
config.general.direct_relay_copy_buf_c2s_bytes,
config.general.direct_relay_copy_buf_s2c_bytes,
user,
Arc::clone(&stats),
config.access.user_data_quota.get(user).copied(),
buffer_pool,
traffic_lease,
relay_activity_timeout,
);
let relay_result =
crate::proxy::relay::relay_bidirectional_with_activity_timeout_lease_and_cancel(
client_reader,
client_writer,
tg_reader,
tg_writer,
config.general.direct_relay_copy_buf_c2s_bytes,
config.general.direct_relay_copy_buf_s2c_bytes,
user,
Arc::clone(&stats),
config.access.user_data_quota.get(user).copied(),
buffer_pool,
traffic_lease,
relay_activity_timeout,
session_cancel.clone(),
);
tokio::pin!(relay_result);
let relay_result = loop {
if let Some(cutover) =
@@ -371,6 +387,11 @@ where
break relay_result.await;
}
}
_ = session_cancel.cancelled() => {
break Err(ProxyError::UserDisabled {
user: user.to_string(),
});
}
}
};
+59 -9
View File
@@ -1473,14 +1473,60 @@ where
return HandshakeResult::BadClient { reader, writer };
}
let cached = if config.censorship.tls_emulation {
let cached_entry = if config.censorship.tls_emulation {
if let Some(cache) = tls_cache.as_ref() {
let selected_domain =
matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
let cached_entry = cache.get(selected_domain).await;
let use_full_cert_payload = if config.censorship.serverhello_compact
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
{
Some(cached_entry)
} else {
None
}
} else {
None
};
let preferred_key_share_group = cached_entry
.as_ref()
.and_then(|cached_entry| emulator::profiled_server_hello_key_share_group(cached_entry));
let Some(server_key_share) =
tls::build_server_hello_key_share(handshake, preferred_key_share_group, rng)
else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
debug!(
peer = %peer,
"TLS handshake rejected: ClientHello did not offer a usable TLS 1.3 key_share"
);
return HandshakeResult::BadClient { reader, writer };
};
let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() {
if cached_entry.server_hello_template.cipher_suite == [0, 0] {
[0x13, 0x01]
} else {
cached_entry.server_hello_template.cipher_suite
}
} else {
[0x13, 0x01]
};
let Some(selected_cipher_suite) =
tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite)
else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
debug!(
peer = %peer,
"TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite"
);
return HandshakeResult::BadClient { reader, writer };
};
let cached = if let Some(cached_entry) = cached_entry {
let use_full_cert_payload = if config.censorship.serverhello_compact
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
{
if let Some(cache) = tls_cache.as_ref() {
cache
.take_full_cert_budget_for_ip(
peer.ip(),
@@ -1489,11 +1535,11 @@ where
.await
} else {
true
};
Some((cached_entry, use_full_cert_payload))
}
} else {
None
}
true
};
Some((cached_entry, use_full_cert_payload))
} else {
None
};
@@ -1512,17 +1558,21 @@ where
use_full_cert_payload,
config.censorship.serverhello_compact,
client_tls_version,
selected_cipher_suite,
&server_key_share,
rng,
selected_alpn.clone(),
config.censorship.tls_new_session_tickets,
)
} else {
tls::build_server_hello(
tls::build_server_hello_with_cipher(
&validated_secret,
&validation_digest,
validation_session_id_slice,
config.censorship.fake_cert_len,
rng,
selected_cipher_suite,
&server_key_share,
selected_alpn.clone(),
config.censorship.tls_new_session_tickets,
)
+30 -13
View File
@@ -385,7 +385,7 @@ mod tls_domain_mask_host_tests {
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.censorship.mask_host = None;
config
}
@@ -419,6 +419,15 @@ mod tls_domain_mask_host_tests {
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com");
}
#[test]
fn mask_host_uses_primary_domain_when_dynamic_masking_is_disabled() {
let mut config = config_with_tls_domains();
config.censorship.mask_dynamic = false;
let initial_data = client_hello_with_sni("b.com");
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "a.com");
}
#[test]
fn exclusive_mask_target_overrides_only_matching_sni() {
let mut config = config_with_tls_domains();
@@ -577,24 +586,32 @@ fn default_mask_tcp_target_for_initial_data<'a>(
.as_deref()
.unwrap_or(&config.censorship.tls_domain);
if !configured_mask_host.eq_ignore_ascii_case(&config.censorship.tls_domain) {
if config.censorship.mask_host.is_none() && config.censorship.mask_dynamic {
let extracted_sni = if sni.is_none() {
tls::extract_sni_from_client_hello(initial_data)
} else {
None
};
if let Some(host) = sni
.or(extracted_sni.as_deref())
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
{
return MaskTcpTarget {
host,
port: config.censorship.mask_port,
};
}
}
if let Some(mask_host) = config.censorship.mask_host.as_deref() {
return MaskTcpTarget {
host: configured_mask_host,
host: mask_host,
port: config.censorship.mask_port,
};
}
let extracted_sni = if sni.is_none() {
tls::extract_sni_from_client_hello(initial_data)
} else {
None
};
let host = sni
.or(extracted_sni.as_deref())
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
.unwrap_or(configured_mask_host);
MaskTcpTarget {
host,
host: configured_mask_host,
port: config.censorship.mask_port,
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use std::collections::BTreeSet;
#[cfg(test)]
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeSet, HashMap};
#[cfg(test)]
use std::future::Future;
#[cfg(test)]
+4 -2
View File
@@ -437,8 +437,9 @@ where
W: AsyncWrite + Unpin + Send + 'static,
{
tokio::select! {
result = client_writer.write_all(bytes) => result.map_err(ProxyError::Io),
biased;
_ = cancel.cancelled() => Err(ProxyError::MiddleClientWriterCancelled),
result = client_writer.write_all(bytes) => result.map_err(ProxyError::Io),
}
}
@@ -450,7 +451,8 @@ where
W: AsyncWrite + Unpin + Send + 'static,
{
tokio::select! {
result = client_writer.flush() => result.map_err(ProxyError::Io),
biased;
_ = cancel.cancelled() => Err(ProxyError::MiddleClientWriterCancelled),
result = client_writer.flush() => result.map_err(ProxyError::Io),
}
}
+107 -113
View File
@@ -1,4 +1,5 @@
use super::*;
use dashmap::DashMap;
mod read;
@@ -10,10 +11,10 @@ pub(crate) use self::read::{
#[derive(Default)]
pub(crate) struct RelayIdleCandidateRegistry {
pub(in crate::proxy::middle_relay) by_conn_id: HashMap<u64, RelayIdleCandidateMeta>,
pub(in crate::proxy::middle_relay) ordered: BTreeSet<(u64, u64)>,
pressure_event_seq: u64,
pressure_consumed_seq: u64,
pub(in crate::proxy::middle_relay) by_conn_id: DashMap<u64, RelayIdleCandidateMeta>,
pub(in crate::proxy::middle_relay) ordered: parking_lot::Mutex<BTreeSet<(u64, u64)>>,
pressure_event_seq: AtomicU64,
pressure_consumed_seq: AtomicU64,
}
/// Queue metadata used to preserve FIFO ordering for idle relay eviction.
@@ -23,25 +24,10 @@ pub(in crate::proxy::middle_relay) struct RelayIdleCandidateMeta {
pub(in crate::proxy::middle_relay) mark_pressure_seq: u64,
}
pub(super) fn relay_idle_candidate_registry_lock_in(
shared: &ProxySharedState,
) -> std::sync::MutexGuard<'_, RelayIdleCandidateRegistry> {
let registry = &shared.middle_relay.relay_idle_registry;
match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
}
}
pub(super) fn mark_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) -> bool {
let mut guard = relay_idle_candidate_registry_lock_in(shared);
let registry = &shared.middle_relay.relay_idle_registry;
if guard.by_conn_id.contains_key(&conn_id) {
if registry.by_conn_id.contains_key(&conn_id) {
return false;
}
@@ -52,24 +38,38 @@ pub(super) fn mark_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u
.saturating_add(1);
let meta = RelayIdleCandidateMeta {
mark_order_seq,
mark_pressure_seq: guard.pressure_event_seq,
mark_pressure_seq: registry.pressure_event_seq.load(Ordering::Relaxed),
};
guard.by_conn_id.insert(conn_id, meta);
guard.ordered.insert((meta.mark_order_seq, conn_id));
true
match registry.by_conn_id.entry(conn_id) {
dashmap::mapref::entry::Entry::Occupied(_) => false,
dashmap::mapref::entry::Entry::Vacant(entry) => {
entry.insert(meta);
registry
.ordered
.lock()
.insert((meta.mark_order_seq, conn_id));
true
}
}
}
pub(super) fn clear_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) {
let mut guard = relay_idle_candidate_registry_lock_in(shared);
let registry = &shared.middle_relay.relay_idle_registry;
if let Some(meta) = guard.by_conn_id.remove(&conn_id) {
guard.ordered.remove(&(meta.mark_order_seq, conn_id));
if let Some((_, meta)) = registry.by_conn_id.remove(&conn_id) {
registry
.ordered
.lock()
.remove(&(meta.mark_order_seq, conn_id));
}
}
pub(super) fn note_relay_pressure_event_in(shared: &ProxySharedState) {
let mut guard = relay_idle_candidate_registry_lock_in(shared);
guard.pressure_event_seq = guard.pressure_event_seq.wrapping_add(1);
shared
.middle_relay
.relay_idle_registry
.pressure_event_seq
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn note_global_relay_pressure(shared: &ProxySharedState) {
@@ -77,8 +77,11 @@ pub(crate) fn note_global_relay_pressure(shared: &ProxySharedState) {
}
pub(super) fn relay_pressure_event_seq_in(shared: &ProxySharedState) -> u64 {
let guard = relay_idle_candidate_registry_lock_in(shared);
guard.pressure_event_seq
shared
.middle_relay
.relay_idle_registry
.pressure_event_seq
.load(Ordering::Relaxed)
}
pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
@@ -87,33 +90,52 @@ pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
seen_pressure_seq: &mut u64,
stats: &Stats,
) -> bool {
let mut guard = relay_idle_candidate_registry_lock_in(shared);
let registry = &shared.middle_relay.relay_idle_registry;
let latest_pressure_seq = guard.pressure_event_seq;
let latest_pressure_seq = registry.pressure_event_seq.load(Ordering::Relaxed);
if latest_pressure_seq == *seen_pressure_seq {
return false;
}
*seen_pressure_seq = latest_pressure_seq;
if latest_pressure_seq == guard.pressure_consumed_seq {
let consumed_pressure_seq = registry.pressure_consumed_seq.load(Ordering::Relaxed);
if latest_pressure_seq == consumed_pressure_seq {
return false;
}
if guard.ordered.is_empty() {
guard.pressure_consumed_seq = latest_pressure_seq;
return false;
}
let oldest = guard
.ordered
.iter()
.next()
.map(|(_, candidate_conn_id)| *candidate_conn_id);
let oldest = {
let mut ordered = registry.ordered.lock();
loop {
let Some((mark_order_seq, candidate_conn_id)) = ordered.iter().next().copied() else {
// Empty queues consume the event so later candidates cannot replay stale pressure.
let _ = registry.pressure_consumed_seq.compare_exchange(
consumed_pressure_seq,
latest_pressure_seq,
Ordering::Relaxed,
Ordering::Relaxed,
);
return false;
};
let Some(candidate_meta) = registry.by_conn_id.get(&candidate_conn_id) else {
ordered.remove(&(mark_order_seq, candidate_conn_id));
continue;
};
if candidate_meta.mark_order_seq != mark_order_seq {
ordered.remove(&(mark_order_seq, candidate_conn_id));
continue;
}
break Some(candidate_conn_id);
}
};
if oldest != Some(conn_id) {
return false;
}
let Some(candidate_meta) = guard.by_conn_id.get(&conn_id).copied() else {
let Some(candidate_meta) = registry
.by_conn_id
.get(&conn_id)
.map(|entry| *entry.value())
else {
return false;
};
@@ -121,10 +143,27 @@ pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
return false;
}
if let Some(meta) = guard.by_conn_id.remove(&conn_id) {
guard.ordered.remove(&(meta.mark_order_seq, conn_id));
// Claim the global pressure budget before removal; otherwise racing sessions
// can observe the next FIFO item and spend the same event more than once.
if registry
.pressure_consumed_seq
.compare_exchange(
consumed_pressure_seq,
latest_pressure_seq,
Ordering::Relaxed,
Ordering::Relaxed,
)
.is_err()
{
return false;
}
if let Some((_, meta)) = registry.by_conn_id.remove(&conn_id) {
registry
.ordered
.lock()
.remove(&(meta.mark_order_seq, conn_id));
}
guard.pressure_consumed_seq = latest_pressure_seq;
stats.increment_relay_pressure_evict_total();
true
}
@@ -220,72 +259,32 @@ pub(crate) fn mark_relay_idle_candidate_for_testing(
shared: &ProxySharedState,
conn_id: u64,
) -> bool {
let registry = &shared.middle_relay.relay_idle_registry;
let mut guard = match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
};
if guard.by_conn_id.contains_key(&conn_id) {
return false;
}
let mark_order_seq = shared
.middle_relay
.relay_idle_mark_seq
.fetch_add(1, Ordering::Relaxed);
let mark_pressure_seq = guard.pressure_event_seq;
let meta = RelayIdleCandidateMeta {
mark_order_seq,
mark_pressure_seq,
};
guard.by_conn_id.insert(conn_id, meta);
guard.ordered.insert((mark_order_seq, conn_id));
true
mark_relay_idle_candidate_in(shared, conn_id)
}
#[cfg(test)]
pub(crate) fn oldest_relay_idle_candidate_for_testing(shared: &ProxySharedState) -> Option<u64> {
let registry = &shared.middle_relay.relay_idle_registry;
let guard = match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
};
guard.ordered.iter().next().map(|(_, conn_id)| *conn_id)
registry
.ordered
.lock()
.iter()
.next()
.map(|(_, conn_id)| *conn_id)
}
#[cfg(test)]
pub(crate) fn clear_relay_idle_candidate_for_testing(shared: &ProxySharedState, conn_id: u64) {
let registry = &shared.middle_relay.relay_idle_registry;
let mut guard = match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
};
if let Some(meta) = guard.by_conn_id.remove(&conn_id) {
guard.ordered.remove(&(meta.mark_order_seq, conn_id));
}
clear_relay_idle_candidate_in(shared, conn_id);
}
#[cfg(test)]
pub(crate) fn clear_relay_idle_pressure_state_for_testing_in_shared(shared: &ProxySharedState) {
if let Ok(mut guard) = shared.middle_relay.relay_idle_registry.lock() {
*guard = RelayIdleCandidateRegistry::default();
}
let registry = &shared.middle_relay.relay_idle_registry;
registry.by_conn_id.clear();
registry.ordered.lock().clear();
registry.pressure_event_seq.store(0, Ordering::Relaxed);
registry.pressure_consumed_seq.store(0, Ordering::Relaxed);
shared
.middle_relay
.relay_idle_mark_seq
@@ -327,15 +326,10 @@ pub(crate) fn set_relay_pressure_state_for_testing(
pressure_consumed_seq: u64,
) {
let registry = &shared.middle_relay.relay_idle_registry;
let mut guard = match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
};
guard.pressure_event_seq = pressure_event_seq;
guard.pressure_consumed_seq = pressure_consumed_seq;
registry
.pressure_event_seq
.store(pressure_event_seq, Ordering::Relaxed);
registry
.pressure_consumed_seq
.store(pressure_consumed_seq, Ordering::Relaxed);
}
+4 -2
View File
@@ -41,11 +41,12 @@ pub(super) async fn reserve_user_quota_with_yield(
return Err(MiddleQuotaReserveError::DeadlineExceeded);
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(backoff_ms)) => {}
biased;
_ = cancel.cancelled() => {
stats.increment_quota_acquire_cancelled_total();
return Err(MiddleQuotaReserveError::Cancelled);
}
_ = tokio::time::sleep(Duration::from_millis(backoff_ms)) => {}
}
backoff_rounds = backoff_rounds.saturating_add(1);
if backoff_rounds >= QUOTA_RESERVE_MAX_BACKOFF_ROUNDS {
@@ -128,11 +129,12 @@ pub(super) async fn wait_for_traffic_budget_or_cancel(
return Err(ProxyError::TrafficBudgetWaitDeadlineExceeded);
}
tokio::select! {
_ = tokio::time::sleep(next_refill_delay()) => {}
biased;
_ = cancel.cancelled() => {
stats.increment_flow_wait_middle_rate_limit_cancelled_total();
return Err(ProxyError::TrafficBudgetWaitCancelled);
}
_ = tokio::time::sleep(next_refill_delay()) => {}
}
let wait_ms = wait_started_at
.elapsed()
+24
View File
@@ -13,6 +13,7 @@ pub(crate) async fn handle_via_middle_proxy<R, W>(
mut route_rx: watch::Receiver<RouteCutoverState>,
route_snapshot: RouteCutoverState,
session_id: u64,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>,
) -> Result<()>
where
@@ -20,6 +21,10 @@ where
W: AsyncWrite + Unpin + Send + 'static,
{
let user = success.user.clone();
if session_cancel.is_cancelled() {
return Err(ProxyError::UserDisabled { user });
}
let quota_limit = config.access.user_data_quota.get(&user).copied();
let quota_user_stats = quota_limit.map(|_| stats.get_or_create_user_stats_handle(&user));
let peer = success.peer;
@@ -590,6 +595,25 @@ where
}
tokio::select! {
_ = session_cancel.cancelled() => {
warn!(
user = %user,
conn_id,
"Disabled user middle session cancelled"
);
let _ = enqueue_c2me_command_in(
shared.as_ref(),
&c2me_tx,
C2MeCommand::Close,
c2me_send_timeout,
stats.as_ref(),
)
.await;
main_result = Err(ProxyError::UserDisabled {
user: user.clone(),
});
break;
}
changed = route_rx.changed(), if route_watch_open => {
if changed.is_err() {
route_watch_open = false;
+119 -8
View File
@@ -55,11 +55,13 @@ use crate::error::{ProxyError, Result};
use crate::proxy::traffic_limiter::TrafficLease;
use crate::stats::Stats;
use crate::stream::BufferPool;
use std::future::pending;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, copy_bidirectional_with_sizes};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
// ============= Constants =============
@@ -191,6 +193,84 @@ pub async fn relay_bidirectional_with_activity_timeout_and_lease<CR, CW, SR, SW>
traffic_lease: Option<Arc<TrafficLease>>,
activity_timeout: Duration,
) -> Result<()>
where
CR: AsyncRead + Unpin + Send + 'static,
CW: AsyncWrite + Unpin + Send + 'static,
SR: AsyncRead + Unpin + Send + 'static,
SW: AsyncWrite + Unpin + Send + 'static,
{
relay_bidirectional_with_activity_timeout_lease_cancel_inner(
client_reader,
client_writer,
server_reader,
server_writer,
c2s_buf_size,
s2c_buf_size,
user,
stats,
quota_limit,
_buffer_pool,
traffic_lease,
activity_timeout,
None,
)
.await
}
pub async fn relay_bidirectional_with_activity_timeout_lease_and_cancel<CR, CW, SR, SW>(
client_reader: CR,
client_writer: CW,
server_reader: SR,
server_writer: SW,
c2s_buf_size: usize,
s2c_buf_size: usize,
user: &str,
stats: Arc<Stats>,
quota_limit: Option<u64>,
_buffer_pool: Arc<BufferPool>,
traffic_lease: Option<Arc<TrafficLease>>,
activity_timeout: Duration,
session_cancel: CancellationToken,
) -> Result<()>
where
CR: AsyncRead + Unpin + Send + 'static,
CW: AsyncWrite + Unpin + Send + 'static,
SR: AsyncRead + Unpin + Send + 'static,
SW: AsyncWrite + Unpin + Send + 'static,
{
relay_bidirectional_with_activity_timeout_lease_cancel_inner(
client_reader,
client_writer,
server_reader,
server_writer,
c2s_buf_size,
s2c_buf_size,
user,
stats,
quota_limit,
_buffer_pool,
traffic_lease,
activity_timeout,
Some(session_cancel),
)
.await
}
async fn relay_bidirectional_with_activity_timeout_lease_cancel_inner<CR, CW, SR, SW>(
client_reader: CR,
client_writer: CW,
server_reader: SR,
server_writer: SW,
c2s_buf_size: usize,
s2c_buf_size: usize,
user: &str,
stats: Arc<Stats>,
quota_limit: Option<u64>,
_buffer_pool: Arc<BufferPool>,
traffic_lease: Option<Arc<TrafficLease>>,
activity_timeout: Duration,
session_cancel: Option<CancellationToken>,
) -> Result<()>
where
CR: AsyncRead + Unpin + Send + 'static,
CW: AsyncWrite + Unpin + Send + 'static,
@@ -287,14 +367,29 @@ where
//
// When the watchdog fires, select! drops the copy future,
// releasing the &mut borrows on client and server.
let copy_result = tokio::select! {
enum RelayOutcome {
Copy(std::io::Result<(u64, u64)>),
ActivityTimeout,
UserDisabled,
}
let cancel_wait = async move {
match session_cancel {
Some(token) => token.cancelled().await,
None => pending::<()>().await,
}
};
tokio::pin!(cancel_wait);
let relay_outcome = tokio::select! {
result = copy_bidirectional_with_sizes(
&mut client,
&mut server,
c2s_buf_size.max(1),
s2c_buf_size.max(1),
) => Some(result),
_ = watchdog => None, // Activity timeout — cancel relay
) => RelayOutcome::Copy(result),
_ = watchdog => RelayOutcome::ActivityTimeout,
_ = &mut cancel_wait => RelayOutcome::UserDisabled,
};
// ── Clean shutdown ──────────────────────────────────────────────
@@ -308,8 +403,8 @@ where
let s2c_ops = counters.s2c_ops.load(Ordering::Relaxed);
let duration = epoch.elapsed();
match copy_result {
Some(Ok((c2s, s2c))) => {
match relay_outcome {
RelayOutcome::Copy(Ok((c2s, s2c))) => {
// Normal completion — one side closed the connection
debug!(
user = %user_owned,
@@ -322,7 +417,7 @@ where
);
Ok(())
}
Some(Err(e)) if is_quota_io_error(&e) => {
RelayOutcome::Copy(Err(e)) if is_quota_io_error(&e) => {
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
warn!(
@@ -338,7 +433,7 @@ where
user: user_owned.clone(),
})
}
Some(Err(e)) => {
RelayOutcome::Copy(Err(e)) => {
// I/O error in one of the directions
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
@@ -354,7 +449,7 @@ where
);
Err(e.into())
}
None => {
RelayOutcome::ActivityTimeout => {
// Activity timeout (watchdog fired)
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
@@ -369,6 +464,22 @@ where
);
Ok(())
}
RelayOutcome::UserDisabled => {
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
debug!(
user = %user_owned,
c2s_bytes = c2s,
s2c_bytes = s2c,
c2s_msgs = c2s_ops,
s2c_msgs = s2c_ops,
duration_secs = duration.as_secs(),
"Relay finished (user disabled)"
);
Err(ProxyError::UserDisabled {
user: user_owned.clone(),
})
}
}
}
+145 -3
View File
@@ -1,5 +1,5 @@
use std::collections::HashSet;
use std::collections::hash_map::RandomState;
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -7,6 +7,7 @@ use std::time::Instant;
use dashmap::DashMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState};
use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry};
@@ -59,7 +60,7 @@ pub(crate) struct MiddleRelaySharedState {
pub(crate) desync_hasher: RandomState,
pub(crate) desync_full_cache_last_emit_at: Mutex<Option<Instant>>,
pub(crate) desync_dedup_rotation_state: Mutex<DesyncDedupRotationState>,
pub(crate) relay_idle_registry: Mutex<RelayIdleCandidateRegistry>,
pub(crate) relay_idle_registry: RelayIdleCandidateRegistry,
pub(crate) relay_idle_mark_seq: AtomicU64,
}
@@ -67,10 +68,35 @@ pub(crate) struct ProxySharedState {
pub(crate) handshake: HandshakeSharedState,
pub(crate) middle_relay: MiddleRelaySharedState,
pub(crate) traffic_limiter: Arc<TrafficLimiter>,
disabled_users: DashMap<String, ()>,
active_user_sessions: DashMap<(String, u64), CancellationToken>,
pub(crate) conntrack_pressure_active: AtomicBool,
pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>,
}
#[must_use = "registered user sessions must be kept alive until relay completion"]
pub(crate) struct UserSessionRegistration {
token: CancellationToken,
_guard: UserSessionGuard,
}
impl UserSessionRegistration {
pub(crate) fn token(&self) -> CancellationToken {
self.token.clone()
}
}
struct UserSessionGuard {
shared: Arc<ProxySharedState>,
key: (String, u64),
}
impl Drop for UserSessionGuard {
fn drop(&mut self) {
self.shared.active_user_sessions.remove(&self.key);
}
}
impl ProxySharedState {
pub(crate) fn new() -> Arc<Self> {
Arc::new(Self {
@@ -97,15 +123,86 @@ impl ProxySharedState {
desync_hasher: RandomState::new(),
desync_full_cache_last_emit_at: Mutex::new(None),
desync_dedup_rotation_state: Mutex::new(DesyncDedupRotationState::default()),
relay_idle_registry: Mutex::new(RelayIdleCandidateRegistry::default()),
relay_idle_registry: RelayIdleCandidateRegistry::default(),
relay_idle_mark_seq: AtomicU64::new(0),
},
traffic_limiter: TrafficLimiter::new(),
disabled_users: DashMap::new(),
active_user_sessions: DashMap::new(),
conntrack_pressure_active: AtomicBool::new(false),
conntrack_close_tx: Mutex::new(None),
})
}
pub(crate) fn is_user_enabled(&self, user: &str) -> bool {
!self.disabled_users.contains_key(user)
}
pub(crate) fn set_user_enabled(&self, user: &str, enabled: bool) -> bool {
if enabled {
self.disabled_users.remove(user);
false
} else {
self.disabled_users.insert(user.to_string(), ()).is_none()
}
}
pub(crate) fn apply_user_enabled_config(
&self,
user_enabled: &HashMap<String, bool>,
) -> Vec<String> {
let desired_disabled = user_enabled
.iter()
.filter_map(|(user, enabled)| (!*enabled).then_some(user.clone()))
.collect::<HashSet<_>>();
let current_disabled = self
.disabled_users
.iter()
.map(|entry| entry.key().clone())
.collect::<HashSet<_>>();
for user in current_disabled.difference(&desired_disabled) {
self.disabled_users.remove(user);
}
let newly_disabled = desired_disabled
.difference(&current_disabled)
.cloned()
.collect::<Vec<_>>();
for user in desired_disabled {
self.disabled_users.insert(user, ());
}
newly_disabled
}
pub(crate) fn register_user_session(
self: &Arc<Self>,
user: &str,
session_id: u64,
) -> UserSessionRegistration {
let token = CancellationToken::new();
let key = (user.to_string(), session_id);
self.active_user_sessions.insert(key.clone(), token.clone());
UserSessionRegistration {
token,
_guard: UserSessionGuard {
shared: Arc::clone(self),
key,
},
}
}
pub(crate) fn cancel_user_sessions(&self, user: &str) -> usize {
let tokens = self
.active_user_sessions
.iter()
.filter_map(|entry| (entry.key().0 == user).then(|| entry.value().clone()))
.collect::<Vec<_>>();
for token in &tokens {
token.cancel();
}
tokens.len()
}
pub(crate) fn set_conntrack_close_sender(&self, tx: mpsc::Sender<ConntrackCloseEvent>) {
match self.conntrack_close_tx.lock() {
Ok(mut guard) => {
@@ -166,3 +263,48 @@ impl ProxySharedState {
self.conntrack_pressure_active.load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_enabled_config_sync_tracks_disabled_overrides() {
let shared = ProxySharedState::new();
assert!(shared.is_user_enabled("alice"));
let mut user_enabled = HashMap::new();
user_enabled.insert("alice".to_string(), false);
user_enabled.insert("bob".to_string(), true);
let mut newly_disabled = shared.apply_user_enabled_config(&user_enabled);
newly_disabled.sort();
assert_eq!(newly_disabled, vec!["alice".to_string()]);
assert!(!shared.is_user_enabled("alice"));
assert!(shared.is_user_enabled("bob"));
assert!(shared.apply_user_enabled_config(&user_enabled).is_empty());
user_enabled.clear();
assert!(shared.apply_user_enabled_config(&user_enabled).is_empty());
assert!(shared.is_user_enabled("alice"));
}
#[test]
fn cancel_user_sessions_cancels_only_registered_matching_user() {
let shared = ProxySharedState::new();
let alice_1 = shared.register_user_session("alice", 1);
let alice_2 = shared.register_user_session("alice", 2);
let bob = shared.register_user_session("bob", 1);
let alice_1_token = alice_1.token();
let alice_2_token = alice_2.token();
let bob_token = bob.token();
drop(alice_1);
assert_eq!(shared.cancel_user_sessions("alice"), 1);
assert!(!alice_1_token.is_cancelled());
assert!(alice_2_token.is_cancelled());
assert!(!bob_token.is_cancelled());
}
}
@@ -39,6 +39,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -85,17 +86,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -182,10 +238,11 @@ async fn run_tls_success_mtproto_fail_capture(
assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side.write_all(&bad_mtproto_record).await.unwrap();
let mut client_payload = bad_mtproto_record;
for record in trailing_records {
client_side.write_all(&record).await.unwrap();
client_payload.extend_from_slice(&record);
}
client_side.write_all(&client_payload).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(4), accept_task)
.await
@@ -434,11 +491,9 @@ async fn blackhat_campaign_06_replayed_tls_hello_is_masked_without_serverhello()
client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&first_tail).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&first_tail);
client_side.write_all(&client_payload).await.unwrap();
} else {
let mut one = [0u8; 1];
let no_server_hello = tokio::time::timeout(
@@ -740,8 +795,9 @@ async fn blackhat_campaign_12_parallel_tls_success_mtproto_fail_sessions_keep_is
let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap();
read_and_discard_tls_record_body(&mut client_side, head).await;
client_side.write_all(&bad).await.unwrap();
client_side.write_all(&tail).await.unwrap();
let mut client_payload = bad;
client_payload.extend_from_slice(&tail);
client_side.write_all(&client_payload).await.unwrap();
client_side.shutdown().await.unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), handler)
@@ -35,6 +35,7 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -64,17 +65,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -239,11 +295,9 @@ async fn tls_mtproto_bad_client_does_not_reinject_clienthello_into_mask_backend(
assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -33,6 +33,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -79,17 +80,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -172,13 +228,11 @@ async fn run_tls_success_mtproto_fail_capture(
assert_eq!(tls_response_head[0], 0x16);
read_tls_record_body(&mut client_side, tls_response_head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let mut client_payload = invalid_mtproto_record;
for record in trailing_records {
client_side.write_all(&record).await.unwrap();
client_payload.extend_from_slice(&record);
}
client_side.write_all(&client_payload).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -343,11 +397,9 @@ async fn replayed_tls_hello_gets_no_serverhello_and_is_masked() {
client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16);
read_tls_record_body(&mut client_side, head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&first_tail).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&first_tail);
client_side.write_all(&client_payload).await.unwrap();
} else {
let mut one = [0u8; 1];
let no_server_hello = tokio::time::timeout(
@@ -418,11 +470,9 @@ async fn connects_bad_increments_once_per_invalid_mtproto() {
let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap();
read_tls_record_body(&mut client_side, head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&tail).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&tail);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -675,8 +725,9 @@ async fn concurrent_tls_mtproto_fail_sessions_are_isolated() {
let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap();
read_tls_record_body(&mut client_side, head).await;
client_side.write_all(&invalid_mtproto).await.unwrap();
client_side.write_all(&trailing).await.unwrap();
let mut client_payload = invalid_mtproto;
client_payload.extend_from_slice(&trailing);
client_side.write_all(&client_payload).await.unwrap();
client_side.shutdown().await.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(3), handler)
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -46,6 +46,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -70,17 +71,77 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
}
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
assert!(
tls_len <= u16::MAX as usize,
"TLS length must fit into record header"
);
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -249,11 +310,9 @@ async fn blackhat_integration_empty_initial_data_path_is_byte_exact_and_eof_clea
assert_eq!(head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
client_side.shutdown().await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
@@ -24,6 +24,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -47,6 +47,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -76,17 +77,73 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
let ts = timestamp.to_le_bytes();
@@ -155,14 +212,9 @@ async fn run_tls_success_mtproto_fail_session(
let mut body = vec![0u8; body_len];
client_side.read_exact(&mut body).await.unwrap();
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side
.write_all(&wrap_tls_application_data(&tail))
.await
.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&wrap_tls_application_data(&tail));
client_side.write_all(&client_payload).await.unwrap();
let forwarded = tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -240,6 +292,7 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -484,6 +537,7 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -561,6 +615,7 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -21,6 +21,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -33,17 +34,77 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
}
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
assert!(
tls_len <= u16::MAX as usize,
"TLS length must fit into record header"
);
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -118,14 +179,9 @@ async fn run_replay_candidate_session(
invalid_mtproto_record.extend_from_slice(&TLS_VERSION);
invalid_mtproto_record.extend_from_slice(&(HANDSHAKE_LEN as u16).to_be_bytes());
invalid_mtproto_record.extend_from_slice(&vec![0u8; HANDSHAKE_LEN]);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side
.write_all(b"GET /replay-fallback HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(b"GET /replay-fallback HTTP/1.1\r\nHost: x\r\n\r\n");
client_side.write_all(&client_payload).await.unwrap();
}
client_side.shutdown().await.unwrap();
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -33,6 +33,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -79,17 +80,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -204,8 +260,13 @@ async fn run_parallel_tail_fallback_case(
assert_eq!(server_hello_head[0], 0x16);
read_tls_record_body(&mut client_side, server_hello_head).await;
client_side.write_all(&invalid_mtproto).await.unwrap();
for chunk in trailing.chunks(write_chunk.max(1)) {
let mut chunks = trailing.chunks(write_chunk.max(1));
let mut client_payload = invalid_mtproto;
if let Some(first_chunk) = chunks.next() {
client_payload.extend_from_slice(first_chunk);
}
client_side.write_all(&client_payload).await.unwrap();
for chunk in chunks {
client_side.write_all(chunk).await.unwrap();
}
client_side.shutdown().await.unwrap();
+115 -14
View File
@@ -3,7 +3,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
use crate::crypto::{AesCtr, sha256, sha256_hmac};
use crate::protocol::constants::{
DC_IDX_POS, HANDSHAKE_LEN, IV_LEN, PREKEY_LEN, PROTO_TAG_POS, ProtoTag, SKIP_LEN,
TLS_RECORD_CHANGE_CIPHER,
TLS_RECORD_CHANGE_CIPHER, TLS_VERSION,
};
use crate::protocol::tls;
use crate::proxy::handshake::HandshakeSuccess;
@@ -341,6 +341,7 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -459,6 +460,7 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -586,6 +588,7 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -759,6 +762,7 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -839,6 +843,7 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load(
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1032,6 +1037,7 @@ async fn short_tls_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1123,6 +1129,7 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1212,6 +1219,7 @@ async fn handle_client_stream_increments_connects_all_exactly_once() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1308,6 +1316,7 @@ async fn running_client_handler_increments_connects_all_exactly_once() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1401,6 +1410,7 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1475,6 +1485,7 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1564,6 +1575,7 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1618,17 +1630,73 @@ fn make_valid_tls_client_hello_with_len(secret: &[u8], timestamp: u32, tls_len:
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![0x42u8; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let fill = 0x42u8;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -1651,6 +1719,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -1662,6 +1733,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0);
let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
@@ -1892,6 +1976,7 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -2004,6 +2089,7 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -2048,8 +2134,9 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side.write_all(&tls_app_record).await.unwrap();
client_side.write_all(&trailing_tls_record).await.unwrap();
let mut client_payload = tls_app_record;
client_payload.extend_from_slice(&trailing_tls_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -2114,6 +2201,7 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -2173,8 +2261,9 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
client.read_exact(&mut tls_response_head).await.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client.write_all(&tls_app_record).await.unwrap();
client.write_all(&trailing_tls_record).await.unwrap();
let mut client_payload = tls_app_record;
client_payload.extend_from_slice(&trailing_tls_record);
client.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), mask_accept_task)
.await
@@ -2239,6 +2328,7 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -2335,6 +2425,7 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -2437,6 +2528,7 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -3395,6 +3487,7 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -3963,6 +4056,7 @@ async fn untrusted_proxy_header_source_is_rejected() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4036,6 +4130,7 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4136,6 +4231,7 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4242,6 +4338,7 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4362,6 +4459,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4468,6 +4566,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4577,6 +4676,7 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -4681,6 +4781,7 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -32,6 +32,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -74,12 +75,17 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap();
let backend_reply = REPLY_404.to_vec();
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let accept_task = tokio::spawn({
let backend_reply = backend_reply.clone();
let expected_probe_len = probe.len();
async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 5];
let mut buf = vec![0u8; expected_probe_len];
stream.read_exact(&mut buf).await.unwrap();
stream.write_all(&backend_reply).await.unwrap();
}
@@ -93,6 +99,7 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
cfg.censorship.mask_port = backend_addr.port();
cfg.censorship.mask_proxy_protocol = 0;
cfg.censorship.mask_shape_hardening = false;
if matches!(class, ProbeClass::PlainWebBaseline) {
cfg.general.modes.classic = false;
@@ -129,11 +136,6 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
false,
));
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let started = Instant::now();
client_side.write_all(&probe).await.unwrap();
client_side.shutdown().await.unwrap();
@@ -169,11 +171,16 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
let front_addr = front_listener.local_addr().unwrap();
let backend_reply = REPLY_404.to_vec();
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let mask_accept_task = tokio::spawn({
let backend_reply = backend_reply.clone();
let expected_probe_len = probe.len();
async move {
let (mut stream, _) = mask_listener.accept().await.unwrap();
let mut buf = [0u8; 5];
let mut buf = vec![0u8; expected_probe_len];
stream.read_exact(&mut buf).await.unwrap();
stream.write_all(&backend_reply).await.unwrap();
}
@@ -187,6 +194,7 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
cfg.censorship.mask_port = backend_addr.port();
cfg.censorship.mask_proxy_protocol = 0;
cfg.censorship.mask_shape_hardening = false;
if matches!(class, ProbeClass::PlainWebBaseline) {
cfg.general.modes.classic = false;
@@ -239,11 +247,6 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
})
};
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let mut client = TcpStream::connect(front_addr).await.unwrap();
let started = Instant::now();
client.write_all(&probe).await.unwrap();
@@ -34,6 +34,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -35,6 +35,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -49,6 +49,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -78,17 +79,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header"
);
let total_len = 5 + tls_len;
let mut handshake = vec![fill; total_len];
handshake[0] = 0x16;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
let mut digest = computed;
@@ -190,11 +246,9 @@ async fn tls_bad_mtproto_fallback_preserves_wire_and_backend_response() {
assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -260,11 +314,9 @@ async fn tls_bad_mtproto_fallback_keeps_connects_bad_accounting() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -334,11 +386,9 @@ async fn tls_bad_mtproto_fallback_forwards_zero_length_tls_record_verbatim() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -402,11 +452,9 @@ async fn tls_bad_mtproto_fallback_forwards_max_tls_record_verbatim() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -480,11 +528,9 @@ async fn tls_bad_mtproto_fallback_light_fuzz_tls_record_lengths_verbatim() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -585,11 +631,9 @@ async fn tls_bad_mtproto_fallback_concurrent_sessions_are_isolated() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
drop(client_side);
let _ = tokio::time::timeout(Duration::from_secs(3), handler)
@@ -659,12 +703,14 @@ async fn tls_bad_mtproto_fallback_forwards_fragmented_client_writes_verbatim() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let mut chunks = trailing_record.chunks(3);
let mut client_payload = invalid_mtproto_record;
if let Some(first_chunk) = chunks.next() {
client_payload.extend_from_slice(first_chunk);
}
client_side.write_all(&client_payload).await.unwrap();
for chunk in trailing_record.chunks(3) {
for chunk in chunks {
client_side.write_all(chunk).await.unwrap();
}
@@ -728,11 +774,13 @@ async fn tls_bad_mtproto_fallback_header_fragmentation_bytewise_is_verbatim() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
for b in trailing_record.iter().copied() {
let mut bytes = trailing_record.iter().copied();
let mut client_payload = invalid_mtproto_record;
if let Some(first_byte) = bytes.next() {
client_payload.push(first_byte);
}
client_side.write_all(&client_payload).await.unwrap();
for b in bytes {
client_side.write_all(&[b]).await.unwrap();
}
@@ -801,14 +849,16 @@ async fn tls_bad_mtproto_fallback_record_splitting_chaos_is_verbatim() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let chaos = [7usize, 1, 19, 3, 5, 31, 2, 11, 13, 17];
let mut pos = 0usize;
let mut idx = 0usize;
let mut client_payload = invalid_mtproto_record;
let first_step = chaos[idx % chaos.len()];
let first_end = first_step.min(trailing_record.len());
client_payload.extend_from_slice(&trailing_record[..first_end]);
client_side.write_all(&client_payload).await.unwrap();
pos = first_end;
idx += 1;
while pos < trailing_record.len() {
let step = chaos[idx % chaos.len()];
let end = (pos + step).min(trailing_record.len());
@@ -883,11 +933,9 @@ async fn tls_bad_mtproto_fallback_multiple_tls_records_are_forwarded_in_order()
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&r1).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&r1);
client_side.write_all(&client_payload).await.unwrap();
client_side.write_all(&r2).await.unwrap();
client_side.write_all(&r3).await.unwrap();
@@ -957,11 +1005,9 @@ async fn tls_bad_mtproto_fallback_client_half_close_propagates_eof_to_backend()
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
client_side.shutdown().await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
@@ -1028,11 +1074,9 @@ async fn tls_bad_mtproto_fallback_backend_half_close_after_response_is_tolerated
assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task)
.await
@@ -1089,11 +1133,9 @@ async fn tls_bad_mtproto_fallback_backend_reset_after_clienthello_is_handled() {
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let write_res = client_side.write_all(&trailing_record).await;
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
let write_res = client_side.write_all(&client_payload).await;
assert!(
write_res.is_ok() || write_res.is_err(),
"write completion is environment dependent under backend reset"
@@ -1169,11 +1211,9 @@ async fn tls_bad_mtproto_fallback_backend_slow_reader_preserves_byte_identity()
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(5), accept_task)
.await
@@ -1253,11 +1293,9 @@ async fn tls_bad_mtproto_fallback_replay_pressure_masks_replay_without_serverhel
let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&trailing_record);
client_side.write_all(&client_payload).await.unwrap();
} else {
let mut one = [0u8; 1];
let no_server_hello = tokio::time::timeout(
@@ -1351,13 +1389,29 @@ async fn tls_bad_mtproto_fallback_large_multi_record_chaos_under_backpressure()
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let chaos = [5usize, 23, 11, 47, 3, 19, 29, 13, 7, 31];
for record in [&a, &b, &c] {
let records = [&a, &b, &c];
let mut records_iter = records.iter().copied();
let mut client_payload = invalid_mtproto_record;
if let Some(first_record) = records_iter.next() {
let first_step = chaos[0].min(first_record.len());
client_payload.extend_from_slice(&first_record[..first_step]);
client_side.write_all(&client_payload).await.unwrap();
let mut pos = first_step;
let mut idx = 1usize;
while pos < first_record.len() {
let step = chaos[idx % chaos.len()];
let end = (pos + step).min(first_record.len());
client_side
.write_all(&first_record[pos..end])
.await
.unwrap();
pos = end;
idx += 1;
}
}
for record in records_iter {
let mut pos = 0usize;
let mut idx = 0usize;
while pos < record.len() {
@@ -1432,11 +1486,9 @@ async fn tls_bad_mtproto_fallback_interleaved_control_and_application_records_ve
.unwrap();
assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
client_side.write_all(&ccs).await.unwrap();
let mut client_payload = invalid_mtproto_record;
client_payload.extend_from_slice(&ccs);
client_side.write_all(&client_payload).await.unwrap();
client_side.write_all(&app).await.unwrap();
client_side.write_all(&alert).await.unwrap();
@@ -1532,11 +1584,13 @@ async fn tls_bad_mtproto_fallback_many_short_sessions_with_chaos_no_cross_leak()
client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
for chunk in record.chunks((idx % 9) + 1) {
let mut chunks = record.chunks((idx % 9) + 1);
let mut client_payload = invalid_mtproto_record;
if let Some(first_chunk) = chunks.next() {
client_payload.extend_from_slice(first_chunk);
}
client_side.write_all(&client_payload).await.unwrap();
for chunk in chunks {
client_side.write_all(chunk).await.unwrap();
}
@@ -1338,6 +1338,7 @@ async fn direct_relay_abort_midflight_releases_route_gauge() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1448,6 +1449,7 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1570,6 +1572,7 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
@@ -1803,6 +1806,7 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
100,
@@ -1897,6 +1901,7 @@ async fn adversarial_direct_relay_cutover_integrity() {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
100,
@@ -21,11 +21,59 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
}
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![0x42u8; len];
let fill = 0x42u8;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
@@ -85,6 +133,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -96,6 +147,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0);
let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
@@ -150,13 +214,7 @@ async fn tls_minimum_viable_length_boundary() {
let rng = SecureRandom::new();
let peer: SocketAddr = "192.0.2.1:12345".parse().unwrap();
let min_len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1;
let mut exact_min_handshake = vec![0x42u8; min_len];
exact_min_handshake[min_len - 1] = 0;
exact_min_handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let digest = sha256_hmac(&secret, &exact_min_handshake);
exact_min_handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.copy_from_slice(&digest);
let exact_min_handshake = make_valid_tls_handshake(&secret, 0);
let res = handle_tls_handshake(
&exact_min_handshake,
@@ -171,12 +229,12 @@ async fn tls_minimum_viable_length_boundary() {
.await;
assert!(
matches!(res, HandshakeResult::Success(_)),
"Exact minimum length TLS handshake must succeed"
"Minimum valid TLS ClientHello must succeed"
);
let short_handshake = vec![0x42u8; min_len - 1];
let short_handshake = &exact_min_handshake[..exact_min_handshake.len() - 1];
let res_short = handle_tls_handshake(
&short_handshake,
short_handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
@@ -188,7 +246,7 @@ async fn tls_minimum_viable_length_boundary() {
.await;
assert!(
matches!(res_short, HandshakeResult::BadClient { .. }),
"Handshake 1 byte shorter than minimum must fail closed"
"Handshake 1 byte shorter than minimum valid ClientHello must fail closed"
);
}
@@ -1,5 +1,6 @@
use super::*;
use crate::crypto::sha256_hmac;
use crate::protocol::constants::{TLS_RECORD_HANDSHAKE, TLS_VERSION};
use crate::stats::ReplayChecker;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant};
@@ -17,11 +18,59 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
}
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![0x42u8; len];
let fill = 0x42u8;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
+67 -3
View File
@@ -25,11 +25,59 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
}
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![0x42u8; len];
let fill = 0x42u8;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
@@ -90,6 +138,9 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
sni_host: &str,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -112,6 +163,19 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_payload);
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
@@ -24,6 +24,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -35,6 +38,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0);
let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
+94 -25
View File
@@ -10,11 +10,62 @@ use std::time::{Duration, Instant};
use tokio::sync::Barrier;
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![0x42u8; len];
make_valid_tls_handshake_with_fill(secret, timestamp, 0x42)
}
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
fn make_valid_tls_handshake_with_fill(secret: &[u8], timestamp: u32, fill: u8) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
@@ -34,6 +85,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -45,6 +99,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0);
let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
@@ -92,6 +159,9 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
sni_host: &str,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -114,6 +184,19 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_payload);
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
@@ -549,25 +632,6 @@ async fn adversarial_tls_replay_churn_allows_only_unique_digests() {
let replay_checker = Arc::new(ReplayChecker::new(8192, Duration::from_secs(60)));
let rng = Arc::new(SecureRandom::new());
let make_tagged_handshake = |timestamp: u32, tag: u8| {
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![tag; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(&secret, &handshake);
let mut digest = computed;
let ts = timestamp.to_le_bytes();
for i in 0..4 {
digest[28 + i] ^= ts[i];
}
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.copy_from_slice(&digest);
handshake
};
let mut tasks = Vec::new();
// 128 exact duplicates: only one should pass.
@@ -596,12 +660,17 @@ async fn adversarial_tls_replay_churn_allows_only_unique_digests() {
}));
}
// 128 unique timestamps: all should pass because HMAC digest differs.
// 128 unique ClientHello bodies: all should pass because replay tracks the
// first digest half, while timestamp skew is encoded in the last bytes.
for i in 0..128u16 {
let config = Arc::clone(&config);
let replay_checker = Arc::clone(&replay_checker);
let rng = Arc::clone(&rng);
let handshake = make_tagged_handshake(10_000 + i as u32, (i as u8).wrapping_add(0x80));
let handshake = make_valid_tls_handshake_with_fill(
&secret,
10_000 + i as u32,
(i as u8).wrapping_add(0x80),
);
tasks.push(tokio::spawn(async move {
let peer = SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(198, 18, 0, ((i % 250) + 1) as u8)),
@@ -47,11 +47,59 @@ fn make_valid_mtproto_handshake(
}
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![0x42u8; len];
let fill = 0x42u8;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake);
@@ -72,6 +120,9 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
sni_host: &str,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
@@ -93,6 +144,19 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_payload);
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
@@ -22,6 +22,7 @@ async fn adversarial_delayed_interface_lookup_does_not_consume_outcome_floor_bud
let refresh_lock = LOCAL_INTERFACE_REFRESH_LOCK.get_or_init(|| AsyncMutex::new(()));
let held_refresh_guard = refresh_lock.lock().await;
reset_local_interface_enumerations_for_tests();
let (mut client, server) = duplex(1024);
let started = Instant::now();
@@ -1,33 +1,21 @@
use super::*;
use std::panic::{AssertUnwindSafe, catch_unwind};
#[test]
fn blackhat_registry_poison_recovers_with_fail_closed_reset_and_pressure_accounting() {
fn blackhat_registry_stale_order_entry_is_skipped_and_pressure_accounting_continues() {
let shared = ProxySharedState::new();
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
let _ = catch_unwind(AssertUnwindSafe(|| {
let mut guard = shared
.middle_relay
.relay_idle_registry
.lock()
.expect("registry lock must be acquired before poison");
guard.by_conn_id.insert(
999,
RelayIdleCandidateMeta {
mark_order_seq: 1,
mark_pressure_seq: 0,
},
);
guard.ordered.insert((1, 999));
panic!("intentional poison for idle-registry recovery");
}));
shared
.middle_relay
.relay_idle_registry
.ordered
.lock()
.insert((0, 999));
// Helper lock must recover from poison, reset stale state, and continue.
assert!(mark_relay_idle_candidate_for_testing(shared.as_ref(), 42));
assert_eq!(
oldest_relay_idle_candidate_for_testing(shared.as_ref()),
Some(42)
Some(999)
);
let before = relay_pressure_event_seq_for_testing(shared.as_ref());
@@ -35,25 +23,43 @@ fn blackhat_registry_poison_recovers_with_fail_closed_reset_and_pressure_account
let after = relay_pressure_event_seq_for_testing(shared.as_ref());
assert!(
after > before,
"pressure accounting must still advance after poison"
"pressure accounting must still advance with stale ordered entries"
);
let mut seen_pressure_seq = before;
assert!(maybe_evict_idle_candidate_on_pressure_for_testing(
shared.as_ref(),
42,
&mut seen_pressure_seq,
&Stats::new()
));
assert_eq!(
oldest_relay_idle_candidate_for_testing(shared.as_ref()),
None
);
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
}
#[test]
fn clear_state_helper_must_reset_poisoned_registry_for_deterministic_fifo_tests() {
fn clear_state_helper_must_reset_split_registry_for_deterministic_fifo_tests() {
let shared = ProxySharedState::new();
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
let _ = catch_unwind(AssertUnwindSafe(|| {
let _guard = shared
.middle_relay
.relay_idle_registry
.lock()
.expect("registry lock must be acquired before poison");
panic!("intentional poison while lock held");
}));
shared.middle_relay.relay_idle_registry.by_conn_id.insert(
999,
RelayIdleCandidateMeta {
mark_order_seq: 1,
mark_pressure_seq: 0,
},
);
shared
.middle_relay
.relay_idle_registry
.ordered
.lock()
.insert((1, 999));
set_relay_pressure_state_for_testing(shared.as_ref(), 7, 6);
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
@@ -61,6 +61,7 @@ fn new_client_harness() -> ClientHarness {
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
}],
1,
1,
+3
View File
@@ -10,6 +10,7 @@ mod me_counters;
mod me_getters;
mod replay;
pub mod telemetry;
pub mod tls_fingerprints;
mod users;
mod writer_counters;
@@ -22,6 +23,7 @@ use std::time::Instant;
#[allow(unused_imports)]
pub use self::replay::{ReplayChecker, ReplayStats};
use self::telemetry::TelemetryPolicy;
pub use self::tls_fingerprints::TlsFingerprintSnapshotRow;
use crate::config::MeWriterPickMode;
#[derive(Clone, Copy)]
@@ -333,6 +335,7 @@ pub struct Stats {
telemetry_user_enabled: AtomicBool,
telemetry_me_level: AtomicU8,
cached_epoch_secs: AtomicU64,
tls_fingerprints: tls_fingerprints::TlsFingerprintCollector,
user_stats: DashMap<String, Arc<UserStats>>,
user_stats_last_cleanup_epoch_secs: AtomicU64,
start_time: parking_lot::RwLock<Option<Instant>>,
+556
View File
@@ -0,0 +1,556 @@
//! Bounded TLS JA3/JA4 fingerprint aggregation.
use std::cmp::Reverse;
use std::hash::Hash;
use std::net::{IpAddr, Ipv6Addr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use dashmap::DashMap;
use dashmap::mapref::entry::Entry;
use crate::protocol::tls_fingerprint::TlsClientFingerprint;
use super::Stats;
const CLEANUP_INTERVAL_SECS: u64 = 30;
const MAX_TLS_FINGERPRINT_BUCKETS: usize = 65_536;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TlsFingerprintScopeKind {
Fingerprint,
Ip,
Cidr,
User,
}
#[derive(Clone, Debug)]
pub struct TlsFingerprintSnapshotRow {
pub scope_key: String,
pub ja3: String,
pub ja3_raw: String,
pub ja4: String,
pub ja4_raw: String,
pub total: u64,
pub auth_success: u64,
pub bad_or_probe: u64,
pub first_seen_epoch_secs: u64,
pub last_seen_epoch_secs: u64,
}
#[derive(Clone, Debug)]
pub struct TlsFingerprintSnapshot {
pub retention_secs: u64,
pub capacity: usize,
pub dropped_total: u64,
pub parse_error_total: u64,
pub by_fingerprint: Vec<TlsFingerprintSnapshotRow>,
pub by_ip: Vec<TlsFingerprintSnapshotRow>,
pub by_cidr: Vec<TlsFingerprintSnapshotRow>,
pub by_user: Vec<TlsFingerprintSnapshotRow>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TlsFingerprintKey {
scope_kind: TlsFingerprintScopeKind,
scope_key: String,
ja3: String,
ja3_raw: String,
ja4: String,
ja4_raw: String,
}
struct TlsFingerprintEntry {
first_seen_epoch_secs: AtomicU64,
last_seen_epoch_secs: AtomicU64,
total: AtomicU64,
auth_success: AtomicU64,
bad_or_probe: AtomicU64,
}
#[derive(Default)]
pub struct TlsFingerprintCollector {
entries: DashMap<TlsFingerprintKey, TlsFingerprintEntry>,
dropped_total: AtomicU64,
parse_error_total: AtomicU64,
last_cleanup_epoch_secs: AtomicU64,
}
impl TlsFingerprintCollector {
pub fn record_observed(
&self,
fingerprint: &TlsClientFingerprint,
peer_ip: IpAddr,
ttl: Duration,
) {
if ttl.is_zero() {
return;
}
let now = now_epoch_secs();
self.cleanup_if_needed(now, ttl.as_secs());
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Fingerprint, ""),
fingerprint,
now,
true,
false,
false,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Ip, &peer_ip.to_string()),
fingerprint,
now,
true,
false,
false,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Cidr, &cidr_bucket(peer_ip)),
fingerprint,
now,
true,
false,
false,
);
}
pub fn record_auth_success(
&self,
fingerprint: &TlsClientFingerprint,
peer_ip: IpAddr,
user: &str,
ttl: Duration,
) {
if ttl.is_zero() || user.is_empty() {
return;
}
let now = now_epoch_secs();
self.cleanup_if_needed(now, ttl.as_secs());
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Fingerprint, ""),
fingerprint,
now,
false,
true,
false,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Ip, &peer_ip.to_string()),
fingerprint,
now,
false,
true,
false,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Cidr, &cidr_bucket(peer_ip)),
fingerprint,
now,
false,
true,
false,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::User, user),
fingerprint,
now,
true,
true,
false,
);
}
pub fn record_bad_or_probe(
&self,
fingerprint: &TlsClientFingerprint,
peer_ip: IpAddr,
ttl: Duration,
) {
if ttl.is_zero() {
return;
}
let now = now_epoch_secs();
self.cleanup_if_needed(now, ttl.as_secs());
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Fingerprint, ""),
fingerprint,
now,
false,
false,
true,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Ip, &peer_ip.to_string()),
fingerprint,
now,
false,
false,
true,
);
self.record_scoped(
scope_key(TlsFingerprintScopeKind::Cidr, &cidr_bucket(peer_ip)),
fingerprint,
now,
false,
false,
true,
);
}
pub fn increment_parse_error(&self) {
self.parse_error_total.fetch_add(1, Ordering::Relaxed);
}
pub fn snapshot(&self, ttl: Duration, limit: usize) -> TlsFingerprintSnapshot {
let now = now_epoch_secs();
self.cleanup(now, ttl.as_secs());
let limit = limit.clamp(1, 1000);
let mut by_fingerprint = Vec::new();
let mut by_ip = Vec::new();
let mut by_cidr = Vec::new();
let mut by_user = Vec::new();
for entry in self.entries.iter() {
let row = snapshot_row(entry.key(), entry.value());
match entry.key().scope_kind {
TlsFingerprintScopeKind::Fingerprint => by_fingerprint.push(row),
TlsFingerprintScopeKind::Ip => by_ip.push(row),
TlsFingerprintScopeKind::Cidr => by_cidr.push(row),
TlsFingerprintScopeKind::User => by_user.push(row),
}
}
sort_and_truncate(&mut by_fingerprint, limit);
sort_and_truncate(&mut by_ip, limit);
sort_and_truncate(&mut by_cidr, limit);
sort_and_truncate(&mut by_user, limit);
TlsFingerprintSnapshot {
retention_secs: ttl.as_secs(),
capacity: MAX_TLS_FINGERPRINT_BUCKETS,
dropped_total: self.dropped_total.load(Ordering::Relaxed),
parse_error_total: self.parse_error_total.load(Ordering::Relaxed),
by_fingerprint,
by_ip,
by_cidr,
by_user,
}
}
pub fn snapshot_text(&self, ttl: Duration, limit: usize) -> String {
let snapshot = self.snapshot(ttl, limit);
if snapshot.by_fingerprint.is_empty()
&& snapshot.by_ip.is_empty()
&& snapshot.by_cidr.is_empty()
&& snapshot.by_user.is_empty()
{
return String::new();
}
let mut out = String::new();
out.push_str("[tls_fingerprints]\n");
out.push_str(&format!(
"retention_secs={} capacity={} dropped_total={} parse_error_total={}\n",
snapshot.retention_secs,
snapshot.capacity,
snapshot.dropped_total,
snapshot.parse_error_total
));
append_rows(
&mut out,
"tls_fingerprints.by_fingerprint",
&snapshot.by_fingerprint,
);
append_rows(&mut out, "tls_fingerprints.by_ip", &snapshot.by_ip);
append_rows(&mut out, "tls_fingerprints.by_cidr", &snapshot.by_cidr);
append_rows(&mut out, "tls_fingerprints.by_user", &snapshot.by_user);
out
}
fn record_scoped(
&self,
scope: (TlsFingerprintScopeKind, String),
fingerprint: &TlsClientFingerprint,
now_epoch_secs: u64,
count_total: bool,
count_auth_success: bool,
count_bad_or_probe: bool,
) {
let key = TlsFingerprintKey {
scope_kind: scope.0,
scope_key: scope.1,
ja3: fingerprint.ja3.clone(),
ja3_raw: fingerprint.ja3_raw.clone(),
ja4: fingerprint.ja4.clone(),
ja4_raw: fingerprint.ja4_raw.clone(),
};
if let Some(entry) = self.entries.get(&key) {
update_entry(
entry.value(),
now_epoch_secs,
count_total,
count_auth_success,
count_bad_or_probe,
);
return;
}
if self.entries.len() >= MAX_TLS_FINGERPRINT_BUCKETS {
self.dropped_total.fetch_add(1, Ordering::Relaxed);
return;
}
match self.entries.entry(key) {
Entry::Occupied(entry) => {
update_entry(
entry.get(),
now_epoch_secs,
count_total,
count_auth_success,
count_bad_or_probe,
);
}
Entry::Vacant(entry) => {
entry.insert(TlsFingerprintEntry::new(
now_epoch_secs,
if count_total { 1 } else { 0 },
if count_auth_success { 1 } else { 0 },
if count_bad_or_probe { 1 } else { 0 },
));
}
}
}
fn cleanup_if_needed(&self, now_epoch_secs: u64, ttl_secs: u64) {
let last = self.last_cleanup_epoch_secs.load(Ordering::Relaxed);
if now_epoch_secs.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
return;
}
if self
.last_cleanup_epoch_secs
.compare_exchange(last, now_epoch_secs, Ordering::AcqRel, Ordering::Relaxed)
.is_err()
{
return;
}
self.cleanup(now_epoch_secs, ttl_secs);
}
fn cleanup(&self, now_epoch_secs: u64, ttl_secs: u64) {
if ttl_secs == 0 {
self.entries.clear();
return;
}
self.entries.retain(|_, entry| {
let last_seen = entry.last_seen_epoch_secs.load(Ordering::Relaxed);
now_epoch_secs.saturating_sub(last_seen) <= ttl_secs
});
}
}
impl TlsFingerprintEntry {
fn new(now_epoch_secs: u64, total: u64, auth_success: u64, bad_or_probe: u64) -> Self {
Self {
first_seen_epoch_secs: AtomicU64::new(now_epoch_secs),
last_seen_epoch_secs: AtomicU64::new(now_epoch_secs),
total: AtomicU64::new(total),
auth_success: AtomicU64::new(auth_success),
bad_or_probe: AtomicU64::new(bad_or_probe),
}
}
}
fn update_entry(
entry: &TlsFingerprintEntry,
now_epoch_secs: u64,
count_total: bool,
count_auth_success: bool,
count_bad_or_probe: bool,
) {
entry
.last_seen_epoch_secs
.store(now_epoch_secs, Ordering::Relaxed);
if count_total {
entry.total.fetch_add(1, Ordering::Relaxed);
}
if count_auth_success {
entry.auth_success.fetch_add(1, Ordering::Relaxed);
}
if count_bad_or_probe {
entry.bad_or_probe.fetch_add(1, Ordering::Relaxed);
}
}
fn snapshot_row(key: &TlsFingerprintKey, entry: &TlsFingerprintEntry) -> TlsFingerprintSnapshotRow {
TlsFingerprintSnapshotRow {
scope_key: key.scope_key.clone(),
ja3: key.ja3.clone(),
ja3_raw: key.ja3_raw.clone(),
ja4: key.ja4.clone(),
ja4_raw: key.ja4_raw.clone(),
total: entry.total.load(Ordering::Relaxed),
auth_success: entry.auth_success.load(Ordering::Relaxed),
bad_or_probe: entry.bad_or_probe.load(Ordering::Relaxed),
first_seen_epoch_secs: entry.first_seen_epoch_secs.load(Ordering::Relaxed),
last_seen_epoch_secs: entry.last_seen_epoch_secs.load(Ordering::Relaxed),
}
}
fn sort_and_truncate(rows: &mut Vec<TlsFingerprintSnapshotRow>, limit: usize) {
rows.sort_by_key(|row| {
(
Reverse(row.total),
row.scope_key.clone(),
row.ja4.clone(),
row.ja3.clone(),
)
});
rows.truncate(limit);
}
fn append_rows(out: &mut String, section: &str, rows: &[TlsFingerprintSnapshotRow]) {
if rows.is_empty() {
return;
}
out.push('[');
out.push_str(section);
out.push_str("]\n");
for row in rows {
if row.scope_key.is_empty() {
out.push_str(&format!(
"ja4={} ja3={} total={} auth_success={} bad_or_probe={} first_seen={} last_seen={}\n",
row.ja4,
row.ja3,
row.total,
row.auth_success,
row.bad_or_probe,
row.first_seen_epoch_secs,
row.last_seen_epoch_secs
));
} else {
out.push_str(&format!(
"scope={} ja4={} ja3={} total={} auth_success={} bad_or_probe={} first_seen={} last_seen={}\n",
row.scope_key,
row.ja4,
row.ja3,
row.total,
row.auth_success,
row.bad_or_probe,
row.first_seen_epoch_secs,
row.last_seen_epoch_secs
));
}
}
}
fn scope_key(kind: TlsFingerprintScopeKind, key: &str) -> (TlsFingerprintScopeKind, String) {
(kind, key.to_string())
}
fn cidr_bucket(ip: IpAddr) -> String {
match ip {
IpAddr::V4(ip) => {
let [a, b, c, _] = ip.octets();
format!("{a}.{b}.{c}.0/24")
}
IpAddr::V6(ip) => {
let mut octets = ip.octets();
for byte in &mut octets[7..] {
*byte = 0;
}
format!("{}/56", Ipv6Addr::from(octets))
}
}
}
fn now_epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl Stats {
pub fn record_tls_fingerprint_observed(
&self,
fingerprint: &TlsClientFingerprint,
peer_ip: IpAddr,
ttl: Duration,
) {
if self.telemetry_core_enabled() {
self.tls_fingerprints
.record_observed(fingerprint, peer_ip, ttl);
}
}
pub fn record_tls_fingerprint_auth_success(
&self,
fingerprint: &TlsClientFingerprint,
peer_ip: IpAddr,
user: &str,
ttl: Duration,
) {
if self.telemetry_core_enabled() {
self.tls_fingerprints
.record_auth_success(fingerprint, peer_ip, user, ttl);
}
}
pub fn record_tls_fingerprint_bad_or_probe(
&self,
fingerprint: &TlsClientFingerprint,
peer_ip: IpAddr,
ttl: Duration,
) {
if self.telemetry_core_enabled() {
self.tls_fingerprints
.record_bad_or_probe(fingerprint, peer_ip, ttl);
}
}
pub fn increment_tls_fingerprint_parse_error(&self) {
if self.telemetry_core_enabled() {
self.tls_fingerprints.increment_parse_error();
}
}
pub fn tls_fingerprint_snapshot(&self, ttl: Duration, limit: usize) -> TlsFingerprintSnapshot {
self.tls_fingerprints.snapshot(ttl, limit)
}
pub fn tls_fingerprint_snapshot_text(&self, ttl: Duration, limit: usize) -> String {
self.tls_fingerprints.snapshot_text(ttl, limit)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp() -> TlsClientFingerprint {
TlsClientFingerprint {
ja3: "ja3".to_string(),
ja3_raw: "771,4865,,,0".to_string(),
ja4: "t13d010100_hash_hash".to_string(),
ja4_raw: "raw".to_string(),
}
}
#[test]
fn aggregates_ip_cidr_and_user_scopes() {
let collector = TlsFingerprintCollector::default();
let ip: IpAddr = "192.0.2.15".parse().expect("test IP parses");
collector.record_observed(&fp(), ip, Duration::from_secs(60));
collector.record_auth_success(&fp(), ip, "alice", Duration::from_secs(60));
let snapshot = collector.snapshot(Duration::from_secs(60), 10);
assert_eq!(snapshot.by_fingerprint[0].total, 1);
assert_eq!(snapshot.by_fingerprint[0].auth_success, 1);
assert_eq!(snapshot.by_ip[0].scope_key, "192.0.2.15");
assert_eq!(snapshot.by_cidr[0].scope_key, "192.0.2.0/24");
assert_eq!(snapshot.by_user[0].scope_key, "alice");
assert_eq!(snapshot.by_user[0].total, 1);
}
}
+601
View File
@@ -0,0 +1,601 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::watch;
use tracing::warn;
use crate::config::{ProxyConfig, SynLimitMode};
const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT";
const IPTABLES_HASHLIMIT_NAME: &str = "TELEMT-BUMPER";
const NFT_TABLE: &str = "telemt_synlimit";
const NFT_CHAIN: &str = "input";
type SynLimitTarget = (Option<IpAddr>, u16, u32, u32, u32);
#[derive(Default)]
struct SynLimitTargets {
iptables_v4: Vec<SynLimitTarget>,
iptables_v6: Vec<SynLimitTarget>,
nft_v4: Vec<SynLimitTarget>,
nft_v6: Vec<SynLimitTarget>,
}
#[derive(Clone, Copy)]
struct NftTableFamilies {
inet: bool,
ip: bool,
ip6: bool,
}
#[derive(Clone, Copy)]
enum NftFamily {
Inet,
Ip,
Ip6,
}
struct NftApplyPlan<'a> {
family: NftFamily,
v4_targets: &'a [SynLimitTarget],
v6_targets: &'a [SynLimitTarget],
}
impl SynLimitTargets {
fn is_empty(&self) -> bool {
self.iptables_v4.is_empty()
&& self.iptables_v6.is_empty()
&& self.nft_v4.is_empty()
&& self.nft_v6.is_empty()
}
fn has_iptables_targets(&self) -> bool {
!self.iptables_v4.is_empty() || !self.iptables_v6.is_empty()
}
fn has_nft_targets(&self) -> bool {
!self.nft_v4.is_empty() || !self.nft_v6.is_empty()
}
}
impl NftFamily {
fn as_str(self) -> &'static str {
match self {
Self::Inet => "inet",
Self::Ip => "ip",
Self::Ip6 => "ip6",
}
}
}
pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<Arc<ProxyConfig>>) {
if !cfg!(target_os = "linux") {
if has_synlimit_config(&config_rx.borrow()) {
warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules");
}
return;
}
tokio::spawn(async move {
wait_for_config_channel_close_and_reconcile(config_rx).await;
if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules after config channel close");
}
});
}
async fn wait_for_config_channel_close_and_reconcile(
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
) {
while config_rx.changed().await.is_ok() {
let cfg = config_rx.borrow_and_update().clone();
reconcile_synlimit_rules(&cfg).await;
}
}
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile");
}
let targets = synlimit_targets(cfg);
if targets.is_empty() {
return;
}
if !has_cap_net_admin() {
warn!(
"SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"
);
return;
}
if targets.has_iptables_targets()
&& let Err(error) = apply_iptables_synlimit_rules(&targets).await
{
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
}
if targets.has_nft_targets()
&& let Err(error) = apply_nft_synlimit_rules(&targets).await
{
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
}
}
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
let mut errors = Vec::new();
if let Err(error) = clear_nft_synlimit_rules_all_families().await {
errors.push(error);
}
if let Err(error) = clear_iptables_synlimit_rules_for_binary("iptables").await {
errors.push(error);
}
if let Err(error) = clear_iptables_synlimit_rules_for_binary("ip6tables").await {
errors.push(error);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
cfg.server
.listeners
.iter()
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
}
fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets {
let mut iptables_v4 = BTreeSet::new();
let mut iptables_v6 = BTreeSet::new();
let mut nft_v4 = BTreeSet::new();
let mut nft_v6 = BTreeSet::new();
for listener in &cfg.server.listeners {
let backend = listener.synlimit;
if matches!(backend, SynLimitMode::Off) {
continue;
}
let port = listener.port.unwrap_or(cfg.server.port);
let ip = (!listener.ip.is_unspecified()).then_some(listener.ip);
let seconds = listener.synlimit_seconds;
let hitcount = listener.synlimit_hitcount;
let burst = listener.synlimit_burst;
match (backend, listener.ip.is_ipv4()) {
(SynLimitMode::Iptables, true) => {
iptables_v4.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Iptables, false) => {
iptables_v6.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Nftables, true) => {
nft_v4.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Nftables, false) => {
nft_v6.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Off, _) => {}
}
}
SynLimitTargets {
iptables_v4: iptables_v4.into_iter().collect(),
iptables_v6: iptables_v6.into_iter().collect(),
nft_v4: nft_v4.into_iter().collect(),
nft_v6: nft_v6.into_iter().collect(),
}
}
async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
apply_iptables_synlimit_rules_for_binary("iptables", &targets.iptables_v4).await?;
apply_iptables_synlimit_rules_for_binary("ip6tables", &targets.iptables_v6).await
}
async fn apply_iptables_synlimit_rules_for_binary(
binary: &str,
targets: &[SynLimitTarget],
) -> Result<(), String> {
if targets.is_empty() {
return Ok(());
}
let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await;
run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?;
if run_command(
binary,
&["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await
.is_err()
{
run_command(
binary,
&["-t", "filter", "-A", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await?;
}
for (idx, (ip, port, seconds, hitcount, burst)) in targets.iter().enumerate() {
let hashlimit_name = format!("{IPTABLES_HASHLIMIT_NAME}-{idx}");
let accept_args = iptables_hashlimit_accept_rule_args(
ip,
*port,
*seconds,
*hitcount,
*burst,
&hashlimit_name,
);
let drop_args = iptables_synlimit_drop_rule_args(ip, *port);
let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect();
let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect();
run_command(binary, &accept_refs, None).await?;
run_command(binary, &drop_refs, None).await?;
}
run_command(
binary,
&["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"],
None,
)
.await?;
Ok(())
}
fn iptables_hashlimit_accept_rule_args(
ip: &Option<IpAddr>,
port: u16,
seconds: u32,
hitcount: u32,
burst: u32,
hashlimit_name: &str,
) -> Vec<String> {
let mut args = vec![
"-t".to_string(),
"filter".to_string(),
"-A".to_string(),
IPTABLES_CHAIN.to_string(),
"-p".to_string(),
"tcp".to_string(),
"--syn".to_string(),
];
if let Some(ip) = ip {
args.push("-d".to_string());
args.push(ip.to_string());
}
let rate = synlimit_rate_arg(seconds, hitcount);
args.extend([
"--dport".to_string(),
port.to_string(),
"-m".to_string(),
"hashlimit".to_string(),
"--hashlimit-name".to_string(),
hashlimit_name.to_string(),
"--hashlimit-mode".to_string(),
"srcip".to_string(),
"--hashlimit-upto".to_string(),
rate,
"--hashlimit-burst".to_string(),
burst.to_string(),
"--hashlimit-htable-expire".to_string(),
"15000".to_string(),
"-j".to_string(),
"ACCEPT".to_string(),
]);
args
}
fn iptables_synlimit_drop_rule_args(ip: &Option<IpAddr>, port: u16) -> Vec<String> {
let mut args = vec![
"-t".to_string(),
"filter".to_string(),
"-A".to_string(),
IPTABLES_CHAIN.to_string(),
"-p".to_string(),
"tcp".to_string(),
"--syn".to_string(),
];
if let Some(ip) = ip {
args.push("-d".to_string());
args.push(ip.to_string());
}
args.extend([
"--dport".to_string(),
port.to_string(),
"-j".to_string(),
"DROP".to_string(),
]);
args
}
fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
let seconds = u64::from(seconds.max(1));
let hitcount = u64::from(hitcount.max(1));
for (unit_seconds, unit_name) in [
(1_u64, "second"),
(60_u64, "minute"),
(3_600_u64, "hour"),
(86_400_u64, "day"),
] {
let amount = hitcount.saturating_mul(unit_seconds);
if amount >= seconds && amount % seconds == 0 {
return format!("{}/{}", amount / seconds, unit_name);
}
}
let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds;
format!("{}/day", amount.max(1))
}
async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> {
let mut errors = Vec::new();
for _ in 0..8 {
match run_command(
binary,
&["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await
{
Ok(()) => {}
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
Err(error) => {
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
break;
}
}
}
if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await
&& !is_missing_command_or_iptables_rule(&error)
{
errors.push(format!("{binary} flush chain failed: {error}"));
}
if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await
&& !is_missing_command_or_iptables_rule(&error)
{
errors.push(format!("{binary} delete chain failed: {error}"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(", "))
}
}
async fn apply_nft_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
let families = detect_nft_table_families().await;
for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) {
let script = nft_synlimit_script(plan);
run_command("nft", &["-f", "-"], Some(script)).await?;
}
Ok(())
}
async fn detect_nft_table_families() -> NftTableFamilies {
let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else {
return NftTableFamilies {
inet: false,
ip: false,
ip6: false,
};
};
let mut families = NftTableFamilies {
inet: false,
ip: false,
ip6: false,
};
for line in output.lines() {
let mut fields = line.split_whitespace();
if fields.next() != Some("table") {
continue;
}
match fields.next() {
Some("inet") => families.inet = true,
Some("ip") => families.ip = true,
Some("ip6") => families.ip6 = true,
_ => {}
}
}
families
}
fn nft_apply_plan<'a>(
families: NftTableFamilies,
v4_targets: &'a [SynLimitTarget],
v6_targets: &'a [SynLimitTarget],
) -> Vec<NftApplyPlan<'a>> {
if !v4_targets.is_empty() && !v6_targets.is_empty() {
return vec![NftApplyPlan {
family: NftFamily::Inet,
v4_targets,
v6_targets,
}];
}
if !v4_targets.is_empty() {
return vec![NftApplyPlan {
family: if families.inet || !families.ip {
NftFamily::Inet
} else {
NftFamily::Ip
},
v4_targets,
v6_targets: &[],
}];
}
if !v6_targets.is_empty() {
return vec![NftApplyPlan {
family: if families.inet || !families.ip6 {
NftFamily::Inet
} else {
NftFamily::Ip6
},
v4_targets: &[],
v6_targets,
}];
}
Vec::new()
}
fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String {
let mut script = String::new();
script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str()));
script.push_str(&format!(" chain {NFT_CHAIN} {{\n"));
script.push_str(" type filter hook input priority filter; policy accept;\n");
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v4_targets.iter().enumerate() {
let daddr = ip
.map(|ip| format!(" ip daddr {ip}"))
.unwrap_or_else(String::new);
let rate = synlimit_rate_arg(*seconds, *hitcount);
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v4_{idx} {{ ip saddr limit rate over {rate} burst {burst} packets }} drop\n"
));
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
));
}
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v6_targets.iter().enumerate() {
let daddr = ip
.map(|ip| format!(" ip6 daddr {ip}"))
.unwrap_or_else(String::new);
let rate = synlimit_rate_arg(*seconds, *hitcount);
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v6_{idx} {{ ip6 saddr limit rate over {rate} burst {burst} packets }} drop\n"
));
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
));
}
script.push_str(" }\n");
script.push_str("}\n");
script
}
async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> {
let mut errors = Vec::new();
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
if let Err(error) = run_command(
"nft",
&["delete", "table", family.as_str(), NFT_TABLE],
None,
)
.await
&& !is_missing_command_or_nft_table(&error)
{
errors.push(format!(
"nft delete table {} {NFT_TABLE} failed: {error}",
family.as_str()
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(", "))
}
}
fn is_missing_command_or_iptables_rule(error: &str) -> bool {
error.contains("is not available")
|| error.contains("No chain/target/match by that name")
|| error.contains("does not exist")
}
fn is_missing_command_or_nft_table(error: &str) -> bool {
error.contains("is not available") || error.contains("No such file or directory")
}
async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Result<(), String> {
let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available"));
};
let mut command = Command::new(command_path);
command.args(args);
if stdin.is_some() {
command.stdin(std::process::Stdio::piped());
}
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("spawn {binary} failed: {e}"))?;
if let Some(blob) = stdin
&& let Some(mut writer) = child.stdin.take()
{
writer
.write_all(blob.as_bytes())
.await
.map_err(|e| format!("stdin write {binary} failed: {e}"))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("wait {binary} failed: {e}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> {
let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available"));
};
let output = Command::new(command_path)
.args(args)
.output()
.await
.map_err(|e| format!("wait {binary} failed: {e}"))?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).to_string());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
fn resolve_command(binary: &str) -> Option<PathBuf> {
let mut dirs = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.unwrap_or_default();
dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from));
dirs.into_iter()
.map(|dir| dir.join(binary))
.find(|candidate| candidate.exists() && candidate.is_file())
}
fn has_cap_net_admin() -> bool {
#[cfg(target_os = "linux")]
{
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
for line in status.lines() {
if let Some(raw) = line.strip_prefix("CapEff:") {
let caps = raw.trim();
if let Ok(bits) = u64::from_str_radix(caps, 16) {
const CAP_NET_ADMIN_BIT: u64 = 12;
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
}
}
}
false
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
+58 -9
View File
@@ -12,7 +12,8 @@ use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileSource,
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileQuality,
TlsProfileSource,
};
const FULL_CERT_SENT_SWEEP_INTERVAL_SECS: u64 = 30;
@@ -47,10 +48,14 @@ pub struct TlsFrontCache {
pub(crate) struct TlsFrontProfileHealth {
pub(crate) domain: String,
pub(crate) source: &'static str,
pub(crate) quality: &'static str,
pub(crate) key_share_group: &'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) server_hello_record_len: usize,
pub(crate) server_hello_extensions: usize,
pub(crate) app_data_records: usize,
pub(crate) ticket_records: usize,
pub(crate) change_cipher_spec_count: u8,
@@ -66,6 +71,23 @@ fn profile_source_label(source: TlsProfileSource) -> &'static str {
}
}
fn profile_quality_label(quality: TlsProfileQuality) -> &'static str {
match quality {
TlsProfileQuality::Fallback => "fallback",
TlsProfileQuality::RawPartial => "raw_partial",
TlsProfileQuality::RawStrict => "raw_strict",
}
}
fn key_share_group_label(group: Option<u16>) -> &'static str {
match group {
Some(0x001d) => "x25519",
Some(0x11ec) => "x25519mlkem768",
Some(_) => "other",
None => "none",
}
}
#[allow(dead_code)]
impl TlsFrontCache {
pub fn new(domains: &[String], default_len: usize, disk_path: impl AsRef<Path>) -> Self {
@@ -137,7 +159,8 @@ impl TlsFrontCache {
.get(domain)
.cloned()
.unwrap_or_else(|| self.default.clone());
let behavior = &cached.behavior_profile;
let mut behavior = cached.behavior_profile.clone();
behavior.refresh_server_hello_summary(&cached.server_hello_template);
let age_seconds = now
.duration_since(cached.fetched_at)
.map(|duration| duration.as_secs())
@@ -146,10 +169,14 @@ impl TlsFrontCache {
snapshot.push(TlsFrontProfileHealth {
domain: domain.clone(),
source: profile_source_label(behavior.source),
quality: profile_quality_label(behavior.quality),
key_share_group: key_share_group_label(behavior.server_hello_key_share_group),
age_seconds,
is_default: cached.domain == "default",
has_cert_info: cached.cert_info.is_some(),
has_cert_payload: cached.cert_payload.is_some(),
server_hello_record_len: behavior.server_hello_record_len,
server_hello_extensions: behavior.server_hello_extension_types.len(),
app_data_records: cached
.app_data_records_sizes
.len()
@@ -337,6 +364,9 @@ impl TlsFrontCache {
warn!(domain = %cached.domain, "Skipping stale TLS cache entry (>72h)");
continue;
}
cached
.behavior_profile
.refresh_server_hello_summary(&cached.server_hello_template);
let domain = cached.domain.clone();
self.set(&domain, cached).await;
loaded += 1;
@@ -378,20 +408,39 @@ impl TlsFrontCache {
/// Replace cached entry from a fetch result.
pub async fn update_from_fetch(&self, domain: &str, fetched: TlsFetchResult) {
let TlsFetchResult {
server_hello_parsed,
app_data_records_sizes,
total_app_data_len,
mut behavior_profile,
cert_info,
cert_payload,
} = fetched;
behavior_profile.refresh_server_hello_summary(&server_hello_parsed);
let quality = behavior_profile.quality;
let data = CachedTlsData {
server_hello_template: fetched.server_hello_parsed,
cert_info: fetched.cert_info,
cert_payload: fetched.cert_payload,
app_data_records_sizes: fetched.app_data_records_sizes.clone(),
total_app_data_len: fetched.total_app_data_len,
behavior_profile: fetched.behavior_profile,
server_hello_template: server_hello_parsed,
cert_info,
cert_payload,
app_data_records_sizes: app_data_records_sizes.clone(),
total_app_data_len,
behavior_profile,
fetched_at: SystemTime::now(),
domain: domain.to_string(),
};
self.set(domain, data.clone()).await;
self.persist(domain, &data).await;
debug!(domain = %domain, len = fetched.total_app_data_len, "TLS cache updated");
if quality == TlsProfileQuality::RawStrict {
debug!(domain = %domain, len = total_app_data_len, "TLS cache updated");
} else {
warn!(
domain = %domain,
quality = profile_quality_label(quality),
len = total_app_data_len,
"TLS cache updated with non-strict front profile"
);
}
}
pub fn default_entry(&self) -> Arc<CachedTlsData> {
+492 -96
View File
@@ -6,14 +6,75 @@ use crate::protocol::constants::{
TLS_RECORD_HANDSHAKE, TLS_VERSION,
};
use crate::protocol::tls::{
ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key,
ClientHelloTlsVersion, ServerHelloKeyShare, TLS_DIGEST_LEN, TLS_DIGEST_POS,
TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768,
};
use crate::tls_front::types::{
CachedTlsData, ParsedCertificateInfo, TlsExtension, TlsProfileSource,
};
use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource};
use crc32fast::Hasher;
const MIN_APP_DATA: usize = 64;
const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE;
const MAX_TICKET_RECORDS: usize = 4;
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
const EXT_KEY_SHARE: u16 = 0x0033;
const EXT_ALPN: u16 = 0x0010;
#[derive(Clone, Copy)]
enum FallbackShapeFamily {
NginxLike,
BoringSslLike,
RustlsLike,
}
fn parse_profiled_key_share_group(data: &[u8]) -> Option<u16> {
if data.len() < 4 {
return None;
}
let group = u16::from_be_bytes([data[0], data[1]]);
let key_exchange_len = u16::from_be_bytes([data[2], data[3]]) as usize;
if data.len() != 4 + key_exchange_len {
return None;
}
match group {
TLS_NAMED_GROUP_X25519 | TLS_NAMED_GROUP_X25519MLKEM768 => Some(group),
_ => None,
}
}
fn effective_profiled_server_hello_record_len(cached: &CachedTlsData) -> usize {
if cached.behavior_profile.server_hello_record_len == 0 {
cached.server_hello_template.record_body_len()
} else {
cached.behavior_profile.server_hello_record_len
}
}
fn should_replay_profiled_server_hello_shape(cached: &CachedTlsData) -> bool {
matches!(
cached.behavior_profile.source,
TlsProfileSource::Raw | TlsProfileSource::Merged
) && cached
.server_hello_template
.is_replay_safe_tls13_shape(effective_profiled_server_hello_record_len(cached))
}
/// Return the origin-profiled ServerHello key_share group when it is replay-safe.
pub(crate) fn profiled_server_hello_key_share_group(cached: &CachedTlsData) -> Option<u16> {
if !should_replay_profiled_server_hello_shape(cached) {
return None;
}
cached
.server_hello_template
.extensions
.iter()
.find(|ext| ext.ext_type == EXT_KEY_SHARE)
.and_then(|ext| parse_profiled_key_share_group(&ext.data))
}
fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec<usize> {
sizes
@@ -65,31 +126,69 @@ fn ensure_payload_capacity(mut sizes: Vec<usize>, payload_len: usize) -> Vec<usi
sizes
}
fn fallback_shape_family(cached: &CachedTlsData) -> FallbackShapeFamily {
match cached.behavior_profile.source {
TlsProfileSource::Rustls => FallbackShapeFamily::RustlsLike,
TlsProfileSource::Default => {
let mut hasher = Hasher::new();
hasher.update(cached.domain.as_bytes());
hasher.update(&cached.total_app_data_len.to_le_bytes());
if hasher.finalize() & 1 == 0 {
FallbackShapeFamily::NginxLike
} else {
FallbackShapeFamily::BoringSslLike
}
}
TlsProfileSource::Raw | TlsProfileSource::Merged => FallbackShapeFamily::NginxLike,
}
}
fn fallback_total_app_data_len(cached: &CachedTlsData) -> usize {
cached
.total_app_data_len
.max(cached.app_data_records_sizes.iter().sum())
.max(1024)
}
fn push_fallback_size(sizes: &mut Vec<usize>, size: usize) {
sizes.push(size.clamp(MIN_APP_DATA, MAX_APP_DATA));
}
fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
let mut sizes = Vec::with_capacity(1);
let size = if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) {
cached
.app_data_records_sizes
.first()
.copied()
.unwrap_or_else(|| fallback_total_app_data_len(cached))
} else {
fallback_total_app_data_len(cached)
};
push_fallback_size(&mut sizes, size);
sizes
}
fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
match cached.behavior_profile.source {
TlsProfileSource::Raw | TlsProfileSource::Merged => {
return cached
.app_data_records_sizes
.first()
.copied()
.or_else(|| {
cached
.behavior_profile
.app_data_record_sizes
.first()
.copied()
})
.map(|size| vec![size])
.unwrap_or_else(|| vec![cached.total_app_data_len.max(1024)]);
if let Some(size) = cached.behavior_profile.app_data_record_sizes.first() {
return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
}
if let Some(size) = cached.app_data_records_sizes.first() {
return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
}
return vec![
cached
.total_app_data_len
.max(1024)
.clamp(MIN_APP_DATA, MAX_APP_DATA),
];
}
TlsProfileSource::Default | TlsProfileSource::Rustls => {
return fallback_family_app_data_sizes(cached);
}
TlsProfileSource::Default | TlsProfileSource::Rustls => {}
}
let mut sizes = cached.app_data_records_sizes.clone();
if sizes.is_empty() {
sizes.push(cached.total_app_data_len.max(1024));
}
sizes
}
fn emulated_change_cipher_spec_count(_cached: &CachedTlsData) -> usize {
@@ -117,7 +216,13 @@ fn emulated_ticket_record_sizes(
sizes.extend(profiled_sizes.iter().copied().take(target_count));
while sizes.len() < target_count {
sizes.push(rng.range(48) + 48);
let family = fallback_shape_family(cached);
let base = match family {
FallbackShapeFamily::NginxLike => 96,
FallbackShapeFamily::BoringSslLike => 80,
FallbackShapeFamily::RustlsLike => 112,
};
sizes.push(base + rng.range(64));
}
sizes
@@ -185,6 +290,96 @@ fn hash_compact_cert_info_payload(cert_payload: Vec<u8>) -> Option<Vec<u8>> {
Some(hashed)
}
fn push_supported_versions_extension(extensions: &mut Vec<u8>) {
extensions.extend_from_slice(&EXT_SUPPORTED_VERSIONS.to_be_bytes());
extensions.extend_from_slice(&(2u16).to_be_bytes());
extensions.extend_from_slice(&0x0304u16.to_be_bytes());
}
fn push_key_share_entry(extensions: &mut Vec<u8>, group: u16, key_exchange: &[u8]) {
let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else {
return;
};
let Some(entry_len) = key_exchange.len().checked_add(4) else {
return;
};
let Ok(entry_len) = u16::try_from(entry_len) else {
return;
};
extensions.extend_from_slice(&EXT_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&entry_len.to_be_bytes());
extensions.extend_from_slice(&group.to_be_bytes());
extensions.extend_from_slice(&key_exchange_len.to_be_bytes());
extensions.extend_from_slice(key_exchange);
}
fn push_key_share_extension(extensions: &mut Vec<u8>, server_key_share: &ServerHelloKeyShare) {
push_key_share_entry(
extensions,
server_key_share.group(),
server_key_share.key_exchange(),
);
}
fn replay_profiled_server_hello_extension(
ext: &TlsExtension,
extensions: &mut Vec<u8>,
server_key_share: &ServerHelloKeyShare,
saw_supported_versions: &mut bool,
saw_key_share: &mut bool,
) {
match ext.ext_type {
EXT_SUPPORTED_VERSIONS if !*saw_supported_versions => {
push_supported_versions_extension(extensions);
*saw_supported_versions = true;
}
EXT_KEY_SHARE if !*saw_key_share => {
push_key_share_extension(extensions, server_key_share);
*saw_key_share = true;
}
EXT_ALPN => {}
_ => {}
}
}
fn build_profiled_server_hello_extensions(
cached: &CachedTlsData,
server_key_share: &ServerHelloKeyShare,
) -> Vec<u8> {
let capacity = cached
.server_hello_template
.extensions
.iter()
.map(|ext| 4 + ext.data.len())
.sum::<usize>()
.max(44);
let mut extensions = Vec::with_capacity(capacity);
let mut saw_supported_versions = false;
let mut saw_key_share = false;
if should_replay_profiled_server_hello_shape(cached) {
for ext in &cached.server_hello_template.extensions {
replay_profiled_server_hello_extension(
ext,
&mut extensions,
server_key_share,
&mut saw_supported_versions,
&mut saw_key_share,
);
}
}
if !saw_supported_versions {
push_supported_versions_extension(&mut extensions);
}
if !saw_key_share {
push_key_share_extension(&mut extensions, server_key_share);
}
extensions
}
/// Build a ServerHello + CCS + ApplicationData sequence using cached TLS metadata.
pub fn build_emulated_server_hello(
secret: &[u8],
@@ -194,39 +389,29 @@ pub fn build_emulated_server_hello(
use_full_cert_payload: bool,
serverhello_compact: bool,
client_tls_version: ClientHelloTlsVersion,
selected_cipher_suite: [u8; 2],
server_key_share: &ServerHelloKeyShare,
rng: &SecureRandom,
alpn: Option<Vec<u8>>,
new_session_tickets: u8,
) -> Vec<u8> {
// --- ServerHello ---
let mut extensions = Vec::new();
let key = gen_fake_x25519_key(rng);
extensions.extend_from_slice(&0x0033u16.to_be_bytes());
extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes());
extensions.extend_from_slice(&0x001du16.to_be_bytes());
extensions.extend_from_slice(&(32u16).to_be_bytes());
extensions.extend_from_slice(&key);
extensions.extend_from_slice(&0x002bu16.to_be_bytes());
extensions.extend_from_slice(&(2u16).to_be_bytes());
extensions.extend_from_slice(&0x0304u16.to_be_bytes());
// ServerHello carries the authenticated digest bytes that the client verifies.
let extensions = build_profiled_server_hello_extensions(cached, server_key_share);
let extensions_len = extensions.len() as u16;
let body_len = 2 + // version
32 + // random
1 + session_id.len() + // session id
2 + // cipher
1 + // compression
2 + extensions.len(); // extensions
let body_len = 2 + 32 + 1 + session_id.len() + 2 + 1 + 2 + extensions.len();
let mut message = Vec::with_capacity(4 + body_len);
message.push(0x02); // ServerHello
message.push(0x02);
let len_bytes = (body_len as u32).to_be_bytes();
message.extend_from_slice(&len_bytes[1..4]);
message.extend_from_slice(&cached.server_hello_template.version); // 0x0303
message.extend_from_slice(&[0u8; 32]); // random placeholder
message.extend_from_slice(&cached.server_hello_template.version);
message.extend_from_slice(&[0u8; 32]);
message.push(session_id.len() as u8);
message.extend_from_slice(session_id);
let cipher = if cached.server_hello_template.cipher_suite == [0, 0] {
let cipher = if selected_cipher_suite != [0, 0] {
selected_cipher_suite
} else if cached.server_hello_template.cipher_suite == [0, 0] {
[0x13, 0x01]
} else {
cached.server_hello_template.cipher_suite
@@ -242,7 +427,7 @@ pub fn build_emulated_server_hello(
server_hello.extend_from_slice(&(message.len() as u16).to_be_bytes());
server_hello.extend_from_slice(&message);
// --- ChangeCipherSpec ---
// ChangeCipherSpec is part of the client-visible TLS shim prefix.
let change_cipher_spec_count = emulated_change_cipher_spec_count(cached);
let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6);
for _ in 0..change_cipher_spec_count {
@@ -256,7 +441,8 @@ pub fn build_emulated_server_hello(
]);
}
// --- ApplicationData (fake encrypted records) ---
// Telegram clients authenticate the hello prefix and then expose any later
// ApplicationData bytes to the MTProto packet parser.
let mut sizes = {
let base_sizes = emulated_app_data_sizes(cached);
match cached.behavior_profile.source {
@@ -303,21 +489,11 @@ pub fn build_emulated_server_hello(
}
let mut app_data = Vec::new();
let alpn_marker = alpn
.as_ref()
.filter(|p| !p.is_empty() && p.len() <= u8::MAX as usize)
.map(|proto| {
let proto_list_len = 1usize + proto.len();
let ext_data_len = 2usize + proto_list_len;
let mut marker = Vec::with_capacity(4 + ext_data_len);
marker.extend_from_slice(&0x0010u16.to_be_bytes());
marker.extend_from_slice(&(ext_data_len as u16).to_be_bytes());
marker.extend_from_slice(&(proto_list_len as u16).to_be_bytes());
marker.push(proto.len() as u8);
marker.extend_from_slice(proto);
marker
});
for (idx, size) in sizes.into_iter().enumerate() {
// ALPN selection is encrypted inside EncryptedExtensions in real TLS 1.3.
// Keeping the FakeTLS record body opaque avoids a stable plaintext marker.
let _ = alpn;
let mut payload_offset = 0usize;
for size in sizes {
let mut rec = Vec::with_capacity(5 + size);
rec.push(TLS_RECORD_APPLICATION);
rec.extend_from_slice(&TLS_VERSION);
@@ -326,47 +502,34 @@ pub fn build_emulated_server_hello(
if let Some(payload) = selected_payload {
if size > 17 {
let body_len = size - 17;
let remaining = payload.len();
let remaining = payload.len().saturating_sub(payload_offset);
let copy_len = remaining.min(body_len);
if copy_len > 0 {
rec.extend_from_slice(&payload[..copy_len]);
rec.extend_from_slice(&payload[payload_offset..payload_offset + copy_len]);
payload_offset += copy_len;
}
if body_len > copy_len {
rec.extend_from_slice(&rng.bytes(body_len - copy_len));
}
rec.push(0x16); // inner content type marker (handshake)
rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag
rec.push(0x16);
rec.extend_from_slice(&rng.bytes(16));
} else {
rec.extend_from_slice(&rng.bytes(size));
}
} else if size > 17 {
let body_len = size - 17;
let mut body = Vec::with_capacity(body_len);
if idx == 0
&& let Some(marker) = &alpn_marker
{
if marker.len() <= body_len {
body.extend_from_slice(marker);
if body_len > marker.len() {
body.extend_from_slice(&rng.bytes(body_len - marker.len()));
}
} else {
body.extend_from_slice(&rng.bytes(body_len));
}
} else {
body.extend_from_slice(&rng.bytes(body_len));
}
body.extend_from_slice(&rng.bytes(body_len));
rec.extend_from_slice(&body);
rec.push(0x16); // inner content type marker (handshake)
rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag
rec.push(0x16);
rec.extend_from_slice(&rng.bytes(16));
} else {
rec.extend_from_slice(&rng.bytes(size));
}
app_data.extend_from_slice(&rec);
}
// --- Combine ---
// Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint).
// Optional NewSessionTicket mimic records are an explicit fingerprint opt-in.
let mut tickets = Vec::new();
for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) {
let mut rec = Vec::with_capacity(5 + ticket_len);
@@ -385,7 +548,7 @@ pub fn build_emulated_server_hello(
response.extend_from_slice(&app_data);
response.extend_from_slice(&tickets);
// --- HMAC ---
// The digest authenticates the server response bytes emitted by this builder.
let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + response.len());
hmac_input.extend_from_slice(client_digest);
hmac_input.extend_from_slice(&response);
@@ -408,18 +571,22 @@ mod tests {
use std::time::SystemTime;
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsExtension,
TlsProfileSource,
};
use super::{
build_compact_cert_info_payload, build_emulated_server_hello,
hash_compact_cert_info_payload,
hash_compact_cert_info_payload, profiled_server_hello_key_share_group,
};
use crate::crypto::SecureRandom;
use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
};
use crate::protocol::tls::ClientHelloTlsVersion;
use crate::protocol::tls::{
ClientHelloTlsVersion, ServerHelloKeyShare, TLS_NAMED_GROUP_X25519,
TLS_NAMED_GROUP_X25519MLKEM768,
};
fn first_app_data_payload(response: &[u8]) -> &[u8] {
let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize;
@@ -432,6 +599,38 @@ mod tests {
&response[app_start + 5..app_start + 5 + app_len]
}
fn server_hello_cipher_suite(response: &[u8]) -> [u8; 2] {
let mut pos = 5 + 4 + 2 + 32;
let session_id_len = response[pos] as usize;
pos += 1 + session_id_len;
[response[pos], response[pos + 1]]
}
fn server_hello_extension_types(response: &[u8]) -> Vec<u16> {
let record_len = u16::from_be_bytes([response[3], response[4]]) as usize;
let handshake_end = 5 + record_len;
let mut pos = 5 + 4 + 2 + 32;
let session_id_len = response[pos] as usize;
pos += 1 + session_id_len + 2 + 1;
let extensions_len = u16::from_be_bytes([response[pos], response[pos + 1]]) as usize;
pos += 2;
let extensions_end = (pos + extensions_len).min(handshake_end);
let mut out = Vec::new();
while pos + 4 <= extensions_end {
let ext_type = u16::from_be_bytes([response[pos], response[pos + 1]]);
let ext_len = u16::from_be_bytes([response[pos + 2], response[pos + 3]]) as usize;
pos += 4;
if pos + ext_len > extensions_end {
break;
}
out.push(ext_type);
pos += ext_len;
}
out
}
fn make_cached(cert_payload: Option<TlsCertPayload>) -> CachedTlsData {
CachedTlsData {
server_hello_template: ParsedServerHello {
@@ -452,6 +651,50 @@ mod tests {
}
}
fn test_server_key_share() -> ServerHelloKeyShare {
ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519MLKEM768, vec![0x42; 1120])
}
fn server_key_share_extension_data(group: u16, len: usize) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&group.to_be_bytes());
data.extend_from_slice(&(len as u16).to_be_bytes());
data.resize(4 + len, 0x42);
data
}
#[test]
fn profiled_server_hello_key_share_group_reads_raw_x25519_profile() {
let mut cached = make_cached(None);
cached.behavior_profile.source = TlsProfileSource::Raw;
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
},
];
assert_eq!(
profiled_server_hello_key_share_group(&cached),
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn profiled_server_hello_key_share_group_ignores_default_profile() {
let mut cached = make_cached(None);
cached.server_hello_template.extensions = vec![TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
}];
assert_eq!(profiled_server_hello_key_share_group(&cached), None);
}
#[test]
fn test_build_emulated_server_hello_uses_cached_cert_payload() {
let cert_msg = vec![0x0b, 0x00, 0x00, 0x05, 0x00, 0xaa, 0xbb, 0xcc, 0xdd];
@@ -468,6 +711,8 @@ mod tests {
true,
true,
ClientHelloTlsVersion::Tls12,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -484,6 +729,143 @@ mod tests {
assert!(payload.starts_with(&cert_msg));
}
#[test]
fn test_build_emulated_server_hello_uses_selected_cipher_suite() {
let cached = make_cached(None);
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x10; 32],
&[0x20; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x03],
&test_server_key_share(),
&rng,
None,
0,
);
assert_eq!(server_hello_cipher_suite(&response), [0x13, 0x03]);
}
#[test]
fn test_build_emulated_server_hello_replays_profiled_safe_extension_order() {
let mut cached = make_cached(None);
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
TlsExtension {
ext_type: 0x0010,
data: vec![0x00, 0x03, 0x02, b'h', b'2'],
},
TlsExtension {
ext_type: 0x0033,
data: vec![0; 36],
},
];
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x21; 32],
&[0x22; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
Some(b"h2".to_vec()),
0,
);
assert_eq!(
server_hello_extension_types(&response),
vec![0x002b, 0x0033]
);
}
#[test]
fn test_build_emulated_server_hello_replays_safe_raw_extension_order() {
let mut cached = make_cached(None);
cached.behavior_profile.source = TlsProfileSource::Raw;
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
},
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
];
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x21; 32],
&[0x22; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
);
assert_eq!(
server_hello_extension_types(&response),
vec![0x0033, 0x002b]
);
}
#[test]
fn test_build_emulated_server_hello_uses_canonical_order_for_unsafe_raw_shape() {
let mut cached = make_cached(None);
cached.behavior_profile.source = TlsProfileSource::Raw;
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x0010,
data: vec![0x00, 0x03, 0x02, b'h', b'2'],
},
TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
},
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
];
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x21; 32],
&[0x22; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
);
assert_eq!(
server_hello_extension_types(&response),
vec![0x002b, 0x0033]
);
}
#[test]
fn test_build_emulated_server_hello_random_fallback_when_no_cert_payload() {
let cached = make_cached(None);
@@ -496,6 +878,8 @@ mod tests {
true,
true,
ClientHelloTlsVersion::Tls12,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -530,6 +914,8 @@ mod tests {
false,
true,
ClientHelloTlsVersion::Tls12,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -570,6 +956,8 @@ mod tests {
true,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -583,7 +971,7 @@ mod tests {
}
#[test]
fn test_build_emulated_server_hello_compact_disabled_skips_compact_payload() {
fn test_build_emulated_server_hello_keeps_alpn_marker_out_of_random_payload() {
let mut cached = make_cached(None);
cached.cert_info = Some(crate::tls_front::types::ParsedCertificateInfo {
not_after_unix: Some(1_900_000_000),
@@ -602,6 +990,8 @@ mod tests {
false,
false,
ClientHelloTlsVersion::Tls12,
[0x13, 0x01],
&test_server_key_share(),
&rng,
Some(b"h2".to_vec()),
0,
@@ -610,8 +1000,8 @@ mod tests {
let payload = first_app_data_payload(&response);
let expected_alpn_marker = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2'];
assert!(
payload.starts_with(&expected_alpn_marker),
"when compact mode is disabled and no full cert payload exists, the random/alpn path must be used"
!payload.starts_with(&expected_alpn_marker),
"random fallback payload must not expose plaintext ALPN marker bytes"
);
}
@@ -633,6 +1023,8 @@ mod tests {
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -640,11 +1032,15 @@ mod tests {
let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize;
let ccs_start = 5 + hello_len;
let app_start = ccs_start + 6;
let app_len =
u16::from_be_bytes([response[app_start + 3], response[app_start + 4]]) as usize;
assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
assert_eq!(app_len, 64);
assert_eq!(app_start + 5 + app_len, response.len());
let mut pos = ccs_start + 6;
let mut app_lens = Vec::new();
while pos + 5 <= response.len() {
let record_len = u16::from_be_bytes([response[pos + 3], response[pos + 4]]) as usize;
assert_eq!(response[pos], TLS_RECORD_APPLICATION);
app_lens.push(record_len);
pos += 5 + record_len;
}
assert_eq!(app_lens, vec![64]);
assert_eq!(pos, response.len());
}
}
+124 -29
View File
@@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use ml_kem::{DecapsulationKey as MlKemDecapsulationKey, KeyExport, MlKem768, Seed as MlKemSeed};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
#[cfg(unix)]
@@ -33,6 +34,7 @@ use crate::network::dns_overrides::resolve_socket_addr;
use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
};
use crate::protocol::tls::{TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768};
use crate::tls_front::types::{
ParsedCertificateInfo, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsExtension,
TlsFetchResult, TlsProfileSource,
@@ -40,6 +42,10 @@ use crate::tls_front::types::{
use crate::transport::UpstreamStream;
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
#[cfg(test)]
const X25519_KEY_SHARE_LEN: usize = 32;
const MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN: usize = 1184;
/// No-op verifier: accept any certificate (we only need lengths and metadata).
#[derive(Debug)]
struct NoVerify;
@@ -393,8 +399,13 @@ fn profile_cipher_suites(profile: TlsFetchProfile) -> &'static [u16] {
}
fn profile_groups(profile: TlsFetchProfile) -> &'static [u16] {
const MODERN: &[u16] = &[0x001d, 0x0017, 0x0018]; // x25519, secp256r1, secp384r1
const COMPAT: &[u16] = &[0x001d, 0x0017];
const MODERN: &[u16] = &[
TLS_NAMED_GROUP_X25519MLKEM768,
TLS_NAMED_GROUP_X25519,
0x0017,
0x0018,
];
const COMPAT: &[u16] = &[TLS_NAMED_GROUP_X25519, 0x0017];
const LEGACY: &[u16] = &[0x0017];
match profile {
@@ -454,7 +465,9 @@ fn profile_supported_versions(profile: TlsFetchProfile) -> &'static [u16] {
fn profile_padding_target(profile: TlsFetchProfile) -> usize {
match profile {
TlsFetchProfile::ModernChromeLike => 220,
// X25519MLKEM768 makes the Chrome-like ClientHello much larger than
// legacy pre-hybrid profiles; keep enough headroom for padding.
TlsFetchProfile::ModernChromeLike => 1450,
TlsFetchProfile::ModernFirefoxLike => 200,
TlsFetchProfile::CompatTls12 => 180,
TlsFetchProfile::LegacyMinimal => 64,
@@ -475,6 +488,48 @@ fn grease_value(rng: &SecureRandom, deterministic: bool, seed: &str) -> u16 {
}
}
fn gen_mlkem768_client_encapsulation_key(
rng: &SecureRandom,
deterministic: bool,
seed: &str,
) -> Option<Vec<u8>> {
let seed_bytes = if deterministic {
deterministic_bytes(seed, 64)
} else {
rng.bytes(64)
};
let seed = MlKemSeed::try_from(seed_bytes.as_slice()).ok()?;
let decapsulation_key = MlKemDecapsulationKey::<MlKem768>::from_seed(seed);
let encapsulation_key = decapsulation_key.encapsulation_key().to_bytes();
let bytes = encapsulation_key.as_slice();
if bytes.len() == MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN {
Some(bytes.to_vec())
} else {
None
}
}
fn gen_x25519mlkem768_client_key_share(
rng: &SecureRandom,
deterministic: bool,
seed: &str,
) -> Option<Vec<u8>> {
let mlkem_key =
gen_mlkem768_client_encapsulation_key(rng, deterministic, &format!("{seed}:mlkem768"))?;
let x25519_key = gen_key_share(rng, deterministic, &format!("{seed}:x25519"));
let mut key_share =
Vec::with_capacity(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN + x25519_key.len());
key_share.extend_from_slice(&mlkem_key);
key_share.extend_from_slice(&x25519_key);
Some(key_share)
}
fn push_client_key_share_entry(keyshare: &mut Vec<u8>, group: u16, key: &[u8]) {
keyshare.extend_from_slice(&group.to_be_bytes());
keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes());
keyshare.extend_from_slice(key);
}
fn build_client_hello(
sni: &str,
rng: &SecureRandom,
@@ -597,16 +652,20 @@ fn build_client_hello(
push_extension(0x002d, &[0x01, 0x01]);
}
// key_share (x25519)
let key = gen_key_share(
rng,
deterministic,
&format!("tls-fetch-keyshare:{sni}:{}", profile.as_str()),
);
let mut keyshare = Vec::with_capacity(4 + key.len());
keyshare.extend_from_slice(&0x001du16.to_be_bytes());
keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes());
keyshare.extend_from_slice(&key);
// key_share
let key_share_seed = format!("tls-fetch-keyshare:{sni}:{}", profile.as_str());
let mut keyshare = Vec::new();
if matches!(
profile,
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike
) {
if let Some(key) = gen_x25519mlkem768_client_key_share(rng, deterministic, &key_share_seed)
{
push_client_key_share_entry(&mut keyshare, TLS_NAMED_GROUP_X25519MLKEM768, &key);
}
}
let key = gen_key_share(rng, deterministic, &key_share_seed);
push_client_key_share_entry(&mut keyshare, TLS_NAMED_GROUP_X25519, &key);
let mut keyshare_ext = Vec::with_capacity(2 + keyshare.len());
keyshare_ext.extend_from_slice(&(keyshare.len() as u16).to_be_bytes());
keyshare_ext.extend_from_slice(&keyshare);
@@ -776,6 +835,7 @@ fn derive_behavior_profile(records: &[(u8, Vec<u8>)]) -> TlsBehaviorProfile {
app_data_record_sizes,
ticket_record_sizes,
source: TlsProfileSource::Raw,
..TlsBehaviorProfile::default()
}
}
@@ -1025,24 +1085,26 @@ where
}
let mut server_hello = None;
let mut server_hello_record_len = 0usize;
for (t, body) in &records {
if *t == TLS_RECORD_HANDSHAKE && server_hello.is_none() {
server_hello = parse_server_hello(body);
server_hello_record_len = body.len();
}
}
let parsed = server_hello.ok_or_else(|| anyhow!("ServerHello not received"))?;
let behavior_profile = derive_behavior_profile(&records);
let mut behavior_profile = derive_behavior_profile(&records);
behavior_profile.server_hello_record_len = server_hello_record_len;
behavior_profile.refresh_server_hello_summary(&parsed);
let mut app_sizes = behavior_profile.app_data_record_sizes.clone();
app_sizes.extend_from_slice(&behavior_profile.ticket_record_sizes);
let total_app_data_len = app_sizes.iter().sum::<usize>().max(1024);
let app_data_records_sizes = behavior_profile
.app_data_record_sizes
.first()
.copied()
.or_else(|| behavior_profile.ticket_record_sizes.first().copied())
.map(|size| vec![size])
.unwrap_or_else(|| vec![total_app_data_len]);
let app_data_records_sizes = if app_sizes.is_empty() {
vec![total_app_data_len]
} else {
app_sizes
};
Ok(TlsFetchResult {
server_hello_parsed: parsed,
@@ -1212,6 +1274,7 @@ where
app_data_record_sizes: app_data_records_sizes,
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Rustls,
..TlsBehaviorProfile::default()
},
cert_info,
cert_payload,
@@ -1411,6 +1474,8 @@ pub async fn fetch_real_tls_with_strategy(
raw.cert_info = rustls.cert_info;
raw.cert_payload = rustls.cert_payload;
raw.behavior_profile.source = TlsProfileSource::Merged;
raw.behavior_profile
.refresh_server_hello_summary(&raw.server_hello_parsed);
debug!(sni = %sni, "Fetched TLS metadata via adaptive raw probe + rustls cert chain");
Ok(raw)
} else {
@@ -1462,9 +1527,10 @@ mod tests {
use std::time::{Duration, Instant};
use super::{
ProfileCacheValue, TlsFetchStrategy, build_client_hello, build_tls_fetch_proxy_header,
derive_behavior_profile, encode_tls13_certificate_message, fetch_via_rustls_stream,
order_profiles, profile_alpn, profile_cache, profile_cache_key,
MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN, ProfileCacheValue, TLS_NAMED_GROUP_X25519,
TLS_NAMED_GROUP_X25519MLKEM768, TlsFetchStrategy, X25519_KEY_SHARE_LEN, build_client_hello,
build_tls_fetch_proxy_header, derive_behavior_profile, encode_tls13_certificate_message,
fetch_via_rustls_stream, order_profiles, profile_alpn, profile_cache, profile_cache_key,
};
use crate::config::TlsFetchProfile;
use crate::crypto::SecureRandom;
@@ -1790,11 +1856,40 @@ mod tests {
key_share_data.len() - 2,
"key_share list length mismatch"
);
let group = u16::from_be_bytes([key_share_data[2], key_share_data[3]]);
let key_len = u16::from_be_bytes([key_share_data[4], key_share_data[5]]) as usize;
let key = &key_share_data[6..6 + key_len];
assert_eq!(group, 0x001d, "key_share group must be x25519");
assert_eq!(key_len, 32, "x25519 key length must be 32");
let mut pos = 2usize;
let hybrid_group = u16::from_be_bytes([key_share_data[pos], key_share_data[pos + 1]]);
let hybrid_len =
u16::from_be_bytes([key_share_data[pos + 2], key_share_data[pos + 3]]) as usize;
pos += 4;
let hybrid_key = &key_share_data[pos..pos + hybrid_len];
pos += hybrid_len;
assert_eq!(
hybrid_group, TLS_NAMED_GROUP_X25519MLKEM768,
"first key_share group must be X25519MLKEM768"
);
assert_eq!(
hybrid_len,
MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN + X25519_KEY_SHARE_LEN,
"hybrid key length must match X25519MLKEM768"
);
assert!(
hybrid_key.iter().any(|b| *b != 0),
"hybrid key must not be all zero"
);
let group = u16::from_be_bytes([key_share_data[pos], key_share_data[pos + 1]]);
let key_len =
u16::from_be_bytes([key_share_data[pos + 2], key_share_data[pos + 3]]) as usize;
pos += 4;
let key = &key_share_data[pos..pos + key_len];
assert_eq!(
group, TLS_NAMED_GROUP_X25519,
"second key_share group must be x25519"
);
assert_eq!(
key_len, X25519_KEY_SHARE_LEN,
"x25519 key length must be 32"
);
assert!(
key.iter().any(|b| *b != 0),
"x25519 key must not be all zero"
@@ -4,7 +4,9 @@ use crate::crypto::SecureRandom;
use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
};
use crate::protocol::tls::ClientHelloTlsVersion;
use crate::protocol::tls::{
ClientHelloTlsVersion, ServerHelloKeyShare, TLS_NAMED_GROUP_X25519MLKEM768,
};
use crate::tls_front::emulator::build_emulated_server_hello;
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource,
@@ -29,6 +31,7 @@ fn make_cached() -> CachedTlsData {
app_data_record_sizes: vec![1200, 900],
ticket_record_sizes: vec![220, 180],
source: TlsProfileSource::Merged,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),
@@ -52,6 +55,10 @@ fn record_lengths_by_type(response: &[u8], wanted_type: u8) -> Vec<usize> {
out
}
fn test_server_key_share() -> ServerHelloKeyShare {
ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519MLKEM768, vec![0x42; 1120])
}
#[test]
fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibility() {
let cached = make_cached();
@@ -65,6 +72,8 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -89,6 +98,8 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() {
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
@@ -98,6 +109,36 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() {
assert_eq!(app_records, vec![1200]);
}
#[test]
fn emulated_server_hello_keeps_default_profile_primary_app_data_single() {
let mut cached = make_cached();
cached.behavior_profile.source = TlsProfileSource::Default;
cached.behavior_profile.app_data_record_sizes.clear();
cached.behavior_profile.ticket_record_sizes.clear();
cached.app_data_records_sizes = vec![2048, 1024];
cached.total_app_data_len = 5000;
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x85; 32],
&[0x86; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
);
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
assert_eq!(app_records.len(), 1);
assert!(app_records[0] >= 64);
}
#[test]
fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() {
let cached = make_cached();
@@ -111,6 +152,8 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() {
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
2,
+17 -4
View File
@@ -4,7 +4,9 @@ use crate::crypto::SecureRandom;
use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
};
use crate::protocol::tls::ClientHelloTlsVersion;
use crate::protocol::tls::{
ClientHelloTlsVersion, ServerHelloKeyShare, TLS_NAMED_GROUP_X25519MLKEM768,
};
use crate::tls_front::emulator::build_emulated_server_hello;
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
@@ -29,6 +31,7 @@ fn make_cached(cert_payload: Option<crate::tls_front::types::TlsCertPayload>) ->
app_data_record_sizes: vec![64],
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),
@@ -44,6 +47,10 @@ fn first_app_data_payload(response: &[u8]) -> &[u8] {
&response[app_start + 5..app_start + 5 + app_len]
}
fn test_server_key_share() -> ServerHelloKeyShare {
ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519MLKEM768, vec![0x42; 1120])
}
#[test]
fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
let cached = make_cached(None);
@@ -58,6 +65,8 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
true,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
Some(oversized_alpn),
0,
@@ -84,7 +93,7 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
}
#[test]
fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() {
fn emulated_server_hello_keeps_alpn_marker_out_of_appdata() {
let cached = make_cached(None);
let rng = SecureRandom::new();
@@ -96,6 +105,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() {
true,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
Some(b"h2".to_vec()),
0,
@@ -104,8 +115,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() {
let payload = first_app_data_payload(&response);
let expected = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2'];
assert!(
payload.starts_with(&expected),
"when body has enough capacity, emulated first application record must include full ALPN marker"
!payload.starts_with(&expected),
"emulated ApplicationData must not expose plaintext ALPN marker bytes"
);
}
@@ -126,6 +137,8 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() {
true,
true,
ClientHelloTlsVersion::Tls12,
[0x13, 0x01],
&test_server_key_share(),
&rng,
Some(b"h2".to_vec()),
0,
+249
View File
@@ -1,6 +1,14 @@
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
const EXT_ALPN: u16 = 0x0010;
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
const EXT_KEY_SHARE: u16 = 0x0033;
const TLS_LEGACY_SERVER_HELLO_VERSION: [u8; 2] = [0x03, 0x03];
const TLS_VERSION_13: [u8; 2] = [0x03, 0x04];
const TLS_NAMED_GROUP_X25519: u16 = 0x001d;
const TLS_NAMED_GROUP_X25519MLKEM768: u16 = 0x11ec;
/// Parsed representation of an unencrypted TLS ServerHello.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedServerHello {
@@ -19,6 +27,96 @@ pub struct TlsExtension {
pub data: Vec<u8>,
}
impl ParsedServerHello {
/// Return the TLS record body length that would contain this ServerHello.
pub(crate) fn record_body_len(&self) -> usize {
let extensions_len = self
.extensions
.iter()
.map(|extension| 4 + extension.data.len())
.sum::<usize>();
4 + 2 + 32 + 1 + self.session_id.len() + 2 + 1 + 2 + extensions_len
}
/// Return visible ServerHello extension types in wire order.
pub(crate) fn extension_types(&self) -> Vec<u16> {
self.extensions
.iter()
.map(|extension| extension.ext_type)
.collect()
}
/// Return a replay-safe ServerHello key_share group when the extension is well-formed.
pub(crate) fn key_share_group(&self) -> Option<u16> {
self.extensions
.iter()
.find(|extension| extension.ext_type == EXT_KEY_SHARE)
.and_then(|extension| parse_key_share_group(&extension.data))
}
/// Return true when the cached ServerHello can safely drive visible TLS 1.3 replay.
pub(crate) fn is_replay_safe_tls13_shape(&self, record_body_len: usize) -> bool {
if self.version != TLS_LEGACY_SERVER_HELLO_VERSION
|| self.compression != 0
|| self.session_id.len() > 32
|| !is_supported_tls13_cipher_suite(self.cipher_suite)
{
return false;
}
if record_body_len != 0 && record_body_len != self.record_body_len() {
return false;
}
let mut saw_supported_versions = false;
let mut saw_key_share = false;
for extension in &self.extensions {
match extension.ext_type {
EXT_SUPPORTED_VERSIONS => {
if saw_supported_versions || extension.data.as_slice() != TLS_VERSION_13 {
return false;
}
saw_supported_versions = true;
}
EXT_KEY_SHARE => {
if saw_key_share || parse_key_share_group(&extension.data).is_none() {
return false;
}
saw_key_share = true;
}
EXT_ALPN => {
return false;
}
_ => {}
}
}
saw_supported_versions && saw_key_share
}
}
fn is_supported_tls13_cipher_suite(cipher_suite: [u8; 2]) -> bool {
matches!(u16::from_be_bytes(cipher_suite), 0x1301 | 0x1302 | 0x1303)
}
fn parse_key_share_group(data: &[u8]) -> Option<u16> {
if data.len() < 4 {
return None;
}
let group = u16::from_be_bytes([data[0], data[1]]);
let key_exchange_len = u16::from_be_bytes([data[2], data[3]]) as usize;
if data.len() != 4 + key_exchange_len {
return None;
}
match group {
TLS_NAMED_GROUP_X25519 | TLS_NAMED_GROUP_X25519MLKEM768 => Some(group),
_ => None,
}
}
/// Basic certificate metadata (optional, informative).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedCertificateInfo {
@@ -54,6 +152,19 @@ pub enum TlsProfileSource {
Merged,
}
/// DPI-facing quality class of a cached TLS front profile.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TlsProfileQuality {
/// No raw origin ServerHello shape is available.
#[default]
Fallback,
/// Raw origin ServerHello was captured, but encrypted flight shape is incomplete.
RawPartial,
/// Raw origin ServerHello and encrypted flight record sizes were captured.
RawStrict,
}
/// Coarse-grained TLS response behavior captured per SNI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsBehaviorProfile {
@@ -69,6 +180,18 @@ pub struct TlsBehaviorProfile {
/// Source of this behavior profile.
#[serde(default)]
pub source: TlsProfileSource,
/// DPI-facing quality of this profile.
#[serde(default)]
pub quality: TlsProfileQuality,
/// Captured ServerHello TLS record body length.
#[serde(default)]
pub server_hello_record_len: usize,
/// Captured visible ServerHello extension types in wire order.
#[serde(default)]
pub server_hello_extension_types: Vec<u16>,
/// Captured ServerHello key_share group when replay-safe.
#[serde(default)]
pub server_hello_key_share_group: Option<u16>,
}
fn default_change_cipher_spec_count() -> u8 {
@@ -82,10 +205,54 @@ impl Default for TlsBehaviorProfile {
app_data_record_sizes: Vec::new(),
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
quality: TlsProfileQuality::Fallback,
server_hello_record_len: 0,
server_hello_extension_types: Vec::new(),
server_hello_key_share_group: None,
}
}
}
impl TlsBehaviorProfile {
/// Refresh cached visible ServerHello summary fields and quality.
pub(crate) fn refresh_server_hello_summary(&mut self, server_hello: &ParsedServerHello) {
let mut has_replay_safe_server_hello = false;
if matches!(
self.source,
TlsProfileSource::Raw | TlsProfileSource::Merged
) {
if self.server_hello_record_len == 0 {
self.server_hello_record_len = server_hello.record_body_len();
}
self.server_hello_extension_types = server_hello.extension_types();
self.server_hello_key_share_group = server_hello.key_share_group();
has_replay_safe_server_hello =
server_hello.is_replay_safe_tls13_shape(self.server_hello_record_len);
} else {
self.server_hello_record_len = 0;
self.server_hello_extension_types.clear();
self.server_hello_key_share_group = None;
}
self.refresh_quality(has_replay_safe_server_hello);
}
/// Recompute the profile quality from current source and record-size evidence.
fn refresh_quality(&mut self, has_replay_safe_server_hello: bool) {
let has_raw_server_hello = matches!(
self.source,
TlsProfileSource::Raw | TlsProfileSource::Merged
) && has_replay_safe_server_hello;
self.quality = if has_raw_server_hello && !self.app_data_record_sizes.is_empty() {
TlsProfileQuality::RawStrict
} else if has_raw_server_hello {
TlsProfileQuality::RawPartial
} else {
TlsProfileQuality::Fallback
};
}
}
/// Cached data per SNI used by the emulator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedTlsData {
@@ -122,6 +289,34 @@ pub struct TlsFetchResult {
mod tests {
use super::*;
fn tls13_key_share_extension() -> TlsExtension {
let mut data = Vec::new();
data.extend_from_slice(&TLS_NAMED_GROUP_X25519.to_be_bytes());
data.extend_from_slice(&32u16.to_be_bytes());
data.resize(36, 0x42);
TlsExtension {
ext_type: EXT_KEY_SHARE,
data,
}
}
fn replay_safe_server_hello() -> ParsedServerHello {
ParsedServerHello {
version: TLS_LEGACY_SERVER_HELLO_VERSION,
random: [0u8; 32],
session_id: vec![0x11; 32],
cipher_suite: [0x13, 0x01],
compression: 0,
extensions: vec![
TlsExtension {
ext_type: EXT_SUPPORTED_VERSIONS,
data: TLS_VERSION_13.to_vec(),
},
tls13_key_share_extension(),
],
}
}
#[test]
fn cached_tls_data_deserializes_without_behavior_profile() {
let json = r#"
@@ -147,5 +342,59 @@ mod tests {
assert!(cached.behavior_profile.app_data_record_sizes.is_empty());
assert!(cached.behavior_profile.ticket_record_sizes.is_empty());
assert_eq!(cached.behavior_profile.source, TlsProfileSource::Default);
assert_eq!(cached.behavior_profile.quality, TlsProfileQuality::Fallback);
}
#[test]
fn replay_safe_raw_server_hello_with_app_data_is_raw_strict() {
let server_hello = replay_safe_server_hello();
let mut behavior = TlsBehaviorProfile {
source: TlsProfileSource::Raw,
app_data_record_sizes: vec![1200],
..TlsBehaviorProfile::default()
};
behavior.refresh_server_hello_summary(&server_hello);
assert_eq!(behavior.quality, TlsProfileQuality::RawStrict);
assert_eq!(
behavior.server_hello_extension_types,
vec![EXT_SUPPORTED_VERSIONS, EXT_KEY_SHARE]
);
assert_eq!(
behavior.server_hello_key_share_group,
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn replay_safe_raw_server_hello_without_app_data_is_raw_partial() {
let server_hello = replay_safe_server_hello();
let mut behavior = TlsBehaviorProfile {
source: TlsProfileSource::Raw,
..TlsBehaviorProfile::default()
};
behavior.refresh_server_hello_summary(&server_hello);
assert_eq!(behavior.quality, TlsProfileQuality::RawPartial);
}
#[test]
fn malformed_raw_server_hello_is_fallback_quality() {
let mut server_hello = replay_safe_server_hello();
server_hello.extensions.push(TlsExtension {
ext_type: EXT_ALPN,
data: vec![0x00, 0x03, 0x02, b'h', b'2'],
});
let mut behavior = TlsBehaviorProfile {
source: TlsProfileSource::Raw,
app_data_record_sizes: vec![1200],
..TlsBehaviorProfile::default()
};
behavior.refresh_server_hello_summary(&server_hello);
assert_eq!(behavior.quality, TlsProfileQuality::Fallback);
}
}
+63 -17
View File
@@ -8,6 +8,7 @@ use std::time::{Duration, Instant};
use bytes::BytesMut;
use rand::RngExt;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TrySendError;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
@@ -26,6 +27,7 @@ const ME_ACTIVE_PING_JITTER_SECS: i64 = 5;
const ME_IDLE_KEEPALIVE_MAX_SECS: u64 = 5;
const ME_RPC_PROXY_REQ_RESPONSE_WAIT_MS: u64 = 700;
const ME_PING_TRACKER_CLEANUP_EVERY: u32 = 32;
const ME_SERVICE_SIGNAL_SEND_TIMEOUT_MS: u64 = 50;
#[derive(Clone, Copy)]
enum WriterTeardownMode {
@@ -45,6 +47,11 @@ enum WriterLifecycleExit {
Cancelled,
}
enum ServiceWriterCommandSendError {
Closed,
TimedOut,
}
async fn writer_command_loop(
mut rx: mpsc::Receiver<WriterCommand>,
mut rpc_writer: RpcWriter,
@@ -52,6 +59,8 @@ async fn writer_command_loop(
) -> Result<()> {
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => return Ok(()),
cmd = rx.recv() => {
match cmd {
Some(WriterCommand::Data(payload)) => {
@@ -69,7 +78,27 @@ async fn writer_command_loop(
Some(WriterCommand::Close) | None => return Ok(()),
}
}
_ = cancel.cancelled() => return Ok(()),
}
}
}
async fn send_service_writer_command(
tx: &mpsc::Sender<WriterCommand>,
cmd: WriterCommand,
) -> std::result::Result<(), ServiceWriterCommandSendError> {
match tx.try_send(cmd) {
Ok(()) => Ok(()),
Err(TrySendError::Closed(_)) => Err(ServiceWriterCommandSendError::Closed),
Err(TrySendError::Full(cmd)) => {
let wait = Duration::from_millis(ME_SERVICE_SIGNAL_SEND_TIMEOUT_MS);
match tokio::time::timeout(wait, tx.reserve()).await {
Ok(Ok(permit)) => {
permit.send(cmd);
Ok(())
}
Ok(Err(_)) => Err(ServiceWriterCommandSendError::Closed),
Err(_) => Err(ServiceWriterCommandSendError::TimedOut),
}
}
}
}
@@ -108,6 +137,7 @@ async fn ping_loop(
Duration::from_secs(wait)
};
tokio::select! {
biased;
_ = cancel_ping_token.cancelled() => return,
_ = tokio::time::sleep(startup_jitter) => {}
}
@@ -131,6 +161,7 @@ async fn ping_loop(
Duration::from_secs(secs)
};
tokio::select! {
biased;
_ = cancel_ping_token.cancelled() => return,
_ = tokio::time::sleep(wait) => {}
}
@@ -151,14 +182,24 @@ async fn ping_loop(
}
ping_id = ping_id.wrapping_add(1);
stats_ping.increment_me_keepalive_sent();
if tx_ping
.send(WriterCommand::ControlAndFlush(payload))
.await
.is_err()
if let Err(error) =
send_service_writer_command(&tx_ping, WriterCommand::ControlAndFlush(payload)).await
{
{
let mut tracker = ping_tracker_ping.lock().await;
tracker.remove(&sent_id);
}
stats_ping.increment_me_keepalive_failed();
debug!("ME ping failed, removing dead writer");
return;
match error {
ServiceWriterCommandSendError::Closed => {
debug!("ME ping failed, removing dead writer");
return;
}
ServiceWriterCommandSendError::TimedOut => {
debug!("ME ping skipped: writer command channel is full");
continue;
}
}
}
}
}
@@ -191,6 +232,7 @@ async fn rpc_proxy_req_signal_loop(
};
tokio::select! {
biased;
_ = cancel_signal.cancelled() => return,
_ = tokio::time::sleep(Duration::from_millis(startup_jitter_ms)) => {}
}
@@ -207,6 +249,7 @@ async fn rpc_proxy_req_signal_loop(
};
tokio::select! {
biased;
_ = cancel_signal.cancelled() => return,
_ = tokio::time::sleep(wait) => {}
}
@@ -233,14 +276,15 @@ async fn rpc_proxy_req_signal_loop(
meta.proto_flags,
);
if tx_signal
.send(WriterCommand::DataAndFlush(payload))
.await
.is_err()
if let Err(error) =
send_service_writer_command(&tx_signal, WriterCommand::DataAndFlush(payload)).await
{
stats_signal.increment_me_rpc_proxy_req_signal_failed_total();
let _ = pool.registry.unregister(conn_id).await;
return;
match error {
ServiceWriterCommandSendError::Closed => return,
ServiceWriterCommandSendError::TimedOut => continue,
}
}
stats_signal.increment_me_rpc_proxy_req_signal_sent_total();
@@ -258,14 +302,16 @@ async fn rpc_proxy_req_signal_loop(
let close_payload = build_control_payload(RPC_CLOSE_EXT_U32, conn_id);
if tx_signal
.send(WriterCommand::ControlAndFlush(close_payload))
.await
.is_err()
if let Err(error) =
send_service_writer_command(&tx_signal, WriterCommand::ControlAndFlush(close_payload))
.await
{
stats_signal.increment_me_rpc_proxy_req_signal_failed_total();
let _ = pool.registry.unregister(conn_id).await;
return;
match error {
ServiceWriterCommandSendError::Closed => return,
ServiceWriterCommandSendError::TimedOut => continue,
}
}
stats_signal.increment_me_rpc_proxy_req_signal_close_sent_total();
+3 -2
View File
@@ -242,6 +242,7 @@ pub(crate) async fn reader_loop(
let mut raw = enc_leftover;
let mut expected_seq: i32 = 0;
let mut data_route_queue_full_streak = HashMap::<u64, u8>::new();
let mut tmp = [0u8; 65_536];
let mut fairness = WorkerFairnessState::new(
WorkerFairnessConfig {
worker_id: (writer_id as u16).saturating_add(1),
@@ -263,18 +264,18 @@ pub(crate) async fn reader_loop(
let fairshare_enabled = route_fairshare_enabled.load(Ordering::Relaxed);
fairness.set_backpressure_enabled(backpressure_enabled);
let fairness_has_backlog = should_schedule_fairness_retry(&fairness_snapshot);
let mut tmp = [0u8; 65_536];
let backlog_retry_enabled = fairness_has_backlog;
let backlog_retry_delay =
fairness_retry_delay(reader_route_data_wait_ms.load(Ordering::Relaxed));
let mut retry_only = false;
let n = tokio::select! {
biased;
_ = cancel.cancelled() => return Ok(()),
res = rd.read(&mut tmp) => res.map_err(ProxyError::Io)?,
_ = tokio::time::sleep(backlog_retry_delay), if backlog_retry_enabled => {
retry_only = true;
0usize
},
_ = cancel.cancelled() => return Ok(()),
};
if retry_only {
let route_wait_ms = reader_route_data_wait_ms.load(Ordering::Relaxed);
+8 -6
View File
@@ -77,26 +77,24 @@ struct HotBindingTable {
struct BindingState {
inner: Mutex<BindingInner>,
writer_idle_since_epoch_secs: DashMap<u64, u64>,
bound_clients_by_writer: DashMap<u64, usize>,
active_sessions_by_target_dc: DashMap<i16, usize>,
last_meta_for_writer: DashMap<u64, ConnMeta>,
}
struct BindingInner {
writers: HashMap<u64, mpsc::Sender<WriterCommand>>,
writer_for_conn: HashMap<u64, u64>,
conns_for_writer: HashMap<u64, HashSet<u64>>,
meta: HashMap<u64, ConnMeta>,
last_meta_for_writer: HashMap<u64, ConnMeta>,
writer_idle_since_epoch_secs: HashMap<u64, u64>,
}
impl BindingInner {
fn new() -> Self {
Self {
writers: HashMap::new(),
writer_for_conn: HashMap::new(),
conns_for_writer: HashMap::new(),
meta: HashMap::new(),
last_meta_for_writer: HashMap::new(),
writer_idle_since_epoch_secs: HashMap::new(),
}
}
}
@@ -149,6 +147,10 @@ impl ConnRegistry {
},
binding: BindingState {
inner: Mutex::new(BindingInner::new()),
writer_idle_since_epoch_secs: DashMap::new(),
bound_clients_by_writer: DashMap::new(),
active_sessions_by_target_dc: DashMap::new(),
last_meta_for_writer: DashMap::new(),
},
next_id: AtomicU64::new(start),
route_channel_capacity,

Some files were not shown because too many files have changed in this diff Show More