Compare commits

..

78 Commits

Author SHA1 Message Date
Alexey
25847c9a00 Merge branch 'flow-sec' into fix/api-docker-connection 2026-03-17 11:24:57 +03:00
Alexey
822bcbf7a5 Update Cargo.toml 2026-03-17 11:21:35 +03:00
Alexey
b25ec97a43 Merge pull request #447 from DavidOsipov/pr-sec-1
PR-SEC-1 (WIP): Первый PR с узкой пачкой исправлений безопасности и маскировки. Упор сделан на /src/proxy
2026-03-17 11:20:36 +03:00
Frew777
bd740e6088 whitelist docker network range (172.16.0.0/12) 2026-03-17 01:45:45 +03:00
Frew777
8175df059d Update docker-compose to include logging and api port
Added API port
Added logging configuration with json-file driver.
2026-03-17 01:40:26 +03:00
David Osipov
8821e38013 feat(proxy): enhance auth probe capacity with stale entry pruning and new tests 2026-03-17 02:19:14 +04:00
David Osipov
a1caebbe6f feat(proxy): implement timeout handling for client payload reads and add corresponding tests 2026-03-17 01:53:44 +04:00
David Osipov
e0d821c6b6 Merge remote-tracking branch 'upstream/main' into pr-sec-1 2026-03-17 01:51:35 +04:00
David Osipov
205fc88718 feat(proxy): enhance logging and deduplication for unknown datacenters
- Implemented a mechanism to log unknown datacenter indices with a distinct limit to avoid excessive logging.
- Introduced tests to ensure that logging is deduplicated per datacenter index and respects the distinct limit.
- Updated the fallback logic for datacenter resolution to prevent panics when only a single datacenter is available.

feat(proxy): add authentication probe throttling

- Added a pre-authentication probe throttling mechanism to limit the rate of invalid TLS and MTProto handshake attempts.
- Introduced a backoff strategy for repeated failures and ensured that successful handshakes reset the failure count.
- Implemented tests to validate the behavior of the authentication probe under various conditions.

fix(proxy): ensure proper flushing of masked writes

- Added a flush operation after writing initial data to the mask writer to ensure data integrity.

refactor(proxy): optimize desynchronization deduplication

- Replaced the Mutex-based deduplication structure with a DashMap for improved concurrency and performance.
- Implemented a bounded cache for deduplication to limit memory usage and prevent stale entries from persisting.

test(proxy): enhance security tests for middle relay and handshake

- Added comprehensive tests for the middle relay and handshake processes, including scenarios for deduplication and authentication probe behavior.
- Ensured that the tests cover edge cases and validate the expected behavior of the system under load.
2026-03-17 01:29:30 +04:00
Alexey
95c1306166 Merge pull request #444 from Dimasssss/patch-1
Update FAQ (add max_connections)
2026-03-16 22:06:27 +03:00
Dimasssss
e1ef192c10 Update FAQ.en.md 2026-03-16 22:03:28 +03:00
Dimasssss
ee4d15fed6 Update FAQ.ru.md 2026-03-16 22:02:55 +03:00
Alexey
0040e9b6da Merge pull request #442 from telemt/bump
Update Cargo.toml
2026-03-16 21:25:44 +03:00
Alexey
2c10560795 Update Cargo.toml 2026-03-16 21:25:14 +03:00
Alexey
5eff38eb82 Merge pull request #441 from kavore/feat/configurable-max-connections
feat: configurable max_connections limit
2026-03-16 21:17:21 +03:00
kavore
b6206a6dfe feat: make max_connections configurable via [server] section
The concurrent connection limit was hardcoded to 10,000.
Add server.max_connections config option (default: 10000, 0 = unlimited).
2026-03-16 20:40:10 +03:00
David Osipov
e4a50f9286 feat(tls): add boot time timestamp constant and validation for SNI hostnames
- Introduced `BOOT_TIME_MAX_SECS` constant to define the maximum accepted boot-time timestamp.
- Updated `validate_tls_handshake_at_time` to utilize the new boot time constant for timestamp validation.
- Enhanced `extract_sni_from_client_hello` to validate SNI hostnames against specified criteria, rejecting invalid hostnames.
- Added tests to ensure proper handling of boot time timestamps and SNI validation.

feat(handshake): improve user secret decoding and ALPN enforcement

- Refactored user secret decoding to provide better error handling and logging for invalid secrets.
- Added tests for concurrent identical handshakes to ensure replay protection works as expected.
- Implemented ALPN enforcement in handshake processing, rejecting unsupported protocols and allowing valid ones.

fix(masking): implement timeout handling for masking operations

- Added timeout handling for writing proxy headers and consuming client data in masking.
- Adjusted timeout durations for testing to ensure faster feedback during unit tests.
- Introduced tests to verify behavior when masking is disabled and when proxy header writes exceed the timeout.

test(masking): add tests for slowloris connections and proxy header timeouts

- Created tests to validate that slowloris connections are closed by consume timeout when masking is disabled.
- Added a test for proxy header write timeout to ensure it returns false when the write operation does not complete.
2026-03-16 21:37:59 +04:00
David Osipov
213ce4555a Merge remote-tracking branch 'upstream/main' into pr-sec-1 2026-03-16 20:51:53 +04:00
David Osipov
5a16e68487 Enhance TLS record handling and security tests
- Enforce TLS record length constraints in client handling to comply with RFC 8446, rejecting records outside the range of 512 to 16,384 bytes.
- Update security tests to validate behavior for oversized and undersized TLS records, ensuring they are correctly masked or rejected.
- Introduce new tests to verify the handling of TLS records in both generic and client handler pipelines.
- Refactor handshake logic to enforce mode restrictions based on transport type, preventing misuse of secure tags.
- Add tests for nonce generation and encryption consistency, ensuring correct behavior for different configurations.
- Improve masking tests to ensure proper logging and detection of client types, including SSH and unknown probes.
2026-03-16 20:43:49 +04:00
David Osipov
6ffbc51fb0 security: harden handshake/masking flows and add adversarial regressions
- forward valid-TLS/invalid-MTProto clients to mask backend in both client paths\n- harden TLS validation against timing and clock edge cases\n- move replay tracking behind successful authentication to avoid cache pollution\n- tighten secret decoding and key-material handling paths\n- add dedicated security test modules for tls/client/handshake/masking\n- include production-path regression for ClientHandler fallback behavior
2026-03-16 20:04:41 +04:00
Alexey
4d8a5ca174 Merge pull request #436 from Dimasssss/patch-1
Update QUICK_START_GUIDE
2026-03-16 13:49:31 +03:00
Dimasssss
0ae67db492 Update QUICK_START_GUIDE.en.md 2026-03-16 13:40:50 +03:00
Dimasssss
c4f77814ee Update QUICK_START_GUIDE.ru.md 2026-03-16 13:40:20 +03:00
David Osipov
dcab19a64f ci: remove CI workflow changes (deferred to later PR) 2026-03-16 13:56:46 +04:00
David Osipov
f10ca192fa chore: merge upstream/main (92972ab) into pr-sec-1 2026-03-16 13:50:46 +04:00
Alexey
92972ab6bf Merge pull request #433 from Linaro1985/feat_data_path
add support for data path option
2026-03-16 10:54:35 +03:00
Maxim Anisimov
c351e08c43 add support for data path option
This commit adds support for configuring the data path via a
configuration file or command-line option. This may be useful
on systems without systemd, such as OpenWrt or Alpine Linux.

Signed-off-by: Maxim Anisimov <maxim.anisimov.ua@gmail.com>
2026-03-16 10:01:59 +03:00
Alexey
e29855c8c6 Merge pull request #432 from telemt/readme
Update README.md
2026-03-15 23:00:35 +03:00
Alexey
3634fbd7e8 Update README.md 2026-03-15 23:00:17 +03:00
Alexey
bb29797bfb Merge pull request #429 from Dimasssss/patch-1
Update FAQ.ru.md
2026-03-15 22:44:22 +03:00
Dimasssss
3d5af3d248 Update FAQ.en.md 2026-03-15 19:09:02 +03:00
Dimasssss
2d7df3da6c Update FAQ.ru.md 2026-03-15 19:06:56 +03:00
Alexey
4abc0e5134 ME Draining Writers threshold + Inherited per-user unique IP limit: merge pull request #426 from telemt/flow
ME Draining Writers threshold + Inherited per-user unique IP limit
2026-03-15 15:00:20 +03:00
Alexey
4028579068 Inherited per-user unique IP limit 2026-03-15 12:43:31 +03:00
Alexey
58f26ba8a7 Configurable ME draining writer overflow threshold 2026-03-15 12:13:46 +03:00
Alexey
2be3e4ab7f Merge pull request #423 from telemt/bump
Update Cargo.toml
2026-03-15 00:33:09 +03:00
Alexey
3d43ff6e57 Update Cargo.toml 2026-03-15 00:32:57 +03:00
Alexey
1294da586f ME Writer Rebinding - Lifecycle and Consistency fixes: merge pull request #422 from telemt/flow
ME Writer Rebinding - Lifecycle and Consistency fixes
2026-03-15 00:30:57 +03:00
Alexey
ac0698b772 ME Writer Rebinding - Lifecycle and Consistency fixes 2026-03-15 00:17:54 +03:00
David Osipov
2bd9036908 ci: add security policy, cargo-deny configuration, and audit workflow
- Add deny.toml with license/advisory policy for cargo-deny
- Add security.yml GitHub Actions workflow for automated audit
- Update rust.yml with hardened clippy lint enforcement
- Update Cargo.toml/Cargo.lock with audit-related dependency additions
- Fix clippy lint placement in config.toml (Clippy lints must not live in rustflags)

Part of PR-SEC-1: no Rust source changes, establishes CI gates for all subsequent PRs.
2026-03-15 00:30:36 +04:00
Alexey
dda31b3d2f New Hot-Reload method + TLS-F New Methods + TLS-F/TCP-S Docs: merge pull request #420 from telemt/flow
New Hot-Reload method + TLS-F New Methods + TLS-F/TCP-S Docs
2026-03-14 20:45:47 +03:00
Alexey
7d5e1cb9e8 Rename TLS-F-TCP-s.ru.md to TLS-F-TCP-S.ru.md 2026-03-14 20:42:21 +03:00
Alexey
56e38e8d00 Update TLS-F-TCP-s.ru.md 2026-03-14 20:41:14 +03:00
Alexey
4677b43c6e TLS-F New Methods 2026-03-14 20:38:24 +03:00
Alexey
4ddbb97908 Create TLS-F-TCP-s.ru.md 2026-03-14 20:29:12 +03:00
Alexey
8b0b47145d New Hot-Reload method 2026-03-14 18:54:05 +03:00
Alexey
f7e3ddcdb6 Update LICENSE 2026-03-14 16:02:40 +03:00
Alexey
af5cff3304 Merge pull request #417 from telemt/licensing-md
Update LICENSING.md
2026-03-14 15:59:35 +03:00
Alexey
cb9144bdb3 Update LICENSING.md 2026-03-14 15:59:21 +03:00
Alexey
fa82634faf Merge pull request #416 from telemt/license-1
Update LICENSE
2026-03-14 15:57:31 +03:00
Alexey
37b1a0289e Update LICENSE 2026-03-14 15:56:31 +03:00
Alexey
9be33bcf93 Merge pull request #414 from telemt/license
Update LICENSE
2026-03-14 15:27:59 +03:00
Alexey
bc9f691284 Merge branch 'license' of https://github.com/telemt/telemt into license 2026-03-14 15:23:43 +03:00
Alexey
58e5605f39 Telemt PL 3 на русском языке 2026-03-14 15:23:41 +03:00
Alexey
75a654c766 TELEMT-Lizenz 3 auf Deutsch 2026-03-14 15:23:24 +03:00
Alexey
2b058f7df7 Create LICENSE.en.md 2026-03-14 15:11:12 +03:00
Alexey
01af2999bb Update LICENSE 2026-03-14 15:10:46 +03:00
Alexey
c12d27f08a Middle-End docs 2026-03-14 15:10:07 +03:00
Alexey
5e3408e80b Update LICENSE 2026-03-14 15:08:14 +03:00
Alexey
052110618d Merge pull request #413 from telemt/no-config-full
Delete config.full.toml
2026-03-14 14:55:57 +03:00
Alexey
47b8f0f656 Delete config.full.toml 2026-03-14 14:55:48 +03:00
Alexey
67b2e25e39 Merge pull request #396 from 13werwolf13/main
systemd contrib
2026-03-14 14:54:27 +03:00
Alexey
9a08b541ed License:: merge pull request #412 from telemt/license
License
2026-03-14 14:48:06 +03:00
Alexey
04379b4374 Merge branch 'main' into license 2026-03-14 14:47:51 +03:00
Alexey
5cfb05b1f4 Update LICENSING.md 2026-03-14 14:47:21 +03:00
Alexey
aa68ce531e Update LICENSE 2026-03-14 14:42:36 +03:00
Alexey
d4ce304a37 Update LICENSE 2026-03-14 14:40:10 +03:00
Alexey
8a579d9bda Update LICENSE 2026-03-14 14:38:51 +03:00
Alexey
70cc6f22aa Update LICENSE 2026-03-14 14:32:41 +03:00
Alexey
1674ba36b2 Update LICENSE 2026-03-14 14:31:57 +03:00
Alexey
0c1a5c24d5 Update LICENSE 2026-03-14 14:27:45 +03:00
Alexey
5df08300e2 Merge pull request #411 from telemt/license-1
Update LICENSE
2026-03-14 14:08:22 +03:00
Alexey
543a87e166 Update LICENSE 2026-03-14 14:08:08 +03:00
Alexey
519c8d276b Merge pull request #410 from telemt/license
Update LICENSING.md
2026-03-14 14:03:39 +03:00
Alexey
4dc733d3e3 Create LICENSE 2026-03-14 14:03:29 +03:00
Alexey
4506f38bfb Update LICENSING.md 2026-03-14 14:02:12 +03:00
Дмитрий Марков
5073248911 systemd contrib, add sysuser & tmpfiles configs, fix service 2026-03-12 12:47:03 +05:00
Дмитрий Марков
ae72e6f356 systemd contrib, add sysuser & tmpfiles configs, fix service 2026-03-12 12:26:23 +05:00
64 changed files with 8345 additions and 1596 deletions

15
.cargo/deny.toml Normal file
View File

@@ -0,0 +1,15 @@
[bans]
multiple-versions = "deny"
wildcards = "allow"
highlight = "all"
# Explicitly flag the weak cryptography so the agent is forced to justify its existence
[[bans.skip]]
name = "md-5"
version = "*"
reason = "MUST VERIFY: Only allowed for legacy checksums, never for security."
[[bans.skip]]
name = "sha1"
version = "*"
reason = "MUST VERIFY: Only allowed for backwards compatibility."

View File

@@ -5,6 +5,22 @@ Your responses are precise, minimal, and architecturally sound. You are working
---
### Context: The Telemt Project
You are working on **Telemt**, a high-performance, production-grade Telegram MTProxy implementation written in Rust. It is explicitly designed to operate in highly hostile network environments and evade advanced network censorship.
**Adversarial Threat Model:**
The proxy operates under constant surveillance by DPI (Deep Packet Inspection) systems and active scanners (state firewalls, mobile operator fraud controls). These entities actively probe IPs, analyze protocol handshakes, and look for known proxy signatures to block or throttle traffic.
**Core Architectural Pillars:**
1. **TLS-Fronting (TLS-F) & TCP-Splitting (TCP-S):** To the outside world, Telemt looks like a standard TLS server. If a client presents a valid MTProxy key, the connection is handled internally. If a censor's scanner, web browser, or unauthorized crawler connects, Telemt seamlessly splices the TCP connection (L4) to a real, legitimate HTTPS fallback server (e.g., Nginx) without modifying the `ClientHello` or terminating the TLS handshake.
2. **Middle-End (ME) Orchestration:** A highly concurrent, generation-based pool managing upstream connections to Telegram Datacenters (DCs). It utilizes an **Adaptive Floor** (dynamically scaling writer connections based on traffic), **Hardswaps** (zero-downtime pool reconfiguration), and **STUN/NAT** reflection mechanisms.
3. **Strict KDF Routing:** Cryptographic Key Derivation Functions (KDF) in this protocol strictly rely on the exact pairing of Source IP/Port and Destination IP/Port. Deviations or missing port logic will silently break the MTProto handshake.
4. **Data Plane vs. Control Plane Isolation:** The Data Plane (readers, writers, payload relay, TCP splicing) must remain strictly non-blocking, zero-allocation in hot paths, and highly resilient to network backpressure. The Control Plane (API, metrics, pool generation swaps, config reloads) orchestrates the state asynchronously without stalling the Data Plane.
Any modification you make must preserve Telemt's invisibility to censors, its strict memory-safety invariants, and its hot-path throughput.
### 0. Priority Resolution — Scope Control
This section resolves conflicts between code quality enforcement and scope limitation.

10
Cargo.lock generated
View File

@@ -2025,6 +2025,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -2087,7 +2093,7 @@ dependencies = [
[[package]]
name = "telemt"
version = "3.3.15"
version = "3.3.19"
dependencies = [
"aes",
"anyhow",
@@ -2127,6 +2133,8 @@ dependencies = [
"sha1",
"sha2",
"socket2 0.5.10",
"static_assertions",
"subtle",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",

View File

@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.3.17"
version = "3.3.20"
edition = "2024"
[dependencies]
@@ -22,6 +22,8 @@ hmac = "0.12"
crc32fast = "1.4"
crc32c = "0.6"
zeroize = { version = "1.8", features = ["derive"] }
subtle = "2.6"
static_assertions = "1.1"
# Network
socket2 = { version = "0.5", features = ["all"] }

165
LICENSE Normal file
View File

@@ -0,0 +1,165 @@
###### TELEMT Public License 3 ######
##### Copyright (c) 2026 Telemt #####
Permission is hereby granted, free of charge, to any person obtaining a copy
of this Software and associated documentation files (the "Software"),
to use, reproduce, modify, prepare derivative works of, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, provided that all
copyright notices, license terms, and conditions set forth in this License
are preserved and complied with.
### Official Translations
The canonical version of this License is the English version.
Official translations are provided for informational purposes only
and for convenience, and do not have legal force. In case of any
discrepancy, the English version of this License shall prevail.
Available versions:
- English in Markdown: docs/LICENSE/LICENSE.md
- German: docs/LICENSE/LICENSE.de.md
- Russian: docs/LICENSE/LICENSE.ru.md
### License Versioning Policy
This License is version 3 of the TELEMT Public License.
Each version of the Software is licensed under the License that
accompanies its corresponding source code distribution.
Future versions of the Software may be distributed under a different
version of the TELEMT Public License or under a different license,
as determined by the Telemt maintainers.
Any such change of license applies only to the versions of the
Software distributed with the new license and SHALL NOT retroactively
affect any previously released versions of the Software.
Recipients of the Software are granted rights only under the License
provided with the version of the Software they received.
Redistributions of the Software, including Modified Versions, MUST
preserve the copyright notices, license text, and conditions of this
License for all portions of the Software derived from Telemt.
Additional terms or licenses may be applied to modifications or
additional code added by a redistributor, provided that such terms
do not restrict or alter the rights granted under this License for
the original Telemt Software.
Nothing in this section limits the rights granted under this License
for versions of the Software already released.
### Definitions
For the purposes of this License:
- "Software" means the Telemt software, including source code, documentation,
and any associated files distributed under this License.
- "Contributor" means any person or entity that submits code, patches,
documentation, or other contributions to the Software that are accepted
into the Software by the maintainers.
- "Contribution" means any work of authorship intentionally submitted
to the Software for inclusion in the Software.
- "Modified Version" means any version of the Software that has been
changed, adapted, extended, or otherwise modified from the original
Software.
- "Maintainers" means the individuals or entities responsible for
the official Telemt project and its releases.
#### 1 Attribution
Redistributions of the Software, in source or binary form, MUST RETAIN the
above copyright notice, this license text, and any existing attribution
notices.
#### 2 Modification Notice
If you modify the Software, you MUST clearly state that the Software has been
modified and include a brief description of the changes made.
Modified versions MUST NOT be presented as the original Telemt.
#### 3 Trademark and Branding
This license DOES NOT grant permission to use the name "Telemt",
the Telemt logo, or any Telemt trademarks or branding.
Redistributed or modified versions of the Software MAY NOT use the Telemt
name in a way that suggests endorsement or official origin without explicit
permission from the Telemt maintainers.
Use of the name "Telemt" to describe a modified version of the Software
is permitted only if the modified version is clearly identified as a
modified or unofficial version.
Any distribution that could reasonably confuse users into believing that
the software is an official Telemt release is prohibited.
#### 4 Binary Distribution Transparency
If you distribute compiled binaries of the Software,
you are ENCOURAGED to provide access to the corresponding
source code and build instructions where reasonably possible.
This helps preserve transparency and allows recipients to verify the
integrity and reproducibility of distributed builds.
#### 5 Patent Grant and Defensive Termination Clause
Each contributor grants you a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Software.
This patent license applies only to those patent claims necessarily
infringed by the contributors contribution alone or by combination of
their contribution with the Software.
If you initiate or participate in any patent litigation, including
cross-claims or counterclaims, alleging that the Software or any
contribution incorporated within the Software constitutes patent
infringement, then **all rights granted to you under this license shall
terminate immediately** as of the date such litigation is filed.
Additionally, if you initiate legal action alleging that the
Software itself infringes your patent or other intellectual
property rights, then all rights granted to you under this
license SHALL TERMINATE automatically.
#### 6 Contributions
Unless you explicitly state otherwise, any Contribution intentionally
submitted for inclusion in the Software shall be licensed under the terms
of this License.
By submitting a Contribution, you grant the Telemt maintainers and all
recipients of the Software the rights described in this License with
respect to that Contribution.
#### 7 Network Use Attribution
If the Software is used to provide a publicly accessible network service,
the operator of such service SHOULD provide attribution to Telemt in at least
one of the following locations:
- service documentation
- service description
- an "About" or similar informational page
- other user-visible materials reasonably associated with the service
Such attribution MUST NOT imply endorsement by the Telemt project or its
maintainers.
#### 8 Disclaimer of Warranty and Severability Clause
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE
IF ANY PROVISION OF THIS LICENSE IS HELD TO BE INVALID OR UNENFORCEABLE,
SUCH PROVISION SHALL BE INTERPRETED TO REFLECT THE ORIGINAL INTENT
OF THE PARTIES AS CLOSELY AS POSSIBLE, AND THE REMAINING PROVISIONS
SHALL REMAIN IN FULL FORCE AND EFFECT

View File

@@ -1,17 +1,12 @@
# LICENSING
## Licenses for Versions
| Version | License |
|---------|---------------|
| 1.0 | NO LICNESE |
| 1.1 | NO LICENSE |
| 1.2 | NO LICENSE |
| 2.0 | NO LICENSE |
| 3.0 | TELEMT UL 1 |
| Version ≥ | Version ≤ | License |
|-----------|-----------|---------------|
| 1.0 | 3.3.17 | NO LICNESE |
| 3.3.18 | 3.4.0 | TELEMT PL 3 |
### License Types
- **NO LICENSE** = ***ALL RIGHT RESERVED***
- **TELEMT UL1** - work in progress license for source code of `telemt`, which encourages:
- fair use,
- contributions,
- distribution,
- but prohibits NOT mentioning the authors
- **TELEMT PL** - special Telemt Public License based on Apache License 2 principles
## [Telemt Public License 3](https://github.com/telemt/telemt/blob/main/LICENSE)

View File

@@ -19,9 +19,9 @@
### 🇷🇺 RU
#### Релиз 3.3.16
#### Релиз 3.3.15 Semistable
[3.3.16](https://github.com/telemt/telemt/releases/tag/3.3.16)!
[3.3.15](https://github.com/telemt/telemt/releases/tag/3.3.15) по итогам работы в продакшн признан одним из самых стабильных и рекомендуется к использованию, когда cutting-edge фичи некритичны!
Будем рады вашему фидбеку и предложениям по улучшению — особенно в части **API**, **статистики**, **UX**
@@ -40,9 +40,9 @@
### 🇬🇧 EN
#### Release 3.3.16
#### Release 3.3.15 Semistable
[3.3.16](https://github.com/telemt/telemt/releases/tag/3.3.16)
[3.3.15](https://github.com/telemt/telemt/releases/tag/3.3.15) is, based on the results of his work in production, recognized as one of the most stable and recommended for use when cutting-edge features are not so necessary!
We are looking forward to your feedback and improvement proposals — especially regarding **API**, **statistics**, **UX**

View File

@@ -1,697 +0,0 @@
# ==============================================================================
#
# TELEMT — Advanced Rust-based Telegram MTProto Proxy
# Full Configuration Reference
#
# This file is both a working config and a complete documentation.
# Every parameter is explained. Read it top to bottom before deploying.
#
# Quick Start:
# 1. Set [server].port to your desired port (443 recommended)
# 2. Generate a secret: openssl rand -hex 16
# 3. Put it in [access.users] under a name you choose
# 4. Set [censorship].tls_domain to a popular unblocked HTTPS site
# 5. Set your public IP in [general].middle_proxy_nat_ip
# and [general.links].public_host
# 6. Set announce IP in [[server.listeners]]
# 7. Run Telemt. It prints a tg:// link. Send it to your users.
#
# Modes of Operation:
# Direct Mode (use_middle_proxy = false)
# Connects straight to Telegram DCs via TCP. Simple, fast, low overhead.
# No ad_tag support. No CDN DC support (203, etc).
#
# Middle-Proxy Mode (use_middle_proxy = true)
# Connects to Telegram Middle-End servers via RPC protocol.
# Required for ad_tag monetization and CDN support.
# Requires proxy_secret_path and a valid public IP.
#
# ==============================================================================
# ==============================================================================
# LEGACY TOP-LEVEL FIELDS
# ==============================================================================
# Deprecated. Use [general.links].show instead.
# Accepts "*" for all users, or an array like ["alice", "bob"].
show_link = ["0"]
# Fallback Datacenter index (1-5) when a client requests an unknown DC ID.
# DC 2 is Amsterdam (Europe), closest for most CIS users.
# default_dc = 2
# ==============================================================================
# GENERAL SETTINGS
# ==============================================================================
[general]
# ------------------------------------------------------------------------------
# Core Protocol
# ------------------------------------------------------------------------------
# Coalesce the MTProto handshake and first data payload into a single TCP packet.
# Significantly reduces connection latency. No reason to disable.
fast_mode = true
# How the proxy connects to Telegram servers.
# false = Direct TCP to Telegram DCs (simple, low overhead)
# true = Middle-End RPC protocol (required for ad_tag and CDN DCs)
use_middle_proxy = true
# 32-char hex Ad-Tag from @MTProxybot for sponsored channel injection.
# Only works when use_middle_proxy = true.
# Obtain yours: message @MTProxybot on Telegram, register your proxy.
# ad_tag = "00000000000000000000000000000000"
# ------------------------------------------------------------------------------
# Middle-End Authentication
# ------------------------------------------------------------------------------
# Path to the Telegram infrastructure AES key file.
# Auto-downloaded from https://core.telegram.org/getProxySecret on first run.
# This key authenticates your proxy with Middle-End servers.
proxy_secret_path = "proxy-secret"
# ------------------------------------------------------------------------------
# Public IP Configuration (Critical for Middle-Proxy Mode)
# ------------------------------------------------------------------------------
# Your server's PUBLIC IPv4 address.
# Middle-End servers need this for the cryptographic Key Derivation Function.
# If your server has a direct public IP, set it here.
# If behind NAT (AWS, Docker, etc.), this MUST be your external IP.
# If omitted, Telemt uses STUN to auto-detect (see middle_proxy_nat_probe).
# middle_proxy_nat_ip = "203.0.113.10"
# Auto-detect public IP via STUN servers defined in [network].
# Set to false if you hardcoded middle_proxy_nat_ip above.
# Set to true if you want automatic detection.
middle_proxy_nat_probe = true
# ------------------------------------------------------------------------------
# Middle-End Connection Pool
# ------------------------------------------------------------------------------
# Number of persistent multiplexed RPC connections to ME servers.
# All client traffic is routed through these "fat pipes".
# 8 handles thousands of concurrent users comfortably.
middle_proxy_pool_size = 8
# Legacy field. Connections kept initialized but idle as warm standby.
middle_proxy_warm_standby = 16
# ------------------------------------------------------------------------------
# Middle-End Keepalive
# Telegram ME servers aggressively kill idle TCP connections.
# These settings send periodic RPC_PING frames to keep pipes alive.
# ------------------------------------------------------------------------------
me_keepalive_enabled = true
# Base interval between pings in seconds.
me_keepalive_interval_secs = 25
# Random jitter added to interval to prevent all connections pinging simultaneously.
me_keepalive_jitter_secs = 5
# Randomize ping payload bytes to prevent DPI from fingerprinting ping patterns.
me_keepalive_payload_random = true
# ------------------------------------------------------------------------------
# Client-Side Limits
# ------------------------------------------------------------------------------
# Max buffered ciphertext per client (bytes) when upstream is slow.
# Acts as backpressure to prevent memory exhaustion. 256KB is safe.
crypto_pending_buffer = 262144
# Maximum single MTProto frame size from client. 16MB is protocol standard.
max_client_frame = 16777216
# ------------------------------------------------------------------------------
# Crypto Desynchronization Logging
# Desync errors usually mean DPI/GFW is tampering with connections.
# ------------------------------------------------------------------------------
# true = full forensics (trace ID, IP hash, hex dumps) for EVERY desync event
# false = deduplicated logging, one entry per time window (prevents log spam)
# Set true if you are actively debugging DPI interference.
desync_all_full = true
# ------------------------------------------------------------------------------
# Beobachten — Built-in Honeypot / Active Probe Tracker
# Tracks IPs that fail handshakes or behave like TLS scanners.
# Output file can be fed into fail2ban or iptables for auto-blocking.
# ------------------------------------------------------------------------------
beobachten = true
# How long (minutes) to remember a suspicious IP before expiring it.
beobachten_minutes = 30
# How often (seconds) to flush tracker state to disk.
beobachten_flush_secs = 15
# File path for the tracker output.
beobachten_file = "cache/beobachten.txt"
# ------------------------------------------------------------------------------
# Hardswap — Zero-Downtime ME Pool Rotation
# When Telegram updates ME server IPs, Hardswap creates a completely new pool,
# waits until it is fully ready, migrates traffic, then kills the old pool.
# Users experience zero interruption.
# ------------------------------------------------------------------------------
hardswap = true
# ------------------------------------------------------------------------------
# ME Pool Warmup Staggering
# When creating a new pool, connections are opened one by one with delays
# to avoid a burst of SYN packets that could trigger ISP flood protection.
# ------------------------------------------------------------------------------
me_warmup_stagger_enabled = true
# Delay between each connection creation (milliseconds).
me_warmup_step_delay_ms = 500
# Random jitter added to the delay (milliseconds).
me_warmup_step_jitter_ms = 300
# ------------------------------------------------------------------------------
# ME Reconnect Backoff
# If an ME server drops the connection, Telemt retries with this strategy.
# ------------------------------------------------------------------------------
# Max simultaneous reconnect attempts per DC.
me_reconnect_max_concurrent_per_dc = 8
# Exponential backoff base (milliseconds).
me_reconnect_backoff_base_ms = 500
# Backoff ceiling (milliseconds). Will never wait longer than this.
me_reconnect_backoff_cap_ms = 30000
# Number of instant retries before switching to exponential backoff.
me_reconnect_fast_retry_count = 12
# ------------------------------------------------------------------------------
# NAT Mismatch Behavior
# If STUN-detected IP differs from local interface IP (you are behind NAT).
# false = abort ME mode (safe default)
# true = force ME mode anyway (use if you know your NAT setup is correct)
# ------------------------------------------------------------------------------
stun_iface_mismatch_ignore = false
# ------------------------------------------------------------------------------
# Logging
# ------------------------------------------------------------------------------
# File to log unknown DC requests (DC IDs outside standard 1-5).
unknown_dc_log_path = "unknown-dc.txt"
# Verbosity: "debug" | "verbose" | "normal" | "silent"
log_level = "normal"
# Disable ANSI color codes in log output (useful for file logging).
disable_colors = false
# ------------------------------------------------------------------------------
# FakeTLS Record Sizing
# Buffer small MTProto packets into larger TLS records to mimic real HTTPS.
# Real HTTPS servers send records close to MTU size (~1400 bytes).
# A stream of tiny TLS records is a strong DPI signal.
# Set to 0 to disable. Set to 1400 for realistic HTTPS emulation.
# ------------------------------------------------------------------------------
fast_mode_min_tls_record = 1400
# ------------------------------------------------------------------------------
# Periodic Updates
# ------------------------------------------------------------------------------
# How often (seconds) to re-fetch ME server lists and proxy secrets
# from core.telegram.org. Keeps your proxy in sync with Telegram infrastructure.
update_every = 300
# How often (seconds) to force a Hardswap even if the ME map is unchanged.
# Shorter intervals mean shorter-lived TCP flows, harder for DPI to profile.
me_reinit_every_secs = 600
# ------------------------------------------------------------------------------
# Hardswap Warmup Tuning
# Fine-grained control over how the new pool is warmed up before traffic switch.
# ------------------------------------------------------------------------------
me_hardswap_warmup_delay_min_ms = 1000
me_hardswap_warmup_delay_max_ms = 2000
me_hardswap_warmup_extra_passes = 3
me_hardswap_warmup_pass_backoff_base_ms = 500
# ------------------------------------------------------------------------------
# Config Update Debouncing
# Telegram sometimes pushes transient/broken configs. Debouncing requires
# N consecutive identical fetches before applying a change.
# ------------------------------------------------------------------------------
# ME server list must be identical for this many fetches before applying.
me_config_stable_snapshots = 2
# Minimum seconds between config applications.
me_config_apply_cooldown_secs = 300
# Proxy secret must be identical for this many fetches before applying.
proxy_secret_stable_snapshots = 2
# ------------------------------------------------------------------------------
# Proxy Secret Rotation
# ------------------------------------------------------------------------------
# Apply newly downloaded secrets at runtime without restart.
proxy_secret_rotate_runtime = true
# Maximum acceptable secret length (bytes). Rejects abnormally large secrets.
proxy_secret_len_max = 256
# ------------------------------------------------------------------------------
# Hardswap Drain Settings
# Controls graceful shutdown of old ME connections during pool rotation.
# ------------------------------------------------------------------------------
# Seconds to keep old connections alive for in-flight data before force-closing.
me_pool_drain_ttl_secs = 90
# Minimum ratio of healthy connections in new pool before draining old pool.
# 0.8 = at least 80% of new pool must be ready.
me_pool_min_fresh_ratio = 0.8
# Maximum seconds to wait for drain to complete before force-killing.
me_reinit_drain_timeout_secs = 120
# ------------------------------------------------------------------------------
# NTP Clock Check
# MTProto uses timestamps. Clock drift > 30 seconds breaks handshakes.
# Telemt checks on startup and warns if out of sync.
# ------------------------------------------------------------------------------
ntp_check = true
ntp_servers = ["pool.ntp.org"]
# ------------------------------------------------------------------------------
# Auto-Degradation
# If ME servers become completely unreachable (ISP blocking),
# automatically fall back to Direct Mode so users stay connected.
# ------------------------------------------------------------------------------
auto_degradation_enabled = true
# Number of DC groups that must be unreachable before triggering fallback.
degradation_min_unavailable_dc_groups = 2
# ==============================================================================
# ALLOWED CLIENT PROTOCOLS
# Only enable what you need. In censored regions, TLS-only is safest.
# ==============================================================================
[general.modes]
# Classic MTProto. Unobfuscated length prefixes. Trivially detected by DPI.
# No reason to enable unless you have ancient clients.
classic = false
# Obfuscated MTProto with randomized padding. Better than classic, but
# still detectable by statistical analysis of packet sizes.
secure = false
# FakeTLS (ee-secrets). Wraps MTProto in TLS 1.3 framing.
# To DPI, it looks like a normal HTTPS connection.
# This should be the ONLY enabled mode in censored environments.
tls = true
# ==============================================================================
# STARTUP LINK GENERATION
# Controls what tg:// invite links are printed to console on startup.
# ==============================================================================
[general.links]
# Which users to generate links for.
# "*" = all users, or an array like ["alice", "bob"].
show = "*"
# IP or domain to embed in the tg:// link.
# If omitted, Telemt uses STUN to auto-detect.
# Set this to your server's public IP or domain for reliable links.
# public_host = "proxy.example.com"
# Port to embed in the tg:// link.
# If omitted, uses [server].port.
# public_port = 443
# ==============================================================================
# NETWORK & IP RESOLUTION
# ==============================================================================
[network]
# Enable IPv4 for outbound connections to Telegram.
ipv4 = true
# Enable IPv6 for outbound connections to Telegram.
ipv6 = false
# Prefer IPv4 (4) or IPv6 (6) when both are available.
prefer = 4
# Experimental: use both IPv4 and IPv6 ME servers simultaneously.
# May improve reliability but doubles connection count.
multipath = false
# STUN servers for external IP discovery.
# Used for Middle-Proxy KDF (if nat_probe=true) and link generation.
stun_servers = [
"stun.l.google.com:5349",
"stun1.l.google.com:3478",
"stun.gmx.net:3478",
"stun.l.google.com:19302"
]
# If UDP STUN is blocked, attempt TCP-based STUN as fallback.
stun_tcp_fallback = true
# If all STUN fails, use HTTP APIs to discover public IP.
http_ip_detect_urls = [
"https://ifconfig.me/ip",
"https://api.ipify.org"
]
# Cache discovered public IP to this file to survive restarts.
cache_public_ip_path = "cache/public_ip.txt"
# ==============================================================================
# SERVER BINDING & METRICS
# ==============================================================================
[server]
# TCP port to listen on.
# 443 is recommended (looks like normal HTTPS traffic).
port = 443
# IPv4 bind address. "0.0.0.0" = all interfaces.
listen_addr_ipv4 = "0.0.0.0"
# IPv6 bind address. "::" = all interfaces.
listen_addr_ipv6 = "::"
# Unix socket listener (for reverse proxy setups with Nginx/HAProxy).
# listen_unix_sock = "/var/run/telemt.sock"
# listen_unix_sock_perm = "0660"
# Enable PROXY protocol header parsing.
# Set true ONLY if Telemt is behind HAProxy/Nginx that injects PROXY headers.
# If enabled without a proxy in front, clients will fail to connect.
proxy_protocol = false
# Prometheus metrics HTTP endpoint port.
# Uncomment to enable. Access at http://your-server:9090/metrics
# metrics_port = 9090
# IP ranges allowed to access the metrics endpoint.
metrics_whitelist = [
"127.0.0.1/32",
"::1/128"
]
# ------------------------------------------------------------------------------
# Listener Overrides
# Define explicit listeners with specific bind IPs and announce IPs.
# The announce IP is what gets embedded in tg:// links and sent to ME servers.
# You MUST set announce to your server's public IP for ME mode to work.
# ------------------------------------------------------------------------------
# [[server.listeners]]
# ip = "0.0.0.0"
# announce = "203.0.113.10"
# reuse_allow = false
# ==============================================================================
# TIMEOUTS (seconds unless noted)
# ==============================================================================
[timeouts]
# Maximum time for client to complete FakeTLS + MTProto handshake.
client_handshake = 15
# Maximum time to establish TCP connection to upstream Telegram DC.
tg_connect = 10
# TCP keepalive interval for client connections.
client_keepalive = 60
# Maximum client inactivity before dropping the connection.
client_ack = 300
# Instant retry count for a single ME endpoint before giving up on it.
me_one_retry = 3
# Timeout (milliseconds) for a single ME endpoint connection attempt.
me_one_timeout_ms = 1500
# ==============================================================================
# ANTI-CENSORSHIP / FAKETLS / MASKING
# This is where Telemt becomes invisible to Deep Packet Inspection.
# ==============================================================================
[censorship]
# ------------------------------------------------------------------------------
# TLS Domain Fronting
# The SNI (Server Name Indication) your proxy presents to connecting clients.
# Must be a popular, unblocked HTTPS website in your target country.
# DPI sees traffic to this domain. Choose carefully.
# Good choices: major CDNs, banks, government sites, search engines.
# Bad choices: obscure sites, already-blocked domains.
# ------------------------------------------------------------------------------
tls_domain = "www.google.com"
# ------------------------------------------------------------------------------
# Active Probe Masking
# When someone connects but fails the MTProto handshake (wrong secret),
# they might be an ISP active prober testing if this is a proxy.
#
# mask = false: drop the connection (prober knows something is here)
# mask = true: transparently proxy them to mask_host (prober sees a real website)
#
# With mask enabled, your server is indistinguishable from a real web server
# to anyone who doesn't have the correct secret.
# ------------------------------------------------------------------------------
mask = true
# The real web server to forward failed handshakes to.
# If omitted, defaults to tls_domain.
# mask_host = "www.google.com"
# Port on the mask host to connect to.
mask_port = 443
# Inject PROXY protocol header when forwarding to mask host.
# 0 = disabled, 1 = v1, 2 = v2. Leave disabled unless mask_host expects it.
# mask_proxy_protocol = 0
# ------------------------------------------------------------------------------
# TLS Certificate Emulation
# ------------------------------------------------------------------------------
# Size (bytes) of the locally generated fake TLS certificate.
# Only used when tls_emulation is disabled.
fake_cert_len = 2048
# KILLER FEATURE: Real-Time TLS Emulation.
# Telemt connects to tls_domain, fetches its actual TLS 1.3 certificate chain,
# and exactly replicates the byte sizes of ServerHello and Certificate records.
# Defeats DPI that uses TLS record length heuristics to detect proxies.
# Strongly recommended in censored environments.
tls_emulation = true
# Directory to cache fetched TLS certificates.
tls_front_dir = "tlsfront"
# ------------------------------------------------------------------------------
# ServerHello Timing
# Real web servers take 30-150ms to respond to ClientHello due to network
# latency and crypto processing. A proxy responding in <1ms is suspicious.
# These settings add realistic delay to mimic genuine server behavior.
# ------------------------------------------------------------------------------
# Minimum delay before sending ServerHello (milliseconds).
server_hello_delay_min_ms = 50
# Maximum delay before sending ServerHello (milliseconds).
server_hello_delay_max_ms = 150
# ------------------------------------------------------------------------------
# TLS Session Tickets
# Real TLS 1.3 servers send 1-2 NewSessionTicket messages after handshake.
# A server that sends zero tickets is anomalous and may trigger DPI flags.
# Set this to match your tls_domain's behavior (usually 2).
# ------------------------------------------------------------------------------
# tls_new_session_tickets = 0
# ------------------------------------------------------------------------------
# Full Certificate Frequency
# When tls_emulation is enabled, this controls how often (per client IP)
# to send the complete emulated certificate chain.
#
# > 0: Subsequent connections within TTL seconds get a smaller cached version.
# Saves bandwidth but creates a detectable size difference between
# first and repeat connections.
#
# = 0: Every connection gets the full certificate. More bandwidth but
# perfectly consistent behavior, no anomalies for DPI to detect.
# ------------------------------------------------------------------------------
tls_full_cert_ttl_secs = 0
# ------------------------------------------------------------------------------
# ALPN Enforcement
# Ensure ServerHello responds with the exact ALPN protocol the client requested.
# Mismatched ALPN (e.g., client asks h2, server says http/1.1) is a DPI red flag.
# ------------------------------------------------------------------------------
alpn_enforce = true
# ==============================================================================
# ACCESS CONTROL & USERS
# ==============================================================================
[access]
# ------------------------------------------------------------------------------
# Replay Attack Protection
# DPI can record a legitimate user's handshake and replay it later to probe
# whether the server is a proxy. Telemt remembers recent handshake nonces
# and rejects duplicates.
# ------------------------------------------------------------------------------
# Number of nonce slots in the replay detection buffer.
replay_check_len = 65536
# How long (seconds) to remember nonces before expiring them.
replay_window_secs = 1800
# Allow clients with incorrect system clocks to connect.
# false = reject clients with significant time skew (more secure)
# true = accept anyone regardless of clock (more permissive)
ignore_time_skew = false
# ------------------------------------------------------------------------------
# User Secrets
# Each user needs a unique 32-character hex string as their secret.
# Generate with: openssl rand -hex 16
#
# This secret is embedded in the tg:// link. Anyone with it can connect.
# Format: username = "hex_secret"
# ------------------------------------------------------------------------------
[access.users]
# alice = "0123456789abcdef0123456789abcdef"
# bob = "fedcba9876543210fedcba9876543210"
# ------------------------------------------------------------------------------
# Per-User Connection Limits
# Limits concurrent TCP connections per user to prevent secret sharing.
# Uncomment and set for each user as needed.
# ------------------------------------------------------------------------------
[access.user_max_tcp_conns]
# alice = 100
# bob = 50
# ------------------------------------------------------------------------------
# Per-User Expiration Dates
# Automatically revoke access after the specified date (ISO 8601 format).
# ------------------------------------------------------------------------------
[access.user_expirations]
# alice = "2025-12-31T23:59:59Z"
# bob = "2026-06-15T00:00:00Z"
# ------------------------------------------------------------------------------
# Per-User Data Quotas
# Maximum total bytes transferred per user. Connection refused after limit.
# ------------------------------------------------------------------------------
[access.user_data_quota]
# alice = 107374182400
# bob = 53687091200
# ------------------------------------------------------------------------------
# Per-User Unique IP Limits
# Maximum number of different IP addresses that can use this secret
# at the same time. Highly effective against secret leaking/sharing.
# Set to 1 for single-device, 2-3 for phone+desktop, etc.
# ------------------------------------------------------------------------------
[access.user_max_unique_ips]
# alice = 3
# bob = 2
# ==============================================================================
# UPSTREAM ROUTING
# Controls how Telemt connects to Telegram servers (or ME servers).
# If omitted entirely, uses the OS default route.
# ==============================================================================
# ------------------------------------------------------------------------------
# Direct upstream: use the server's own network interface.
# You can optionally bind to a specific interface or local IP.
# ------------------------------------------------------------------------------
# [[upstreams]]
# type = "direct"
# interface = "eth0"
# bind_addresses = ["192.0.2.10"]
# weight = 1
# enabled = true
# scopes = "*"
# ------------------------------------------------------------------------------
# SOCKS5 upstream: route Telegram traffic through a SOCKS5 proxy.
# Useful if your server's IP is blocked from reaching Telegram DCs.
# ------------------------------------------------------------------------------
# [[upstreams]]
# type = "socks5"
# address = "198.51.100.30:1080"
# username = "proxy-user"
# password = "proxy-pass"
# weight = 1
# enabled = true
# ==============================================================================
# DATACENTER OVERRIDES
# Force specific DC IDs to route to specific IP:Port combinations.
# DC 203 (CDN) is auto-injected by Telemt if not specified here.
# ==============================================================================
# [dc_overrides]
# "201" = "149.154.175.50:443"
# "202" = ["149.154.167.51:443", "149.154.175.100:443"]

View File

@@ -37,7 +37,7 @@ port = 443
[server.api]
enabled = true
listen = "0.0.0.0:9091"
whitelist = ["127.0.0.0/8"]
whitelist = ["127.0.0.0/8", "172.16.0.0/12"]
minimal_runtime_enabled = false
minimal_runtime_cache_ttl_ms = 1000

View File

@@ -0,0 +1,3 @@
u telemt - "telemt user" /var/lib/telemt -
g telemt - -
m telemt telemt

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Telemt
Wants=network-online.target
After=multi-user.target network.target network-online.target
[Service]
Type=simple
User=telemt
Group=telemt
WorkingDirectory=/var/lib/telemt
ExecStart=/usr/bin/telemt /etc/telemt/telemt.toml
Restart=on-failure
RestartSec=10
LimitNOFILE=65536
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1 @@
d /var/lib/telemt 700 telemt telemt

View File

@@ -6,7 +6,8 @@ services:
restart: unless-stopped
ports:
- "443:443"
- "127.0.0.1:9090:9090"
- "127.0.0.1:9090:9090" # Metrics
- "127.0.0.1:9091:9091" # API
# Allow caching 'proxy-secret' in read-only container
working_dir: /run/telemt
volumes:
@@ -28,3 +29,8 @@ services:
nofile:
soft: 65536
hard: 65536
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@@ -55,7 +55,10 @@ user2 = "00000000000000000000000000000002"
user3 = "00000000000000000000000000000003"
```
4. Save the config. Ctrl+S -> Ctrl+X. You don't need to restart telemt.
5. Get the links via `journalctl -u telemt -n -g "links" --no-pager -o cat | tac`
5. Get the links via
```bash
curl -s http://127.0.0.1:9091/v1/users | jq
```
## How to view metrics
@@ -80,6 +83,13 @@ To specify a domain in the links, add to the `[general.links]` section of the co
public_host = "proxy.example.com"
```
### Server connection limit
Limits the total number of open connections to the server:
```toml
[server]
max_connections = 10000 # 0 - unlimited, 10000 - default
```
### Upstream Manager
To specify an upstream, add to the `[[upstreams]]` section of the config.toml file:
#### Binding to IP

View File

@@ -55,7 +55,10 @@ user2 = "00000000000000000000000000000002"
user3 = "00000000000000000000000000000003"
```
4. Сохранить конфиг. Ctrl+S -> Ctrl+X. Перезапускать telemt не нужно.
5. Получить ссылки через `journalctl -u telemt -n -g "links" --no-pager -o cat | tac`
5. Получить ссылки через
```bash
curl -s http://127.0.0.1:9091/v1/users | jq
```
## Как посмотреть метрики
@@ -80,6 +83,13 @@ metrics_whitelist = ["127.0.0.1/32", "::1/128", "0.0.0.0/0"]
public_host = "proxy.example.com"
```
### Общий лимит подключений к серверу
Ограничивает общее число открытых подключений к серверу:
```toml
[server]
max_connections = 10000 # 0 - unlimited, 10000 - default
```
### Upstream Manager
Чтобы указать апстрим, добавьте в секцию `[[upstreams]]` файла config.toml:
#### Привязка к IP
@@ -110,3 +120,4 @@ password = "pass" # Password for Auth on SOCKS-server
weight = 1 # Set Weight for Scenarios
enabled = true
```

View File

@@ -0,0 +1,92 @@
# Öffentliche TELEMT-Lizenz 3
***Alle Rechte vorbehalten (c) 2026 Telemt***
Hiermit wird jeder Person, die eine Kopie dieser Software und der dazugehörigen Dokumentation (nachfolgend "Software") erhält, unentgeltlich die Erlaubnis erteilt, die Software ohne Einschränkungen zu nutzen, einschließlich des Rechts, die Software zu verwenden, zu vervielfältigen, zu ändern, abgeleitete Werke zu erstellen, zu verbinden, zu veröffentlichen, zu verbreiten, zu unterlizenzieren und/oder Kopien der Software zu verkaufen sowie diese Rechte auch denjenigen einzuräumen, denen die Software zur Verfügung gestellt wird, vorausgesetzt, dass sämtliche Urheberrechtshinweise sowie die Bedingungen und Bestimmungen dieser Lizenz eingehalten werden.
### Begriffsbestimmungen
Für die Zwecke dieser Lizenz gelten die folgenden Definitionen:
**"Software" (Software)** — die Telemt-Software einschließlich Quellcode, Dokumentation und sämtlicher zugehöriger Dateien, die unter den Bedingungen dieser Lizenz verbreitet werden.
**"Contributor" (Contributor)** — jede natürliche oder juristische Person, die Code, Patches, Dokumentation oder andere Materialien eingereicht hat, die von den Maintainers des Projekts angenommen und in die Software aufgenommen wurden.
**"Beitrag" (Contribution)** — jedes urheberrechtlich geschützte Werk, das bewusst zur Aufnahme in die Software eingereicht wurde.
**"Modifizierte Version" (Modified Version)** — jede Version der Software, die gegenüber der ursprünglichen Software geändert, angepasst, erweitert oder anderweitig modifiziert wurde.
**"Maintainers" (Maintainers)** — natürliche oder juristische Personen, die für das offizielle Telemt-Projekt und dessen offizielle Veröffentlichungen verantwortlich sind.
### 1 Urheberrechtshinweis (Attribution)
Bei der Weitergabe der Software, sowohl in Form des Quellcodes als auch in binärer Form, MÜSSEN folgende Elemente erhalten bleiben:
- der oben genannte Urheberrechtshinweis;
- der vollständige Text dieser Lizenz;
- sämtliche bestehenden Hinweise auf Urheberschaft.
### 2 Hinweis auf Modifikationen
Wenn Änderungen an der Software vorgenommen werden, MUSS die Person, die diese Änderungen vorgenommen hat, eindeutig darauf hinweisen, dass die Software modifiziert wurde, und eine kurze Beschreibung der vorgenommenen Änderungen beifügen.
Modifizierte Versionen der Software DÜRFEN NICHT als die originale Version von Telemt dargestellt werden.
### 3 Marken und Bezeichnungen
Diese Lizenz GEWÄHRT KEINE Rechte zur Nutzung der Bezeichnung **"Telemt"**, des Telemt-Logos oder sonstiger Marken, Kennzeichen oder Branding-Elemente von Telemt.
Weiterverbreitete oder modifizierte Versionen der Software DÜRFEN die Bezeichnung Telemt nicht in einer Weise verwenden, die bei Nutzern den Eindruck eines offiziellen Ursprungs oder einer Billigung durch das Telemt-Projekt erwecken könnte, sofern hierfür keine ausdrückliche Genehmigung der Maintainers vorliegt.
Die Verwendung der Bezeichnung **Telemt** zur Beschreibung einer modifizierten Version der Software ist nur zulässig, wenn diese Version eindeutig als modifiziert oder inoffiziell gekennzeichnet ist.
Jegliche Verbreitung, die Nutzer vernünftigerweise darüber täuschen könnte, dass es sich um eine offizielle Veröffentlichung von Telemt handelt, ist untersagt.
### 4 Transparenz bei der Verbreitung von Binärversionen
Im Falle der Verbreitung kompilierter Binärversionen der Software wird der Verbreiter HIERMIT ERMUTIGT (encouraged), soweit dies vernünftigerweise möglich ist, Zugang zum entsprechenden Quellcode sowie zu den Build-Anweisungen bereitzustellen.
Diese Praxis trägt zur Transparenz bei und ermöglicht es Empfängern, die Integrität und Reproduzierbarkeit der verbreiteten Builds zu überprüfen.
## 5 Gewährung einer Patentlizenz und Beendigung von Rechten
Jeder Contributor gewährt den Empfängern der Software eine unbefristete, weltweite, nicht-exklusive, unentgeltliche, lizenzgebührenfreie und unwiderrufliche Patentlizenz für:
- die Herstellung,
- die Beauftragung der Herstellung,
- die Nutzung,
- das Anbieten zum Verkauf,
- den Verkauf,
- den Import,
- sowie jede sonstige Verbreitung der Software.
Diese Patentlizenz erstreckt sich ausschließlich auf solche Patentansprüche, die notwendigerweise durch den jeweiligen Beitrag des Contributors allein oder in Kombination mit der Software verletzt würden.
Leitet eine Person ein Patentverfahren ein oder beteiligt sich daran, einschließlich Gegenklagen oder Kreuzklagen, mit der Behauptung, dass die Software oder ein darin enthaltener Beitrag ein Patent verletzt, **erlöschen sämtliche durch diese Lizenz gewährten Rechte für diese Person unmittelbar mit Einreichung der Klage**.
Darüber hinaus erlöschen alle durch diese Lizenz gewährten Rechte **automatisch**, wenn eine Person ein gerichtliches Verfahren einleitet, in dem behauptet wird, dass die Software selbst ein Patent oder andere Rechte des geistigen Eigentums verletzt.
### 6 Beteiligung und Beiträge zur Entwicklung
Sofern ein Contributor nicht ausdrücklich etwas anderes erklärt, gilt jeder Beitrag, der bewusst zur Aufnahme in die Software eingereicht wird, als unter den Bedingungen dieser Lizenz lizenziert.
Durch die Einreichung eines Beitrags gewährt der Contributor den Maintainers des Telemt-Projekts sowie allen Empfängern der Software die in dieser Lizenz beschriebenen Rechte in Bezug auf diesen Beitrag.
### 7 Urheberhinweis bei Netzwerk- und Servicenutzung
Wird die Software zur Bereitstellung eines öffentlich zugänglichen Netzwerkdienstes verwendet, MUSS der Betreiber dieses Dienstes einen Hinweis auf die Urheberschaft von Telemt an mindestens einer der folgenden Stellen anbringen:
* in der Servicedokumentation;
* in der Dienstbeschreibung;
* auf einer Seite "Über" oder einer vergleichbaren Informationsseite;
* in anderen für Nutzer zugänglichen Materialien, die in angemessenem Zusammenhang mit dem Dienst stehen.
Ein solcher Hinweis DARF NICHT den Eindruck erwecken, dass der Dienst vom Telemt-Projekt oder dessen Maintainers unterstützt oder offiziell gebilligt wird.
### 8 Haftungsausschluss und salvatorische Klausel
DIE SOFTWARE WIRD "WIE BESEHEN" BEREITGESTELLT, OHNE JEGLICHE AUSDRÜCKLICHE ODER STILLSCHWEIGENDE GEWÄHRLEISTUNG, EINSCHLIESSLICH, ABER NICHT BESCHRÄNKT AUF GEWÄHRLEISTUNGEN DER MARKTGÄNGIGKEIT, DER EIGNUNG FÜR EINEN BESTIMMTEN ZWECK UND DER NICHTVERLETZUNG VON RECHTEN.
IN KEINEM FALL HAFTEN DIE AUTOREN ODER RECHTEINHABER FÜR IRGENDWELCHE ANSPRÜCHE, SCHÄDEN ODER SONSTIGE HAFTUNG, DIE AUS VERTRAG, UNERLAUBTER HANDLUNG ODER AUF ANDERE WEISE AUS DER SOFTWARE ODER DER NUTZUNG DER SOFTWARE ENTSTEHEN.
SOLLTE EINE BESTIMMUNG DIESER LIZENZ ALS UNWIRKSAM ODER NICHT DURCHSETZBAR ANGESEHEN WERDEN, IST DIESE BESTIMMUNG SO AUSZULEGEN, DASS SIE DEM URSPRÜNGLICHEN WILLEN DER PARTEIEN MÖGLICHST NAHEKOMMT; DIE ÜBRIGEN BESTIMMUNGEN BLEIBEN DAVON UNBERÜHRT UND IN VOLLER WIRKUNG.

143
docs/LICENSE/LICENSE.en.md Normal file
View File

@@ -0,0 +1,143 @@
###### TELEMT Public License 3 ######
##### Copyright (c) 2026 Telemt #####
Permission is hereby granted, free of charge, to any person obtaining a copy
of this Software and associated documentation files (the "Software"),
to use, reproduce, modify, prepare derivative works of, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, provided that all
copyright notices, license terms, and conditions set forth in this License
are preserved and complied with.
### Official Translations
The canonical version of this License is the English version.
Official translations are provided for informational purposes only
and for convenience, and do not have legal force. In case of any
discrepancy, the English version of this License shall prevail.
Available versions:
- English in Markdown: docs/LICENSE/LICENSE.md
- German: docs/LICENSE/LICENSE.de.md
- Russian: docs/LICENSE/LICENSE.ru.md
### Definitions
For the purposes of this License:
"Software" means the Telemt software, including source code, documentation,
and any associated files distributed under this License.
"Contributor" means any person or entity that submits code, patches,
documentation, or other contributions to the Software that are accepted
into the Software by the maintainers.
"Contribution" means any work of authorship intentionally submitted
to the Software for inclusion in the Software.
"Modified Version" means any version of the Software that has been
changed, adapted, extended, or otherwise modified from the original
Software.
"Maintainers" means the individuals or entities responsible for
the official Telemt project and its releases.
#### 1 Attribution
Redistributions of the Software, in source or binary form, MUST RETAIN the
above copyright notice, this license text, and any existing attribution
notices.
#### 2 Modification Notice
If you modify the Software, you MUST clearly state that the Software has been
modified and include a brief description of the changes made.
Modified versions MUST NOT be presented as the original Telemt.
#### 3 Trademark and Branding
This license DOES NOT grant permission to use the name "Telemt",
the Telemt logo, or any Telemt trademarks or branding.
Redistributed or modified versions of the Software MAY NOT use the Telemt
name in a way that suggests endorsement or official origin without explicit
permission from the Telemt maintainers.
Use of the name "Telemt" to describe a modified version of the Software
is permitted only if the modified version is clearly identified as a
modified or unofficial version.
Any distribution that could reasonably confuse users into believing that
the software is an official Telemt release is prohibited.
#### 4 Binary Distribution Transparency
If you distribute compiled binaries of the Software,
you are ENCOURAGED to provide access to the corresponding
source code and build instructions where reasonably possible.
This helps preserve transparency and allows recipients to verify the
integrity and reproducibility of distributed builds.
#### 5 Patent Grant and Defensive Termination Clause
Each contributor grants you a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Software.
This patent license applies only to those patent claims necessarily
infringed by the contributors contribution alone or by combination of
their contribution with the Software.
If you initiate or participate in any patent litigation, including
cross-claims or counterclaims, alleging that the Software or any
contribution incorporated within the Software constitutes patent
infringement, then **all rights granted to you under this license shall
terminate immediately** as of the date such litigation is filed.
Additionally, if you initiate legal action alleging that the
Software itself infringes your patent or other intellectual
property rights, then all rights granted to you under this
license SHALL TERMINATE automatically.
#### 6 Contributions
Unless you explicitly state otherwise, any Contribution intentionally
submitted for inclusion in the Software shall be licensed under the terms
of this License.
By submitting a Contribution, you grant the Telemt maintainers and all
recipients of the Software the rights described in this License with
respect to that Contribution.
#### 7 Network Use Attribution
If the Software is used to provide a publicly accessible network service,
the operator of such service MUST provide attribution to Telemt in at least
one of the following locations:
- service documentation
- service description
- an "About" or similar informational page
- other user-visible materials reasonably associated with the service
Such attribution MUST NOT imply endorsement by the Telemt project or its
maintainers.
#### 8 Disclaimer of Warranty and Severability Clause
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE
IF ANY PROVISION OF THIS LICENSE IS HELD TO BE INVALID OR UNENFORCEABLE,
SUCH PROVISION SHALL BE INTERPRETED TO REFLECT THE ORIGINAL INTENT
OF THE PARTIES AS CLOSELY AS POSSIBLE, AND THE REMAINING PROVISIONS
SHALL REMAIN IN FULL FORCE AND EFFECT

View File

@@ -0,0 +1,90 @@
# Публичная лицензия TELEMT 3
***Все права защищёны (c) 2026 Telemt***
Настоящим любому лицу, получившему копию данного программного обеспечения и сопутствующей документации (далее — "Программное обеспечение"), безвозмездно предоставляется разрешение использовать Программное обеспечение без ограничений, включая право использовать, воспроизводить, изменять, создавать производные произведения, объединять, публиковать, распространять, сублицензировать и (или) продавать копии Программного обеспечения, а также предоставлять такие права лицам, которым предоставляется Программное обеспечение, при условии соблюдения всех уведомлений об авторских правах, условий и положений настоящей Лицензии.
### Определения
Для целей настоящей Лицензии применяются следующие определения:
**"Программное обеспечение" (Software)** — программное обеспечение Telemt, включая исходный код, документацию и любые связанные файлы, распространяемые на условиях настоящей Лицензии.
**"Контрибьютор" (Contributor)** — любое физическое или юридическое лицо, направившее код, исправления (патчи), документацию или иные материалы, которые были приняты мейнтейнерами проекта и включены в состав Программного обеспечения.
**"Вклад" (Contribution)** — любое произведение авторского права, намеренно представленное для включения в состав Программного обеспечения.
**"Модифицированная версия" (Modified Version)** — любая версия Программного обеспечения, которая была изменена, адаптирована, расширена или иным образом модифицирована по сравнению с исходным Программным обеспечением.
**"Мейнтейнеры" (Maintainers)** — физические или юридические лица, ответственные за официальный проект Telemt и его официальные релизы.
### 1 Указание авторства
При распространении Программного обеспечения, как в форме исходного кода, так и в бинарной форме, ДОЛЖНЫ СОХРАНЯТЬСЯ:
- указанное выше уведомление об авторских правах;
- текст настоящей Лицензии;
- любые существующие уведомления об авторстве.
### 2 Уведомление о модификации
В случае внесения изменений в Программное обеспечение лицо, осуществившее такие изменения, ОБЯЗАНО явно указать, что Программное обеспечение было модифицировано, а также включить краткое описание внесённых изменений.
Модифицированные версии Программного обеспечения НЕ ДОЛЖНЫ представляться как оригинальная версия Telemt.
### 3 Товарные знаки и обозначения
Настоящая Лицензия НЕ ПРЕДОСТАВЛЯЕТ права использовать наименование **"Telemt"**, логотип Telemt, а также любые товарные знаки, фирменные обозначения или элементы бренда Telemt.
Распространяемые или модифицированные версии Программного обеспечения НЕ ДОЛЖНЫ использовать наименование Telemt таким образом, который может создавать у пользователей впечатление официального происхождения либо одобрения со стороны проекта Telemt без явного разрешения мейнтейнеров проекта.
Использование наименования **Telemt** для описания модифицированной версии Программного обеспечения допускается только при условии, что такая версия ясно обозначена как модифицированная или неофициальная.
Запрещается любое распространение, которое может разумно вводить пользователей в заблуждение относительно того, что программное обеспечение является официальным релизом Telemt.
### 4 Прозрачность распространения бинарных версий
В случае распространения скомпилированных бинарных версий Программного обеспечения распространитель НАСТОЯЩИМ ПОБУЖДАЕТСЯ предоставлять доступ к соответствующему исходному коду и инструкциям по сборке, если это разумно возможно.
Такая практика способствует прозрачности распространения и позволяет получателям проверять целостность и воспроизводимость распространяемых сборок.
### 5 Предоставление патентной лицензии и прекращение прав
Каждый контрибьютор предоставляет получателям Программного обеспечения бессрочную, всемирную, неисключительную, безвозмездную, не требующую выплаты роялти и безотзывную патентную лицензию на:
- изготовление,
- поручение изготовления,
- использование,
- предложение к продаже,
- продажу,
- импорт,
- и иное распространение Программного обеспечения.
Такая патентная лицензия распространяется исключительно на те патентные требования, которые неизбежно нарушаются соответствующим вкладом контрибьютора как таковым либо его сочетанием с Программным обеспечением.
Если лицо инициирует либо участвует в каком-либо судебном разбирательстве по патентному спору, включая встречные или перекрёстные иски, утверждая, что Программное обеспечение либо любой вклад, включённый в него, нарушает патент, **все права, предоставленные такому лицу настоящей Лицензией, немедленно прекращаются** с даты подачи соответствующего иска.
Кроме того, если лицо инициирует судебное разбирательство, утверждая, что само Программное обеспечение нарушает его патентные либо иные права интеллектуальной собственности, все права, предоставленные настоящей Лицензией, **автоматически прекращаются**.
### 6 Участие и вклад в разработку
Если контрибьютор явно не указал иное, любой Вклад, намеренно представленный для включения в Программное обеспечение, считается лицензированным на условиях настоящей Лицензии.
Путём предоставления Вклада контрибьютор предоставляет мейнтейнером проекта Telemt и всем получателям Программного обеспечения права, предусмотренные настоящей Лицензией, в отношении такого Вклада.
### 7 Указание авторства при сетевом и сервисном использовании
В случае использования Программного обеспечения для предоставления публично доступного сетевого сервиса оператор такого сервиса ОБЯЗАН обеспечить указание авторства Telemt как минимум в одном из следующих мест:
- документация сервиса;
- описание сервиса;
- страница "О программе" или аналогичная информационная страница;
- иные материалы, доступные пользователям и разумно связанные с данным сервисом.
Такое указание авторства НЕ ДОЛЖНО создавать впечатление одобрения или официальной поддержки со стороны проекта Telemt либо его мейнтейнеров.
### 8 Отказ от гарантий и делимость положений
ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ "КАК ЕСТЬ", БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ, ЯВНЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ ГАРАНТИЯМИ КОММЕРЧЕСКОЙ ПРИГОДНОСТИ, ПРИГОДНОСТИ ДЛЯ КОНКРЕТНОЙ ЦЕЛИ И НЕНАРУШЕНИЯ ПРАВ.
НИ ПРИ КАКИХ ОБСТОЯТЕЛЬСТВАХ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ ОТВЕТСТВЕННОСТИ ПО КАКИМ-ЛИБО ТРЕБОВАНИЯМ, УБЫТКАМ ИЛИ ИНОЙ ОТВЕТСТВЕННОСТИ, ВОЗНИКАЮЩЕЙ В РЕЗУЛЬТАТЕ ДОГОВОРА, ДЕЛИКТА ИЛИ ИНЫМ ОБРАЗОМ, СВЯЗАННЫМ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ ИЛИ ЕГО ИСПОЛЬЗОВАНИЕМ.
В СЛУЧАЕ ЕСЛИ КАКОЕ-ЛИБО ПОЛОЖЕНИЕ НАСТОЯЩЕЙ ЛИЦЕНЗИИ ПРИЗНАЁТСЯ НЕДЕЙСТВИТЕЛЬНЫМ ИЛИ НЕПРИМЕНИМЫМ, ТАКОЕ ПОЛОЖЕНИЕ ПОДЛЕЖИТ ТОЛКОВАНИЮ МАКСИМАЛЬНО БЛИЗКО К ИСХОДНОМУ НАМЕРЕНИЮ СТОРОН, ПРИ ЭТОМ ОСТАЛЬНЫЕ ПОЛОЖЕНИЯ СОХРАНЯЮТ ПОЛНУЮ ЮРИДИЧЕСКУЮ СИЛУ.

View File

@@ -72,6 +72,9 @@ classic = false
secure = false
tls = true
[server]
port = 443
[server.api]
enabled = true
# listen = "127.0.0.1:9091"

View File

@@ -72,6 +72,9 @@ classic = false
secure = false
tls = true
[server]
port = 443
[server.api]
enabled = true
# listen = "127.0.0.1:9091"

View File

@@ -0,0 +1,274 @@
# TLS-F и TCP-S в Telemt
## Общая архитектура
**Telemt** - это прежде всего реализация **MTProxy**, через которую проходит payload Telegram
Подсистема **TLS-Fronting / TCP-Splitting** служит **маскировочным транспортным слоем**, задача которого - сделать MTProxy-соединение внешне похожим на обычное TLS-подключение к легитимному сайту
Таким образом:
- **MTProxy** - основной функциональный слой Telemt для обработки Telegram-трафика
- **TLS-Fronting / TCP-Splitting** - подсистема маскировки транспорта
С точки зрения сети Telemt ведёт себя как **TLS-сервер**, но фактически:
- валидные MTProxy-клиенты остаются внутри контура Telemt
- любые другие TLS-клиенты проксируются на обычный HTTPS-сервер-заглушку
# Базовый сценарий / Best-practice
Предположим, у вас есть домен:
```
umweltschutz.de
```
### 1 DNS
Вы создаёте A-запись:
```
umweltschutz.de -> A-запись 198.18.88.88
```
где `198.18.88.88` - IP вашего сервера с telemt
### 2 TLS-домен
В конфигурации Telemt:
```
tls_domain = umweltschutz.de
```
Этот домен используется клиентом как SNI в ClientHello
### 3 Сервер-заглушка
Вы поднимаете обычный HTTPS-сервер, например **nginx**, с сертификатом для этого домена.
Он может работать:
- на том же сервере
- на другом сервере
- на другом порту
В конфигурации Telemt:
```
mask_host = 127.0.0.1
mask_port = 8443
```
где `127.0.0.1` - IP сервера-заглушки, а 8443 - порт, который он слушает
Этот сервер нужен **для обработки любых non-MTProxy запросов**
### 4 Работа Telemt
После запуска Telemt действует следующим образом:
1) принимает входящее TCP-соединение
2) анализирует TLS-ClientHello
3) пытается определить, является ли соединение валидным **MTProxy FakeTLS**
Далее работают два варианта логики:
---
# Сценарий 1 - MTProxy клиент с валидным ключом
Если клиент предъявил **валидный MTProxy-ключ**:
- соединение **остаётся внутри Telemt**
- TLS используется только как **транспортная маскировка**
- далее запускается обычная логика **MTProxy**
Для внешнего наблюдателя это выглядит как:
```
TLS connection -> umweltschutz.de
```
Хотя внутри передаётся **MTProto-трафик Telegram**
# Сценарий 2 - обычный TLS-клиент - crawler / scanner / browser
Если Telemt не обнаруживает валидный MTProxy-ключ:
соединение **переключается в режим TCP-Splitting / TCP-Splicing**.
В этом режиме Telemt:
1. открывает новое TCP-соединение к
```
mask_host:mask_port
```
2. начинает **проксировать TCP-трафик**
Важно:
* клиентский TLS-запрос **НЕ модифицируется**
* **ClientHello передаётся "как есть", без изменений**
* **SNI остаётся неизменным**
* Telemt **не завершает TLS-рукопожатие**, а только перенаправляет его на более низком уровне сетевого стека - L4
Таким образом upstream-сервер получает **оригинальное TLS-соединение клиента**:
- если это nginx-заглушка, он просто отдаёт обычный сайт
- для внешнего наблюдателя это выглядит как обычный HTTPS-сервер
# TCP-S / TCP-Splitting / TCP-Splicing
Ключевые свойства механизма:
**Telemt работает как TCP-переключатель:**
1) принимает соединение
2) определяет тип клиента
3) либо:
- обрабатывает MTProxy внутри
- либо проксирует TCP-поток
При проксировании:
- Telemt **разрешает `mask_host` в IP**
- устанавливает TCP-соединение
- начинает **bidirectional TCP relay**
При этом:
- TLS-рукопожатие происходит **между клиентом и `mask_host`**
- Telemt выступает только **на уровне L4 - как TCP-релей**, такой же как HAProxy в TCP-режиме
# Использование чужого домена
Можно использовать и внешний сайт.
Например:
```
tls_domain = github.com
mask_host = github.com
mask_port = 443
```
или
```
mask_host = 140.82.121.4
```
В этом случае:
- цензор видит **TLS-подключение к github.com**
- обычные клиенты/краулер действительно получают **настоящий GitHub**
Telemt просто **проксирует TCP-соединение на GitHub**
# Что видит анализатор трафика?
Для DPI это выглядит так:
```
client -> TLS -> github.com
```
или
```
client -> TLS -> umweltschutz.de
```
TLS-handshake выглядит валидным, SNI соответствует домену, сертификат корректный - от целевого `mask_host:mask_port`
# Что видит сканер / краулер?
Если сканер попытается подключиться:
```
openssl s_client -connect 198.18.88.88:443 -servername umweltschutz.de
```
он получит **обычный HTTPS-сайт-заглушку**
Потому что:
- он не предъявил MTProxy-ключ
- Telemt отправил соединение на `mask_host:mask_port`, на котором находится nginx
# Какую проблему решает TLS-Fronting / TCP-Splitting?
Эта архитектура решает сразу несколько проблем обхода цензуры.
## 1 Закрытие плоскости MTProxy от активного сканирования
Многие цензоры:
- сканируют IP-адреса
- проверяют известные сигнатуры прокси
Telemt отвечает на такие проверки **обычным HTTPS-сайтом**, поэтому прокси невозможно обнаружить простым сканированием
---
## 2 Маскировка трафика под легитимный TLS
Для DPI-систем соединение выглядит как:
```
обычный TLS-трафик к популярному домену
```
Это делает блокировку значительно сложнее и непредсказуемее
---
## 3 Устойчивость к протокольному анализу
MTProxy трафик проходит **внутри TLS-like-потока**, поэтому:
- не видны характерные сигнатуры MTProto
- соединение выглядит как обычный HTTPS
---
## 4 Правдоподобное поведение сервера
Даже если краулер:
- подключится сам
- выполнит TLS-handshake
- попытается получить HTTP-ответ
он увидит **реальный сайт**, а не telemt
Это устраняет один из главных признаков для антифрод-краулеров мобильных операторов
# Схема
```text
Client
│ TCP
V
Telemt
├── valid MTProxy key
│ │
│ V
│ MTProxy logic
└── обычный TLS клиент
V
TCP-Splitting
V
mask_host:mask_port
```

View File

@@ -236,6 +236,8 @@ pub(super) struct MeWritersSummary {
pub(super) required_writers: usize,
pub(super) alive_writers: usize,
pub(super) coverage_pct: f64,
pub(super) fresh_alive_writers: usize,
pub(super) fresh_coverage_pct: f64,
}
#[derive(Serialize, Clone)]
@@ -250,6 +252,12 @@ pub(super) struct MeWriterStatus {
pub(super) bound_clients: usize,
pub(super) idle_for_secs: Option<u64>,
pub(super) rtt_ema_ms: Option<f64>,
pub(super) matches_active_generation: bool,
pub(super) in_desired_map: bool,
pub(super) allow_drain_fallback: bool,
pub(super) drain_started_at_epoch_secs: Option<u64>,
pub(super) drain_deadline_epoch_secs: Option<u64>,
pub(super) drain_over_ttl: bool,
}
#[derive(Serialize, Clone)]
@@ -276,6 +284,8 @@ pub(super) struct DcStatus {
pub(super) floor_capped: bool,
pub(super) alive_writers: usize,
pub(super) coverage_pct: f64,
pub(super) fresh_alive_writers: usize,
pub(super) fresh_coverage_pct: f64,
pub(super) rtt_ms: Option<f64>,
pub(super) load: usize,
}

View File

@@ -314,6 +314,8 @@ async fn get_minimal_payload_cached(
required_writers: status.required_writers,
alive_writers: status.alive_writers,
coverage_pct: status.coverage_pct,
fresh_alive_writers: status.fresh_alive_writers,
fresh_coverage_pct: status.fresh_coverage_pct,
},
writers: status
.writers
@@ -329,6 +331,12 @@ async fn get_minimal_payload_cached(
bound_clients: entry.bound_clients,
idle_for_secs: entry.idle_for_secs,
rtt_ema_ms: entry.rtt_ema_ms,
matches_active_generation: entry.matches_active_generation,
in_desired_map: entry.in_desired_map,
allow_drain_fallback: entry.allow_drain_fallback,
drain_started_at_epoch_secs: entry.drain_started_at_epoch_secs,
drain_deadline_epoch_secs: entry.drain_deadline_epoch_secs,
drain_over_ttl: entry.drain_over_ttl,
})
.collect(),
};
@@ -363,6 +371,8 @@ async fn get_minimal_payload_cached(
floor_capped: entry.floor_capped,
alive_writers: entry.alive_writers,
coverage_pct: entry.coverage_pct,
fresh_alive_writers: entry.fresh_alive_writers,
fresh_coverage_pct: entry.fresh_coverage_pct,
rtt_ms: entry.rtt_ms,
load: entry.load,
})
@@ -486,6 +496,8 @@ fn disabled_me_writers(now_epoch_secs: u64, reason: &'static str) -> MeWritersDa
required_writers: 0,
alive_writers: 0,
coverage_pct: 0.0,
fresh_alive_writers: 0,
fresh_coverage_pct: 0.0,
},
writers: Vec::new(),
}

View File

@@ -90,6 +90,7 @@ pub(super) struct EffectiveMiddleProxyLimits {
#[derive(Serialize)]
pub(super) struct EffectiveUserIpPolicyLimits {
pub(super) global_each: usize,
pub(super) mode: &'static str,
pub(super) window_secs: u64,
}
@@ -262,6 +263,7 @@ pub(super) fn build_limits_effective_data(cfg: &ProxyConfig) -> EffectiveLimitsD
me2dc_fallback: cfg.general.me2dc_fallback,
},
user_ip_policy: EffectiveUserIpPolicyLimits {
global_each: cfg.access.user_max_unique_ips_global_each,
mode: user_max_unique_ips_mode_label(cfg.access.user_max_unique_ips_mode),
window_secs: cfg.access.user_max_unique_ips_window_secs,
},

View File

@@ -386,7 +386,16 @@ pub(super) async fn users_from_config(
.get(&username)
.map(chrono::DateTime::<chrono::Utc>::to_rfc3339),
data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(),
max_unique_ips: cfg.access.user_max_unique_ips.get(&username).copied(),
max_unique_ips: cfg
.access
.user_max_unique_ips
.get(&username)
.copied()
.filter(|limit| *limit > 0)
.or(
(cfg.access.user_max_unique_ips_global_each > 0)
.then_some(cfg.access.user_max_unique_ips_global_each),
),
current_connections: stats.get_user_curr_connects(&username),
active_unique_ips: active_ip_list.len(),
active_unique_ips_list: active_ip_list,

View File

@@ -147,6 +147,10 @@ pub(crate) fn default_proxy_protocol_header_timeout_ms() -> u64 {
500
}
pub(crate) fn default_server_max_connections() -> u32 {
10_000
}
pub(crate) fn default_prefer_4() -> u8 {
4
}
@@ -584,6 +588,10 @@ pub(crate) fn default_me_pool_drain_ttl_secs() -> u64 {
90
}
pub(crate) fn default_me_pool_drain_threshold() -> u64 {
128
}
pub(crate) fn default_me_bind_stale_ttl_secs() -> u64 {
default_me_pool_drain_ttl_secs()
}
@@ -635,6 +643,10 @@ pub(crate) fn default_user_max_unique_ips_window_secs() -> u64 {
DEFAULT_USER_MAX_UNIQUE_IPS_WINDOW_SECS
}
pub(crate) fn default_user_max_unique_ips_global_each() -> usize {
0
}
// Custom deserializer helpers
#[derive(Deserialize)]

View File

@@ -21,9 +21,11 @@
//! `network.*`, `use_middle_proxy`) are **not** applied; a warning is emitted.
//! Non-hot changes are never mixed into the runtime config snapshot.
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock as StdRwLock};
use std::time::Duration;
use notify::{EventKind, RecursiveMode, Watcher, recommended_watcher};
use tokio::sync::{mpsc, watch};
@@ -33,7 +35,10 @@ use crate::config::{
LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
MeWriterPickMode,
};
use super::load::ProxyConfig;
use super::load::{LoadedConfig, ProxyConfig};
const HOT_RELOAD_STABLE_SNAPSHOTS: u8 = 2;
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
// ── Hot fields ────────────────────────────────────────────────────────────────
@@ -50,6 +55,7 @@ pub struct HotFields {
pub me_reinit_coalesce_window_ms: u64,
pub hardswap: bool,
pub me_pool_drain_ttl_secs: u64,
pub me_pool_drain_threshold: u64,
pub me_pool_min_fresh_ratio: f32,
pub me_reinit_drain_timeout_secs: u64,
pub me_hardswap_warmup_delay_min_ms: u64,
@@ -113,6 +119,7 @@ pub struct HotFields {
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
pub user_data_quota: std::collections::HashMap<String, u64>,
pub user_max_unique_ips: std::collections::HashMap<String, usize>,
pub user_max_unique_ips_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64,
}
@@ -130,6 +137,7 @@ impl HotFields {
me_reinit_coalesce_window_ms: cfg.general.me_reinit_coalesce_window_ms,
hardswap: cfg.general.hardswap,
me_pool_drain_ttl_secs: cfg.general.me_pool_drain_ttl_secs,
me_pool_drain_threshold: cfg.general.me_pool_drain_threshold,
me_pool_min_fresh_ratio: cfg.general.me_pool_min_fresh_ratio,
me_reinit_drain_timeout_secs: cfg.general.me_reinit_drain_timeout_secs,
me_hardswap_warmup_delay_min_ms: cfg.general.me_hardswap_warmup_delay_min_ms,
@@ -227,6 +235,7 @@ impl HotFields {
user_expirations: cfg.access.user_expirations.clone(),
user_data_quota: cfg.access.user_data_quota.clone(),
user_max_unique_ips: cfg.access.user_max_unique_ips.clone(),
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,
}
@@ -287,6 +296,149 @@ fn listeners_equal(
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct WatchManifest {
files: BTreeSet<PathBuf>,
dirs: BTreeSet<PathBuf>,
}
impl WatchManifest {
fn from_source_files(source_files: &[PathBuf]) -> Self {
let mut files = BTreeSet::new();
let mut dirs = BTreeSet::new();
for path in source_files {
let normalized = normalize_watch_path(path);
files.insert(normalized.clone());
if let Some(parent) = normalized.parent() {
dirs.insert(parent.to_path_buf());
}
}
Self { files, dirs }
}
fn matches_event_paths(&self, event_paths: &[PathBuf]) -> bool {
event_paths
.iter()
.map(|path| normalize_watch_path(path))
.any(|path| self.files.contains(&path))
}
}
#[derive(Debug, Default)]
struct ReloadState {
applied_snapshot_hash: Option<u64>,
candidate_snapshot_hash: Option<u64>,
candidate_hits: u8,
}
impl ReloadState {
fn new(applied_snapshot_hash: Option<u64>) -> Self {
Self {
applied_snapshot_hash,
candidate_snapshot_hash: None,
candidate_hits: 0,
}
}
fn is_applied(&self, hash: u64) -> bool {
self.applied_snapshot_hash == Some(hash)
}
fn observe_candidate(&mut self, hash: u64) -> u8 {
if self.candidate_snapshot_hash == Some(hash) {
self.candidate_hits = self.candidate_hits.saturating_add(1);
} else {
self.candidate_snapshot_hash = Some(hash);
self.candidate_hits = 1;
}
self.candidate_hits
}
fn reset_candidate(&mut self) {
self.candidate_snapshot_hash = None;
self.candidate_hits = 0;
}
fn mark_applied(&mut self, hash: u64) {
self.applied_snapshot_hash = Some(hash);
self.reset_candidate();
}
}
fn normalize_watch_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
})
}
fn sync_watch_paths<W: Watcher>(
watcher: &mut W,
current: &BTreeSet<PathBuf>,
next: &BTreeSet<PathBuf>,
recursive_mode: RecursiveMode,
kind: &str,
) {
for path in current.difference(next) {
if let Err(e) = watcher.unwatch(path) {
warn!(path = %path.display(), error = %e, "config watcher: failed to unwatch {kind}");
}
}
for path in next.difference(current) {
if let Err(e) = watcher.watch(path, recursive_mode) {
warn!(path = %path.display(), error = %e, "config watcher: failed to watch {kind}");
}
}
}
fn apply_watch_manifest<W1: Watcher, W2: Watcher>(
notify_watcher: Option<&mut W1>,
poll_watcher: Option<&mut W2>,
manifest_state: &Arc<StdRwLock<WatchManifest>>,
next_manifest: WatchManifest,
) {
let current_manifest = manifest_state
.read()
.map(|manifest| manifest.clone())
.unwrap_or_default();
if current_manifest == next_manifest {
return;
}
if let Some(watcher) = notify_watcher {
sync_watch_paths(
watcher,
&current_manifest.dirs,
&next_manifest.dirs,
RecursiveMode::NonRecursive,
"config directory",
);
}
if let Some(watcher) = poll_watcher {
sync_watch_paths(
watcher,
&current_manifest.files,
&next_manifest.files,
RecursiveMode::NonRecursive,
"config file",
);
}
if let Ok(mut manifest) = manifest_state.write() {
*manifest = next_manifest;
}
}
fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
let mut cfg = old.clone();
@@ -302,6 +454,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.general.me_reinit_coalesce_window_ms = new.general.me_reinit_coalesce_window_ms;
cfg.general.hardswap = new.general.hardswap;
cfg.general.me_pool_drain_ttl_secs = new.general.me_pool_drain_ttl_secs;
cfg.general.me_pool_drain_threshold = new.general.me_pool_drain_threshold;
cfg.general.me_pool_min_fresh_ratio = new.general.me_pool_min_fresh_ratio;
cfg.general.me_reinit_drain_timeout_secs = new.general.me_reinit_drain_timeout_secs;
cfg.general.me_hardswap_warmup_delay_min_ms = new.general.me_hardswap_warmup_delay_min_ms;
@@ -384,6 +537,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.access.user_expirations = new.access.user_expirations.clone();
cfg.access.user_data_quota = new.access.user_data_quota.clone();
cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone();
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;
@@ -675,6 +829,13 @@ fn log_changes(
);
}
if old_hot.me_pool_drain_threshold != new_hot.me_pool_drain_threshold {
info!(
"config reload: me_pool_drain_threshold: {} → {}",
old_hot.me_pool_drain_threshold, new_hot.me_pool_drain_threshold,
);
}
if (old_hot.me_pool_min_fresh_ratio - new_hot.me_pool_min_fresh_ratio).abs() > f32::EPSILON {
info!(
"config reload: me_pool_min_fresh_ratio: {:.3} → {:.3}",
@@ -951,12 +1112,14 @@ fn log_changes(
new_hot.user_max_unique_ips.len()
);
}
if old_hot.user_max_unique_ips_mode != new_hot.user_max_unique_ips_mode
if old_hot.user_max_unique_ips_global_each != new_hot.user_max_unique_ips_global_each
|| old_hot.user_max_unique_ips_mode != new_hot.user_max_unique_ips_mode
|| old_hot.user_max_unique_ips_window_secs
!= new_hot.user_max_unique_ips_window_secs
{
info!(
"config reload: user_max_unique_ips policy mode={:?} window={}s",
"config reload: user_max_unique_ips policy global_each={} mode={:?} window={}s",
new_hot.user_max_unique_ips_global_each,
new_hot.user_max_unique_ips_mode,
new_hot.user_max_unique_ips_window_secs
);
@@ -970,18 +1133,42 @@ fn reload_config(
log_tx: &watch::Sender<LogLevel>,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
) {
let new_cfg = match ProxyConfig::load(config_path) {
Ok(c) => c,
reload_state: &mut ReloadState,
) -> Option<WatchManifest> {
let loaded = match ProxyConfig::load_with_metadata(config_path) {
Ok(loaded) => loaded,
Err(e) => {
reload_state.reset_candidate();
error!("config reload: failed to parse {:?}: {}", config_path, e);
return;
return None;
}
};
let LoadedConfig {
config: new_cfg,
source_files,
rendered_hash,
} = loaded;
let next_manifest = WatchManifest::from_source_files(&source_files);
if let Err(e) = new_cfg.validate() {
reload_state.reset_candidate();
error!("config reload: validation failed: {}; keeping old config", e);
return;
return Some(next_manifest);
}
if reload_state.is_applied(rendered_hash) {
return Some(next_manifest);
}
let candidate_hits = reload_state.observe_candidate(rendered_hash);
if candidate_hits < HOT_RELOAD_STABLE_SNAPSHOTS {
info!(
snapshot_hash = rendered_hash,
candidate_hits,
required_hits = HOT_RELOAD_STABLE_SNAPSHOTS,
"config reload: candidate snapshot observed but not stable yet"
);
return Some(next_manifest);
}
let old_cfg = config_tx.borrow().clone();
@@ -996,17 +1183,19 @@ fn reload_config(
}
if !hot_changed {
return;
reload_state.mark_applied(rendered_hash);
return Some(next_manifest);
}
if old_hot.dns_overrides != applied_hot.dns_overrides
&& let Err(e) = crate::network::dns_overrides::install_entries(&applied_hot.dns_overrides)
{
reload_state.reset_candidate();
error!(
"config reload: invalid network.dns_overrides: {}; keeping old config",
e
);
return;
return Some(next_manifest);
}
log_changes(
@@ -1018,6 +1207,8 @@ fn reload_config(
detected_ip_v6,
);
config_tx.send(Arc::new(applied_cfg)).ok();
reload_state.mark_applied(rendered_hash);
Some(next_manifest)
}
// ── Public API ────────────────────────────────────────────────────────────────
@@ -1040,80 +1231,86 @@ pub fn spawn_config_watcher(
let (config_tx, config_rx) = watch::channel(initial);
let (log_tx, log_rx) = watch::channel(initial_level);
// Bridge: sync notify callbacks → async task via mpsc.
let (notify_tx, mut notify_rx) = mpsc::channel::<()>(4);
let config_path = normalize_watch_path(&config_path);
let initial_loaded = ProxyConfig::load_with_metadata(&config_path).ok();
let initial_manifest = initial_loaded
.as_ref()
.map(|loaded| WatchManifest::from_source_files(&loaded.source_files))
.unwrap_or_else(|| WatchManifest::from_source_files(std::slice::from_ref(&config_path)));
let initial_snapshot_hash = initial_loaded.as_ref().map(|loaded| loaded.rendered_hash);
// Canonicalize so path matches what notify returns (absolute) in events.
let config_path = match config_path.canonicalize() {
Ok(p) => p,
Err(_) => config_path.to_path_buf(),
};
// Watch the parent directory rather than the file itself, because many
// editors (vim, nano) and systemd write via rename, which would cause
// inotify to lose track of the original inode.
let watch_dir = config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf();
// ── inotify watcher (instant on local fs) ────────────────────────────
let config_file = config_path.clone();
let tx_inotify = notify_tx.clone();
let inotify_ok = match recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
let is_our_file = event.paths.iter().any(|p| p == &config_file);
if !is_our_file { return; }
if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
let _ = tx_inotify.try_send(());
}
}) {
Ok(mut w) => match w.watch(&watch_dir, RecursiveMode::NonRecursive) {
Ok(()) => {
info!("config watcher: inotify active on {:?}", config_path);
Box::leak(Box::new(w));
true
}
Err(e) => { warn!("config watcher: inotify watch failed: {}", e); false }
},
Err(e) => { warn!("config watcher: inotify unavailable: {}", e); false }
};
// ── poll watcher (always active, fixes Docker bind mounts / NFS) ─────
// inotify does not receive events for files mounted from the host into
// a container. PollWatcher compares file contents every 3 s and fires
// on any change regardless of the underlying fs.
let config_file2 = config_path.clone();
let tx_poll = notify_tx.clone();
match notify::poll::PollWatcher::new(
move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
let is_our_file = event.paths.iter().any(|p| p == &config_file2);
if !is_our_file { return; }
if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
let _ = tx_poll.try_send(());
}
},
notify::Config::default()
.with_poll_interval(std::time::Duration::from_secs(3))
.with_compare_contents(true),
) {
Ok(mut w) => match w.watch(&config_path, RecursiveMode::NonRecursive) {
Ok(()) => {
if inotify_ok {
info!("config watcher: poll watcher also active (Docker/NFS safe)");
} else {
info!("config watcher: poll watcher active on {:?} (3s interval)", config_path);
}
Box::leak(Box::new(w));
}
Err(e) => warn!("config watcher: poll watch failed: {}", e),
},
Err(e) => warn!("config watcher: poll watcher unavailable: {}", e),
}
// ── event loop ───────────────────────────────────────────────────────
tokio::spawn(async move {
let (notify_tx, mut notify_rx) = mpsc::channel::<()>(4);
let manifest_state = Arc::new(StdRwLock::new(WatchManifest::default()));
let mut reload_state = ReloadState::new(initial_snapshot_hash);
let tx_inotify = notify_tx.clone();
let manifest_for_inotify = manifest_state.clone();
let mut inotify_watcher = match recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
return;
}
let is_our_file = manifest_for_inotify
.read()
.map(|manifest| manifest.matches_event_paths(&event.paths))
.unwrap_or(false);
if is_our_file {
let _ = tx_inotify.try_send(());
}
}) {
Ok(watcher) => Some(watcher),
Err(e) => {
warn!("config watcher: inotify unavailable: {}", e);
None
}
};
apply_watch_manifest(
inotify_watcher.as_mut(),
Option::<&mut notify::poll::PollWatcher>::None,
&manifest_state,
initial_manifest.clone(),
);
if inotify_watcher.is_some() {
info!("config watcher: inotify active on {:?}", config_path);
}
let tx_poll = notify_tx.clone();
let manifest_for_poll = manifest_state.clone();
let mut poll_watcher = match notify::poll::PollWatcher::new(
move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
return;
}
let is_our_file = manifest_for_poll
.read()
.map(|manifest| manifest.matches_event_paths(&event.paths))
.unwrap_or(false);
if is_our_file {
let _ = tx_poll.try_send(());
}
},
notify::Config::default()
.with_poll_interval(Duration::from_secs(3))
.with_compare_contents(true),
) {
Ok(watcher) => Some(watcher),
Err(e) => {
warn!("config watcher: poll watcher unavailable: {}", e);
None
}
};
apply_watch_manifest(
Option::<&mut notify::RecommendedWatcher>::None,
poll_watcher.as_mut(),
&manifest_state,
initial_manifest.clone(),
);
if poll_watcher.is_some() {
info!("config watcher: poll watcher active (Docker/NFS safe)");
}
#[cfg(unix)]
let mut sighup = {
use tokio::signal::unix::{SignalKind, signal};
@@ -1133,11 +1330,25 @@ pub fn spawn_config_watcher(
#[cfg(not(unix))]
if notify_rx.recv().await.is_none() { break; }
// Debounce: drain extra events that arrive within 50 ms.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Debounce: drain extra events that arrive within a short quiet window.
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
while notify_rx.try_recv().is_ok() {}
reload_config(&config_path, &config_tx, &log_tx, detected_ip_v4, detected_ip_v6);
if let Some(next_manifest) = reload_config(
&config_path,
&config_tx,
&log_tx,
detected_ip_v4,
detected_ip_v6,
&mut reload_state,
) {
apply_watch_manifest(
inotify_watcher.as_mut(),
poll_watcher.as_mut(),
&manifest_state,
next_manifest,
);
}
}
});
@@ -1152,6 +1363,40 @@ mod tests {
ProxyConfig::default()
}
fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option<u16>) {
let mut config = String::from(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
if ad_tag.is_some() {
config.push_str("\n[general]\n");
if let Some(tag) = ad_tag {
config.push_str(&format!("ad_tag = \"{tag}\"\n"));
}
}
if let Some(port) = server_port {
config.push_str("\n[server]\n");
config.push_str(&format!("port = {port}\n"));
}
std::fs::write(path, config).unwrap();
}
fn temp_config_path(prefix: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{prefix}_{nonce}.toml"))
}
#[test]
fn overlay_applies_hot_and_preserves_non_hot() {
let old = sample_config();
@@ -1219,4 +1464,61 @@ mod tests {
assert_eq!(applied.general.use_middle_proxy, old.general.use_middle_proxy);
assert!(!config_equal(&applied, &new));
}
#[test]
fn reload_requires_stable_snapshot_before_hot_apply() {
let initial_tag = "11111111111111111111111111111111";
let final_tag = "22222222222222222222222222222222";
let path = temp_config_path("telemt_hot_reload_stable");
write_reload_config(&path, Some(initial_tag), None);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path).unwrap().rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_reload_config(&path, None, None);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
assert_eq!(
config_tx.borrow().general.ad_tag.as_deref(),
Some(initial_tag)
);
write_reload_config(&path, Some(final_tag), None);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
assert_eq!(
config_tx.borrow().general.ad_tag.as_deref(),
Some(initial_tag)
);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
assert_eq!(config_tx.borrow().general.ad_tag.as_deref(), Some(final_tag));
let _ = std::fs::remove_file(path);
}
#[test]
fn reload_keeps_hot_apply_when_non_hot_fields_change() {
let initial_tag = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let final_tag = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let path = temp_config_path("telemt_hot_reload_mixed");
write_reload_config(&path, Some(initial_tag), None);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path).unwrap().rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_reload_config(&path, Some(final_tag), Some(initial_cfg.server.port + 1));
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
let applied = config_tx.borrow().clone();
assert_eq!(applied.general.ad_tag.as_deref(), Some(final_tag));
assert_eq!(applied.server.port, initial_cfg.server.port);
let _ = std::fs::remove_file(path);
}
}

View File

@@ -1,8 +1,9 @@
#![allow(deprecated)]
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::path::{Path, PathBuf};
use rand::Rng;
use tracing::warn;
@@ -13,7 +14,37 @@ use crate::error::{ProxyError, Result};
use super::defaults::*;
use super::types::*;
fn preprocess_includes(content: &str, base_dir: &Path, depth: u8) -> Result<String> {
#[derive(Debug, Clone)]
pub(crate) struct LoadedConfig {
pub(crate) config: ProxyConfig,
pub(crate) source_files: Vec<PathBuf>,
pub(crate) rendered_hash: u64,
}
fn normalize_config_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
})
}
fn hash_rendered_snapshot(rendered: &str) -> u64 {
let mut hasher = DefaultHasher::new();
rendered.hash(&mut hasher);
hasher.finish()
}
fn preprocess_includes(
content: &str,
base_dir: &Path,
depth: u8,
source_files: &mut BTreeSet<PathBuf>,
) -> Result<String> {
if depth > 10 {
return Err(ProxyError::Config("Include depth > 10".into()));
}
@@ -25,10 +56,16 @@ fn preprocess_includes(content: &str, base_dir: &Path, depth: u8) -> Result<Stri
if let Some(rest) = rest.strip_prefix('=') {
let path_str = rest.trim().trim_matches('"');
let resolved = base_dir.join(path_str);
source_files.insert(normalize_config_path(&resolved));
let included = std::fs::read_to_string(&resolved)
.map_err(|e| ProxyError::Config(e.to_string()))?;
let included_dir = resolved.parent().unwrap_or(base_dir);
output.push_str(&preprocess_includes(&included, included_dir, depth + 1)?);
output.push_str(&preprocess_includes(
&included,
included_dir,
depth + 1,
source_files,
)?);
output.push('\n');
continue;
}
@@ -138,10 +175,16 @@ pub struct ProxyConfig {
impl ProxyConfig {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content =
std::fs::read_to_string(&path).map_err(|e| ProxyError::Config(e.to_string()))?;
let base_dir = path.as_ref().parent().unwrap_or(Path::new("."));
let processed = preprocess_includes(&content, base_dir, 0)?;
Self::load_with_metadata(path).map(|loaded| loaded.config)
}
pub(crate) fn load_with_metadata<P: AsRef<Path>>(path: P) -> Result<LoadedConfig> {
let path = path.as_ref();
let content = std::fs::read_to_string(path).map_err(|e| ProxyError::Config(e.to_string()))?;
let base_dir = path.parent().unwrap_or(Path::new("."));
let mut source_files = BTreeSet::new();
source_files.insert(normalize_config_path(path));
let processed = preprocess_includes(&content, base_dir, 0, &mut source_files)?;
let parsed_toml: toml::Value =
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
@@ -786,7 +829,11 @@ impl ProxyConfig {
.entry("203".to_string())
.or_insert_with(|| vec!["91.105.192.100:443".to_string()]);
Ok(config)
Ok(LoadedConfig {
config,
source_files: source_files.into_iter().collect(),
rendered_hash: hash_rendered_snapshot(&processed),
})
}
pub fn validate(&self) -> Result<()> {
@@ -1111,6 +1158,48 @@ mod tests {
);
}
#[test]
fn load_with_metadata_collects_include_files() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("telemt_load_metadata_{nonce}"));
std::fs::create_dir_all(&dir).unwrap();
let main_path = dir.join("config.toml");
let include_path = dir.join("included.toml");
std::fs::write(
&include_path,
r#"
[access.users]
user = "00000000000000000000000000000000"
"#,
)
.unwrap();
std::fs::write(
&main_path,
r#"
include = "included.toml"
[censorship]
tls_domain = "example.com"
"#,
)
.unwrap();
let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap();
let main_normalized = normalize_config_path(&main_path);
let include_normalized = normalize_config_path(&include_path);
assert!(loaded.source_files.contains(&main_normalized));
assert!(loaded.source_files.contains(&include_normalized));
let _ = std::fs::remove_file(main_path);
let _ = std::fs::remove_file(include_path);
let _ = std::fs::remove_dir(dir);
}
#[test]
fn dc_overrides_inject_dc203_default() {
let toml = r#"

View File

@@ -3,6 +3,7 @@ use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use super::defaults::*;
@@ -356,6 +357,9 @@ impl Default for NetworkConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default)]
pub data_path: Option<PathBuf>,
#[serde(default)]
pub modes: ProxyModes,
@@ -794,6 +798,11 @@ pub struct GeneralConfig {
#[serde(default = "default_me_pool_drain_ttl_secs")]
pub me_pool_drain_ttl_secs: u64,
/// Maximum allowed number of draining ME writers before oldest ones are force-closed in batches.
/// Set to 0 to disable threshold-based draining cleanup and keep timeout-only behavior.
#[serde(default = "default_me_pool_drain_threshold")]
pub me_pool_drain_threshold: u64,
/// Policy for new binds on stale draining writers.
#[serde(default)]
pub me_bind_stale_mode: MeBindStaleMode,
@@ -866,6 +875,7 @@ pub struct GeneralConfig {
impl Default for GeneralConfig {
fn default() -> Self {
Self {
data_path: None,
modes: ProxyModes::default(),
prefer_ipv6: false,
fast_mode: default_true(),
@@ -973,6 +983,7 @@ impl Default for GeneralConfig {
me_secret_atomic_snapshot: default_me_secret_atomic_snapshot(),
proxy_secret_len_max: default_proxy_secret_len_max(),
me_pool_drain_ttl_secs: default_me_pool_drain_ttl_secs(),
me_pool_drain_threshold: default_me_pool_drain_threshold(),
me_bind_stale_mode: MeBindStaleMode::default(),
me_bind_stale_ttl_secs: default_me_bind_stale_ttl_secs(),
me_pool_min_fresh_ratio: default_me_pool_min_fresh_ratio(),
@@ -1145,6 +1156,13 @@ pub struct ServerConfig {
#[serde(default = "default_proxy_protocol_header_timeout_ms")]
pub proxy_protocol_header_timeout_ms: u64,
/// Trusted source CIDRs allowed to send incoming PROXY protocol headers.
///
/// When non-empty, connections from addresses outside this allowlist are
/// rejected before `src_addr` is applied.
#[serde(default)]
pub proxy_protocol_trusted_cidrs: Vec<IpNetwork>,
#[serde(default)]
pub metrics_port: Option<u16>,
@@ -1156,6 +1174,11 @@ pub struct ServerConfig {
#[serde(default)]
pub listeners: Vec<ListenerConfig>,
/// Maximum number of concurrent client connections.
/// 0 means unlimited.
#[serde(default = "default_server_max_connections")]
pub max_connections: u32,
}
impl Default for ServerConfig {
@@ -1169,10 +1192,12 @@ impl Default for ServerConfig {
listen_tcp: None,
proxy_protocol: false,
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
proxy_protocol_trusted_cidrs: Vec::new(),
metrics_port: None,
metrics_whitelist: default_metrics_whitelist(),
api: ApiConfig::default(),
listeners: Vec::new(),
max_connections: default_server_max_connections(),
}
}
}
@@ -1317,6 +1342,11 @@ pub struct AccessConfig {
#[serde(default)]
pub user_max_unique_ips: HashMap<String, usize>,
/// Global per-user unique IP limit applied when a user has no individual override.
/// `0` disables the inherited limit.
#[serde(default = "default_user_max_unique_ips_global_each")]
pub user_max_unique_ips_global_each: usize,
#[serde(default)]
pub user_max_unique_ips_mode: UserMaxUniqueIpsMode,
@@ -1342,6 +1372,7 @@ impl Default for AccessConfig {
user_expirations: HashMap::new(),
user_data_quota: HashMap::new(),
user_max_unique_ips: HashMap::new(),
user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(),
user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(),
user_max_unique_ips_window_secs: default_user_max_unique_ips_window_secs(),
replay_check_len: default_replay_check_len(),

View File

@@ -17,6 +17,7 @@ pub struct UserIpTracker {
active_ips: Arc<RwLock<HashMap<String, HashMap<IpAddr, usize>>>>,
recent_ips: Arc<RwLock<HashMap<String, HashMap<IpAddr, Instant>>>>,
max_ips: Arc<RwLock<HashMap<String, usize>>>,
default_max_ips: Arc<RwLock<usize>>,
limit_mode: Arc<RwLock<UserMaxUniqueIpsMode>>,
limit_window: Arc<RwLock<Duration>>,
last_compact_epoch_secs: Arc<AtomicU64>,
@@ -28,6 +29,7 @@ impl UserIpTracker {
active_ips: Arc::new(RwLock::new(HashMap::new())),
recent_ips: Arc::new(RwLock::new(HashMap::new())),
max_ips: Arc::new(RwLock::new(HashMap::new())),
default_max_ips: Arc::new(RwLock::new(0)),
limit_mode: Arc::new(RwLock::new(UserMaxUniqueIpsMode::ActiveWindow)),
limit_window: Arc::new(RwLock::new(Duration::from_secs(30))),
last_compact_epoch_secs: Arc::new(AtomicU64::new(0)),
@@ -100,7 +102,10 @@ impl UserIpTracker {
limits.remove(username);
}
pub async fn load_limits(&self, limits: &HashMap<String, usize>) {
pub async fn load_limits(&self, default_limit: usize, limits: &HashMap<String, usize>) {
let mut default_max_ips = self.default_max_ips.write().await;
*default_max_ips = default_limit;
drop(default_max_ips);
let mut max_ips = self.max_ips.write().await;
max_ips.clone_from(limits);
}
@@ -114,9 +119,14 @@ impl UserIpTracker {
pub async fn check_and_add(&self, username: &str, ip: IpAddr) -> Result<(), String> {
self.maybe_compact_empty_users().await;
let default_max_ips = *self.default_max_ips.read().await;
let limit = {
let max_ips = self.max_ips.read().await;
max_ips.get(username).copied()
max_ips
.get(username)
.copied()
.filter(|limit| *limit > 0)
.or((default_max_ips > 0).then_some(default_max_ips))
};
let mode = *self.limit_mode.read().await;
let window = *self.limit_window.read().await;
@@ -255,10 +265,16 @@ impl UserIpTracker {
pub async fn get_stats(&self) -> Vec<(String, usize, usize)> {
let active_ips = self.active_ips.read().await;
let max_ips = self.max_ips.read().await;
let default_max_ips = *self.default_max_ips.read().await;
let mut stats = Vec::new();
for (username, user_ips) in active_ips.iter() {
let limit = max_ips.get(username).copied().unwrap_or(0);
let limit = max_ips
.get(username)
.copied()
.filter(|limit| *limit > 0)
.or((default_max_ips > 0).then_some(default_max_ips))
.unwrap_or(0);
stats.push((username.clone(), user_ips.len(), limit));
}
@@ -293,8 +309,13 @@ impl UserIpTracker {
}
pub async fn get_user_limit(&self, username: &str) -> Option<usize> {
let default_max_ips = *self.default_max_ips.read().await;
let max_ips = self.max_ips.read().await;
max_ips.get(username).copied()
max_ips
.get(username)
.copied()
.filter(|limit| *limit > 0)
.or((default_max_ips > 0).then_some(default_max_ips))
}
pub async fn format_stats(&self) -> String {
@@ -546,7 +567,7 @@ mod tests {
config_limits.insert("user1".to_string(), 5);
config_limits.insert("user2".to_string(), 3);
tracker.load_limits(&config_limits).await;
tracker.load_limits(0, &config_limits).await;
assert_eq!(tracker.get_user_limit("user1").await, Some(5));
assert_eq!(tracker.get_user_limit("user2").await, Some(3));
@@ -560,16 +581,46 @@ mod tests {
let mut first = HashMap::new();
first.insert("user1".to_string(), 2);
first.insert("user2".to_string(), 3);
tracker.load_limits(&first).await;
tracker.load_limits(0, &first).await;
let mut second = HashMap::new();
second.insert("user2".to_string(), 5);
tracker.load_limits(&second).await;
tracker.load_limits(0, &second).await;
assert_eq!(tracker.get_user_limit("user1").await, None);
assert_eq!(tracker.get_user_limit("user2").await, Some(5));
}
#[tokio::test]
async fn test_global_each_limit_applies_without_user_override() {
let tracker = UserIpTracker::new();
tracker.load_limits(2, &HashMap::new()).await;
let ip1 = test_ipv4(172, 16, 0, 1);
let ip2 = test_ipv4(172, 16, 0, 2);
let ip3 = test_ipv4(172, 16, 0, 3);
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
assert!(tracker.check_and_add("test_user", ip2).await.is_ok());
assert!(tracker.check_and_add("test_user", ip3).await.is_err());
assert_eq!(tracker.get_user_limit("test_user").await, Some(2));
}
#[tokio::test]
async fn test_user_override_wins_over_global_each_limit() {
let tracker = UserIpTracker::new();
let mut limits = HashMap::new();
limits.insert("test_user".to_string(), 1);
tracker.load_limits(3, &limits).await;
let ip1 = test_ipv4(172, 17, 0, 1);
let ip2 = test_ipv4(172, 17, 0, 2);
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
assert_eq!(tracker.get_user_limit("test_user").await, Some(1));
}
#[tokio::test]
async fn test_time_window_mode_blocks_recent_ip_churn() {
let tracker = UserIpTracker::new();

View File

@@ -1,4 +1,5 @@
use std::time::Duration;
use std::path::PathBuf;
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
@@ -9,8 +10,9 @@ use crate::transport::middle_proxy::{
ProxyConfigData, fetch_proxy_config_with_raw, load_proxy_config_cache, save_proxy_config_cache,
};
pub(crate) fn parse_cli() -> (String, bool, Option<String>) {
pub(crate) fn parse_cli() -> (String, Option<PathBuf>, bool, Option<String>) {
let mut config_path = "config.toml".to_string();
let mut data_path: Option<PathBuf> = None;
let mut silent = false;
let mut log_level: Option<String> = None;
@@ -28,6 +30,18 @@ pub(crate) fn parse_cli() -> (String, bool, Option<String>) {
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--data-path" => {
i += 1;
if i < args.len() {
data_path = Some(PathBuf::from(args[i].clone()));
} else {
eprintln!("Missing value for --data-path");
std::process::exit(0);
}
}
s if s.starts_with("--data-path=") => {
data_path = Some(PathBuf::from(s.trim_start_matches("--data-path=").to_string()));
}
"--silent" | "-s" => {
silent = true;
}
@@ -44,6 +58,7 @@ pub(crate) fn parse_cli() -> (String, bool, Option<String>) {
eprintln!("Usage: telemt [config.toml] [OPTIONS]");
eprintln!();
eprintln!("Options:");
eprintln!(" --data-path <DIR> Set data directory (absolute path; overrides config value)");
eprintln!(" --silent, -s Suppress info logs");
eprintln!(" --log-level <LEVEL> debug|verbose|normal|silent");
eprintln!(" --help, -h Show this help");
@@ -78,7 +93,7 @@ pub(crate) fn parse_cli() -> (String, bool, Option<String>) {
i += 1;
}
(config_path, silent, log_level)
(config_path, data_path, silent, log_level)
}
pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {

View File

@@ -237,6 +237,7 @@ pub(crate) async fn initialize_me_pool(
config.general.me_adaptive_floor_max_warm_writers_global,
config.general.hardswap,
config.general.me_pool_drain_ttl_secs,
config.general.me_pool_drain_threshold,
config.general.effective_me_pool_force_close_secs(),
config.general.me_pool_min_fresh_ratio,
config.general.me_hardswap_warmup_delay_min_ms,

View File

@@ -58,7 +58,7 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
startup_tracker
.start_component(COMPONENT_CONFIG_LOAD, Some("load and validate config".to_string()))
.await;
let (config_path, cli_silent, cli_log_level) = parse_cli();
let (config_path, data_path, cli_silent, cli_log_level) = parse_cli();
let mut config = match ProxyConfig::load(&config_path) {
Ok(c) => c,
@@ -80,6 +80,34 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
}
if let Some(p) = data_path {
config.general.data_path = Some(p);
}
if let Some(ref data_path) = config.general.data_path {
if !data_path.is_absolute() {
eprintln!("[telemt] data_path must be absolute: {}", data_path.display());
std::process::exit(1);
}
if data_path.exists() {
if !data_path.is_dir() {
eprintln!("[telemt] data_path exists but is not a directory: {}", data_path.display());
std::process::exit(1);
}
} else {
if let Err(e) = std::fs::create_dir_all(data_path) {
eprintln!("[telemt] Can't create data_path {}: {}", data_path.display(), e);
std::process::exit(1);
}
}
if let Err(e) = std::env::set_current_dir(data_path) {
eprintln!("[telemt] Can't use data_path {}: {}", data_path.display(), e);
std::process::exit(1);
}
}
if let Err(e) = crate::network::dns_overrides::install_entries(&config.network.dns_overrides) {
eprintln!("[telemt] Invalid network.dns_overrides: {}", e);
std::process::exit(1);
@@ -168,17 +196,24 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
stats.clone(),
));
let ip_tracker = Arc::new(UserIpTracker::new());
ip_tracker.load_limits(&config.access.user_max_unique_ips).await;
ip_tracker
.load_limits(
config.access.user_max_unique_ips_global_each,
&config.access.user_max_unique_ips,
)
.await;
ip_tracker
.set_limit_policy(
config.access.user_max_unique_ips_mode,
config.access.user_max_unique_ips_window_secs,
)
.await;
if !config.access.user_max_unique_ips.is_empty() {
if config.access.user_max_unique_ips_global_each > 0 || !config.access.user_max_unique_ips.is_empty()
{
info!(
"IP limits configured for {} users",
config.access.user_max_unique_ips.len()
global_each_limit = config.access.user_max_unique_ips_global_each,
explicit_user_limits = config.access.user_max_unique_ips.len(),
"User unique IP limits configured"
);
}
if !config.network.dns_overrides.is_empty() {
@@ -314,8 +349,13 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
let beobachten = Arc::new(BeobachtenStore::new());
let rng = Arc::new(SecureRandom::new());
// Connection concurrency limit
let max_connections = Arc::new(Semaphore::new(10_000));
// Connection concurrency limit (0 = unlimited)
let max_connections_limit = if config.server.max_connections == 0 {
Semaphore::MAX_PERMITS
} else {
config.server.max_connections as usize
};
let max_connections = Arc::new(Semaphore::new(max_connections_limit));
let me2dc_fallback = config.general.me2dc_fallback;
let me_init_retry_attempts = config.general.me_init_retry_attempts;

View File

@@ -131,6 +131,10 @@ pub(crate) async fn spawn_runtime_tasks(
let mut config_rx_ip_limits = config_rx.clone();
tokio::spawn(async move {
let mut prev_limits = config_rx_ip_limits.borrow().access.user_max_unique_ips.clone();
let mut prev_global_each = config_rx_ip_limits
.borrow()
.access
.user_max_unique_ips_global_each;
let mut prev_mode = config_rx_ip_limits.borrow().access.user_max_unique_ips_mode;
let mut prev_window = config_rx_ip_limits
.borrow()
@@ -143,9 +147,17 @@ pub(crate) async fn spawn_runtime_tasks(
}
let cfg = config_rx_ip_limits.borrow_and_update().clone();
if prev_limits != cfg.access.user_max_unique_ips {
ip_tracker_policy.load_limits(&cfg.access.user_max_unique_ips).await;
if prev_limits != cfg.access.user_max_unique_ips
|| prev_global_each != cfg.access.user_max_unique_ips_global_each
{
ip_tracker_policy
.load_limits(
cfg.access.user_max_unique_ips_global_each,
&cfg.access.user_max_unique_ips,
)
.await;
prev_limits = cfg.access.user_max_unique_ips.clone();
prev_global_each = cfg.access.user_max_unique_ips_global_each;
}
if prev_mode != cfg.access.user_max_unique_ips_mode

View File

@@ -1774,14 +1774,24 @@ async fn render_metrics(stats: &Stats, config: &ProxyConfig, ip_tracker: &UserIp
"# HELP telemt_user_unique_ips_recent_window Per-user unique IPs seen in configured observation window"
);
let _ = writeln!(out, "# TYPE telemt_user_unique_ips_recent_window gauge");
let _ = writeln!(out, "# HELP telemt_user_unique_ips_limit Per-user configured unique IP limit (0 means unlimited)");
let _ = writeln!(out, "# HELP telemt_user_unique_ips_limit Effective per-user unique IP limit (0 means unlimited)");
let _ = writeln!(out, "# TYPE telemt_user_unique_ips_limit gauge");
let _ = writeln!(out, "# HELP telemt_user_unique_ips_utilization Per-user unique IP usage ratio (0 for unlimited)");
let _ = writeln!(out, "# TYPE telemt_user_unique_ips_utilization gauge");
for user in unique_users {
let current = ip_counts.get(&user).copied().unwrap_or(0);
let limit = config.access.user_max_unique_ips.get(&user).copied().unwrap_or(0);
let limit = config
.access
.user_max_unique_ips
.get(&user)
.copied()
.filter(|limit| *limit > 0)
.or(
(config.access.user_max_unique_ips_global_each > 0)
.then_some(config.access.user_max_unique_ips_global_each),
)
.unwrap_or(0);
let utilization = if limit > 0 {
current as f64 / limit as f64
} else {
@@ -1904,6 +1914,25 @@ mod tests {
assert!(output.contains("telemt_user_unique_ips_recent_window{user="));
}
#[tokio::test]
async fn test_render_uses_global_each_unique_ip_limit() {
let stats = Stats::new();
stats.increment_user_connects("alice");
stats.increment_user_curr_connects("alice");
let tracker = UserIpTracker::new();
tracker
.check_and_add("alice", "203.0.113.10".parse().unwrap())
.await
.unwrap();
let mut config = ProxyConfig::default();
config.access.user_max_unique_ips_global_each = 2;
let output = render_metrics(&stats, &config, &tracker).await;
assert!(output.contains("telemt_user_unique_ips_limit{user=\"alice\"} 2"));
assert!(output.contains("telemt_user_unique_ips_utilization{user=\"alice\"} 0.500000"));
}
#[tokio::test]
async fn test_render_has_type_annotations() {
let stats = Stats::new();

View File

@@ -13,6 +13,7 @@ use super::constants::*;
use std::time::{SystemTime, UNIX_EPOCH};
use num_bigint::BigUint;
use num_traits::One;
use subtle::ConstantTimeEq;
// ============= Public Constants =============
@@ -28,6 +29,8 @@ pub const TLS_DIGEST_HALF_LEN: usize = 16;
/// Time skew limits for anti-replay (in seconds)
pub const TIME_SKEW_MIN: i64 = -20 * 60; // 20 minutes before
pub const TIME_SKEW_MAX: i64 = 10 * 60; // 10 minutes after
/// Maximum accepted boot-time timestamp (seconds) before skew checks are enforced.
pub const BOOT_TIME_MAX_SECS: u32 = 7 * 24 * 60 * 60;
// ============= Private Constants =============
@@ -125,7 +128,7 @@ impl TlsExtensionBuilder {
// protocol name length (1 byte)
// protocol name bytes
let proto_len = proto.len() as u8;
let list_len: u16 = 1 + proto_len as u16;
let list_len: u16 = 1 + u16::from(proto_len);
let ext_len: u16 = 2 + list_len;
self.extensions.extend_from_slice(&ext_len.to_be_bytes());
@@ -273,13 +276,86 @@ impl ServerHelloBuilder {
// ============= Public Functions =============
/// Validate TLS ClientHello against user secrets
/// Validate TLS ClientHello against user secrets.
///
/// Returns validation result if a matching user is found.
/// The result **must** be used — ignoring it silently bypasses authentication.
#[must_use]
pub fn validate_tls_handshake(
handshake: &[u8],
secrets: &[(String, Vec<u8>)],
ignore_time_skew: bool,
) -> Option<TlsValidation> {
validate_tls_handshake_with_replay_window(
handshake,
secrets,
ignore_time_skew,
u64::from(BOOT_TIME_MAX_SECS),
)
}
/// Validate TLS ClientHello and cap the boot-time bypass by replay-cache TTL.
///
/// A boot-time timestamp is only accepted when it falls below both
/// `BOOT_TIME_MAX_SECS` and the configured replay window, preventing timestamp
/// reuse outside replay cache coverage.
#[must_use]
pub fn validate_tls_handshake_with_replay_window(
handshake: &[u8],
secrets: &[(String, Vec<u8>)],
ignore_time_skew: bool,
replay_window_secs: u64,
) -> Option<TlsValidation> {
// Only pay the clock syscall when we will actually compare against it.
// If `ignore_time_skew` is set, a broken or unavailable system clock
// must not block legitimate clients — that would be a DoS via clock failure.
let now = if !ignore_time_skew {
system_time_to_unix_secs(SystemTime::now())?
} else {
0_i64
};
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
let boot_time_cap_secs = BOOT_TIME_MAX_SECS.min(replay_window_u32);
validate_tls_handshake_at_time_with_boot_cap(
handshake,
secrets,
ignore_time_skew,
now,
boot_time_cap_secs,
)
}
fn system_time_to_unix_secs(now: SystemTime) -> Option<i64> {
// `try_from` rejects values that overflow i64 (> ~292 billion years CE),
// whereas `as i64` would silently wrap to a negative timestamp and corrupt
// every subsequent time-skew comparison.
let d = now.duration_since(UNIX_EPOCH).ok()?;
i64::try_from(d.as_secs()).ok()
}
fn validate_tls_handshake_at_time(
handshake: &[u8],
secrets: &[(String, Vec<u8>)],
ignore_time_skew: bool,
now: i64,
) -> Option<TlsValidation> {
validate_tls_handshake_at_time_with_boot_cap(
handshake,
secrets,
ignore_time_skew,
now,
BOOT_TIME_MAX_SECS,
)
}
fn validate_tls_handshake_at_time_with_boot_cap(
handshake: &[u8],
secrets: &[(String, Vec<u8>)],
ignore_time_skew: bool,
now: i64,
boot_time_cap_secs: u32,
) -> Option<TlsValidation> {
if handshake.len() < TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 {
return None;
@@ -305,50 +381,56 @@ pub fn validate_tls_handshake(
let mut msg = handshake.to_vec();
msg[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0);
// Get current time
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let mut first_match: Option<TlsValidation> = None;
for (user, secret) in secrets {
let computed = sha256_hmac(secret, &msg);
// XOR digests
let xored: Vec<u8> = digest.iter()
.zip(computed.iter())
.map(|(a, b)| a ^ b)
.collect();
// Check that first 28 bytes are zeros (timestamp in last 4)
if !xored[..28].iter().all(|&b| b == 0) {
// Constant-time equality check on the 28-byte HMAC window.
// A variable-time short-circuit here lets an active censor measure how many
// bytes matched, enabling secret brute-force via timing side-channels.
// Direct comparison on the original arrays avoids a heap allocation and
// removes the `try_into().unwrap()` that the intermediate Vec would require.
if !bool::from(digest[..28].ct_eq(&computed[..28])) {
continue;
}
// Extract timestamp
let timestamp = u32::from_le_bytes(xored[28..32].try_into().unwrap());
let time_diff = now - timestamp as i64;
// Check time skew
// The last 4 bytes encode the timestamp as XOR(digest[28..32], computed[28..32]).
// Inline array construction is infallible: both slices are [u8; 32] by construction.
let timestamp = u32::from_le_bytes([
digest[28] ^ computed[28],
digest[29] ^ computed[29],
digest[30] ^ computed[30],
digest[31] ^ computed[31],
]);
// time_diff is only meaningful (and `now` is only valid) when we are
// actually checking the window. Keep both inside the guard to make
// the dead-code path explicit and prevent accidental future use of
// a sentinel `now` value outside its intended scope.
if !ignore_time_skew {
// Allow very small timestamps (boot time instead of unix time)
// This is a quirk in some clients that use uptime instead of real time
let is_boot_time = timestamp < 60 * 60 * 24 * 1000; // < ~2.7 years in seconds
if !is_boot_time && !(TIME_SKEW_MIN..=TIME_SKEW_MAX).contains(&time_diff) {
continue;
let is_boot_time = timestamp < boot_time_cap_secs;
if !is_boot_time {
let time_diff = now - i64::from(timestamp);
if !(TIME_SKEW_MIN..=TIME_SKEW_MAX).contains(&time_diff) {
continue;
}
}
}
return Some(TlsValidation {
user: user.clone(),
session_id,
digest,
timestamp,
});
if first_match.is_none() {
first_match = Some(TlsValidation {
user: user.clone(),
session_id: session_id.clone(),
digest,
timestamp,
});
}
}
None
first_match
}
fn curve25519_prime() -> BigUint {
@@ -528,7 +610,9 @@ pub fn extract_sni_from_client_hello(handshake: &[u8]) -> Option<String> {
if name_type == 0 && name_len > 0
&& let Ok(host) = std::str::from_utf8(&handshake[sn_pos..sn_pos + name_len])
{
return Some(host.to_string());
if is_valid_sni_hostname(host) {
return Some(host.to_string());
}
}
sn_pos += name_len;
}
@@ -539,6 +623,35 @@ pub fn extract_sni_from_client_hello(handshake: &[u8]) -> Option<String> {
None
}
fn is_valid_sni_hostname(host: &str) -> bool {
if host.is_empty() || host.len() > 253 {
return false;
}
if host.starts_with('.') || host.ends_with('.') {
return false;
}
if host.parse::<std::net::IpAddr>().is_ok() {
return false;
}
for label in host.split('.') {
if label.is_empty() || label.len() > 63 {
return false;
}
if label.starts_with('-') || label.ends_with('-') {
return false;
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return false;
}
}
true
}
/// Extract ALPN protocol list from ClientHello, return in offered order.
pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec<Vec<u8>> {
let mut pos = 5; // after record header
@@ -667,291 +780,29 @@ fn validate_server_hello_structure(data: &[u8]) -> Result<(), ProxyError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_tls_handshake() {
assert!(is_tls_handshake(&[0x16, 0x03, 0x01]));
assert!(is_tls_handshake(&[0x16, 0x03, 0x01, 0x02, 0x00]));
assert!(!is_tls_handshake(&[0x17, 0x03, 0x01])); // Application data
assert!(!is_tls_handshake(&[0x16, 0x03, 0x02])); // Wrong version
assert!(!is_tls_handshake(&[0x16, 0x03])); // Too short
}
#[test]
fn test_parse_tls_record_header() {
let header = [0x16, 0x03, 0x01, 0x02, 0x00];
let result = parse_tls_record_header(&header).unwrap();
assert_eq!(result.0, TLS_RECORD_HANDSHAKE);
assert_eq!(result.1, 512);
let header = [0x17, 0x03, 0x03, 0x40, 0x00];
let result = parse_tls_record_header(&header).unwrap();
assert_eq!(result.0, TLS_RECORD_APPLICATION);
assert_eq!(result.1, 16384);
}
#[test]
fn test_gen_fake_x25519_key() {
let rng = SecureRandom::new();
let key1 = gen_fake_x25519_key(&rng);
let key2 = gen_fake_x25519_key(&rng);
assert_eq!(key1.len(), 32);
assert_eq!(key2.len(), 32);
assert_ne!(key1, key2); // Should be random
}
// ============= Compile-time Security Invariants =============
#[test]
fn test_fake_x25519_key_is_quadratic_residue() {
let rng = SecureRandom::new();
let key = gen_fake_x25519_key(&rng);
let p = curve25519_prime();
let k_num = BigUint::from_bytes_le(&key);
let exponent = (&p - BigUint::one()) >> 1;
let legendre = k_num.modpow(&exponent, &p);
assert_eq!(legendre, BigUint::one());
}
#[test]
fn test_tls_extension_builder() {
let key = [0x42u8; 32];
let mut builder = TlsExtensionBuilder::new();
builder.add_key_share(&key);
builder.add_supported_versions(0x0304);
let result = builder.build();
// Check length prefix
let len = u16::from_be_bytes([result[0], result[1]]) as usize;
assert_eq!(len, result.len() - 2);
// Check key_share extension is present
assert!(result.len() > 40); // At least key share
}
#[test]
fn test_server_hello_builder() {
let session_id = vec![0x01, 0x02, 0x03, 0x04];
let key = [0x55u8; 32];
let builder = ServerHelloBuilder::new(session_id.clone())
.with_x25519_key(&key)
.with_tls13_version();
let record = builder.build_record();
// Validate structure
validate_server_hello_structure(&record).expect("Invalid ServerHello structure");
// Check record type
assert_eq!(record[0], TLS_RECORD_HANDSHAKE);
// Check version
assert_eq!(&record[1..3], &TLS_VERSION);
// Check message type (ServerHello = 0x02)
assert_eq!(record[5], 0x02);
}
#[test]
fn test_build_server_hello_structure() {
let secret = b"test secret";
let client_digest = [0x42u8; 32];
let session_id = vec![0xAA; 32];
let rng = SecureRandom::new();
let response = build_server_hello(secret, &client_digest, &session_id, 2048, &rng, None, 0);
// Should have at least 3 records
assert!(response.len() > 100);
// First record should be ServerHello
assert_eq!(response[0], TLS_RECORD_HANDSHAKE);
// Validate ServerHello structure
validate_server_hello_structure(&response).expect("Invalid ServerHello");
// Find Change Cipher Spec
let server_hello_len = 5 + u16::from_be_bytes([response[3], response[4]]) as usize;
let ccs_start = server_hello_len;
assert!(response.len() > ccs_start + 6);
assert_eq!(response[ccs_start], TLS_RECORD_CHANGE_CIPHER);
// Find Application Data
let ccs_len = 5 + u16::from_be_bytes([response[ccs_start + 3], response[ccs_start + 4]]) as usize;
let app_start = ccs_start + ccs_len;
assert!(response.len() > app_start + 5);
assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
}
#[test]
fn test_build_server_hello_digest() {
let secret = b"test secret key here";
let client_digest = [0x42u8; 32];
let session_id = vec![0xAA; 32];
let rng = SecureRandom::new();
let response1 = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 0);
let response2 = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 0);
// Digest position should have non-zero data
let digest1 = &response1[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN];
assert!(!digest1.iter().all(|&b| b == 0));
// Different calls should have different digests (due to random cert)
let digest2 = &response2[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN];
assert_ne!(digest1, digest2);
}
#[test]
fn test_server_hello_extensions_length() {
let session_id = vec![0x01; 32];
let key = [0x55u8; 32];
let builder = ServerHelloBuilder::new(session_id)
.with_x25519_key(&key)
.with_tls13_version();
let record = builder.build_record();
// Parse to find extensions
let msg_start = 5; // After record header
let msg_len = u32::from_be_bytes([0, record[6], record[7], record[8]]) as usize;
// Skip to session ID
let session_id_pos = msg_start + 4 + 2 + 32; // header(4) + version(2) + random(32)
let session_id_len = record[session_id_pos] as usize;
// Skip to extensions
let ext_len_pos = session_id_pos + 1 + session_id_len + 2 + 1; // session_id + cipher(2) + compression(1)
let ext_len = u16::from_be_bytes([record[ext_len_pos], record[ext_len_pos + 1]]) as usize;
// Verify extensions length matches actual data
let extensions_data = &record[ext_len_pos + 2..msg_start + 4 + msg_len];
assert_eq!(ext_len, extensions_data.len(),
"Extension length mismatch: declared {}, actual {}", ext_len, extensions_data.len());
}
#[test]
fn test_validate_tls_handshake_format() {
// Build a minimal ClientHello-like structure
let mut handshake = vec![0u8; 100];
// Put a valid-looking digest at position 11
handshake[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN]
.copy_from_slice(&[0x42; 32]);
// Session ID length
handshake[TLS_DIGEST_POS + TLS_DIGEST_LEN] = 32;
// This won't validate (wrong HMAC) but shouldn't panic
let secrets = vec![("test".to_string(), b"secret".to_vec())];
let result = validate_tls_handshake(&handshake, &secrets, true);
// Should return None (no match) but not panic
assert!(result.is_none());
}
/// Compile-time checks that enforce invariants the rest of the code relies on.
/// Using `static_assertions` ensures these can never silently break across
/// refactors without a compile error.
mod compile_time_security_checks {
use super::{TLS_DIGEST_LEN, TLS_DIGEST_HALF_LEN};
use static_assertions::const_assert;
fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); // legacy version
body.extend_from_slice(&[0u8; 32]); // random
body.push(0); // session id len
body.extend_from_slice(&2u16.to_be_bytes()); // cipher suites len
body.extend_from_slice(&[0x13, 0x01]); // TLS_AES_128_GCM_SHA256
body.push(1); // compression len
body.push(0); // null compression
// The digest must be exactly one SHA-256 output.
const_assert!(TLS_DIGEST_LEN == 32);
// Build SNI extension
let host_bytes = host.as_bytes();
let mut sni_ext = Vec::new();
sni_ext.extend_from_slice(&(host_bytes.len() as u16 + 3).to_be_bytes());
sni_ext.push(0);
sni_ext.extend_from_slice(&(host_bytes.len() as u16).to_be_bytes());
sni_ext.extend_from_slice(host_bytes);
// Replay-dedup stores the first half; verify it is literally half.
const_assert!(TLS_DIGEST_HALF_LEN * 2 == TLS_DIGEST_LEN);
let mut ext_blob = Vec::new();
for (typ, data) in exts {
ext_blob.extend_from_slice(&typ.to_be_bytes());
ext_blob.extend_from_slice(&(data.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&data);
}
// SNI last
ext_blob.extend_from_slice(&0x0000u16.to_be_bytes());
ext_blob.extend_from_slice(&(sni_ext.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_ext);
body.extend_from_slice(&(ext_blob.len() as u16).to_be_bytes());
body.extend_from_slice(&ext_blob);
let mut handshake = Vec::new();
handshake.push(0x01); // ClientHello
let len_bytes = (body.len() as u32).to_be_bytes();
handshake.extend_from_slice(&len_bytes[1..4]);
handshake.extend_from_slice(&body);
let mut record = Vec::new();
record.push(TLS_RECORD_HANDSHAKE);
record.extend_from_slice(&[0x03, 0x01]);
record.extend_from_slice(&(handshake.len() as u16).to_be_bytes());
record.extend_from_slice(&handshake);
record
}
#[test]
fn test_extract_sni_with_grease_extension() {
// GREASE type 0x0a0a with zero length before SNI
let ch = build_client_hello_with_exts(vec![(0x0a0a, Vec::new())], "example.com");
let sni = extract_sni_from_client_hello(&ch);
assert_eq!(sni.as_deref(), Some("example.com"));
}
#[test]
fn test_extract_sni_tolerates_empty_unknown_extension() {
let ch = build_client_hello_with_exts(vec![(0x1234, Vec::new())], "test.local");
let sni = extract_sni_from_client_hello(&ch);
assert_eq!(sni.as_deref(), Some("test.local"));
}
#[test]
fn test_extract_alpn_single() {
let mut alpn_data = Vec::new();
// list length = 3 (1 length byte + "h2")
alpn_data.extend_from_slice(&3u16.to_be_bytes());
alpn_data.push(2);
alpn_data.extend_from_slice(b"h2");
let ch = build_client_hello_with_exts(vec![(0x0010, alpn_data)], "alpn.test");
let alpn = extract_alpn_from_client_hello(&ch);
let alpn_str: Vec<String> = alpn
.iter()
.map(|p| std::str::from_utf8(p).unwrap().to_string())
.collect();
assert_eq!(alpn_str, vec!["h2"]);
}
#[test]
fn test_extract_alpn_multiple() {
let mut alpn_data = Vec::new();
// list length = 11 (sum of per-proto lengths including length bytes)
alpn_data.extend_from_slice(&11u16.to_be_bytes());
alpn_data.push(2);
alpn_data.extend_from_slice(b"h2");
alpn_data.push(4);
alpn_data.extend_from_slice(b"spdy");
alpn_data.push(2);
alpn_data.extend_from_slice(b"h3");
let ch = build_client_hello_with_exts(vec![(0x0010, alpn_data)], "alpn.test");
let alpn = extract_alpn_from_client_hello(&ch);
let alpn_str: Vec<String> = alpn
.iter()
.map(|p| std::str::from_utf8(p).unwrap().to_string())
.collect();
assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]);
}
// The HMAC check window (28 bytes) plus the embedded timestamp (4 bytes)
// must exactly fill the digest. If TLS_DIGEST_LEN ever changes, these
// assertions will catch the mismatch before any timing-oracle fix is broke.
const_assert!(28 + 4 == TLS_DIGEST_LEN);
}
// ============= Security-focused regression tests =============
#[cfg(test)]
#[path = "tls_security_tests.rs"]
mod security_tests;

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,10 @@ use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use ipnetwork::IpNetwork;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
use tokio::net::TcpStream;
use tokio::time::timeout;
@@ -23,7 +26,7 @@ enum HandshakeOutcome {
use crate::config::ProxyConfig;
use crate::crypto::SecureRandom;
use crate::error::{HandshakeResult, ProxyError, Result};
use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
use crate::ip_tracker::UserIpTracker;
use crate::protocol::constants::*;
use crate::protocol::tls;
@@ -63,14 +66,30 @@ fn record_handshake_failure_class(
peer_ip: IpAddr,
error: &ProxyError,
) {
let class = if error.to_string().contains("expected 64 bytes, got 0") {
"expected_64_got_0"
} else {
"other"
let class = match error {
ProxyError::Io(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {
"expected_64_got_0"
}
ProxyError::Stream(StreamError::UnexpectedEof) => "expected_64_got_0",
_ => "other",
};
record_beobachten_class(beobachten, config, peer_ip, class);
}
fn is_trusted_proxy_source(peer_ip: IpAddr, trusted: &[IpNetwork]) -> bool {
if trusted.is_empty() {
static EMPTY_PROXY_TRUST_WARNED: OnceLock<AtomicBool> = OnceLock::new();
let warned = EMPTY_PROXY_TRUST_WARNED.get_or_init(|| AtomicBool::new(false));
if !warned.swap(true, Ordering::Relaxed) {
warn!(
"PROXY protocol enabled but server.proxy_protocol_trusted_cidrs is empty; rejecting all PROXY headers by default"
);
}
return false;
}
trusted.iter().any(|cidr| cidr.contains(peer_ip))
}
pub async fn handle_client_stream<S>(
mut stream: S,
peer: SocketAddr,
@@ -104,6 +123,17 @@ where
);
match timeout(proxy_header_timeout, parse_proxy_protocol(&mut stream, peer)).await {
Ok(Ok(info)) => {
if !is_trusted_proxy_source(peer.ip(), &config.server.proxy_protocol_trusted_cidrs)
{
stats.increment_connects_bad();
warn!(
peer = %peer,
trusted = ?config.server.proxy_protocol_trusted_cidrs,
"Rejecting PROXY protocol header from untrusted source"
);
record_beobachten_class(&beobachten, &config, peer.ip(), "other");
return Err(ProxyError::InvalidProxyProtocol);
}
debug!(
peer = %peer,
client = %info.src_addr,
@@ -149,8 +179,13 @@ where
if is_tls {
let tls_len = u16::from_be_bytes([first_bytes[3], first_bytes[4]]) as usize;
if tls_len < 512 {
debug!(peer = %real_peer, tls_len = tls_len, "TLS handshake too short");
// RFC 8446 §5.1 mandates that TLSPlaintext records must not exceed 2^14
// bytes (16_384). A client claiming a larger record is non-compliant and
// may be an active probe attempting to force large allocations.
//
// Also enforce a minimum record size to avoid trivial/garbage probes.
if !(512..=MAX_TLS_RECORD_SIZE).contains(&tls_len) {
debug!(peer = %real_peer, tls_len = tls_len, max_tls_len = MAX_TLS_RECORD_SIZE, "TLS handshake length out of bounds");
stats.increment_connects_bad();
let (reader, writer) = tokio::io::split(stream);
handle_bad_client(
@@ -204,9 +239,19 @@ where
&config, &replay_checker, true, Some(tls_user.as_str()),
).await {
HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader: _, writer: _ } => {
HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad();
debug!(peer = %peer, "Valid TLS but invalid MTProto handshake");
handle_bad_client(
reader,
writer,
&mtproto_handshake,
real_peer,
local_addr,
&config,
&beobachten,
)
.await;
return Ok(HandshakeOutcome::Handled);
}
HandshakeResult::Error(e) => return Err(e),
@@ -445,6 +490,24 @@ impl RunningClientHandler {
.await
{
Ok(Ok(info)) => {
if !is_trusted_proxy_source(
self.peer.ip(),
&self.config.server.proxy_protocol_trusted_cidrs,
) {
self.stats.increment_connects_bad();
warn!(
peer = %self.peer,
trusted = ?self.config.server.proxy_protocol_trusted_cidrs,
"Rejecting PROXY protocol header from untrusted source"
);
record_beobachten_class(
&self.beobachten,
&self.config,
self.peer.ip(),
"other",
);
return Err(ProxyError::InvalidProxyProtocol);
}
debug!(
peer = %self.peer,
client = %info.src_addr,
@@ -513,8 +576,10 @@ impl RunningClientHandler {
debug!(peer = %peer, tls_len = tls_len, "Reading TLS handshake");
if tls_len < 512 {
debug!(peer = %peer, tls_len = tls_len, "TLS handshake too short");
// See RFC 8446 §5.1: TLSPlaintext records must not exceed 16_384 bytes.
// Treat too-small or too-large lengths as active probes and mask them.
if !(512..=MAX_TLS_RECORD_SIZE).contains(&tls_len) {
debug!(peer = %peer, tls_len = tls_len, max_tls_len = MAX_TLS_RECORD_SIZE, "TLS handshake length out of bounds");
self.stats.increment_connects_bad();
let (reader, writer) = self.stream.into_split();
handle_bad_client(
@@ -590,12 +655,19 @@ impl RunningClientHandler {
.await
{
HandshakeResult::Success(result) => result,
HandshakeResult::BadClient {
reader: _,
writer: _,
} => {
HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad();
debug!(peer = %peer, "Valid TLS but invalid MTProto handshake");
handle_bad_client(
reader,
writer,
&mtproto_handshake,
peer,
local_addr,
&config,
&self.beobachten,
)
.await;
return Ok(HandshakeOutcome::Handled);
}
HandshakeResult::Error(e) => return Err(e),
@@ -742,7 +814,7 @@ impl RunningClientHandler {
client_writer,
success,
pool.clone(),
stats,
stats.clone(),
config,
buffer_pool,
local_addr,
@@ -759,7 +831,7 @@ impl RunningClientHandler {
client_writer,
success,
upstream_manager,
stats,
stats.clone(),
config,
buffer_pool,
rng,
@@ -776,7 +848,7 @@ impl RunningClientHandler {
client_writer,
success,
upstream_manager,
stats,
stats.clone(),
config,
buffer_pool,
rng,
@@ -787,6 +859,7 @@ impl RunningClientHandler {
.await
};
stats.decrement_user_curr_connects(&user);
ip_tracker.remove_ip(&user, peer_addr.ip()).await;
relay_result
}
@@ -806,9 +879,29 @@ impl RunningClientHandler {
});
}
let ip_reserved = match ip_tracker.check_and_add(user, peer_addr.ip()).await {
Ok(()) => true,
if let Some(quota) = config.access.user_data_quota.get(user)
&& stats.get_user_total_octets(user) >= *quota
{
return Err(ProxyError::DataQuotaExceeded {
user: user.to_string(),
});
}
let limit = config
.access
.user_max_tcp_conns
.get(user)
.map(|v| *v as u64);
if !stats.try_acquire_user_curr_connects(user, limit) {
return Err(ProxyError::ConnectionLimitExceeded {
user: user.to_string(),
});
}
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
Ok(()) => {}
Err(reason) => {
stats.decrement_user_curr_connects(user);
warn!(
user = %user,
ip = %peer_addr.ip(),
@@ -819,33 +912,12 @@ impl RunningClientHandler {
user: user.to_string(),
});
}
};
// IP limit check
if let Some(limit) = config.access.user_max_tcp_conns.get(user)
&& stats.get_user_curr_connects(user) >= *limit as u64
{
if ip_reserved {
ip_tracker.remove_ip(user, peer_addr.ip()).await;
stats.increment_ip_reservation_rollback_tcp_limit_total();
}
return Err(ProxyError::ConnectionLimitExceeded {
user: user.to_string(),
});
}
if let Some(quota) = config.access.user_data_quota.get(user)
&& stats.get_user_total_octets(user) >= *quota
{
if ip_reserved {
ip_tracker.remove_ip(user, peer_addr.ip()).await;
stats.increment_ip_reservation_rollback_quota_limit_total();
}
return Err(ProxyError::DataQuotaExceeded {
user: user.to_string(),
});
}
Ok(())
}
}
#[cfg(test)]
#[path = "client_security_tests.rs"]
mod security_tests;

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ use std::fs::OpenOptions;
use std::io::Write;
use std::net::SocketAddr;
use std::sync::Arc;
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
@@ -22,6 +24,45 @@ use crate::stats::Stats;
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
use crate::transport::UpstreamManager;
const UNKNOWN_DC_LOG_DISTINCT_LIMIT: usize = 1024;
static LOGGED_UNKNOWN_DCS: OnceLock<Mutex<HashSet<i16>>> = OnceLock::new();
// In tests, this function shares global mutable state. Callers that also use
// cache-reset helpers must hold `unknown_dc_test_lock()` to keep assertions
// deterministic under parallel execution.
fn should_log_unknown_dc(dc_idx: i16) -> bool {
let set = LOGGED_UNKNOWN_DCS.get_or_init(|| Mutex::new(HashSet::new()));
match set.lock() {
Ok(mut guard) => {
if guard.contains(&dc_idx) {
return false;
}
if guard.len() >= UNKNOWN_DC_LOG_DISTINCT_LIMIT {
return false;
}
guard.insert(dc_idx)
}
// If the lock is poisoned, keep logging rather than silently dropping
// operator-visible diagnostics.
Err(_) => true,
}
}
#[cfg(test)]
fn clear_unknown_dc_log_cache_for_testing() {
if let Some(set) = LOGGED_UNKNOWN_DCS.get()
&& let Ok(mut guard) = set.lock()
{
guard.clear();
}
}
#[cfg(test)]
fn unknown_dc_test_lock() -> &'static Mutex<()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK.get_or_init(|| Mutex::new(()))
}
pub(crate) async fn handle_via_direct<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
@@ -64,7 +105,6 @@ where
debug!(peer = %success.peer, "TG handshake complete, starting relay");
stats.increment_user_connects(user);
stats.increment_user_curr_connects(user);
stats.increment_current_connections_direct();
let relay_result = relay_bidirectional(
@@ -109,7 +149,6 @@ where
};
stats.decrement_current_connections_direct();
stats.decrement_user_curr_connects(user);
match &relay_result {
Ok(()) => debug!(user = %user, "Direct relay completed"),
@@ -160,6 +199,7 @@ fn get_dc_addr_static(dc_idx: i16, config: &ProxyConfig) -> Result<SocketAddr> {
warn!(dc_idx = dc_idx, "Requested non-standard DC with no override; falling back to default cluster");
if config.general.unknown_dc_file_log_enabled
&& let Some(path) = &config.general.unknown_dc_log_path
&& should_log_unknown_dc(dc_idx)
&& let Ok(handle) = tokio::runtime::Handle::try_current()
{
let path = path.clone();
@@ -175,7 +215,7 @@ fn get_dc_addr_static(dc_idx: i16, config: &ProxyConfig) -> Result<SocketAddr> {
let fallback_idx = if default_dc >= 1 && default_dc <= num_dcs {
default_dc - 1
} else {
1
0
};
info!(
@@ -203,8 +243,6 @@ async fn do_tg_handshake_static(
let (nonce, _tg_enc_key, _tg_enc_iv, _tg_dec_key, _tg_dec_iv) = generate_tg_nonce(
success.proto_tag,
success.dc_idx,
&success.dec_key,
success.dec_iv,
&success.enc_key,
success.enc_iv,
rng,
@@ -230,3 +268,7 @@ async fn do_tg_handshake_static(
CryptoWriter::new(write_half, tg_encryptor, max_pending),
))
}
#[cfg(test)]
#[path = "direct_relay_security_tests.rs"]
mod security_tests;

View File

@@ -0,0 +1,51 @@
use super::*;
#[test]
fn unknown_dc_log_is_deduplicated_per_dc_idx() {
let _guard = unknown_dc_test_lock()
.lock()
.expect("unknown dc test lock must be available");
clear_unknown_dc_log_cache_for_testing();
assert!(should_log_unknown_dc(777));
assert!(
!should_log_unknown_dc(777),
"same unknown dc_idx must not be logged repeatedly"
);
assert!(
should_log_unknown_dc(778),
"different unknown dc_idx must still be loggable"
);
}
#[test]
fn unknown_dc_log_respects_distinct_limit() {
let _guard = unknown_dc_test_lock()
.lock()
.expect("unknown dc test lock must be available");
clear_unknown_dc_log_cache_for_testing();
for dc in 1..=UNKNOWN_DC_LOG_DISTINCT_LIMIT {
assert!(
should_log_unknown_dc(dc as i16),
"expected first-time unknown dc_idx to be loggable"
);
}
assert!(
!should_log_unknown_dc(i16::MAX),
"distinct unknown dc_idx entries above limit must not be logged"
);
}
#[test]
fn fallback_dc_never_panics_with_single_dc_list() {
let mut cfg = ProxyConfig::default();
cfg.network.prefer = 6;
cfg.network.ipv6 = Some(true);
cfg.default_dc = Some(42);
let addr = get_dc_addr_static(999, &cfg).expect("fallback dc must resolve safely");
let expected = SocketAddr::new(TG_DATACENTERS_V6[0], TG_DATACENTER_PORT);
assert_eq!(addr, expected);
}

View File

@@ -3,8 +3,12 @@
#![allow(dead_code)]
use std::net::SocketAddr;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use dashmap::DashMap;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tracing::{debug, warn, trace};
use zeroize::Zeroize;
@@ -19,6 +23,231 @@ use crate::stats::ReplayChecker;
use crate::config::ProxyConfig;
use crate::tls_front::{TlsFrontCache, emulator};
const ACCESS_SECRET_BYTES: usize = 16;
static INVALID_SECRET_WARNED: OnceLock<Mutex<HashSet<(String, String)>>> = OnceLock::new();
const AUTH_PROBE_TRACK_RETENTION_SECS: u64 = 10 * 60;
#[cfg(test)]
const AUTH_PROBE_TRACK_MAX_ENTRIES: usize = 256;
#[cfg(not(test))]
const AUTH_PROBE_TRACK_MAX_ENTRIES: usize = 65_536;
const AUTH_PROBE_PRUNE_SCAN_LIMIT: usize = 1_024;
const AUTH_PROBE_BACKOFF_START_FAILS: u32 = 4;
#[cfg(test)]
const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1;
#[cfg(not(test))]
const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 25;
#[cfg(test)]
const AUTH_PROBE_BACKOFF_MAX_MS: u64 = 16;
#[cfg(not(test))]
const AUTH_PROBE_BACKOFF_MAX_MS: u64 = 1_000;
#[derive(Clone, Copy)]
struct AuthProbeState {
fail_streak: u32,
blocked_until: Instant,
last_seen: Instant,
}
static AUTH_PROBE_STATE: OnceLock<DashMap<IpAddr, AuthProbeState>> = OnceLock::new();
fn auth_probe_state_map() -> &'static DashMap<IpAddr, AuthProbeState> {
AUTH_PROBE_STATE.get_or_init(DashMap::new)
}
fn auth_probe_backoff(fail_streak: u32) -> Duration {
if fail_streak < AUTH_PROBE_BACKOFF_START_FAILS {
return Duration::ZERO;
}
let shift = (fail_streak - AUTH_PROBE_BACKOFF_START_FAILS).min(10);
let multiplier = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
let ms = AUTH_PROBE_BACKOFF_BASE_MS
.saturating_mul(multiplier)
.min(AUTH_PROBE_BACKOFF_MAX_MS);
Duration::from_millis(ms)
}
fn auth_probe_state_expired(state: &AuthProbeState, now: Instant) -> bool {
let retention = Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS);
now.duration_since(state.last_seen) > retention
}
fn auth_probe_is_throttled(peer_ip: IpAddr, now: Instant) -> bool {
let state = auth_probe_state_map();
let Some(entry) = state.get(&peer_ip) else {
return false;
};
if auth_probe_state_expired(&entry, now) {
drop(entry);
state.remove(&peer_ip);
return false;
}
now < entry.blocked_until
}
fn auth_probe_record_failure(peer_ip: IpAddr, now: Instant) {
let state = auth_probe_state_map();
auth_probe_record_failure_with_state(state, peer_ip, now);
}
fn auth_probe_record_failure_with_state(
state: &DashMap<IpAddr, AuthProbeState>,
peer_ip: IpAddr,
now: Instant,
) {
if let Some(mut entry) = state.get_mut(&peer_ip) {
if auth_probe_state_expired(&entry, now) {
*entry = AuthProbeState {
fail_streak: 1,
blocked_until: now + auth_probe_backoff(1),
last_seen: now,
};
return;
}
entry.fail_streak = entry.fail_streak.saturating_add(1);
entry.last_seen = now;
entry.blocked_until = now + auth_probe_backoff(entry.fail_streak);
return;
};
if state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES {
let mut stale_keys = Vec::new();
for entry in state.iter().take(AUTH_PROBE_PRUNE_SCAN_LIMIT) {
if auth_probe_state_expired(entry.value(), now) {
stale_keys.push(*entry.key());
}
}
for stale_key in stale_keys {
state.remove(&stale_key);
}
if state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES {
return;
}
}
state.insert(peer_ip, AuthProbeState {
fail_streak: 0,
blocked_until: now,
last_seen: now,
});
if let Some(mut entry) = state.get_mut(&peer_ip) {
entry.fail_streak = 1;
entry.blocked_until = now + auth_probe_backoff(1);
}
}
fn auth_probe_record_success(peer_ip: IpAddr) {
let state = auth_probe_state_map();
state.remove(&peer_ip);
}
#[cfg(test)]
fn clear_auth_probe_state_for_testing() {
if let Some(state) = AUTH_PROBE_STATE.get() {
state.clear();
}
}
#[cfg(test)]
fn auth_probe_fail_streak_for_testing(peer_ip: IpAddr) -> Option<u32> {
let state = AUTH_PROBE_STATE.get()?;
state.get(&peer_ip).map(|entry| entry.fail_streak)
}
#[cfg(test)]
fn auth_probe_is_throttled_for_testing(peer_ip: IpAddr) -> bool {
auth_probe_is_throttled(peer_ip, Instant::now())
}
#[cfg(test)]
fn auth_probe_test_lock() -> &'static Mutex<()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK.get_or_init(|| Mutex::new(()))
}
#[cfg(test)]
fn clear_warned_secrets_for_testing() {
if let Some(warned) = INVALID_SECRET_WARNED.get()
&& let Ok(mut guard) = warned.lock()
{
guard.clear();
}
}
fn warn_invalid_secret_once(name: &str, reason: &str, expected: usize, got: Option<usize>) {
let key = (name.to_string(), reason.to_string());
let warned = INVALID_SECRET_WARNED.get_or_init(|| Mutex::new(HashSet::new()));
let should_warn = match warned.lock() {
Ok(mut guard) => guard.insert(key),
Err(_) => true,
};
if !should_warn {
return;
}
match got {
Some(actual) => {
warn!(
user = %name,
expected = expected,
got = actual,
"Skipping user: access secret has unexpected length"
);
}
None => {
warn!(
user = %name,
"Skipping user: access secret is not valid hex"
);
}
}
}
fn decode_user_secret(name: &str, secret_hex: &str) -> Option<Vec<u8>> {
match hex::decode(secret_hex) {
Ok(bytes) if bytes.len() == ACCESS_SECRET_BYTES => Some(bytes),
Ok(bytes) => {
warn_invalid_secret_once(
name,
"invalid_length",
ACCESS_SECRET_BYTES,
Some(bytes.len()),
);
None
}
Err(_) => {
warn_invalid_secret_once(name, "invalid_hex", ACCESS_SECRET_BYTES, None);
None
}
}
}
// Decide whether a client-supplied proto tag is allowed given the configured
// proxy modes and the transport that carried the handshake.
//
// A common mistake is to treat `modes.tls` and `modes.secure` as interchangeable
// even though they correspond to different transport profiles: `modes.tls` is
// for the TLS-fronted (EE-TLS) path, while `modes.secure` is for direct MTProto
// over TCP (DD). Enforcing this separation prevents an attacker from using a
// TLS-capable client to bypass the operator intent for the direct MTProto mode,
// and vice versa.
fn mode_enabled_for_proto(config: &ProxyConfig, proto_tag: ProtoTag, is_tls: bool) -> bool {
match proto_tag {
ProtoTag::Secure => {
if is_tls {
config.general.modes.tls
} else {
config.general.modes.secure
}
}
ProtoTag::Intermediate | ProtoTag::Abridged => config.general.modes.classic,
}
}
fn decode_user_secrets(
config: &ProxyConfig,
preferred_user: Option<&str>,
@@ -27,7 +256,7 @@ fn decode_user_secrets(
if let Some(preferred) = preferred_user
&& let Some(secret_hex) = config.access.users.get(preferred)
&& let Ok(bytes) = hex::decode(secret_hex)
&& let Some(bytes) = decode_user_secret(preferred, secret_hex)
{
secrets.push((preferred.to_string(), bytes));
}
@@ -36,7 +265,7 @@ fn decode_user_secrets(
if preferred_user.is_some_and(|preferred| preferred == name.as_str()) {
continue;
}
if let Ok(bytes) = hex::decode(secret_hex) {
if let Some(bytes) = decode_user_secret(name, secret_hex) {
secrets.push((name.clone(), bytes));
}
}
@@ -48,7 +277,7 @@ fn decode_user_secrets(
///
/// Key material (`dec_key`, `dec_iv`, `enc_key`, `enc_iv`) is
/// zeroized on drop.
#[derive(Debug, Clone)]
#[derive(Debug)]
pub struct HandshakeSuccess {
/// Authenticated user name
pub user: String,
@@ -94,28 +323,27 @@ where
{
debug!(peer = %peer, handshake_len = handshake.len(), "Processing TLS handshake");
if auth_probe_is_throttled(peer.ip(), Instant::now()) {
debug!(peer = %peer, "TLS handshake rejected by pre-auth probe throttle");
return HandshakeResult::BadClient { reader, writer };
}
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
debug!(peer = %peer, "TLS handshake too short");
return HandshakeResult::BadClient { reader, writer };
}
let digest = &handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN];
let digest_half = &digest[..tls::TLS_DIGEST_HALF_LEN];
if replay_checker.check_and_add_tls_digest(digest_half) {
warn!(peer = %peer, "TLS replay attack detected (duplicate digest)");
return HandshakeResult::BadClient { reader, writer };
}
let secrets = decode_user_secrets(config, None);
let validation = match tls::validate_tls_handshake(
let validation = match tls::validate_tls_handshake_with_replay_window(
handshake,
&secrets,
config.access.ignore_time_skew,
config.access.replay_window_secs,
) {
Some(v) => v,
None => {
auth_probe_record_failure(peer.ip(), Instant::now());
debug!(
peer = %peer,
ignore_time_skew = config.access.ignore_time_skew,
@@ -125,6 +353,15 @@ where
}
};
// Replay tracking is applied only after successful authentication to avoid
// letting unauthenticated probes evict valid entries from the replay cache.
let digest_half = &validation.digest[..tls::TLS_DIGEST_HALF_LEN];
if replay_checker.check_and_add_tls_digest(digest_half) {
auth_probe_record_failure(peer.ip(), Instant::now());
warn!(peer = %peer, "TLS replay attack detected (duplicate digest)");
return HandshakeResult::BadClient { reader, writer };
}
let secret = match secrets.iter().find(|(name, _)| *name == validation.user) {
Some((_, s)) => s,
None => return HandshakeResult::BadClient { reader, writer },
@@ -166,6 +403,9 @@ where
Some(b"h2".to_vec())
} else if alpn_list.iter().any(|p| p == b"http/1.1") {
Some(b"http/1.1".to_vec())
} else if !alpn_list.is_empty() {
debug!(peer = %peer, "Client ALPN list has no supported protocol; using masking fallback");
return HandshakeResult::BadClient { reader, writer };
} else {
None
}
@@ -228,6 +468,8 @@ where
"TLS handshake successful"
);
auth_probe_record_success(peer.ip());
HandshakeResult::Success((
FakeTlsReader::new(reader),
FakeTlsWriter::new(writer),
@@ -252,13 +494,13 @@ where
{
trace!(peer = %peer, handshake = ?hex::encode(handshake), "MTProto handshake bytes");
let dec_prekey_iv = &handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN];
if replay_checker.check_and_add_handshake(dec_prekey_iv) {
warn!(peer = %peer, "MTProto replay attack detected");
if auth_probe_is_throttled(peer.ip(), Instant::now()) {
debug!(peer = %peer, "MTProto handshake rejected by pre-auth probe throttle");
return HandshakeResult::BadClient { reader, writer };
}
let dec_prekey_iv = &handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN];
let enc_prekey_iv: Vec<u8> = dec_prekey_iv.iter().rev().copied().collect();
let decoded_users = decode_user_secrets(config, preferred_user);
@@ -273,39 +515,33 @@ where
dec_key_input.extend_from_slice(&secret);
let dec_key = sha256(&dec_key_input);
let dec_iv = u128::from_be_bytes(dec_iv_bytes.try_into().unwrap());
let mut dec_iv_arr = [0u8; IV_LEN];
dec_iv_arr.copy_from_slice(dec_iv_bytes);
let dec_iv = u128::from_be_bytes(dec_iv_arr);
let mut decryptor = AesCtr::new(&dec_key, dec_iv);
let decrypted = decryptor.decrypt(handshake);
let tag_bytes: [u8; 4] = decrypted[PROTO_TAG_POS..PROTO_TAG_POS + 4]
.try_into()
.unwrap();
let tag_bytes: [u8; 4] = [
decrypted[PROTO_TAG_POS],
decrypted[PROTO_TAG_POS + 1],
decrypted[PROTO_TAG_POS + 2],
decrypted[PROTO_TAG_POS + 3],
];
let proto_tag = match ProtoTag::from_bytes(tag_bytes) {
Some(tag) => tag,
None => continue,
};
let mode_ok = match proto_tag {
ProtoTag::Secure => {
if is_tls {
config.general.modes.tls || config.general.modes.secure
} else {
config.general.modes.secure || config.general.modes.tls
}
}
ProtoTag::Intermediate | ProtoTag::Abridged => config.general.modes.classic,
};
let mode_ok = mode_enabled_for_proto(config, proto_tag, is_tls);
if !mode_ok {
debug!(peer = %peer, user = %user, proto = ?proto_tag, "Mode not enabled");
continue;
}
let dc_idx = i16::from_le_bytes(
decrypted[DC_IDX_POS..DC_IDX_POS + 2].try_into().unwrap()
);
let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]);
let enc_prekey = &enc_prekey_iv[..PREKEY_LEN];
let enc_iv_bytes = &enc_prekey_iv[PREKEY_LEN..];
@@ -315,10 +551,24 @@ where
enc_key_input.extend_from_slice(&secret);
let enc_key = sha256(&enc_key_input);
let enc_iv = u128::from_be_bytes(enc_iv_bytes.try_into().unwrap());
let mut enc_iv_arr = [0u8; IV_LEN];
enc_iv_arr.copy_from_slice(enc_iv_bytes);
let enc_iv = u128::from_be_bytes(enc_iv_arr);
let encryptor = AesCtr::new(&enc_key, enc_iv);
// Apply replay tracking only after successful authentication.
//
// This ordering prevents an attacker from producing invalid handshakes that
// still collide with a valid handshake's replay slot and thus evict a valid
// entry from the cache. We accept the cost of performing the full
// authentication check first to avoid poisoning the replay cache.
if replay_checker.check_and_add_handshake(dec_prekey_iv) {
auth_probe_record_failure(peer.ip(), Instant::now());
warn!(peer = %peer, user = %user, "MTProto replay attack detected");
return HandshakeResult::BadClient { reader, writer };
}
let success = HandshakeSuccess {
user: user.clone(),
dc_idx,
@@ -340,6 +590,8 @@ where
"MTProto handshake successful"
);
auth_probe_record_success(peer.ip());
let max_pending = config.general.crypto_pending_buffer;
return HandshakeResult::Success((
CryptoReader::new(reader, decryptor),
@@ -348,6 +600,7 @@ where
));
}
auth_probe_record_failure(peer.ip(), Instant::now());
debug!(peer = %peer, "MTProto handshake: no matching user found");
HandshakeResult::BadClient { reader, writer }
}
@@ -356,8 +609,6 @@ where
pub fn generate_tg_nonce(
proto_tag: ProtoTag,
dc_idx: i16,
_client_dec_key: &[u8; 32],
_client_dec_iv: u128,
client_enc_key: &[u8; 32],
client_enc_iv: u128,
rng: &SecureRandom,
@@ -365,14 +616,16 @@ pub fn generate_tg_nonce(
) -> ([u8; HANDSHAKE_LEN], [u8; 32], u128, [u8; 32], u128) {
loop {
let bytes = rng.bytes(HANDSHAKE_LEN);
let mut nonce: [u8; HANDSHAKE_LEN] = bytes.try_into().unwrap();
let Ok(mut nonce): Result<[u8; HANDSHAKE_LEN], _> = bytes.try_into() else {
continue;
};
if RESERVED_NONCE_FIRST_BYTES.contains(&nonce[0]) { continue; }
let first_four: [u8; 4] = nonce[..4].try_into().unwrap();
let first_four: [u8; 4] = [nonce[0], nonce[1], nonce[2], nonce[3]];
if RESERVED_NONCE_BEGINNINGS.contains(&first_four) { continue; }
let continue_four: [u8; 4] = nonce[4..8].try_into().unwrap();
let continue_four: [u8; 4] = [nonce[4], nonce[5], nonce[6], nonce[7]];
if RESERVED_NONCE_CONTINUES.contains(&continue_four) { continue; }
nonce[PROTO_TAG_POS..PROTO_TAG_POS + 4].copy_from_slice(&proto_tag.to_bytes());
@@ -390,11 +643,17 @@ pub fn generate_tg_nonce(
let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN];
let dec_key_iv: Vec<u8> = enc_key_iv.iter().rev().copied().collect();
let tg_enc_key: [u8; 32] = enc_key_iv[..KEY_LEN].try_into().unwrap();
let tg_enc_iv = u128::from_be_bytes(enc_key_iv[KEY_LEN..].try_into().unwrap());
let mut tg_enc_key = [0u8; 32];
tg_enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]);
let mut tg_enc_iv_arr = [0u8; IV_LEN];
tg_enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]);
let tg_enc_iv = u128::from_be_bytes(tg_enc_iv_arr);
let tg_dec_key: [u8; 32] = dec_key_iv[..KEY_LEN].try_into().unwrap();
let tg_dec_iv = u128::from_be_bytes(dec_key_iv[KEY_LEN..].try_into().unwrap());
let mut tg_dec_key = [0u8; 32];
tg_dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]);
let mut tg_dec_iv_arr = [0u8; IV_LEN];
tg_dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]);
let tg_dec_iv = u128::from_be_bytes(tg_dec_iv_arr);
return (nonce, tg_enc_key, tg_enc_iv, tg_dec_key, tg_dec_iv);
}
@@ -405,11 +664,17 @@ pub fn encrypt_tg_nonce_with_ciphers(nonce: &[u8; HANDSHAKE_LEN]) -> (Vec<u8>, A
let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN];
let dec_key_iv: Vec<u8> = enc_key_iv.iter().rev().copied().collect();
let enc_key: [u8; 32] = enc_key_iv[..KEY_LEN].try_into().unwrap();
let enc_iv = u128::from_be_bytes(enc_key_iv[KEY_LEN..].try_into().unwrap());
let mut enc_key = [0u8; 32];
enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]);
let mut enc_iv_arr = [0u8; IV_LEN];
enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]);
let enc_iv = u128::from_be_bytes(enc_iv_arr);
let dec_key: [u8; 32] = dec_key_iv[..KEY_LEN].try_into().unwrap();
let dec_iv = u128::from_be_bytes(dec_key_iv[KEY_LEN..].try_into().unwrap());
let mut dec_key = [0u8; 32];
dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]);
let mut dec_iv_arr = [0u8; IV_LEN];
dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]);
let dec_iv = u128::from_be_bytes(dec_iv_arr);
let mut encryptor = AesCtr::new(&enc_key, enc_iv);
let encrypted_full = encryptor.encrypt(nonce); // counter: 0 → 4
@@ -429,80 +694,15 @@ pub fn encrypt_tg_nonce(nonce: &[u8; HANDSHAKE_LEN]) -> Vec<u8> {
}
#[cfg(test)]
mod tests {
use super::*;
#[path = "handshake_security_tests.rs"]
mod security_tests;
#[test]
fn test_generate_tg_nonce() {
let client_dec_key = [0x42u8; 32];
let client_dec_iv = 12345u128;
let client_enc_key = [0x24u8; 32];
let client_enc_iv = 54321u128;
/// Compile-time guard: HandshakeSuccess holds cryptographic key material and
/// must never be Copy. A Copy impl would allow silent key duplication,
/// undermining the zeroize-on-drop guarantee.
mod compile_time_security_checks {
use super::HandshakeSuccess;
use static_assertions::assert_not_impl_all;
let rng = SecureRandom::new();
let (nonce, _tg_enc_key, _tg_enc_iv, _tg_dec_key, _tg_dec_iv) =
generate_tg_nonce(
ProtoTag::Secure,
2,
&client_dec_key,
client_dec_iv,
&client_enc_key,
client_enc_iv,
&rng,
false,
);
assert_eq!(nonce.len(), HANDSHAKE_LEN);
let tag_bytes: [u8; 4] = nonce[PROTO_TAG_POS..PROTO_TAG_POS + 4].try_into().unwrap();
assert_eq!(ProtoTag::from_bytes(tag_bytes), Some(ProtoTag::Secure));
}
#[test]
fn test_encrypt_tg_nonce() {
let client_dec_key = [0x42u8; 32];
let client_dec_iv = 12345u128;
let client_enc_key = [0x24u8; 32];
let client_enc_iv = 54321u128;
let rng = SecureRandom::new();
let (nonce, _, _, _, _) =
generate_tg_nonce(
ProtoTag::Secure,
2,
&client_dec_key,
client_dec_iv,
&client_enc_key,
client_enc_iv,
&rng,
false,
);
let encrypted = encrypt_tg_nonce(&nonce);
assert_eq!(encrypted.len(), HANDSHAKE_LEN);
assert_eq!(&encrypted[..PROTO_TAG_POS], &nonce[..PROTO_TAG_POS]);
assert_ne!(&encrypted[PROTO_TAG_POS..], &nonce[PROTO_TAG_POS..]);
}
#[test]
fn test_handshake_success_zeroize_on_drop() {
let success = HandshakeSuccess {
user: "test".to_string(),
dc_idx: 2,
proto_tag: ProtoTag::Secure,
dec_key: [0xAA; 32],
dec_iv: 0xBBBBBBBB,
enc_key: [0xCC; 32],
enc_iv: 0xDDDDDDDD,
peer: "127.0.0.1:1234".parse().unwrap(),
is_tls: true,
};
assert_eq!(success.dec_key, [0xAA; 32]);
assert_eq!(success.enc_key, [0xCC; 32]);
drop(success);
// Drop impl zeroizes key material without panic
}
assert_not_impl_all!(HandshakeSuccess: Copy, Clone);
}

View File

@@ -0,0 +1,891 @@
use super::*;
use crate::crypto::sha256_hmac;
use dashmap::DashMap;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::{Duration, Instant};
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];
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
}
fn make_valid_tls_client_hello_with_alpn(
secret: &[u8],
timestamp: u32,
alpn_protocols: &[&[u8]],
) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]);
body.push(32);
body.extend_from_slice(&[0x42u8; 32]);
body.extend_from_slice(&2u16.to_be_bytes());
body.extend_from_slice(&[0x13, 0x01]);
body.push(1);
body.push(0);
let mut ext_blob = Vec::new();
if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new();
for proto in alpn_protocols {
alpn_list.push(proto.len() as u8);
alpn_list.extend_from_slice(proto);
}
let mut alpn_data = Vec::new();
alpn_data.extend_from_slice(&(alpn_list.len() as u16).to_be_bytes());
alpn_data.extend_from_slice(&alpn_list);
ext_blob.extend_from_slice(&0x0010u16.to_be_bytes());
ext_blob.extend_from_slice(&(alpn_data.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&alpn_data);
}
body.extend_from_slice(&(ext_blob.len() as u16).to_be_bytes());
body.extend_from_slice(&ext_blob);
let mut handshake = Vec::new();
handshake.push(0x01);
let body_len = (body.len() as u32).to_be_bytes();
handshake.extend_from_slice(&body_len[1..4]);
handshake.extend_from_slice(&body);
let mut record = Vec::new();
record.push(TLS_RECORD_HANDSHAKE);
record.extend_from_slice(&[0x03, 0x01]);
record.extend_from_slice(&(handshake.len() as u16).to_be_bytes());
record.extend_from_slice(&handshake);
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &record);
let mut digest = computed;
let ts = timestamp.to_le_bytes();
for i in 0..4 {
digest[28 + i] ^= ts[i];
}
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.copy_from_slice(&digest);
record
}
fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
clear_auth_probe_state_for_testing();
let mut cfg = ProxyConfig::default();
cfg.access.users.clear();
cfg.access
.users
.insert("user".to_string(), secret_hex.to_string());
cfg.access.ignore_time_skew = true;
cfg
}
#[test]
fn test_generate_tg_nonce() {
let client_enc_key = [0x24u8; 32];
let client_enc_iv = 54321u128;
let rng = SecureRandom::new();
let (nonce, _tg_enc_key, _tg_enc_iv, _tg_dec_key, _tg_dec_iv) = generate_tg_nonce(
ProtoTag::Secure,
2,
&client_enc_key,
client_enc_iv,
&rng,
false,
);
assert_eq!(nonce.len(), HANDSHAKE_LEN);
let tag_bytes: [u8; 4] = nonce[PROTO_TAG_POS..PROTO_TAG_POS + 4].try_into().unwrap();
assert_eq!(ProtoTag::from_bytes(tag_bytes), Some(ProtoTag::Secure));
}
#[test]
fn test_encrypt_tg_nonce() {
let client_enc_key = [0x24u8; 32];
let client_enc_iv = 54321u128;
let rng = SecureRandom::new();
let (nonce, _, _, _, _) = generate_tg_nonce(
ProtoTag::Secure,
2,
&client_enc_key,
client_enc_iv,
&rng,
false,
);
let encrypted = encrypt_tg_nonce(&nonce);
assert_eq!(encrypted.len(), HANDSHAKE_LEN);
assert_eq!(&encrypted[..PROTO_TAG_POS], &nonce[..PROTO_TAG_POS]);
assert_ne!(&encrypted[PROTO_TAG_POS..], &nonce[PROTO_TAG_POS..]);
}
#[test]
fn test_handshake_success_drop_does_not_panic() {
let success = HandshakeSuccess {
user: "test".to_string(),
dc_idx: 2,
proto_tag: ProtoTag::Secure,
dec_key: [0xAA; 32],
dec_iv: 0xBBBBBBBB,
enc_key: [0xCC; 32],
enc_iv: 0xDDDDDDDD,
peer: "198.51.100.10:1234".parse().unwrap(),
is_tls: true,
};
assert_eq!(success.dec_key, [0xAA; 32]);
assert_eq!(success.enc_key, [0xCC; 32]);
drop(success);
}
#[test]
fn test_generate_tg_nonce_enc_dec_material_is_consistent() {
let client_enc_key = [0x34u8; 32];
let client_enc_iv = 0xffeeddccbbaa00998877665544332211u128;
let rng = SecureRandom::new();
let (nonce, tg_enc_key, tg_enc_iv, tg_dec_key, tg_dec_iv) = generate_tg_nonce(
ProtoTag::Secure,
7,
&client_enc_key,
client_enc_iv,
&rng,
false,
);
let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN];
let dec_key_iv: Vec<u8> = enc_key_iv.iter().rev().copied().collect();
let mut expected_tg_enc_key = [0u8; 32];
expected_tg_enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]);
let mut expected_tg_enc_iv_arr = [0u8; IV_LEN];
expected_tg_enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]);
let expected_tg_enc_iv = u128::from_be_bytes(expected_tg_enc_iv_arr);
let mut expected_tg_dec_key = [0u8; 32];
expected_tg_dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]);
let mut expected_tg_dec_iv_arr = [0u8; IV_LEN];
expected_tg_dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]);
let expected_tg_dec_iv = u128::from_be_bytes(expected_tg_dec_iv_arr);
assert_eq!(tg_enc_key, expected_tg_enc_key);
assert_eq!(tg_enc_iv, expected_tg_enc_iv);
assert_eq!(tg_dec_key, expected_tg_dec_key);
assert_eq!(tg_dec_iv, expected_tg_dec_iv);
assert_eq!(
i16::from_le_bytes([nonce[DC_IDX_POS], nonce[DC_IDX_POS + 1]]),
7,
"Generated nonce must keep target dc index in protocol slot"
);
}
#[test]
fn test_generate_tg_nonce_fast_mode_embeds_reversed_client_enc_material() {
let client_enc_key = [0xABu8; 32];
let client_enc_iv = 0x11223344556677889900aabbccddeeffu128;
let rng = SecureRandom::new();
let (nonce, _, _, _, _) = generate_tg_nonce(
ProtoTag::Secure,
9,
&client_enc_key,
client_enc_iv,
&rng,
true,
);
let mut expected = Vec::with_capacity(KEY_LEN + IV_LEN);
expected.extend_from_slice(&client_enc_key);
expected.extend_from_slice(&client_enc_iv.to_be_bytes());
expected.reverse();
assert_eq!(&nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN], expected.as_slice());
}
#[test]
fn test_encrypt_tg_nonce_with_ciphers_matches_manual_suffix_encryption() {
let client_enc_key = [0x24u8; 32];
let client_enc_iv = 54321u128;
let rng = SecureRandom::new();
let (nonce, _, _, _, _) = generate_tg_nonce(
ProtoTag::Secure,
2,
&client_enc_key,
client_enc_iv,
&rng,
false,
);
let (encrypted, _, _) = encrypt_tg_nonce_with_ciphers(&nonce);
let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN];
let mut expected_enc_key = [0u8; 32];
expected_enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]);
let mut expected_enc_iv_arr = [0u8; IV_LEN];
expected_enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]);
let expected_enc_iv = u128::from_be_bytes(expected_enc_iv_arr);
let mut manual_encryptor = AesCtr::new(&expected_enc_key, expected_enc_iv);
let manual = manual_encryptor.encrypt(&nonce);
assert_eq!(encrypted.len(), HANDSHAKE_LEN);
assert_eq!(&encrypted[..PROTO_TAG_POS], &nonce[..PROTO_TAG_POS]);
assert_eq!(
&encrypted[PROTO_TAG_POS..],
&manual[PROTO_TAG_POS..],
"Encrypted nonce suffix must match AES-CTR output with derived enc key/iv"
);
}
#[tokio::test]
async fn tls_replay_second_identical_handshake_is_rejected() {
let secret = [0x11u8; 16];
let config = test_config_with_secret_hex("11111111111111111111111111111111");
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.21:44321".parse().unwrap();
let handshake = make_valid_tls_handshake(&secret, 0);
let first = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(first, HandshakeResult::Success(_)));
let second = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(second, HandshakeResult::BadClient { .. }));
}
#[tokio::test]
async fn tls_replay_concurrent_identical_handshake_allows_exactly_one_success() {
let secret = [0x77u8; 16];
let config = Arc::new(test_config_with_secret_hex("77777777777777777777777777777777"));
let replay_checker = Arc::new(ReplayChecker::new(4096, Duration::from_secs(60)));
let rng = Arc::new(SecureRandom::new());
let handshake = Arc::new(make_valid_tls_handshake(&secret, 0));
let mut tasks = Vec::new();
for _ in 0..50 {
let config = config.clone();
let replay_checker = replay_checker.clone();
let rng = rng.clone();
let handshake = handshake.clone();
tasks.push(tokio::spawn(async move {
handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
"198.51.100.22:45000".parse().unwrap(),
&config,
&replay_checker,
&rng,
None,
)
.await
}));
}
let mut success_count = 0usize;
for task in tasks {
let result = task.await.unwrap();
if matches!(result, HandshakeResult::Success(_)) {
success_count += 1;
} else {
assert!(matches!(result, HandshakeResult::BadClient { .. }));
}
}
assert_eq!(
success_count, 1,
"Concurrent replay attempts must allow exactly one successful handshake"
);
}
#[tokio::test]
async fn invalid_tls_probe_does_not_pollute_replay_cache() {
let config = test_config_with_secret_hex("11111111111111111111111111111111");
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.23:44322".parse().unwrap();
let mut invalid = vec![0x42u8; tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + 32];
invalid[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = 32;
let before = replay_checker.stats();
let result = handle_tls_handshake(
&invalid,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
let after = replay_checker.stats();
assert!(matches!(result, HandshakeResult::BadClient { .. }));
assert_eq!(before.total_additions, after.total_additions);
assert_eq!(before.total_hits, after.total_hits);
}
#[tokio::test]
async fn empty_decoded_secret_is_rejected() {
clear_warned_secrets_for_testing();
let config = test_config_with_secret_hex("");
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.24:44323".parse().unwrap();
let handshake = make_valid_tls_handshake(&[], 0);
let result = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::BadClient { .. }));
}
#[tokio::test]
async fn wrong_length_decoded_secret_is_rejected() {
clear_warned_secrets_for_testing();
let config = test_config_with_secret_hex("aa");
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.25:44324".parse().unwrap();
let handshake = make_valid_tls_handshake(&[0xaau8], 0);
let result = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::BadClient { .. }));
}
#[tokio::test]
async fn invalid_mtproto_probe_does_not_pollute_replay_cache() {
let config = test_config_with_secret_hex("11111111111111111111111111111111");
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let peer: SocketAddr = "198.51.100.26:44325".parse().unwrap();
let handshake = [0u8; HANDSHAKE_LEN];
let before = replay_checker.stats();
let result = handle_mtproto_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
false,
None,
)
.await;
let after = replay_checker.stats();
assert!(matches!(result, HandshakeResult::BadClient { .. }));
assert_eq!(before.total_additions, after.total_additions);
assert_eq!(before.total_hits, after.total_hits);
}
#[tokio::test]
async fn mixed_secret_lengths_keep_valid_user_authenticating() {
clear_warned_secrets_for_testing();
clear_auth_probe_state_for_testing();
let good_secret = [0x22u8; 16];
let mut config = ProxyConfig::default();
config.access.users.clear();
config
.access
.users
.insert("broken_user".to_string(), "aa".to_string());
config
.access
.users
.insert("valid_user".to_string(), "22222222222222222222222222222222".to_string());
config.access.ignore_time_skew = true;
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.27:44326".parse().unwrap();
let handshake = make_valid_tls_handshake(&good_secret, 0);
let result = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::Success(_)));
}
#[tokio::test]
async fn alpn_enforce_rejects_unsupported_client_alpn() {
let secret = [0x33u8; 16];
let mut config = test_config_with_secret_hex("33333333333333333333333333333333");
config.censorship.alpn_enforce = true;
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.28:44327".parse().unwrap();
let handshake = make_valid_tls_client_hello_with_alpn(&secret, 0, &[b"h3"]);
let result = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::BadClient { .. }));
}
#[tokio::test]
async fn alpn_enforce_accepts_h2() {
let secret = [0x44u8; 16];
let mut config = test_config_with_secret_hex("44444444444444444444444444444444");
config.censorship.alpn_enforce = true;
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.29:44328".parse().unwrap();
let handshake = make_valid_tls_client_hello_with_alpn(&secret, 0, &[b"h2", b"h3"]);
let result = handle_tls_handshake(
&handshake,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::Success(_)));
}
#[tokio::test]
async fn malformed_tls_classes_complete_within_bounded_time() {
let secret = [0x55u8; 16];
let mut config = test_config_with_secret_hex("55555555555555555555555555555555");
config.censorship.alpn_enforce = true;
let replay_checker = ReplayChecker::new(512, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.30:44329".parse().unwrap();
let too_short = vec![0x16, 0x03, 0x01];
let mut bad_hmac = make_valid_tls_handshake(&secret, 0);
bad_hmac[tls::TLS_DIGEST_POS] ^= 0x01;
let alpn_mismatch = make_valid_tls_client_hello_with_alpn(&secret, 0, &[b"h3"]);
for probe in [too_short, bad_hmac, alpn_mismatch] {
let result = tokio::time::timeout(
Duration::from_millis(200),
handle_tls_handshake(
&probe,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
),
)
.await
.expect("Malformed TLS classes must be rejected within bounded time");
assert!(matches!(result, HandshakeResult::BadClient { .. }));
}
}
#[tokio::test]
#[ignore = "timing-sensitive; run manually on low-jitter hosts"]
async fn malformed_tls_classes_share_close_latency_buckets() {
const ITER: usize = 24;
const BUCKET_MS: u128 = 10;
let secret = [0x99u8; 16];
let mut config = test_config_with_secret_hex("99999999999999999999999999999999");
config.censorship.alpn_enforce = true;
let replay_checker = ReplayChecker::new(4096, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.31:44330".parse().unwrap();
let too_short = vec![0x16, 0x03, 0x01];
let mut bad_hmac = make_valid_tls_handshake(&secret, 0);
bad_hmac[tls::TLS_DIGEST_POS + 1] ^= 0x01;
let alpn_mismatch = make_valid_tls_client_hello_with_alpn(&secret, 0, &[b"h3"]);
let mut class_means_ms = Vec::new();
for probe in [too_short, bad_hmac, alpn_mismatch] {
let mut sum_micros: u128 = 0;
for _ in 0..ITER {
let started = Instant::now();
let result = handle_tls_handshake(
&probe,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
let elapsed = started.elapsed();
assert!(matches!(result, HandshakeResult::BadClient { .. }));
sum_micros += elapsed.as_micros();
}
class_means_ms.push(sum_micros / ITER as u128 / 1_000);
}
let min_bucket = class_means_ms
.iter()
.map(|ms| ms / BUCKET_MS)
.min()
.unwrap();
let max_bucket = class_means_ms
.iter()
.map(|ms| ms / BUCKET_MS)
.max()
.unwrap();
assert!(
max_bucket <= min_bucket + 1,
"Malformed TLS classes diverged across latency buckets: means_ms={:?}",
class_means_ms
);
}
#[test]
fn secure_tag_requires_tls_mode_on_tls_transport() {
let mut config = ProxyConfig::default();
config.general.modes.classic = false;
config.general.modes.secure = true;
config.general.modes.tls = false;
assert!(
!mode_enabled_for_proto(&config, ProtoTag::Secure, true),
"Secure tag over TLS must be rejected when tls mode is disabled"
);
config.general.modes.tls = true;
assert!(
mode_enabled_for_proto(&config, ProtoTag::Secure, true),
"Secure tag over TLS must be accepted when tls mode is enabled"
);
}
#[test]
fn secure_tag_requires_secure_mode_on_direct_transport() {
let mut config = ProxyConfig::default();
config.general.modes.classic = false;
config.general.modes.secure = false;
config.general.modes.tls = true;
assert!(
!mode_enabled_for_proto(&config, ProtoTag::Secure, false),
"Secure tag without TLS must be rejected when secure mode is disabled"
);
config.general.modes.secure = true;
assert!(
mode_enabled_for_proto(&config, ProtoTag::Secure, false),
"Secure tag without TLS must be accepted when secure mode is enabled"
);
}
#[test]
fn mode_policy_matrix_is_stable_for_all_tag_transport_mode_combinations() {
let tags = [ProtoTag::Secure, ProtoTag::Intermediate, ProtoTag::Abridged];
for classic in [false, true] {
for secure in [false, true] {
for tls in [false, true] {
let mut config = ProxyConfig::default();
config.general.modes.classic = classic;
config.general.modes.secure = secure;
config.general.modes.tls = tls;
for is_tls in [false, true] {
for tag in tags {
let expected = match (tag, is_tls) {
(ProtoTag::Secure, true) => tls,
(ProtoTag::Secure, false) => secure,
(ProtoTag::Intermediate | ProtoTag::Abridged, _) => classic,
};
assert_eq!(
mode_enabled_for_proto(&config, tag, is_tls),
expected,
"mode policy drifted for tag={:?}, transport_tls={}, modes=(classic={}, secure={}, tls={})",
tag,
is_tls,
classic,
secure,
tls
);
}
}
}
}
}
}
#[test]
fn invalid_secret_warning_keys_do_not_collide_on_colon_boundaries() {
clear_warned_secrets_for_testing();
warn_invalid_secret_once("a:b", "c", ACCESS_SECRET_BYTES, Some(1));
warn_invalid_secret_once("a", "b:c", ACCESS_SECRET_BYTES, Some(2));
let warned = INVALID_SECRET_WARNED
.get()
.expect("warned set must be initialized");
let guard = warned.lock().expect("warned set lock must be available");
assert_eq!(
guard.len(),
2,
"(name, reason) pairs that stringify to the same colon-joined key must remain distinct"
);
}
#[tokio::test]
async fn repeated_invalid_tls_probes_trigger_pre_auth_throttle() {
let _guard = auth_probe_test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_auth_probe_state_for_testing();
let config = test_config_with_secret_hex("11111111111111111111111111111111");
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.61:44361".parse().unwrap();
let mut invalid = vec![0x42u8; tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + 32];
invalid[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = 32;
for _ in 0..AUTH_PROBE_BACKOFF_START_FAILS {
let result = handle_tls_handshake(
&invalid,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::BadClient { .. }));
}
assert!(
auth_probe_is_throttled_for_testing(peer.ip()),
"invalid probe burst must activate per-IP pre-auth throttle"
);
}
#[tokio::test]
async fn successful_tls_handshake_clears_pre_auth_failure_streak() {
let _guard = auth_probe_test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_auth_probe_state_for_testing();
let secret = [0x23u8; 16];
let config = test_config_with_secret_hex("23232323232323232323232323232323");
let replay_checker = ReplayChecker::new(256, Duration::from_secs(60));
let rng = SecureRandom::new();
let peer: SocketAddr = "198.51.100.62:44362".parse().unwrap();
let mut invalid = vec![0x42u8; tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + 32];
invalid[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = 32;
for expected in 1..AUTH_PROBE_BACKOFF_START_FAILS {
let result = handle_tls_handshake(
&invalid,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(result, HandshakeResult::BadClient { .. }));
assert_eq!(
auth_probe_fail_streak_for_testing(peer.ip()),
Some(expected),
"failure streak must grow before a successful authentication"
);
}
let valid = make_valid_tls_handshake(&secret, 0);
let success = handle_tls_handshake(
&valid,
tokio::io::empty(),
tokio::io::sink(),
peer,
&config,
&replay_checker,
&rng,
None,
)
.await;
assert!(matches!(success, HandshakeResult::Success(_)));
assert_eq!(
auth_probe_fail_streak_for_testing(peer.ip()),
None,
"successful authentication must clear accumulated pre-auth failures"
);
}
#[test]
fn auth_probe_capacity_prunes_stale_entries_for_new_ips() {
let state = DashMap::new();
let now = Instant::now();
let stale_seen = now - Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS + 1);
for idx in 0..AUTH_PROBE_TRACK_MAX_ENTRIES {
let ip = IpAddr::V4(Ipv4Addr::new(
10,
1,
((idx >> 8) & 0xff) as u8,
(idx & 0xff) as u8,
));
state.insert(
ip,
AuthProbeState {
fail_streak: 1,
blocked_until: now,
last_seen: stale_seen,
},
);
}
let newcomer = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200));
auth_probe_record_failure_with_state(&state, newcomer, now);
assert_eq!(
state.get(&newcomer).map(|entry| entry.fail_streak),
Some(1),
"stale-entry pruning must admit and track a new probe source"
);
assert!(
state.len() <= AUTH_PROBE_TRACK_MAX_ENTRIES,
"auth probe map must remain bounded after stale pruning"
);
}
#[test]
fn auth_probe_capacity_stays_fail_closed_when_map_is_fresh_and_full() {
let state = DashMap::new();
let now = Instant::now();
for idx in 0..AUTH_PROBE_TRACK_MAX_ENTRIES {
let ip = IpAddr::V4(Ipv4Addr::new(
172,
16,
((idx >> 8) & 0xff) as u8,
(idx & 0xff) as u8,
));
state.insert(
ip,
AuthProbeState {
fail_streak: 1,
blocked_until: now,
last_seen: now,
},
);
}
let newcomer = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 55));
auth_probe_record_failure_with_state(&state, newcomer, now);
assert!(
state.get(&newcomer).is_none(),
"when all entries are fresh and full, new probes must not be admitted"
);
assert_eq!(
state.len(),
AUTH_PROBE_TRACK_MAX_ENTRIES,
"auth probe map must stay at the configured cap"
);
}

View File

@@ -14,12 +14,41 @@ use crate::network::dns_overrides::resolve_socket_addr;
use crate::stats::beobachten::BeobachtenStore;
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
#[cfg(not(test))]
const MASK_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
const MASK_TIMEOUT: Duration = Duration::from_millis(50);
/// Maximum duration for the entire masking relay.
/// Limits resource consumption from slow-loris attacks and port scanners.
#[cfg(not(test))]
const MASK_RELAY_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(test)]
const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200);
const MASK_BUFFER_SIZE: usize = 8192;
async fn write_proxy_header_with_timeout<W>(mask_write: &mut W, header: &[u8]) -> bool
where
W: AsyncWrite + Unpin,
{
match timeout(MASK_TIMEOUT, mask_write.write_all(header)).await {
Ok(Ok(())) => true,
Ok(Err(_)) => false,
Err(_) => {
debug!("Timeout writing proxy protocol header to mask backend");
false
}
}
}
async fn consume_client_data_with_timeout<R>(reader: R)
where
R: AsyncRead + Unpin,
{
if timeout(MASK_RELAY_TIMEOUT, consume_client_data(reader)).await.is_err() {
debug!("Timed out while consuming client data on masking fallback path");
}
}
/// Detect client type based on initial data
fn detect_client_type(data: &[u8]) -> &'static str {
// Check for HTTP request
@@ -71,7 +100,7 @@ where
if !config.censorship.mask {
// Masking disabled, just consume data
consume_client_data(reader).await;
consume_client_data_with_timeout(reader).await;
return;
}
@@ -107,7 +136,7 @@ where
}
};
if let Some(header) = proxy_header {
if mask_write.write_all(&header).await.is_err() {
if !write_proxy_header_with_timeout(&mut mask_write, &header).await {
return;
}
}
@@ -117,11 +146,11 @@ where
}
Ok(Err(e)) => {
debug!(error = %e, "Failed to connect to mask unix socket");
consume_client_data(reader).await;
consume_client_data_with_timeout(reader).await;
}
Err(_) => {
debug!("Timeout connecting to mask unix socket");
consume_client_data(reader).await;
consume_client_data_with_timeout(reader).await;
}
}
return;
@@ -166,7 +195,7 @@ where
let (mask_read, mut mask_write) = stream.into_split();
if let Some(header) = proxy_header {
if mask_write.write_all(&header).await.is_err() {
if !write_proxy_header_with_timeout(&mut mask_write, &header).await {
return;
}
}
@@ -176,11 +205,11 @@ where
}
Ok(Err(e)) => {
debug!(error = %e, "Failed to connect to mask host");
consume_client_data(reader).await;
consume_client_data_with_timeout(reader).await;
}
Err(_) => {
debug!("Timeout connecting to mask host");
consume_client_data(reader).await;
consume_client_data_with_timeout(reader).await;
}
}
}
@@ -194,55 +223,51 @@ async fn relay_to_mask<R, W, MR, MW>(
initial_data: &[u8],
)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
MR: AsyncRead + Unpin + Send + 'static,
MW: AsyncWrite + Unpin + Send + 'static,
R: AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
MR: AsyncRead + Unpin + Send,
MW: AsyncWrite + Unpin + Send,
{
// Send initial data to mask host
if mask_write.write_all(initial_data).await.is_err() {
return;
}
if mask_write.flush().await.is_err() {
return;
}
// Relay traffic
let c2m = tokio::spawn(async move {
let mut buf = vec![0u8; MASK_BUFFER_SIZE];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => {
let _ = mask_write.shutdown().await;
break;
}
Ok(n) => {
if mask_write.write_all(&buf[..n]).await.is_err() {
let mut client_buf = vec![0u8; MASK_BUFFER_SIZE];
let mut mask_buf = vec![0u8; MASK_BUFFER_SIZE];
loop {
tokio::select! {
client_read = reader.read(&mut client_buf) => {
match client_read {
Ok(0) | Err(_) => {
let _ = mask_write.shutdown().await;
break;
}
Ok(n) => {
if mask_write.write_all(&client_buf[..n]).await.is_err() {
break;
}
}
}
}
mask_read_res = mask_read.read(&mut mask_buf) => {
match mask_read_res {
Ok(0) | Err(_) => {
let _ = writer.shutdown().await;
break;
}
Ok(n) => {
if writer.write_all(&mask_buf[..n]).await.is_err() {
break;
}
}
}
}
}
});
let m2c = tokio::spawn(async move {
let mut buf = vec![0u8; MASK_BUFFER_SIZE];
loop {
match mask_read.read(&mut buf).await {
Ok(0) | Err(_) => {
let _ = writer.shutdown().await;
break;
}
Ok(n) => {
if writer.write_all(&buf[..n]).await.is_err() {
break;
}
}
}
}
});
// Wait for either to complete
tokio::select! {
_ = c2m => {}
_ = m2c => {}
}
}
@@ -255,3 +280,7 @@ async fn consume_client_data<R: AsyncRead + Unpin>(mut reader: R) {
}
}
}
#[cfg(test)]
#[path = "masking_security_tests.rs"]
mod security_tests;

View File

@@ -0,0 +1,550 @@
use super::*;
use crate::config::ProxyConfig;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{duplex, AsyncBufReadExt, BufReader};
use tokio::net::TcpListener;
#[cfg(unix)]
use tokio::net::UnixListener;
use tokio::time::{timeout, Duration};
#[tokio::test]
async fn bad_client_probe_is_forwarded_verbatim_to_mask_backend() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap();
let probe = b"GET / HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
let backend_reply = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".to_vec();
let accept_task = tokio::spawn({
let probe = probe.clone();
let backend_reply = backend_reply.clone();
async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut received = vec![0u8; probe.len()];
stream.read_exact(&mut received).await.unwrap();
assert_eq!(received, probe);
stream.write_all(&backend_reply).await.unwrap();
}
});
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = true;
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = backend_addr.port();
config.censorship.mask_unix_sock = None;
config.censorship.mask_proxy_protocol = 0;
let peer: SocketAddr = "203.0.113.10:42424".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let (client_reader, _client_writer) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(2048);
let beobachten = BeobachtenStore::new();
handle_bad_client(
client_reader,
client_visible_writer,
&probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
let mut observed = vec![0u8; backend_reply.len()];
client_visible_reader.read_exact(&mut observed).await.unwrap();
assert_eq!(observed, backend_reply);
accept_task.await.unwrap();
}
#[tokio::test]
async fn tls_scanner_probe_keeps_http_like_fallback_surface() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap();
let probe = vec![0x16, 0x03, 0x01, 0x00, 0x10, 0x01, 0x02, 0x03, 0x04];
let backend_reply = b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec();
let accept_task = tokio::spawn({
let probe = probe.clone();
let backend_reply = backend_reply.clone();
async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut received = vec![0u8; probe.len()];
stream.read_exact(&mut received).await.unwrap();
assert_eq!(received, probe);
stream.write_all(&backend_reply).await.unwrap();
}
});
let mut config = ProxyConfig::default();
config.general.beobachten = true;
config.general.beobachten_minutes = 1;
config.censorship.mask = true;
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = backend_addr.port();
config.censorship.mask_unix_sock = None;
config.censorship.mask_proxy_protocol = 0;
let peer: SocketAddr = "198.51.100.44:55221".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let (client_reader, _client_writer) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(2048);
let beobachten = BeobachtenStore::new();
handle_bad_client(
client_reader,
client_visible_writer,
&probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
let mut observed = vec![0u8; backend_reply.len()];
client_visible_reader.read_exact(&mut observed).await.unwrap();
assert_eq!(observed, backend_reply);
let snapshot = beobachten.snapshot_text(Duration::from_secs(60));
assert!(snapshot.contains("[TLS-scanner]"));
assert!(snapshot.contains("198.51.100.44-1"));
accept_task.await.unwrap();
}
#[test]
fn detect_client_type_covers_ssh_port_scanner_and_unknown() {
assert_eq!(detect_client_type(b"SSH-2.0-OpenSSH_9.7"), "SSH");
assert_eq!(detect_client_type(b"\x01\x02\x03"), "port-scanner");
assert_eq!(detect_client_type(b"random-binary-payload"), "unknown");
}
#[test]
fn detect_client_type_len_boundary_9_vs_10_bytes() {
assert_eq!(detect_client_type(b"123456789"), "port-scanner");
assert_eq!(detect_client_type(b"1234567890"), "unknown");
}
#[tokio::test]
async fn beobachten_records_scanner_class_when_mask_is_disabled() {
let mut config = ProxyConfig::default();
config.general.beobachten = true;
config.general.beobachten_minutes = 1;
config.censorship.mask = false;
let peer: SocketAddr = "203.0.113.99:41234".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let initial = b"SSH-2.0-probe";
let (mut client_reader_side, client_reader) = duplex(256);
let (_client_visible_reader, client_visible_writer) = duplex(256);
let beobachten = BeobachtenStore::new();
let task = tokio::spawn(async move {
handle_bad_client(
client_reader,
client_visible_writer,
initial,
peer,
local_addr,
&config,
&beobachten,
)
.await;
beobachten
});
client_reader_side.write_all(b"noise").await.unwrap();
drop(client_reader_side);
let beobachten = timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
let snapshot = beobachten.snapshot_text(Duration::from_secs(60));
assert!(snapshot.contains("[SSH]"));
assert!(snapshot.contains("203.0.113.99-1"));
}
#[tokio::test]
async fn backend_unavailable_falls_back_to_silent_consume() {
let temp_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let unused_port = temp_listener.local_addr().unwrap().port();
drop(temp_listener);
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = true;
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = unused_port;
config.censorship.mask_unix_sock = None;
config.censorship.mask_proxy_protocol = 0;
let peer: SocketAddr = "203.0.113.11:42425".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let probe = b"GET /probe HTTP/1.1\r\nHost: x\r\n\r\n";
let (mut client_reader_side, client_reader) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(256);
let beobachten = BeobachtenStore::new();
let task = tokio::spawn(async move {
handle_bad_client(
client_reader,
client_visible_writer,
probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
});
client_reader_side.write_all(b"noise").await.unwrap();
drop(client_reader_side);
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
let mut buf = [0u8; 1];
let n = timeout(Duration::from_secs(1), client_visible_reader.read(&mut buf))
.await
.unwrap()
.unwrap();
assert_eq!(n, 0);
}
#[tokio::test]
async fn mask_disabled_consumes_client_data_without_response() {
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = false;
let peer: SocketAddr = "198.51.100.12:45454".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let initial = b"scanner";
let (mut client_reader_side, client_reader) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(256);
let beobachten = BeobachtenStore::new();
let task = tokio::spawn(async move {
handle_bad_client(
client_reader,
client_visible_writer,
initial,
peer,
local_addr,
&config,
&beobachten,
)
.await;
});
client_reader_side.write_all(b"untrusted payload").await.unwrap();
drop(client_reader_side);
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
let mut buf = [0u8; 1];
let n = timeout(Duration::from_secs(1), client_visible_reader.read(&mut buf))
.await
.unwrap()
.unwrap();
assert_eq!(n, 0);
}
#[tokio::test]
async fn proxy_protocol_v1_header_is_sent_before_probe() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap();
let probe = b"GET / HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
let backend_reply = b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n".to_vec();
let accept_task = tokio::spawn({
let probe = probe.clone();
let backend_reply = backend_reply.clone();
async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = BufReader::new(stream);
let mut header_line = Vec::new();
reader.read_until(b'\n', &mut header_line).await.unwrap();
let header_text = String::from_utf8(header_line.clone()).unwrap();
assert!(header_text.starts_with("PROXY TCP4 "));
assert!(header_text.ends_with("\r\n"));
let mut received_probe = vec![0u8; probe.len()];
reader.read_exact(&mut received_probe).await.unwrap();
assert_eq!(received_probe, probe);
let mut stream = reader.into_inner();
stream.write_all(&backend_reply).await.unwrap();
}
});
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = true;
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = backend_addr.port();
config.censorship.mask_unix_sock = None;
config.censorship.mask_proxy_protocol = 1;
let peer: SocketAddr = "203.0.113.15:50001".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let (client_reader, _client_writer) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(2048);
let beobachten = BeobachtenStore::new();
handle_bad_client(
client_reader,
client_visible_writer,
&probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
let mut observed = vec![0u8; backend_reply.len()];
client_visible_reader.read_exact(&mut observed).await.unwrap();
assert_eq!(observed, backend_reply);
accept_task.await.unwrap();
}
#[tokio::test]
async fn proxy_protocol_v2_header_is_sent_before_probe() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap();
let probe = b"GET / HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
let backend_reply = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec();
let accept_task = tokio::spawn({
let probe = probe.clone();
let backend_reply = backend_reply.clone();
async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut sig = [0u8; 12];
stream.read_exact(&mut sig).await.unwrap();
assert_eq!(&sig, b"\r\n\r\n\0\r\nQUIT\n");
let mut fixed = [0u8; 4];
stream.read_exact(&mut fixed).await.unwrap();
let addr_len = u16::from_be_bytes([fixed[2], fixed[3]]) as usize;
let mut addr_block = vec![0u8; addr_len];
stream.read_exact(&mut addr_block).await.unwrap();
let mut received_probe = vec![0u8; probe.len()];
stream.read_exact(&mut received_probe).await.unwrap();
assert_eq!(received_probe, probe);
stream.write_all(&backend_reply).await.unwrap();
}
});
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = true;
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = backend_addr.port();
config.censorship.mask_unix_sock = None;
config.censorship.mask_proxy_protocol = 2;
let peer: SocketAddr = "203.0.113.18:50004".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let (client_reader, _client_writer) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(2048);
let beobachten = BeobachtenStore::new();
handle_bad_client(
client_reader,
client_visible_writer,
&probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
let mut observed = vec![0u8; backend_reply.len()];
client_visible_reader.read_exact(&mut observed).await.unwrap();
assert_eq!(observed, backend_reply);
accept_task.await.unwrap();
}
#[tokio::test]
async fn proxy_protocol_v1_mixed_family_falls_back_to_unknown_header() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap();
let probe = b"GET /mix HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
let backend_reply = b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n".to_vec();
let accept_task = tokio::spawn({
let probe = probe.clone();
let backend_reply = backend_reply.clone();
async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = BufReader::new(stream);
let mut header_line = Vec::new();
reader.read_until(b'\n', &mut header_line).await.unwrap();
let header_text = String::from_utf8(header_line).unwrap();
assert_eq!(header_text, "PROXY UNKNOWN\r\n");
let mut received_probe = vec![0u8; probe.len()];
reader.read_exact(&mut received_probe).await.unwrap();
assert_eq!(received_probe, probe);
let mut stream = reader.into_inner();
stream.write_all(&backend_reply).await.unwrap();
}
});
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = true;
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = backend_addr.port();
config.censorship.mask_unix_sock = None;
config.censorship.mask_proxy_protocol = 1;
let peer: SocketAddr = "203.0.113.20:50006".parse().unwrap();
let local_addr: SocketAddr = "[::1]:443".parse().unwrap();
let (client_reader, _client_writer) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(2048);
let beobachten = BeobachtenStore::new();
handle_bad_client(
client_reader,
client_visible_writer,
&probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
let mut observed = vec![0u8; backend_reply.len()];
client_visible_reader.read_exact(&mut observed).await.unwrap();
assert_eq!(observed, backend_reply);
accept_task.await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn unix_socket_mask_path_forwards_probe_and_response() {
let sock_path = format!("/tmp/telemt-mask-test-{}-{}.sock", std::process::id(), rand::random::<u64>());
let _ = std::fs::remove_file(&sock_path);
let listener = UnixListener::bind(&sock_path).unwrap();
let probe = b"GET /unix HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
let backend_reply = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".to_vec();
let accept_task = tokio::spawn({
let probe = probe.clone();
let backend_reply = backend_reply.clone();
async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut received = vec![0u8; probe.len()];
stream.read_exact(&mut received).await.unwrap();
assert_eq!(received, probe);
stream.write_all(&backend_reply).await.unwrap();
}
});
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = true;
config.censorship.mask_unix_sock = Some(sock_path.clone());
config.censorship.mask_proxy_protocol = 0;
let peer: SocketAddr = "203.0.113.30:50010".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let (client_reader, _client_writer) = duplex(256);
let (mut client_visible_reader, client_visible_writer) = duplex(2048);
let beobachten = BeobachtenStore::new();
handle_bad_client(
client_reader,
client_visible_writer,
&probe,
peer,
local_addr,
&config,
&beobachten,
)
.await;
let mut observed = vec![0u8; backend_reply.len()];
client_visible_reader.read_exact(&mut observed).await.unwrap();
assert_eq!(observed, backend_reply);
accept_task.await.unwrap();
let _ = std::fs::remove_file(sock_path);
}
#[tokio::test]
async fn mask_disabled_slowloris_connection_is_closed_by_consume_timeout() {
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.censorship.mask = false;
let peer: SocketAddr = "198.51.100.33:45455".parse().unwrap();
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
let (_client_reader_side, client_reader) = duplex(256);
let (_client_visible_reader, client_visible_writer) = duplex(256);
let beobachten = BeobachtenStore::new();
let task = tokio::spawn(async move {
handle_bad_client(
client_reader,
client_visible_writer,
b"slowloris",
peer,
local_addr,
&config,
&beobachten,
)
.await;
});
timeout(Duration::from_secs(1), task).await.unwrap().unwrap();
}
struct PendingWriter;
impl tokio::io::AsyncWrite for PendingWriter {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Poll::Pending
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn proxy_header_write_timeout_returns_false() {
let mut writer = PendingWriter;
let ok = write_proxy_header_with_timeout(&mut writer, b"PROXY UNKNOWN\r\n").await;
assert!(!ok, "Proxy header writes that never complete must time out");
}

View File

@@ -1,14 +1,17 @@
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
#[cfg(test)]
use std::sync::Mutex;
use bytes::Bytes;
use bytes::{Bytes, BytesMut};
use dashmap::DashMap;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::time::timeout;
use tracing::{debug, trace, warn};
use crate::config::ProxyConfig;
@@ -30,13 +33,15 @@ enum C2MeCommand {
}
const DESYNC_DEDUP_WINDOW: Duration = Duration::from_secs(60);
const DESYNC_DEDUP_MAX_ENTRIES: usize = 65_536;
const DESYNC_DEDUP_PRUNE_SCAN_LIMIT: usize = 1024;
const DESYNC_ERROR_CLASS: &str = "frame_too_large_crypto_desync";
const C2ME_CHANNEL_CAPACITY_FALLBACK: usize = 128;
const C2ME_SOFT_PRESSURE_MIN_FREE_SLOTS: usize = 64;
const C2ME_SENDER_FAIRNESS_BUDGET: usize = 32;
const ME_D2C_FLUSH_BATCH_MAX_FRAMES_MIN: usize = 1;
const ME_D2C_FLUSH_BATCH_MAX_BYTES_MIN: usize = 4096;
static DESYNC_DEDUP: OnceLock<Mutex<HashMap<u64, Instant>>> = OnceLock::new();
static DESYNC_DEDUP: OnceLock<DashMap<u64, Instant>> = OnceLock::new();
struct RelayForensicsState {
trace_id: u64,
@@ -90,24 +95,46 @@ fn should_emit_full_desync(key: u64, all_full: bool, now: Instant) -> bool {
return true;
}
let dedup = DESYNC_DEDUP.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = dedup.lock().expect("desync dedup mutex poisoned");
guard.retain(|_, seen_at| now.duration_since(*seen_at) < DESYNC_DEDUP_WINDOW);
let dedup = DESYNC_DEDUP.get_or_init(DashMap::new);
match guard.get_mut(&key) {
Some(seen_at) => {
if now.duration_since(*seen_at) >= DESYNC_DEDUP_WINDOW {
*seen_at = now;
true
} else {
false
if let Some(mut seen_at) = dedup.get_mut(&key) {
if now.duration_since(*seen_at) >= DESYNC_DEDUP_WINDOW {
*seen_at = now;
return true;
}
return false;
}
if dedup.len() >= DESYNC_DEDUP_MAX_ENTRIES {
let mut stale_keys = Vec::new();
for entry in dedup.iter().take(DESYNC_DEDUP_PRUNE_SCAN_LIMIT) {
if now.duration_since(*entry.value()) >= DESYNC_DEDUP_WINDOW {
stale_keys.push(*entry.key());
}
}
None => {
guard.insert(key, now);
true
for stale_key in stale_keys {
dedup.remove(&stale_key);
}
if dedup.len() >= DESYNC_DEDUP_MAX_ENTRIES {
return false;
}
}
dedup.insert(key, now);
true
}
#[cfg(test)]
fn clear_desync_dedup_for_testing() {
if let Some(dedup) = DESYNC_DEDUP.get() {
dedup.clear();
}
}
#[cfg(test)]
fn desync_dedup_test_lock() -> &'static Mutex<()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK.get_or_init(|| Mutex::new(()))
}
fn report_desync_frame_too_large(
@@ -229,7 +256,7 @@ pub(crate) async fn handle_via_middle_proxy<R, W>(
me_pool: Arc<MePool>,
stats: Arc<Stats>,
config: Arc<ProxyConfig>,
_buffer_pool: Arc<BufferPool>,
buffer_pool: Arc<BufferPool>,
local_addr: SocketAddr,
rng: Arc<SecureRandom>,
mut route_rx: watch::Receiver<RouteCutoverState>,
@@ -271,7 +298,6 @@ where
};
stats.increment_user_connects(&user);
stats.increment_user_curr_connects(&user);
stats.increment_current_connections_me();
if let Some(cutover) = affected_cutover_state(
@@ -291,7 +317,6 @@ where
let _ = me_pool.send_close(conn_id).await;
me_pool.registry().unregister(conn_id).await;
stats.decrement_current_connections_me();
stats.decrement_user_curr_connects(&user);
return Err(ProxyError::Proxy(ROUTE_SWITCH_ERROR_MSG.to_string()));
}
@@ -557,6 +582,8 @@ where
&mut crypto_reader,
proto_tag,
frame_limit,
Duration::from_secs(config.timeouts.client_handshake.max(1)),
&buffer_pool,
&forensics,
&mut frame_counter,
&stats,
@@ -638,7 +665,6 @@ where
);
me_pool.registry().unregister(conn_id).await;
stats.decrement_current_connections_me();
stats.decrement_user_curr_connects(&user);
result
}
@@ -646,6 +672,8 @@ async fn read_client_payload<R>(
client_reader: &mut CryptoReader<R>,
proto_tag: ProtoTag,
max_frame: usize,
frame_read_timeout: Duration,
buffer_pool: &Arc<BufferPool>,
forensics: &RelayForensicsState,
frame_counter: &mut u64,
stats: &Stats,
@@ -653,23 +681,40 @@ async fn read_client_payload<R>(
where
R: AsyncRead + Unpin + Send + 'static,
{
async fn read_exact_with_timeout<R>(
client_reader: &mut CryptoReader<R>,
buf: &mut [u8],
frame_read_timeout: Duration,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
{
match timeout(frame_read_timeout, client_reader.read_exact(buf)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(ProxyError::Io(e)),
Err(_) => Err(ProxyError::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"middle-relay client frame read timeout",
))),
}
}
loop {
let (len, quickack, raw_len_bytes) = match proto_tag {
ProtoTag::Abridged => {
let mut first = [0u8; 1];
match client_reader.read_exact(&mut first).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(ProxyError::Io(e)),
match read_exact_with_timeout(client_reader, &mut first, frame_read_timeout).await {
Ok(()) => {}
Err(ProxyError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(None);
}
Err(e) => return Err(e),
}
let quickack = (first[0] & 0x80) != 0;
let len_words = if (first[0] & 0x7f) == 0x7f {
let mut ext = [0u8; 3];
client_reader
.read_exact(&mut ext)
.await
.map_err(ProxyError::Io)?;
read_exact_with_timeout(client_reader, &mut ext, frame_read_timeout).await?;
u32::from_le_bytes([ext[0], ext[1], ext[2], 0]) as usize
} else {
(first[0] & 0x7f) as usize
@@ -682,10 +727,12 @@ where
}
ProtoTag::Intermediate | ProtoTag::Secure => {
let mut len_buf = [0u8; 4];
match client_reader.read_exact(&mut len_buf).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(ProxyError::Io(e)),
match read_exact_with_timeout(client_reader, &mut len_buf, frame_read_timeout).await {
Ok(()) => {}
Err(ProxyError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(None);
}
Err(e) => return Err(e),
}
let quickack = (len_buf[3] & 0x80) != 0;
(
@@ -737,18 +784,25 @@ where
len
};
let mut payload = vec![0u8; len];
client_reader
.read_exact(&mut payload)
.await
.map_err(ProxyError::Io)?;
let chunk_cap = buffer_pool.buffer_size().max(1024);
let mut payload = BytesMut::with_capacity(len.min(chunk_cap));
let mut remaining = len;
while remaining > 0 {
let chunk_len = remaining.min(chunk_cap);
let mut chunk = buffer_pool.get();
chunk.resize(chunk_len, 0);
read_exact_with_timeout(client_reader, &mut chunk[..chunk_len], frame_read_timeout)
.await?;
payload.extend_from_slice(&chunk[..chunk_len]);
remaining -= chunk_len;
}
// Secure Intermediate: strip validated trailing padding bytes.
if proto_tag == ProtoTag::Secure {
payload.truncate(secure_payload_len);
}
*frame_counter += 1;
return Ok(Some((Bytes::from(payload), quickack)));
return Ok(Some((payload.freeze(), quickack)));
}
}
@@ -940,82 +994,5 @@ where
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::{Duration as TokioDuration, timeout};
#[test]
fn should_yield_sender_only_on_budget_with_backlog() {
assert!(!should_yield_c2me_sender(0, true));
assert!(!should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET - 1, true));
assert!(!should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET, false));
assert!(should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET, true));
}
#[tokio::test]
async fn enqueue_c2me_command_uses_try_send_fast_path() {
let (tx, mut rx) = mpsc::channel::<C2MeCommand>(2);
enqueue_c2me_command(
&tx,
C2MeCommand::Data {
payload: Bytes::from_static(&[1, 2, 3]),
flags: 0,
},
)
.await
.unwrap();
let recv = timeout(TokioDuration::from_millis(50), rx.recv())
.await
.unwrap()
.unwrap();
match recv {
C2MeCommand::Data { payload, flags } => {
assert_eq!(payload.as_ref(), &[1, 2, 3]);
assert_eq!(flags, 0);
}
C2MeCommand::Close => panic!("unexpected close command"),
}
}
#[tokio::test]
async fn enqueue_c2me_command_falls_back_to_send_when_queue_is_full() {
let (tx, mut rx) = mpsc::channel::<C2MeCommand>(1);
tx.send(C2MeCommand::Data {
payload: Bytes::from_static(&[9]),
flags: 9,
})
.await
.unwrap();
let tx2 = tx.clone();
let producer = tokio::spawn(async move {
enqueue_c2me_command(
&tx2,
C2MeCommand::Data {
payload: Bytes::from_static(&[7, 7]),
flags: 7,
},
)
.await
.unwrap();
});
let _ = timeout(TokioDuration::from_millis(100), rx.recv())
.await
.unwrap();
producer.await.unwrap();
let recv = timeout(TokioDuration::from_millis(100), rx.recv())
.await
.unwrap()
.unwrap();
match recv {
C2MeCommand::Data { payload, flags } => {
assert_eq!(payload.as_ref(), &[7, 7]);
assert_eq!(flags, 7);
}
C2MeCommand::Close => panic!("unexpected close command"),
}
}
}
#[path = "middle_relay_security_tests.rs"]
mod security_tests;

View File

@@ -0,0 +1,201 @@
use super::*;
use crate::crypto::AesCtr;
use crate::stats::Stats;
use crate::stream::{BufferPool, CryptoReader};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use tokio::io::AsyncWriteExt;
use tokio::io::duplex;
use tokio::time::{Duration as TokioDuration, timeout};
#[test]
fn should_yield_sender_only_on_budget_with_backlog() {
assert!(!should_yield_c2me_sender(0, true));
assert!(!should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET - 1, true));
assert!(!should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET, false));
assert!(should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET, true));
}
#[tokio::test]
async fn enqueue_c2me_command_uses_try_send_fast_path() {
let (tx, mut rx) = mpsc::channel::<C2MeCommand>(2);
enqueue_c2me_command(
&tx,
C2MeCommand::Data {
payload: Bytes::from_static(&[1, 2, 3]),
flags: 0,
},
)
.await
.unwrap();
let recv = timeout(TokioDuration::from_millis(50), rx.recv())
.await
.unwrap()
.unwrap();
match recv {
C2MeCommand::Data { payload, flags } => {
assert_eq!(payload.as_ref(), &[1, 2, 3]);
assert_eq!(flags, 0);
}
C2MeCommand::Close => panic!("unexpected close command"),
}
}
#[tokio::test]
async fn enqueue_c2me_command_falls_back_to_send_when_queue_is_full() {
let (tx, mut rx) = mpsc::channel::<C2MeCommand>(1);
tx.send(C2MeCommand::Data {
payload: Bytes::from_static(&[9]),
flags: 9,
})
.await
.unwrap();
let tx2 = tx.clone();
let producer = tokio::spawn(async move {
enqueue_c2me_command(
&tx2,
C2MeCommand::Data {
payload: Bytes::from_static(&[7, 7]),
flags: 7,
},
)
.await
.unwrap();
});
let _ = timeout(TokioDuration::from_millis(100), rx.recv())
.await
.unwrap();
producer.await.unwrap();
let recv = timeout(TokioDuration::from_millis(100), rx.recv())
.await
.unwrap()
.unwrap();
match recv {
C2MeCommand::Data { payload, flags } => {
assert_eq!(payload.as_ref(), &[7, 7]);
assert_eq!(flags, 7);
}
C2MeCommand::Close => panic!("unexpected close command"),
}
}
#[test]
fn desync_dedup_cache_is_bounded() {
let _guard = desync_dedup_test_lock()
.lock()
.expect("desync dedup test lock must be available");
clear_desync_dedup_for_testing();
let now = Instant::now();
for key in 0..DESYNC_DEDUP_MAX_ENTRIES as u64 {
assert!(
should_emit_full_desync(key, false, now),
"unique keys up to cap must be tracked"
);
}
assert!(
!should_emit_full_desync(u64::MAX, false, now),
"new key above cap must be suppressed to bound memory"
);
assert!(
!should_emit_full_desync(7, false, now),
"already tracked key inside dedup window must stay suppressed"
);
}
fn make_forensics_state() -> RelayForensicsState {
RelayForensicsState {
trace_id: 1,
conn_id: 2,
user: "test-user".to_string(),
peer: "127.0.0.1:50000".parse::<SocketAddr>().unwrap(),
peer_hash: 3,
started_at: Instant::now(),
bytes_c2me: 0,
bytes_me2c: Arc::new(AtomicU64::new(0)),
desync_all_full: false,
}
}
fn make_crypto_reader(reader: tokio::io::DuplexStream) -> CryptoReader<tokio::io::DuplexStream> {
let key = [0u8; 32];
let iv = 0u128;
CryptoReader::new(reader, AesCtr::new(&key, iv))
}
fn encrypt_for_reader(plaintext: &[u8]) -> Vec<u8> {
let key = [0u8; 32];
let iv = 0u128;
let mut cipher = AesCtr::new(&key, iv);
cipher.encrypt(plaintext)
}
#[tokio::test]
async fn read_client_payload_times_out_on_header_stall() {
let _guard = desync_dedup_test_lock()
.lock()
.expect("middle relay test lock must be available");
let (reader, _writer) = duplex(1024);
let mut crypto_reader = make_crypto_reader(reader);
let buffer_pool = Arc::new(BufferPool::new());
let stats = Stats::new();
let forensics = make_forensics_state();
let mut frame_counter = 0;
let result = read_client_payload(
&mut crypto_reader,
ProtoTag::Intermediate,
1024,
TokioDuration::from_millis(25),
&buffer_pool,
&forensics,
&mut frame_counter,
&stats,
)
.await;
assert!(
matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut),
"stalled header read must time out"
);
}
#[tokio::test]
async fn read_client_payload_times_out_on_payload_stall() {
let _guard = desync_dedup_test_lock()
.lock()
.expect("middle relay test lock must be available");
let (reader, mut writer) = duplex(1024);
let encrypted_len = encrypt_for_reader(&[8, 0, 0, 0]);
writer.write_all(&encrypted_len).await.unwrap();
let mut crypto_reader = make_crypto_reader(reader);
let buffer_pool = Arc::new(BufferPool::new());
let stats = Stats::new();
let forensics = make_forensics_state();
let mut frame_counter = 0;
let result = read_client_payload(
&mut crypto_reader,
ProtoTag::Intermediate,
1024,
TokioDuration::from_millis(25),
&buffer_pool,
&forensics,
&mut frame_counter,
&stats,
)
.await;
assert!(
matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut),
"stalled payload body read must time out"
);
}

View File

@@ -1256,6 +1256,33 @@ impl Stats {
Self::touch_user_stats(stats.value());
stats.curr_connects.fetch_add(1, Ordering::Relaxed);
}
pub fn try_acquire_user_curr_connects(&self, user: &str, limit: Option<u64>) -> bool {
if !self.telemetry_user_enabled() {
return true;
}
self.maybe_cleanup_user_stats();
let stats = self.user_stats.entry(user.to_string()).or_default();
Self::touch_user_stats(stats.value());
let counter = &stats.curr_connects;
let mut current = counter.load(Ordering::Relaxed);
loop {
if let Some(max) = limit && current >= max {
return false;
}
match counter.compare_exchange_weak(
current,
current.saturating_add(1),
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return true,
Err(actual) => current = actual,
}
}
}
pub fn decrement_user_curr_connects(&self, user: &str) {
self.maybe_cleanup_user_stats();

View File

@@ -513,6 +513,7 @@ impl FrameCodecTrait for SecureCodec {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use tokio_util::codec::{FramedRead, FramedWrite};
use tokio::io::duplex;
use futures::{SinkExt, StreamExt};
@@ -630,4 +631,31 @@ mod tests {
let result = codec.decode(&mut buf);
assert!(result.is_err());
}
#[test]
fn secure_codec_always_adds_padding_and_jitters_wire_length() {
let codec = SecureCodec::new(Arc::new(SecureRandom::new()));
let payload = Bytes::from_static(&[1, 2, 3, 4, 5, 6, 7, 8]);
let mut wire_lens = HashSet::new();
for _ in 0..64 {
let frame = Frame::new(payload.clone());
let mut out = BytesMut::new();
codec.encode(&frame, &mut out).unwrap();
assert!(out.len() >= 4 + payload.len() + 1);
let wire_len = u32::from_le_bytes([out[0], out[1], out[2], out[3]]) as usize;
assert!(
(payload.len() + 1..=payload.len() + 3).contains(&wire_len),
"Secure wire length must be payload+1..3, got {wire_len}"
);
assert_ne!(wire_len % 4, 0, "Secure wire length must be non-4-aligned");
wire_lens.insert(wire_len);
}
assert!(
wire_lens.len() >= 2,
"Secure padding should create observable wire-length jitter"
);
}
}

View File

@@ -8,7 +8,9 @@ use tokio::sync::RwLock;
use tokio::time::sleep;
use tracing::{debug, warn, info};
use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsFetchResult};
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult,
};
/// Lightweight in-memory + optional on-disk cache for TLS fronting data.
#[derive(Debug)]
@@ -37,6 +39,7 @@ impl TlsFrontCache {
cert_payload: None,
app_data_records_sizes: vec![default_len],
total_app_data_len: default_len,
behavior_profile: TlsBehaviorProfile::default(),
fetched_at: SystemTime::now(),
domain: "default".to_string(),
});
@@ -189,6 +192,7 @@ impl TlsFrontCache {
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,
fetched_at: SystemTime::now(),
domain: domain.to_string(),
};

View File

@@ -3,7 +3,7 @@ use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_VERSION,
};
use crate::protocol::tls::{TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key};
use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo};
use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource};
const MIN_APP_DATA: usize = 64;
const MAX_APP_DATA: usize = 16640; // RFC 8446 §5.2 allows up to 2^14 + 256
@@ -108,14 +108,12 @@ pub fn build_emulated_server_hello(
) -> Vec<u8> {
// --- ServerHello ---
let mut extensions = Vec::new();
// KeyShare (x25519)
let key = gen_fake_x25519_key(rng);
extensions.extend_from_slice(&0x0033u16.to_be_bytes()); // key_share
extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes()); // len
extensions.extend_from_slice(&0x001du16.to_be_bytes()); // X25519
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);
// supported_versions (TLS1.3)
extensions.extend_from_slice(&0x002bu16.to_be_bytes());
extensions.extend_from_slice(&(2u16).to_be_bytes());
extensions.extend_from_slice(&0x0304u16.to_be_bytes());
@@ -128,7 +126,6 @@ pub fn build_emulated_server_hello(
extensions.push(alpn_proto.len() as u8);
extensions.extend_from_slice(alpn_proto);
}
let extensions_len = extensions.len() as u16;
let body_len = 2 + // version
@@ -173,11 +170,22 @@ pub fn build_emulated_server_hello(
];
// --- ApplicationData (fake encrypted records) ---
// Use the same number and sizes of ApplicationData records as the cached server.
let mut sizes = cached.app_data_records_sizes.clone();
if sizes.is_empty() {
sizes.push(cached.total_app_data_len.max(1024));
}
let sizes = match cached.behavior_profile.source {
TlsProfileSource::Raw | TlsProfileSource::Merged => 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)]),
_ => {
let mut sizes = cached.app_data_records_sizes.clone();
if sizes.is_empty() {
sizes.push(cached.total_app_data_len.max(1024));
}
sizes
}
};
let mut sizes = jitter_and_clamp_sizes(&sizes, rng);
let compact_payload = cached
.cert_info
@@ -269,7 +277,9 @@ pub fn build_emulated_server_hello(
mod tests {
use std::time::SystemTime;
use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsCertPayload};
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
};
use super::build_emulated_server_hello;
use crate::crypto::SecureRandom;
@@ -300,6 +310,7 @@ mod tests {
cert_payload,
app_data_records_sizes: vec![64],
total_app_data_len: 64,
behavior_profile: TlsBehaviorProfile::default(),
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),
}
@@ -385,4 +396,34 @@ mod tests {
let payload = first_app_data_payload(&response);
assert!(payload.starts_with(b"CN=example.com"));
}
#[test]
fn test_build_emulated_server_hello_ignores_tail_records_for_raw_profile() {
let mut cached = make_cached(None);
cached.app_data_records_sizes = vec![27, 3905, 537, 69];
cached.total_app_data_len = 4538;
cached.behavior_profile.source = TlsProfileSource::Merged;
cached.behavior_profile.app_data_record_sizes = vec![27, 3905, 537];
cached.behavior_profile.ticket_record_sizes = vec![69];
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x12; 32],
&[0x34; 16],
&cached,
false,
&rng,
None,
0,
);
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_start + 5 + app_len, response.len());
}
}

View File

@@ -21,14 +21,18 @@ use x509_parser::certificate::X509Certificate;
use crate::crypto::SecureRandom;
use crate::network::dns_overrides::resolve_socket_addr;
use crate::protocol::constants::{TLS_RECORD_APPLICATION, TLS_RECORD_HANDSHAKE};
use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
};
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
use crate::tls_front::types::{
ParsedCertificateInfo,
ParsedServerHello,
TlsBehaviorProfile,
TlsCertPayload,
TlsExtension,
TlsFetchResult,
TlsProfileSource,
};
/// No-op verifier: accept any certificate (we only need lengths and metadata).
@@ -282,6 +286,41 @@ fn parse_server_hello(body: &[u8]) -> Option<ParsedServerHello> {
})
}
fn derive_behavior_profile(records: &[(u8, Vec<u8>)]) -> TlsBehaviorProfile {
let mut change_cipher_spec_count = 0u8;
let mut app_data_record_sizes = Vec::new();
for (record_type, body) in records {
match *record_type {
TLS_RECORD_CHANGE_CIPHER => {
change_cipher_spec_count = change_cipher_spec_count.saturating_add(1);
}
TLS_RECORD_APPLICATION => {
app_data_record_sizes.push(body.len());
}
_ => {}
}
}
let mut ticket_record_sizes = Vec::new();
while app_data_record_sizes
.last()
.is_some_and(|size| *size <= 256 && ticket_record_sizes.len() < 2)
{
if let Some(size) = app_data_record_sizes.pop() {
ticket_record_sizes.push(size);
}
}
ticket_record_sizes.reverse();
TlsBehaviorProfile {
change_cipher_spec_count: change_cipher_spec_count.max(1),
app_data_record_sizes,
ticket_record_sizes,
source: TlsProfileSource::Raw,
}
}
fn parse_cert_info(certs: &[CertificateDer<'static>]) -> Option<ParsedCertificateInfo> {
let first = certs.first()?;
let (_rem, cert) = X509Certificate::from_der(first.as_ref()).ok()?;
@@ -443,39 +482,50 @@ where
.await??;
let mut records = Vec::new();
// Read up to 4 records: ServerHello, CCS, and up to two ApplicationData.
for _ in 0..4 {
let mut app_records_seen = 0usize;
// Read a bounded encrypted flight: ServerHello, CCS, certificate-like data,
// and a small number of ticket-like tail records.
for _ in 0..8 {
match timeout(connect_timeout, read_tls_record(&mut stream)).await {
Ok(Ok(rec)) => records.push(rec),
Ok(Ok(rec)) => {
if rec.0 == TLS_RECORD_APPLICATION {
app_records_seen += 1;
}
records.push(rec);
}
Ok(Err(e)) => return Err(e),
Err(_) => break,
}
if records.len() >= 3 && records.iter().any(|(t, _)| *t == TLS_RECORD_APPLICATION) {
if app_records_seen >= 4 {
break;
}
}
let mut app_sizes = Vec::new();
let mut server_hello = None;
for (t, body) in &records {
if *t == TLS_RECORD_HANDSHAKE && server_hello.is_none() {
server_hello = parse_server_hello(body);
} else if *t == TLS_RECORD_APPLICATION {
app_sizes.push(body.len());
}
}
let parsed = server_hello.ok_or_else(|| anyhow!("ServerHello not received"))?;
let behavior_profile = derive_behavior_profile(&records);
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]);
Ok(TlsFetchResult {
server_hello_parsed: parsed,
app_data_records_sizes: if app_sizes.is_empty() {
vec![total_app_data_len]
} else {
app_sizes
},
app_data_records_sizes,
total_app_data_len,
behavior_profile,
cert_info: None,
cert_payload: None,
})
@@ -608,6 +658,12 @@ where
server_hello_parsed: parsed,
app_data_records_sizes: app_data_records_sizes.clone(),
total_app_data_len: app_data_records_sizes.iter().sum(),
behavior_profile: TlsBehaviorProfile {
change_cipher_spec_count: 1,
app_data_record_sizes: app_data_records_sizes,
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Rustls,
},
cert_info,
cert_payload,
})
@@ -706,6 +762,7 @@ pub async fn fetch_real_tls(
if let Some(mut raw) = raw_result {
raw.cert_info = rustls_result.cert_info;
raw.cert_payload = rustls_result.cert_payload;
raw.behavior_profile.source = TlsProfileSource::Merged;
debug!(sni = %sni, "Fetched TLS metadata via raw probe + rustls cert chain");
Ok(raw)
} else {
@@ -725,7 +782,11 @@ pub async fn fetch_real_tls(
#[cfg(test)]
mod tests {
use super::encode_tls13_certificate_message;
use super::{derive_behavior_profile, encode_tls13_certificate_message};
use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
};
use crate::tls_front::types::TlsProfileSource;
fn read_u24(bytes: &[u8]) -> usize {
((bytes[0] as usize) << 16) | ((bytes[1] as usize) << 8) | (bytes[2] as usize)
@@ -753,4 +814,20 @@ mod tests {
fn test_encode_tls13_certificate_message_empty_chain() {
assert!(encode_tls13_certificate_message(&[]).is_none());
}
#[test]
fn test_derive_behavior_profile_splits_ticket_like_tail_records() {
let profile = derive_behavior_profile(&[
(TLS_RECORD_HANDSHAKE, vec![0u8; 90]),
(TLS_RECORD_CHANGE_CIPHER, vec![0x01]),
(TLS_RECORD_APPLICATION, vec![0u8; 1400]),
(TLS_RECORD_APPLICATION, vec![0u8; 220]),
(TLS_RECORD_APPLICATION, vec![0u8; 180]),
]);
assert_eq!(profile.change_cipher_spec_count, 1);
assert_eq!(profile.app_data_record_sizes, vec![1400]);
assert_eq!(profile.ticket_record_sizes, vec![220, 180]);
assert_eq!(profile.source, TlsProfileSource::Raw);
}
}

View File

@@ -39,6 +39,53 @@ pub struct TlsCertPayload {
pub certificate_message: Vec<u8>,
}
/// Provenance of the cached TLS behavior profile.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TlsProfileSource {
/// Built from hardcoded defaults or legacy cache entries.
#[default]
Default,
/// Derived from raw TLS record capture only.
Raw,
/// Derived from rustls-only metadata fallback.
Rustls,
/// Merged from raw TLS capture and rustls certificate metadata.
Merged,
}
/// Coarse-grained TLS response behavior captured per SNI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsBehaviorProfile {
/// Number of ChangeCipherSpec records observed before encrypted flight.
#[serde(default = "default_change_cipher_spec_count")]
pub change_cipher_spec_count: u8,
/// Sizes of the primary encrypted flight records carrying cert-like payload.
#[serde(default)]
pub app_data_record_sizes: Vec<usize>,
/// Sizes of small tail ApplicationData records that look like tickets.
#[serde(default)]
pub ticket_record_sizes: Vec<usize>,
/// Source of this behavior profile.
#[serde(default)]
pub source: TlsProfileSource,
}
fn default_change_cipher_spec_count() -> u8 {
1
}
impl Default for TlsBehaviorProfile {
fn default() -> Self {
Self {
change_cipher_spec_count: default_change_cipher_spec_count(),
app_data_record_sizes: Vec::new(),
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
}
}
}
/// Cached data per SNI used by the emulator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedTlsData {
@@ -48,6 +95,8 @@ pub struct CachedTlsData {
pub cert_payload: Option<TlsCertPayload>,
pub app_data_records_sizes: Vec<usize>,
pub total_app_data_len: usize,
#[serde(default)]
pub behavior_profile: TlsBehaviorProfile,
#[serde(default = "now_system_time", skip_serializing, skip_deserializing)]
pub fetched_at: SystemTime,
pub domain: String,
@@ -63,6 +112,40 @@ pub struct TlsFetchResult {
pub server_hello_parsed: ParsedServerHello,
pub app_data_records_sizes: Vec<usize>,
pub total_app_data_len: usize,
#[serde(default)]
pub behavior_profile: TlsBehaviorProfile,
pub cert_info: Option<ParsedCertificateInfo>,
pub cert_payload: Option<TlsCertPayload>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cached_tls_data_deserializes_without_behavior_profile() {
let json = r#"
{
"server_hello_template": {
"version": [3, 3],
"random": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"session_id": [],
"cipher_suite": [19, 1],
"compression": 0,
"extensions": []
},
"cert_info": null,
"cert_payload": null,
"app_data_records_sizes": [1024],
"total_app_data_len": 1024,
"domain": "example.com"
}
"#;
let cached: CachedTlsData = serde_json::from_str(json).unwrap();
assert_eq!(cached.behavior_profile.change_cipher_spec_count, 1);
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);
}
}

View File

@@ -298,6 +298,7 @@ async fn run_update_cycle(
pool.update_runtime_reinit_policy(
cfg.general.hardswap,
cfg.general.me_pool_drain_ttl_secs,
cfg.general.me_pool_drain_threshold,
cfg.general.effective_me_pool_force_close_secs(),
cfg.general.me_pool_min_fresh_ratio,
cfg.general.me_hardswap_warmup_delay_min_ms,
@@ -524,6 +525,7 @@ pub async fn me_config_updater(
pool.update_runtime_reinit_policy(
cfg.general.hardswap,
cfg.general.me_pool_drain_ttl_secs,
cfg.general.me_pool_drain_threshold,
cfg.general.effective_me_pool_force_close_secs(),
cfg.general.me_pool_min_fresh_ratio,
cfg.general.me_hardswap_warmup_delay_min_ms,

View File

@@ -62,6 +62,7 @@ pub async fn me_health_monitor(pool: Arc<MePool>, rng: Arc<SecureRandom>, _min_c
let mut adaptive_idle_since: HashMap<(i32, IpFamily), Instant> = HashMap::new();
let mut adaptive_recover_until: HashMap<(i32, IpFamily), Instant> = HashMap::new();
let mut floor_warn_next_allowed: HashMap<(i32, IpFamily), Instant> = HashMap::new();
let mut drain_warn_next_allowed: HashMap<u64, Instant> = HashMap::new();
let mut degraded_interval = true;
loop {
let interval = if degraded_interval {
@@ -71,7 +72,7 @@ pub async fn me_health_monitor(pool: Arc<MePool>, rng: Arc<SecureRandom>, _min_c
};
tokio::time::sleep(interval).await;
pool.prune_closed_writers().await;
reap_draining_writers(&pool).await;
reap_draining_writers(&pool, &mut drain_warn_next_allowed).await;
let v4_degraded = check_family(
IpFamily::V4,
&pool,
@@ -110,17 +111,81 @@ pub async fn me_health_monitor(pool: Arc<MePool>, rng: Arc<SecureRandom>, _min_c
}
}
async fn reap_draining_writers(pool: &Arc<MePool>) {
async fn reap_draining_writers(
pool: &Arc<MePool>,
warn_next_allowed: &mut HashMap<u64, Instant>,
) {
let now_epoch_secs = MePool::now_epoch_secs();
let now = Instant::now();
let drain_ttl_secs = pool.me_pool_drain_ttl_secs.load(std::sync::atomic::Ordering::Relaxed);
let drain_threshold = pool
.me_pool_drain_threshold
.load(std::sync::atomic::Ordering::Relaxed);
let writers = pool.writers.read().await.clone();
let mut draining_writers = Vec::new();
for writer in writers {
if !writer.draining.load(std::sync::atomic::Ordering::Relaxed) {
continue;
}
if pool.registry.is_writer_empty(writer.id).await {
let is_empty = pool.registry.is_writer_empty(writer.id).await;
if is_empty {
pool.remove_writer_and_close_clients(writer.id).await;
continue;
}
draining_writers.push(writer);
}
if drain_threshold > 0 && draining_writers.len() > drain_threshold as usize {
draining_writers.sort_by(|left, right| {
let left_started = left
.draining_started_at_epoch_secs
.load(std::sync::atomic::Ordering::Relaxed);
let right_started = right
.draining_started_at_epoch_secs
.load(std::sync::atomic::Ordering::Relaxed);
left_started
.cmp(&right_started)
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.id.cmp(&right.id))
});
let overflow = draining_writers.len().saturating_sub(drain_threshold as usize);
warn!(
draining_writers = draining_writers.len(),
me_pool_drain_threshold = drain_threshold,
removing_writers = overflow,
"ME draining writer threshold exceeded, force-closing oldest draining writers"
);
for writer in draining_writers.drain(..overflow) {
pool.stats.increment_pool_force_close_total();
pool.remove_writer_and_close_clients(writer.id).await;
}
}
for writer in draining_writers {
let drain_started_at_epoch_secs = writer
.draining_started_at_epoch_secs
.load(std::sync::atomic::Ordering::Relaxed);
if drain_ttl_secs > 0
&& drain_started_at_epoch_secs != 0
&& now_epoch_secs.saturating_sub(drain_started_at_epoch_secs) > drain_ttl_secs
&& should_emit_writer_warn(
warn_next_allowed,
writer.id,
now,
pool.warn_rate_limit_duration(),
)
{
warn!(
writer_id = writer.id,
writer_dc = writer.writer_dc,
endpoint = %writer.addr,
generation = writer.generation,
drain_ttl_secs,
force_close_secs = pool.me_pool_force_close_secs.load(std::sync::atomic::Ordering::Relaxed),
allow_drain_fallback = writer.allow_drain_fallback.load(std::sync::atomic::Ordering::Relaxed),
"ME draining writer remains non-empty past drain TTL"
);
}
let deadline_epoch_secs = writer
.drain_deadline_epoch_secs
.load(std::sync::atomic::Ordering::Relaxed);
@@ -132,6 +197,23 @@ async fn reap_draining_writers(pool: &Arc<MePool>) {
}
}
fn should_emit_writer_warn(
next_allowed: &mut HashMap<u64, Instant>,
writer_id: u64,
now: Instant,
cooldown: Duration,
) -> bool {
let Some(ready_at) = next_allowed.get(&writer_id).copied() else {
next_allowed.insert(writer_id, now + cooldown);
return true;
};
if now >= ready_at {
next_allowed.insert(writer_id, now + cooldown);
return true;
}
false
}
async fn check_family(
family: IpFamily,
pool: &Arc<MePool>,
@@ -1222,3 +1304,190 @@ async fn maybe_rotate_single_endpoint_shadow(
"Single-endpoint shadow writer rotated"
);
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use super::reap_draining_writers;
use crate::config::{GeneralConfig, MeRouteNoWriterMode, MeSocksKdfPolicy, MeWriterPickMode};
use crate::crypto::SecureRandom;
use crate::network::probe::NetworkDecision;
use crate::stats::Stats;
use crate::transport::middle_proxy::codec::WriterCommand;
use crate::transport::middle_proxy::pool::{MePool, MeWriter, WriterContour};
use crate::transport::middle_proxy::registry::ConnMeta;
async fn make_pool(me_pool_drain_threshold: u64) -> Arc<MePool> {
let general = GeneralConfig {
me_pool_drain_threshold,
..GeneralConfig::default()
};
MePool::new(
None,
vec![1u8; 32],
None,
false,
None,
Vec::new(),
1,
None,
12,
1200,
HashMap::new(),
HashMap::new(),
None,
NetworkDecision::default(),
None,
Arc::new(SecureRandom::new()),
Arc::new(Stats::default()),
general.me_keepalive_enabled,
general.me_keepalive_interval_secs,
general.me_keepalive_jitter_secs,
general.me_keepalive_payload_random,
general.rpc_proxy_req_every,
general.me_warmup_stagger_enabled,
general.me_warmup_step_delay_ms,
general.me_warmup_step_jitter_ms,
general.me_reconnect_max_concurrent_per_dc,
general.me_reconnect_backoff_base_ms,
general.me_reconnect_backoff_cap_ms,
general.me_reconnect_fast_retry_count,
general.me_single_endpoint_shadow_writers,
general.me_single_endpoint_outage_mode_enabled,
general.me_single_endpoint_outage_disable_quarantine,
general.me_single_endpoint_outage_backoff_min_ms,
general.me_single_endpoint_outage_backoff_max_ms,
general.me_single_endpoint_shadow_rotate_every_secs,
general.me_floor_mode,
general.me_adaptive_floor_idle_secs,
general.me_adaptive_floor_min_writers_single_endpoint,
general.me_adaptive_floor_min_writers_multi_endpoint,
general.me_adaptive_floor_recover_grace_secs,
general.me_adaptive_floor_writers_per_core_total,
general.me_adaptive_floor_cpu_cores_override,
general.me_adaptive_floor_max_extra_writers_single_per_core,
general.me_adaptive_floor_max_extra_writers_multi_per_core,
general.me_adaptive_floor_max_active_writers_per_core,
general.me_adaptive_floor_max_warm_writers_per_core,
general.me_adaptive_floor_max_active_writers_global,
general.me_adaptive_floor_max_warm_writers_global,
general.hardswap,
general.me_pool_drain_ttl_secs,
general.me_pool_drain_threshold,
general.effective_me_pool_force_close_secs(),
general.me_pool_min_fresh_ratio,
general.me_hardswap_warmup_delay_min_ms,
general.me_hardswap_warmup_delay_max_ms,
general.me_hardswap_warmup_extra_passes,
general.me_hardswap_warmup_pass_backoff_base_ms,
general.me_bind_stale_mode,
general.me_bind_stale_ttl_secs,
general.me_secret_atomic_snapshot,
general.me_deterministic_writer_sort,
MeWriterPickMode::default(),
general.me_writer_pick_sample_size,
MeSocksKdfPolicy::default(),
general.me_writer_cmd_channel_capacity,
general.me_route_channel_capacity,
general.me_route_backpressure_base_timeout_ms,
general.me_route_backpressure_high_timeout_ms,
general.me_route_backpressure_high_watermark_pct,
general.me_reader_route_data_wait_ms,
general.me_health_interval_ms_unhealthy,
general.me_health_interval_ms_healthy,
general.me_warn_rate_limit_ms,
MeRouteNoWriterMode::default(),
general.me_route_no_writer_wait_ms,
general.me_route_inline_recovery_attempts,
general.me_route_inline_recovery_wait_ms,
)
}
async fn insert_draining_writer(
pool: &Arc<MePool>,
writer_id: u64,
drain_started_at_epoch_secs: u64,
) -> u64 {
let (conn_id, _rx) = pool.registry.register().await;
let (tx, _writer_rx) = mpsc::channel::<WriterCommand>(8);
let writer = MeWriter {
id: writer_id,
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4000 + writer_id as u16),
source_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
writer_dc: 2,
generation: 1,
contour: Arc::new(AtomicU8::new(WriterContour::Draining.as_u8())),
created_at: Instant::now() - Duration::from_secs(writer_id),
tx: tx.clone(),
cancel: CancellationToken::new(),
degraded: Arc::new(AtomicBool::new(false)),
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
draining: Arc::new(AtomicBool::new(true)),
draining_started_at_epoch_secs: Arc::new(AtomicU64::new(drain_started_at_epoch_secs)),
drain_deadline_epoch_secs: Arc::new(AtomicU64::new(0)),
allow_drain_fallback: Arc::new(AtomicBool::new(false)),
};
pool.writers.write().await.push(writer);
pool.registry.register_writer(writer_id, tx).await;
pool.conn_count.fetch_add(1, Ordering::Relaxed);
assert!(
pool.registry
.bind_writer(
conn_id,
writer_id,
ConnMeta {
target_dc: 2,
client_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6000),
our_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443),
proto_flags: 0,
},
)
.await
);
conn_id
}
#[tokio::test]
async fn reap_draining_writers_force_closes_oldest_over_threshold() {
let pool = make_pool(2).await;
let now_epoch_secs = MePool::now_epoch_secs();
let conn_a = insert_draining_writer(&pool, 10, now_epoch_secs.saturating_sub(30)).await;
let conn_b = insert_draining_writer(&pool, 20, now_epoch_secs.saturating_sub(20)).await;
let conn_c = insert_draining_writer(&pool, 30, now_epoch_secs.saturating_sub(10)).await;
let mut warn_next_allowed = HashMap::new();
reap_draining_writers(&pool, &mut warn_next_allowed).await;
let writer_ids: Vec<u64> = pool.writers.read().await.iter().map(|writer| writer.id).collect();
assert_eq!(writer_ids, vec![20, 30]);
assert!(pool.registry.get_writer(conn_a).await.is_none());
assert_eq!(pool.registry.get_writer(conn_b).await.unwrap().writer_id, 20);
assert_eq!(pool.registry.get_writer(conn_c).await.unwrap().writer_id, 30);
}
#[tokio::test]
async fn reap_draining_writers_keeps_timeout_only_behavior_when_threshold_disabled() {
let pool = make_pool(0).await;
let now_epoch_secs = MePool::now_epoch_secs();
let conn_a = insert_draining_writer(&pool, 10, now_epoch_secs.saturating_sub(30)).await;
let conn_b = insert_draining_writer(&pool, 20, now_epoch_secs.saturating_sub(20)).await;
let conn_c = insert_draining_writer(&pool, 30, now_epoch_secs.saturating_sub(10)).await;
let mut warn_next_allowed = HashMap::new();
reap_draining_writers(&pool, &mut warn_next_allowed).await;
let writer_ids: Vec<u64> = pool.writers.read().await.iter().map(|writer| writer.id).collect();
assert_eq!(writer_ids, vec![10, 20, 30]);
assert_eq!(pool.registry.get_writer(conn_a).await.unwrap().writer_id, 10);
assert_eq!(pool.registry.get_writer(conn_b).await.unwrap().writer_id, 20);
assert_eq!(pool.registry.get_writer(conn_c).await.unwrap().writer_id, 30);
}
}

View File

@@ -171,6 +171,7 @@ pub struct MePool {
pub(super) endpoint_quarantine: Arc<Mutex<HashMap<SocketAddr, Instant>>>,
pub(super) kdf_material_fingerprint: Arc<RwLock<HashMap<SocketAddr, (u64, u16)>>>,
pub(super) me_pool_drain_ttl_secs: AtomicU64,
pub(super) me_pool_drain_threshold: AtomicU64,
pub(super) me_pool_force_close_secs: AtomicU64,
pub(super) me_pool_min_fresh_ratio_permille: AtomicU32,
pub(super) me_hardswap_warmup_delay_min_ms: AtomicU64,
@@ -271,6 +272,7 @@ impl MePool {
me_adaptive_floor_max_warm_writers_global: u32,
hardswap: bool,
me_pool_drain_ttl_secs: u64,
me_pool_drain_threshold: u64,
me_pool_force_close_secs: u64,
me_pool_min_fresh_ratio: f32,
me_hardswap_warmup_delay_min_ms: u64,
@@ -446,6 +448,7 @@ impl MePool {
endpoint_quarantine: Arc::new(Mutex::new(HashMap::new())),
kdf_material_fingerprint: Arc::new(RwLock::new(HashMap::new())),
me_pool_drain_ttl_secs: AtomicU64::new(me_pool_drain_ttl_secs),
me_pool_drain_threshold: AtomicU64::new(me_pool_drain_threshold),
me_pool_force_close_secs: AtomicU64::new(me_pool_force_close_secs),
me_pool_min_fresh_ratio_permille: AtomicU32::new(Self::ratio_to_permille(
me_pool_min_fresh_ratio,
@@ -492,6 +495,7 @@ impl MePool {
&self,
hardswap: bool,
drain_ttl_secs: u64,
pool_drain_threshold: u64,
force_close_secs: u64,
min_fresh_ratio: f32,
hardswap_warmup_delay_min_ms: u64,
@@ -530,6 +534,8 @@ impl MePool {
self.hardswap.store(hardswap, Ordering::Relaxed);
self.me_pool_drain_ttl_secs
.store(drain_ttl_secs, Ordering::Relaxed);
self.me_pool_drain_threshold
.store(pool_drain_threshold, Ordering::Relaxed);
self.me_pool_force_close_secs
.store(force_close_secs, Ordering::Relaxed);
self.me_pool_min_fresh_ratio_permille

View File

@@ -19,6 +19,12 @@ pub(crate) struct MeApiWriterStatusSnapshot {
pub bound_clients: usize,
pub idle_for_secs: Option<u64>,
pub rtt_ema_ms: Option<f64>,
pub matches_active_generation: bool,
pub in_desired_map: bool,
pub allow_drain_fallback: bool,
pub drain_started_at_epoch_secs: Option<u64>,
pub drain_deadline_epoch_secs: Option<u64>,
pub drain_over_ttl: bool,
}
#[derive(Clone, Debug)]
@@ -35,6 +41,8 @@ pub(crate) struct MeApiDcStatusSnapshot {
pub floor_capped: bool,
pub alive_writers: usize,
pub coverage_pct: f64,
pub fresh_alive_writers: usize,
pub fresh_coverage_pct: f64,
pub rtt_ms: Option<f64>,
pub load: usize,
}
@@ -55,6 +63,8 @@ pub(crate) struct MeApiStatusSnapshot {
pub required_writers: usize,
pub alive_writers: usize,
pub coverage_pct: f64,
pub fresh_alive_writers: usize,
pub fresh_coverage_pct: f64,
pub writers: Vec<MeApiWriterStatusSnapshot>,
pub dcs: Vec<MeApiDcStatusSnapshot>,
}
@@ -213,6 +223,8 @@ impl MePool {
pub(crate) async fn api_status_snapshot(&self) -> MeApiStatusSnapshot {
let now_epoch_secs = Self::now_epoch_secs();
let active_generation = self.current_generation();
let drain_ttl_secs = self.me_pool_drain_ttl_secs.load(Ordering::Relaxed);
let mut endpoints_by_dc = BTreeMap::<i16, BTreeSet<SocketAddr>>::new();
if self.decision.ipv4_me {
@@ -239,6 +251,7 @@ impl MePool {
let mut live_writers_by_dc_endpoint = HashMap::<(i16, SocketAddr), usize>::new();
let mut live_writers_by_dc = HashMap::<i16, usize>::new();
let mut fresh_writers_by_dc = HashMap::<i16, usize>::new();
let mut dc_rtt_agg = HashMap::<i16, (f64, u64)>::new();
let mut writer_rows = Vec::<MeApiWriterStatusSnapshot>::with_capacity(writers.len());
@@ -247,6 +260,10 @@ impl MePool {
let dc = i16::try_from(writer.writer_dc).ok();
let draining = writer.draining.load(Ordering::Relaxed);
let degraded = writer.degraded.load(Ordering::Relaxed);
let matches_active_generation = writer.generation == active_generation;
let in_desired_map = dc
.and_then(|dc_idx| endpoints_by_dc.get(&dc_idx))
.is_some_and(|endpoints| endpoints.contains(&endpoint));
let bound_clients = activity
.bound_clients_by_writer
.get(&writer.id)
@@ -256,6 +273,21 @@ impl MePool {
.get(&writer.id)
.map(|idle_ts| now_epoch_secs.saturating_sub(*idle_ts));
let rtt_ema_ms = rtt.get(&writer.id).map(|(_, ema)| *ema);
let allow_drain_fallback = writer.allow_drain_fallback.load(Ordering::Relaxed);
let drain_started_at_epoch_secs = writer
.draining_started_at_epoch_secs
.load(Ordering::Relaxed);
let drain_deadline_epoch_secs = writer
.drain_deadline_epoch_secs
.load(Ordering::Relaxed);
let drain_started_at_epoch_secs =
(drain_started_at_epoch_secs != 0).then_some(drain_started_at_epoch_secs);
let drain_deadline_epoch_secs =
(drain_deadline_epoch_secs != 0).then_some(drain_deadline_epoch_secs);
let drain_over_ttl = draining
&& drain_ttl_secs > 0
&& drain_started_at_epoch_secs
.is_some_and(|started| now_epoch_secs.saturating_sub(started) > drain_ttl_secs);
let state = match WriterContour::from_u8(writer.contour.load(Ordering::Relaxed)) {
WriterContour::Warm => "warm",
WriterContour::Active => "active",
@@ -273,6 +305,9 @@ impl MePool {
entry.0 += ema_ms;
entry.1 += 1;
}
if matches_active_generation && in_desired_map {
*fresh_writers_by_dc.entry(dc_idx).or_insert(0) += 1;
}
}
}
@@ -287,6 +322,12 @@ impl MePool {
bound_clients,
idle_for_secs,
rtt_ema_ms,
matches_active_generation,
in_desired_map,
allow_drain_fallback,
drain_started_at_epoch_secs,
drain_deadline_epoch_secs,
drain_over_ttl,
});
}
@@ -295,6 +336,7 @@ impl MePool {
let mut dcs = Vec::<MeApiDcStatusSnapshot>::with_capacity(endpoints_by_dc.len());
let mut available_endpoints = 0usize;
let mut alive_writers = 0usize;
let mut fresh_alive_writers = 0usize;
let floor_mode = self.floor_mode();
let adaptive_cpu_cores = (self
.me_adaptive_floor_cpu_cores_effective
@@ -333,6 +375,7 @@ impl MePool {
let floor_capped = matches!(floor_mode, MeFloorMode::Adaptive)
&& dc_required_writers < base_required;
let dc_alive_writers = live_writers_by_dc.get(&dc).copied().unwrap_or(0);
let dc_fresh_alive_writers = fresh_writers_by_dc.get(&dc).copied().unwrap_or(0);
let dc_load = activity
.active_sessions_by_target_dc
.get(&dc)
@@ -344,6 +387,7 @@ impl MePool {
available_endpoints += dc_available_endpoints;
alive_writers += dc_alive_writers;
fresh_alive_writers += dc_fresh_alive_writers;
dcs.push(MeApiDcStatusSnapshot {
dc,
@@ -367,6 +411,8 @@ impl MePool {
floor_capped,
alive_writers: dc_alive_writers,
coverage_pct: ratio_pct(dc_alive_writers, dc_required_writers),
fresh_alive_writers: dc_fresh_alive_writers,
fresh_coverage_pct: ratio_pct(dc_fresh_alive_writers, dc_required_writers),
rtt_ms: dc_rtt_ms,
load: dc_load,
});
@@ -381,6 +427,8 @@ impl MePool {
required_writers,
alive_writers,
coverage_pct: ratio_pct(alive_writers, required_writers),
fresh_alive_writers,
fresh_coverage_pct: ratio_pct(fresh_alive_writers, required_writers),
writers: writer_rows,
dcs,
}

View File

@@ -178,6 +178,7 @@ impl MePool {
allow_drain_fallback: allow_drain_fallback.clone(),
};
self.writers.write().await.push(writer.clone());
self.registry.register_writer(writer_id, tx.clone()).await;
self.registry.mark_writer_idle(writer_id).await;
self.conn_count.fetch_add(1, Ordering::Relaxed);
self.writer_available.notify_one();
@@ -414,9 +415,15 @@ impl MePool {
};
let (conn_id, mut service_rx) = pool.registry.register().await;
pool.registry
.bind_writer(conn_id, writer_id, tx_signal.clone(), meta.clone())
.await;
if !pool
.registry
.bind_writer(conn_id, writer_id, meta.clone())
.await
{
let _ = pool.registry.unregister(conn_id).await;
stats_signal.increment_me_rpc_proxy_req_signal_skipped_no_meta_total();
continue;
}
let payload = build_proxy_req_payload(
conn_id,
@@ -521,6 +528,12 @@ impl MePool {
self.conn_count.fetch_sub(1, Ordering::Relaxed);
}
}
let conns = self.registry.writer_lost(writer_id).await;
{
let mut tracker = self.ping_tracker.lock().await;
tracker.retain(|_, (_, wid)| *wid != writer_id);
}
self.rtt_stats.lock().await.remove(&writer_id);
if let Some(tx) = close_tx {
let _ = tx.send(WriterCommand::Close).await;
}
@@ -533,8 +546,7 @@ impl MePool {
}
self.trigger_immediate_refill_for_dc(addr, writer_dc);
}
self.rtt_stats.lock().await.remove(&writer_id);
self.registry.writer_lost(writer_id).await
conns
}
pub(crate) async fn mark_writer_draining_with_timeout(

View File

@@ -138,6 +138,15 @@ impl ConnRegistry {
(id, rx)
}
pub async fn register_writer(&self, writer_id: u64, tx: mpsc::Sender<WriterCommand>) {
let mut inner = self.inner.write().await;
inner.writers.insert(writer_id, tx);
inner
.conns_for_writer
.entry(writer_id)
.or_insert_with(HashSet::new);
}
/// Unregister connection, returning associated writer_id if any.
pub async fn unregister(&self, id: u64) -> Option<u64> {
let mut inner = self.inner.write().await;
@@ -282,24 +291,39 @@ impl ConnRegistry {
}
}
pub async fn bind_writer(
&self,
conn_id: u64,
writer_id: u64,
tx: mpsc::Sender<WriterCommand>,
meta: ConnMeta,
) {
pub async fn bind_writer(&self, conn_id: u64, writer_id: u64, meta: ConnMeta) -> bool {
let mut inner = self.inner.write().await;
inner.meta.entry(conn_id).or_insert(meta.clone());
inner.writer_for_conn.insert(conn_id, writer_id);
if !inner.writers.contains_key(&writer_id) {
return false;
}
let previous_writer_id = inner.writer_for_conn.insert(conn_id, writer_id);
if let Some(previous_writer_id) = previous_writer_id
&& previous_writer_id != writer_id
{
let became_empty = if let Some(set) = inner.conns_for_writer.get_mut(&previous_writer_id)
{
set.remove(&conn_id);
set.is_empty()
} else {
false
};
if became_empty {
inner
.writer_idle_since_epoch_secs
.insert(previous_writer_id, Self::now_epoch_secs());
}
}
inner.meta.insert(conn_id, meta.clone());
inner.last_meta_for_writer.insert(writer_id, meta);
inner.writer_idle_since_epoch_secs.remove(&writer_id);
inner.writers.entry(writer_id).or_insert_with(|| tx.clone());
inner
.conns_for_writer
.entry(writer_id)
.or_insert_with(HashSet::new)
.insert(conn_id);
true
}
pub async fn mark_writer_idle(&self, writer_id: u64) {
@@ -384,6 +408,9 @@ impl ConnRegistry {
let mut out = Vec::new();
for conn_id in conns {
if inner.writer_for_conn.get(&conn_id).copied() != Some(writer_id) {
continue;
}
inner.writer_for_conn.remove(&conn_id);
if let Some(m) = inner.meta.get(&conn_id) {
out.push(BoundConn {
@@ -427,47 +454,52 @@ mod tests {
let (conn_c, _rx_c) = registry.register().await;
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
registry.register_writer(10, writer_tx_a.clone()).await;
registry.register_writer(20, writer_tx_b.clone()).await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
registry
.bind_writer(
conn_a,
10,
writer_tx_a.clone(),
ConnMeta {
target_dc: 2,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await;
registry
.bind_writer(
conn_b,
10,
writer_tx_a,
ConnMeta {
target_dc: -2,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await;
registry
.bind_writer(
conn_c,
20,
writer_tx_b,
ConnMeta {
target_dc: 4,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await;
assert!(
registry
.bind_writer(
conn_a,
10,
ConnMeta {
target_dc: 2,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await
);
assert!(
registry
.bind_writer(
conn_b,
10,
ConnMeta {
target_dc: -2,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await
);
assert!(
registry
.bind_writer(
conn_c,
20,
ConnMeta {
target_dc: 4,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await
);
let snapshot = registry.writer_activity_snapshot().await;
assert_eq!(snapshot.bound_clients_by_writer.get(&10), Some(&2));
@@ -476,4 +508,130 @@ mod tests {
assert_eq!(snapshot.active_sessions_by_target_dc.get(&-2), Some(&1));
assert_eq!(snapshot.active_sessions_by_target_dc.get(&4), Some(&1));
}
#[tokio::test]
async fn bind_writer_rebinds_conn_atomically() {
let registry = ConnRegistry::new();
let (conn_id, _rx) = registry.register().await;
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
registry.register_writer(10, writer_tx_a).await;
registry.register_writer(20, writer_tx_b).await;
let client_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
let first_our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443);
let second_our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2)), 443);
assert!(
registry
.bind_writer(
conn_id,
10,
ConnMeta {
target_dc: 2,
client_addr,
our_addr: first_our_addr,
proto_flags: 1,
},
)
.await
);
assert!(
registry
.bind_writer(
conn_id,
20,
ConnMeta {
target_dc: 2,
client_addr,
our_addr: second_our_addr,
proto_flags: 2,
},
)
.await
);
let writer = registry.get_writer(conn_id).await.expect("writer binding");
assert_eq!(writer.writer_id, 20);
let meta = registry.get_meta(conn_id).await.expect("conn meta");
assert_eq!(meta.our_addr, second_our_addr);
assert_eq!(meta.proto_flags, 2);
let snapshot = registry.writer_activity_snapshot().await;
assert_eq!(snapshot.bound_clients_by_writer.get(&10), Some(&0));
assert_eq!(snapshot.bound_clients_by_writer.get(&20), Some(&1));
assert!(registry.writer_idle_since_snapshot().await.contains_key(&10));
}
#[tokio::test]
async fn writer_lost_does_not_drop_rebound_conn() {
let registry = ConnRegistry::new();
let (conn_id, _rx) = registry.register().await;
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
registry.register_writer(10, writer_tx_a).await;
registry.register_writer(20, writer_tx_b).await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
assert!(
registry
.bind_writer(
conn_id,
10,
ConnMeta {
target_dc: 2,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await
);
assert!(
registry
.bind_writer(
conn_id,
20,
ConnMeta {
target_dc: 2,
client_addr: addr,
our_addr: addr,
proto_flags: 1,
},
)
.await
);
let lost = registry.writer_lost(10).await;
assert!(lost.is_empty());
assert_eq!(registry.get_writer(conn_id).await.expect("writer").writer_id, 20);
let removed_writer = registry.unregister(conn_id).await;
assert_eq!(removed_writer, Some(20));
assert!(registry.is_writer_empty(20).await);
}
#[tokio::test]
async fn bind_writer_rejects_unregistered_writer() {
let registry = ConnRegistry::new();
let (conn_id, _rx) = registry.register().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
assert!(
!registry
.bind_writer(
conn_id,
10,
ConnMeta {
target_dc: 2,
client_addr: addr,
our_addr: addr,
proto_flags: 0,
},
)
.await
);
assert!(registry.get_writer(conn_id).await.is_none());
}
}

View File

@@ -375,9 +375,14 @@ impl MePool {
match w.tx.try_send(WriterCommand::Data(payload.clone())) {
Ok(()) => {
self.stats.increment_me_writer_pick_success_try_total(pick_mode);
self.registry
.bind_writer(conn_id, w.id, w.tx.clone(), meta)
.await;
if !self.registry.bind_writer(conn_id, w.id, meta).await {
debug!(
conn_id,
writer_id = w.id,
"ME writer disappeared before bind commit, retrying"
);
continue;
}
if w.generation < self.current_generation() {
self.stats.increment_pool_stale_pick_total();
debug!(
@@ -421,9 +426,14 @@ impl MePool {
Ok(()) => {
self.stats
.increment_me_writer_pick_success_fallback_total(pick_mode);
self.registry
.bind_writer(conn_id, w.id, w.tx.clone(), meta)
.await;
if !self.registry.bind_writer(conn_id, w.id, meta).await {
debug!(
conn_id,
writer_id = w.id,
"ME writer disappeared before fallback bind commit, retrying"
);
continue;
}
if w.generation < self.current_generation() {
self.stats.increment_pool_stale_pick_total();
}

View File

@@ -1,16 +0,0 @@
[Unit]
Description=Telemt
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/etc/telemt
ExecStart=/bin/telemt /etc/telemt.toml
Restart=on-failure
LimitNOFILE=262144
TasksMax=8192
MemoryAccounting=yes
[Install]
WantedBy=multi-user.target