mirror of
https://github.com/telemt/telemt.git
synced 2026-06-19 01:11:09 +03:00
Compare commits
166 Commits
e375b78374
...
3.4.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8874396ba5 | |||
| 033ebf5038 | |||
| f7b918875c | |||
| 8960fad8cd | |||
| 493f5c9680 | |||
| 67357310f7 | |||
| 8684378030 | |||
| db8d333ed6 | |||
| 30e73adaac | |||
| 351f2c8458 | |||
| 4ce6b14bd8 | |||
| db114f09c3 | |||
| 09310ff284 | |||
| 1e5b84c0ed | |||
| 926e3aa987 | |||
| aace0129f8 | |||
| 2a7303c129 | |||
| 9cb49bc024 | |||
| 8092283e8f | |||
| 132841da61 | |||
| 372d288806 | |||
| 1c44d45fad | |||
| 3a51a8d9aa | |||
| dd27206104 | |||
| f11c7880e6 | |||
| 5b07ffae7c | |||
| 7bbed133ee | |||
| f1bf95a7de | |||
| 959a16af88 | |||
| a54f9ba719 | |||
| 2d5cd9c8e1 | |||
| 37b6f7b985 | |||
| 50e9e5cf32 | |||
| d72cfd6bc4 | |||
| 1b25bada29 | |||
| fa3566a9cb | |||
| bde30eaf05 | |||
| b447f60a72 | |||
| 093faed0c2 | |||
| 4e59e52454 | |||
| 3ca3e8ff0e | |||
| 7b9b46291d | |||
| 2a168b2600 | |||
| 6e3b4a1ce5 | |||
| cd0771eee4 | |||
| a858dd799e | |||
| 947ef2beb7 | |||
| 376f9b42fb | |||
| 191ca35076 | |||
| 44485a545e | |||
| 17a966b822 | |||
| 073eacbb37 | |||
| 5c99cd8eb7 | |||
| 7494cb3092 | |||
| d100941426 | |||
| d25aa5a1e9 | |||
| f1b7b9aa08 | |||
| 3bff4fbfcd | |||
| f5b5ea3bbf | |||
| f36f2eae24 | |||
| 497ec6aa84 | |||
| 21ca1014ae | |||
| 982bfd20b9 | |||
| 0bcc3bf935 | |||
| f7913721e2 | |||
| 32d5cee01c | |||
| 3a17901e83 | |||
| 902a4e83cf | |||
| 696316f919 | |||
| d7a0319696 | |||
| 3fefcdd11f | |||
| 57dca639f0 | |||
| 13f86062f4 | |||
| 9303c7854a | |||
| 8267149b53 | |||
| 30fab00bfd | |||
| afc07345f5 | |||
| a965b38bd4 | |||
| f0ebbac338 | |||
| 286662fc51 | |||
| c5390baaf1 | |||
| 1cd1e96079 | |||
| 2b995c31b0 | |||
| 442320302d | |||
| ac0dde567b | |||
| b2fe9b78d8 | |||
| f039ce1827 | |||
| abff2fd7fe | |||
| 0b580eccd3 | |||
| 70b63e4e0b | |||
| 5f5a3e3fa0 | |||
| 3f69b54f5d | |||
| 62a90e05a0 | |||
| f9e54ee739 | |||
| 1b3d2d8bc5 | |||
| d477d6ee29 | |||
| 1383dfcbb1 | |||
| 107a7cc758 | |||
| 4f3193fdaa | |||
| d6be691c67 | |||
| 0b0be07a9c | |||
| 26c40092f3 | |||
| 192a852034 | |||
| 16c7a63fbc | |||
| 69a73d5fec | |||
| 7b1aa46753 | |||
| a728c727bc | |||
| d23ce4a184 | |||
| e48e1b141d | |||
| 82da541f9c | |||
| 6d5a1a29df | |||
| 026ca5cc1d | |||
| b11dec7f91 | |||
| edd1405562 | |||
| 45dd7485a9 | |||
| 901cf11c51 | |||
| 7acc76b422 | |||
| 227a64ef06 | |||
| 6748ed920e | |||
| 303b273c77 | |||
| 3bcc129b8d | |||
| 3ffbd294d2 | |||
| ddeda8d914 | |||
| 17fd01a2c4 | |||
| 8ed43a562c | |||
| fd6243b6cc | |||
| 44127c6f96 | |||
| a0c7a9e62c | |||
| d7af1cc206 | |||
| f8e22970c1 | |||
| 792f626336 | |||
| c5c98bb7fa | |||
| 6102280345 | |||
| 177f0f0325 | |||
| abcce12368 | |||
| 31cbf31491 | |||
| f479ecd1ad | |||
| 5c953eb4ba | |||
| 3771eb4ab2 | |||
| b246f0ed99 | |||
| 07d19027f6 | |||
| 877d16659e | |||
| 1265234491 | |||
| 07b53785c5 | |||
| 1e3522652c | |||
| 79f4ff4eec | |||
| e6c64525e3 | |||
| 59df74e341 | |||
| 21a33e4d2a | |||
| 73bf23eb61 | |||
| 4a904568da | |||
| a526fee728 | |||
| 265478b9ca | |||
| 038f688e75 | |||
| fa3a1b4dbc | |||
| e2e8b54f87 | |||
| 970313edcb | |||
| 45c66bc823 | |||
| 5e38a72add | |||
| 7ba02ea3d5 | |||
| 38c5f73d6a | |||
| 185e0081d7 | |||
| b6a30c1b51 | |||
| 19f9eb36ac | |||
| 2b8159a65e | |||
| 86be0d53fe |
@@ -0,0 +1,16 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom:
|
||||
- https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223
|
||||
+45
-56
@@ -3,50 +3,39 @@
|
||||
## Purpose
|
||||
|
||||
**Telemt exists to solve technical problems.**
|
||||
- Telemt is open to contributors who want to learn, improve and build meaningful systems together.
|
||||
- It is a place for building, testing, reasoning, documenting, and improving systems.
|
||||
- Discussions that advance this work are in scope, discussions that divert it are not.
|
||||
- Technology has consequences, responsibility is inherent.
|
||||
|
||||
Telemt is open to contributors who want to learn, improve and build meaningful systems together.
|
||||
> **Absicht bestimmt die Form**
|
||||
|
||||
It is a place for building, testing, reasoning, documenting, and improving systems.
|
||||
|
||||
Discussions that advance this work are in scope. Discussions that divert it are not.
|
||||
|
||||
Technology has consequences. Responsibility is inherent.
|
||||
|
||||
> **Zweck bestimmt die Form.**
|
||||
|
||||
> Purpose defines form.
|
||||
> Design follows intent
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
* **Technical over emotional**
|
||||
|
||||
Arguments are grounded in data, logs, reproducible cases, or clear reasoning.
|
||||
- Arguments are grounded in data, logs, reproducible cases, or clear reasoning.
|
||||
|
||||
* **Clarity over noise**
|
||||
|
||||
Communication is structured, concise, and relevant.
|
||||
- Communication is structured, concise, and relevant.
|
||||
|
||||
* **Openness with standards**
|
||||
|
||||
Participation is open. The work remains disciplined.
|
||||
- Participation is open. The work remains disciplined.
|
||||
|
||||
* **Independence of judgment**
|
||||
|
||||
Claims are evaluated on technical merit, not affiliation or posture.
|
||||
- Claims are evaluated on technical merit, not affiliation or posture.
|
||||
|
||||
* **Responsibility over capability**
|
||||
|
||||
Capability does not justify careless use.
|
||||
- Capability does not justify careless use.
|
||||
|
||||
* **Cooperation over friction**
|
||||
|
||||
Progress depends on coordination, mutual support, and honest review.
|
||||
- Progress depends on coordination, mutual support, and honest review.
|
||||
|
||||
* **Good intent, rigorous method**
|
||||
|
||||
Assume good intent, but require rigor.
|
||||
- Assume good intent, but require rigor.
|
||||
|
||||
> **Aussagen gelten nach ihrer Begründung.**
|
||||
|
||||
@@ -68,7 +57,9 @@ Participants are expected to:
|
||||
|
||||
Precision is learned.
|
||||
|
||||
New contributors are welcome. They are expected to grow into these standards. Existing contributors are expected to make that growth possible.
|
||||
- New contributors are welcome
|
||||
- They are expected to grow into these standards
|
||||
- Existing contributors are expected to make that growth possible
|
||||
|
||||
> **Wer behauptet, belegt.**
|
||||
|
||||
@@ -112,7 +103,7 @@ Security is both technical and behavioral.
|
||||
|
||||
---
|
||||
|
||||
## 6. Openness
|
||||
## Openness
|
||||
|
||||
Telemt is open to contributors of different backgrounds, experience levels, and working styles.
|
||||
|
||||
@@ -148,10 +139,9 @@ Judgment should be exercised with restraint, consistency, and institutional resp
|
||||
|
||||
All decisions are expected to serve the durability, clarity, and integrity of Telemt.
|
||||
|
||||
> **Ordnung ist Voraussetzung der Funktion.**
|
||||
|
||||
> Order is the precondition of function.
|
||||
> **Klarheit vor Zustimmung - Bestand vor Beifall**
|
||||
|
||||
> Clarity above approval - substantiality before success
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
@@ -171,42 +161,41 @@ Actions are taken to maintain function, continuity, and signal quality.
|
||||
|
||||
## Final
|
||||
|
||||
Telemt is built on discipline, structure, and shared intent.
|
||||
- Signal over noise.
|
||||
- Facts over opinion.
|
||||
- Systems over rhetoric.
|
||||
**Telemt is built on discipline, structure, and shared intent**
|
||||
- Signal over noise
|
||||
- Facts over opinion
|
||||
- Systems over rhetoric
|
||||
- Work is collective
|
||||
- Outcomes are shared
|
||||
- Responsibility is distributed
|
||||
- Precision is learned
|
||||
- Rigor is expected
|
||||
- Help is part of the work
|
||||
|
||||
- Work is collective.
|
||||
- Outcomes are shared.
|
||||
- Responsibility is distributed.
|
||||
> **Ordnung ist Voraussetzung der Freiheit**
|
||||
|
||||
- Precision is learned.
|
||||
- Rigor is expected.
|
||||
- Help is part of the work.
|
||||
|
||||
> **Ordnung ist Voraussetzung der Freiheit.**
|
||||
|
||||
- If you contribute — contribute with care.
|
||||
- If you speak — speak with substance.
|
||||
- If you engage — engage constructively.
|
||||
- If you contribute — contribute with care
|
||||
- If you speak — speak with substance
|
||||
- If you engage — engage constructively
|
||||
|
||||
---
|
||||
|
||||
## After All
|
||||
|
||||
Systems outlive intentions.
|
||||
- What is built will be used.
|
||||
- What is released will propagate.
|
||||
- What is maintained will define the future state.
|
||||
Systems outlive intentions
|
||||
- What is built will be used
|
||||
- What is released will propagate
|
||||
- What is maintained will define the future state
|
||||
|
||||
There is no neutral infrastructure, only infrastructure shaped well or poorly.
|
||||
There is no neutral infrastructure, only infrastructure shaped well or poorly
|
||||
|
||||
> **Jedes System trägt Verantwortung.**
|
||||
> **Ordnung → Umsetzung → Ergebnis**
|
||||
|
||||
> Every system carries responsibility.
|
||||
> Order → Implementation → Result
|
||||
|
||||
- Stability requires discipline.
|
||||
- Freedom requires structure.
|
||||
- Trust requires honesty.
|
||||
- Stability requires discipline
|
||||
- Freedom requires structure
|
||||
- Trust requires honesty
|
||||
|
||||
In the end: the system reflects its contributors
|
||||
|
||||
In the end: the system reflects its contributors.
|
||||
|
||||
Generated
+112
-94
@@ -90,9 +90,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.0"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -173,9 +173,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -183,9 +183,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -228,9 +228,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
@@ -299,9 +299,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
version = "1.2.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -346,7 +346,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -416,9 +416,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
@@ -805,9 +805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
@@ -997,7 +997,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -1068,6 +1068,12 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -1096,7 +1102,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
@@ -1118,7 +1124,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
@@ -1213,15 +1219,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@@ -1385,12 +1390,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1401,7 +1406,7 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
@@ -1534,9 +1539,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.94"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1578,9 +1583,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
@@ -1611,9 +1616,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.3"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
@@ -1705,7 +1710,7 @@ version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1728,7 +1733,7 @@ version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
@@ -1746,7 +1751,7 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2012,9 +2017,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
@@ -2059,7 +2064,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2108,9 +2113,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
@@ -2118,13 +2123,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20 0.10.0",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2157,9 +2162,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
@@ -2172,9 +2177,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
@@ -2196,7 +2201,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2326,7 +2331,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -2335,9 +2340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -2399,9 +2404,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -2474,7 +2479,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -2493,9 +2498,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "sendfd"
|
||||
@@ -2615,7 +2620,7 @@ dependencies = [
|
||||
"notify",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"sealed",
|
||||
"sendfd",
|
||||
"serde",
|
||||
@@ -2646,7 +2651,7 @@ dependencies = [
|
||||
"chacha20poly1305",
|
||||
"hkdf",
|
||||
"md-5",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"ring-compat",
|
||||
"sha1",
|
||||
]
|
||||
@@ -2741,6 +2746,12 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -2780,7 +2791,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.3.39"
|
||||
version = "3.4.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
@@ -2812,7 +2823,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"proptest",
|
||||
"rand 0.10.0",
|
||||
"rand 0.10.1",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -2970,9 +2981,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -2988,9 +2999,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3123,7 +3134,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -3160,11 +3171,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
@@ -3239,9 +3251,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
@@ -3297,9 +3309,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -3354,11 +3366,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3367,14 +3379,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -3385,9 +3397,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.67"
|
||||
version = "0.4.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
|
||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3395,9 +3407,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -3405,9 +3417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -3418,9 +3430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -3453,7 +3465,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
@@ -3461,9 +3473,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.94"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3481,18 +3493,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -3841,6 +3853,12 @@ dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
@@ -3890,7 +3908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3922,9 +3940,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.3.39"
|
||||
version = "3.4.6"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
@@ -98,4 +98,3 @@ harness = false
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
|
||||
+30
@@ -77,6 +77,34 @@ COPY config.toml /app/config.toml
|
||||
|
||||
EXPOSE 443 9090 9091
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/app/telemt", "healthcheck", "/app/config.toml", "--mode", "liveness"]
|
||||
|
||||
ENTRYPOINT ["/app/telemt"]
|
||||
CMD ["config.toml"]
|
||||
|
||||
# ==========================
|
||||
# Production Netfilter Profile
|
||||
# ==========================
|
||||
FROM debian:12-slim AS prod-netfilter
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
conntrack \
|
||||
nftables \
|
||||
iptables; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=minimal /telemt /app/telemt
|
||||
COPY config.toml /app/config.toml
|
||||
|
||||
EXPOSE 443 9090 9091
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/app/telemt", "healthcheck", "/app/config.toml", "--mode", "liveness"]
|
||||
|
||||
ENTRYPOINT ["/app/telemt"]
|
||||
CMD ["config.toml"]
|
||||
|
||||
@@ -94,5 +122,7 @@ USER nonroot:nonroot
|
||||
|
||||
EXPOSE 443 9090 9091
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/app/telemt", "healthcheck", "/app/config.toml", "--mode", "liveness"]
|
||||
|
||||
ENTRYPOINT ["/app/telemt"]
|
||||
CMD ["config.toml"]
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
# Telemt - MTProxy on Rust + Tokio
|
||||
|
||||
[](https://github.com/telemt/telemt/releases/latest) [](https://github.com/telemt/telemt/stargazers) [](https://github.com/telemt/telemt/network/members) [](https://t.me/telemtrs)
|
||||
|
||||
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
|
||||
|
||||
***Löst Probleme, bevor andere überhaupt wissen, dass sie existieren*** / ***It solves problems before others even realize they exist***
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Fixed TLS ClientHello is now available:
|
||||
> - in **Telegram Desktop** starting from version **6.7.2**
|
||||
> - in **Telegram Android Client** starting from version **12.6.4**
|
||||
> - **release for iOS is "work in progress"**
|
||||
> Fixed TLS ClientHello is now available in official clients for Desktop / Android / iOS
|
||||
>
|
||||
> To work with EE-MTProxy, please update your client!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/telemtrs">
|
||||
<img src="/docs/assets/telegram_button.svg" width="200"/>
|
||||
<img src="/docs/assets/telegram_button.svg" width="150"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Telemt** is a fast, secure, and feature-rich server written in Rust: it fully implements the official Telegram proxy algo and adds many production-ready improvements such as:
|
||||
- [ME Pool + Reader/Writer + Registry + Refill + Adaptive Floor + Trio-State + Generation Lifecycle](https://github.com/telemt/telemt/blob/main/docs/Architecture/Model/MODEL.en.md);
|
||||
- [Full-covered API w/ management](https://github.com/telemt/telemt/blob/main/docs/Architecture/API/API.md);
|
||||
- Anti-Replay on Sliding Window;
|
||||
- Prometheus-format Metrics;
|
||||
- TLS-Fronting and TCP-Splicing for masking from "prying" eyes.
|
||||
**Telemt** is a fast, secure, and feature-rich server written in Rust: it fully implements the official Telegram proxy algo and adds many production-ready improvements
|
||||
|
||||

|
||||
### One-command Install and Update
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
- [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md)
|
||||
- [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md)
|
||||
|
||||
## Features
|
||||
Our implementation of **TLS-fronting** is one of the most deeply debugged, focused, advanced and *almost* **"behaviorally consistent to real"**: we are confident we have it right - [see evidence on our validation and traces](docs/FAQ.en.md#recognizability-for-dpi-and-crawler)
|
||||
|
||||
Our ***Middle-End Pool*** is fastest by design in standard scenarios, compared to other implementations of connecting to the Middle-End Proxy: non dramatically, but usual
|
||||
@@ -40,29 +42,17 @@ Our ***Middle-End Pool*** is fastest by design in standard scenarios, compared t
|
||||
- Graceful shutdown on Ctrl+C;
|
||||
- Extensive logging via `trace` and `debug` with `RUST_LOG` method.
|
||||
|
||||
## One-command installation (update on re-ru)
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
See more in the [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md).
|
||||
|
||||
# GOTO
|
||||
- [FAQ](#faq)
|
||||
- [Architecture](docs/Architecture)
|
||||
- [Quick Start Guide](#quick-start-guide)
|
||||
- [Config parameters](docs/Config_params)
|
||||
- [Build](#build)
|
||||
- [Why Rust?](#why-rust)
|
||||
|
||||
## Quick Start Guide
|
||||
- [Quick Start Guide RU](docs/Quick_start/QUICK_START_GUIDE.ru.md)
|
||||
- [Quick Start Guide EN](docs/Quick_start/QUICK_START_GUIDE.en.md)
|
||||
|
||||
## FAQ
|
||||
|
||||
- [FAQ RU](docs/FAQ.ru.md)
|
||||
- [FAQ EN](docs/FAQ.en.md)
|
||||
|
||||
# Learn more about Telemt
|
||||
- [Our Architecture](docs/Architecture)
|
||||
- [All Config Options](docs/Config_params)
|
||||
- [How to build your own Telemt?](#build)
|
||||
- [Running on BSD](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md)
|
||||
- [Why Rust?](#why-rust)
|
||||
|
||||
## Build
|
||||
```bash
|
||||
# Cloning repo
|
||||
@@ -72,9 +62,8 @@ cd telemt
|
||||
# Starting Release Build
|
||||
cargo build --release
|
||||
|
||||
# Low-RAM devices (1 GB, e.g. NanoPi Neo3 / Raspberry Pi Zero 2):
|
||||
# release profile uses lto = "thin" to reduce peak linker memory.
|
||||
# If your custom toolchain overrides profiles, avoid enabling fat LTO.
|
||||
# Current release profile uses lto = "fat" for maximum optimization (see Cargo.toml).
|
||||
# On low-RAM systems (~1 GB) you can override it to "thin".
|
||||
|
||||
# Move to /bin
|
||||
mv ./target/release/telemt /bin
|
||||
@@ -84,15 +73,33 @@ chmod +x /bin/telemt
|
||||
telemt config.toml
|
||||
```
|
||||
|
||||
### OpenBSD
|
||||
- Build and service setup guide: [OpenBSD Guide (EN)](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md)
|
||||
- Example rc.d script: [contrib/openbsd/telemt.rcd](contrib/openbsd/telemt.rcd)
|
||||
- Status: OpenBSD sandbox hardening with `pledge(2)` and `unveil(2)` is not implemented yet.
|
||||
|
||||
|
||||
## Why Rust?
|
||||
- Long-running reliability and idempotent behavior
|
||||
- Rust's deterministic resource management - RAII
|
||||
- No garbage collector
|
||||
- Memory safety and reduced attack surface
|
||||
- Tokio's asynchronous architecture
|
||||
|
||||
## Support Telemt
|
||||
|
||||
Telemt is free, open-source, and built in personal time.
|
||||
If it helps you — consider supporting continued development.
|
||||
|
||||
Any cryptocurrency (BTC, ETH, USDT, 350+ coins):
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://nowpayments.io/images/embeds/donation-button-white.svg" alt="Cryptocurrency & Bitcoin donation button by NOWPayments" height="80">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Monero (XMR) directly:
|
||||
|
||||
```
|
||||
8Bk4tZEYPQWSypeD2hrUXG2rKbAKF16GqEN942ZdAP5cFdSqW6h4DwkP5cJMAdszzuPeHeHZPTyjWWFwzeFdjuci3ktfMoB
|
||||
```
|
||||
|
||||
All donations go toward infrastructure, development, and research.
|
||||
|
||||
|
||||

|
||||
|
||||
+39
-40
@@ -1,30 +1,31 @@
|
||||
# Telemt — MTProxy на Rust + Tokio
|
||||
|
||||
[](https://github.com/telemt/telemt/releases/latest) [](https://github.com/telemt/telemt/stargazers) [](https://github.com/telemt/telemt/network/members) [](https://t.me/telemtrs)
|
||||
|
||||
***Решает проблемы раньше, чем другие узнают об их существовании***
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Исправленный TLS ClientHello доступен в **Telegram Desktop** начиная с версии **6.7.2**: для работы с EE-MTProxy обновите клиент.
|
||||
>
|
||||
> Исправленный TLS ClientHello доступен в **Telegram Android** начиная с версии **12.6.4**; **официальный релиз для iOS находится в процессе разработки**.
|
||||
> Исправленный TLS ClientHello доступен в Telegram для настольных ПК, Android и iOS.
|
||||
>
|
||||
> Пожалуйста, обновите клиентское приложение для работы с EE-MTProxy.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/telemtrs">
|
||||
<img src="/docs/assets/telegram_button.svg" width="200"/>
|
||||
<img src="/docs/assets/telegram_button.svg" width="150"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust. Он полностью реализует официальный алгоритм прокси Telegram и добавляет множество улучшений для продакшена:
|
||||
|
||||
- [ME Pool + Reader/Writer + Registry + Refill + Adaptive Floor + Trio-State + жизненный цикл генераций](https://github.com/telemt/telemt/blob/main/docs/Architecture/Model/MODEL.en.md);
|
||||
- [Полноценный API с управлением](https://github.com/telemt/telemt/blob/main/docs/Architecture/API/API.md);
|
||||
- Защита от повторных атак (Anti-Replay on Sliding Window);
|
||||
- Метрики в формате Prometheus;
|
||||
- TLS-fronting и TCP-splicing для маскировки от DPI.
|
||||
## Установка и обновление одной командой
|
||||
|
||||

|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
|
||||
## Особенности
|
||||
- [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md)
|
||||
- [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md)
|
||||
|
||||
Реализация **TLS-fronting** максимально приближена к поведению реального HTTPS-трафика (подробнее - [FAQ](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров)).
|
||||
|
||||
@@ -40,35 +41,19 @@
|
||||
- Корректное завершение работы (Ctrl+C);
|
||||
- Подробное логирование через `trace` и `debug`.
|
||||
|
||||
|
||||
## Быстрая установка (обновление при повторном запуске)
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
|
||||
Подробнее об установке в [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.ru.md).
|
||||
|
||||
# Навигация
|
||||
# Подробнее о Telemt
|
||||
- [FAQ](#faq)
|
||||
- [Архитектура](docs/Architecture)
|
||||
- [Быстрый старт](#quick-start-guide)
|
||||
- [Параметры конфигурационного файла](docs/Config_params)
|
||||
- [Сборка](#build)
|
||||
- [Установка на BSD](#%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D0%BD%D0%B0-bsd)
|
||||
- [Почему Rust?](#why-rust)
|
||||
- [Известные проблемы](#issues)
|
||||
- [Планы](#roadmap)
|
||||
|
||||
## Быстрый старт
|
||||
- [Quick Start Guide RU](docs/Quick_start/QUICK_START_GUIDE.ru.md)
|
||||
- [Quick Start Guide EN](docs/Quick_start/QUICK_START_GUIDE.en.md)
|
||||
|
||||
## FAQ
|
||||
|
||||
- [FAQ RU](docs/FAQ.ru.md)
|
||||
- [FAQ EN](docs/FAQ.en.md)
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Клонируйте репозиторий
|
||||
git clone https://github.com/telemt/telemt
|
||||
@@ -77,9 +62,8 @@ cd telemt
|
||||
# Начните процесс сборки
|
||||
cargo build --release
|
||||
|
||||
# Устройства с небольшим объёмом оперативной памяти (1 ГБ, например NanoPi Neo3 / Raspberry Pi Zero 2):
|
||||
# используется параметр lto = «thin» для уменьшения пикового потребления памяти.
|
||||
# Если ваш пользовательский набор инструментов переопределяет профили, не используйте Fat LTO.
|
||||
# В текущем release-профиле используется lto = "fat" для максимальной оптимизации (см. Cargo.toml).
|
||||
# На системах с малым объёмом RAM (~1 ГБ) можно переопределить это значение на "thin".
|
||||
|
||||
# Перейдите в каталог /bin
|
||||
mv ./target/release/telemt /bin
|
||||
@@ -89,22 +73,37 @@ chmod +x /bin/telemt
|
||||
telemt config.toml
|
||||
```
|
||||
|
||||
### Устройства с малым объемом RAM
|
||||
Для устройств с ~1 ГБ RAM (например Raspberry Pi):
|
||||
- используется облегчённая оптимизация линковщика (thin LTO);
|
||||
- не рекомендуется включать fat LTO.
|
||||
|
||||
## OpenBSD
|
||||
|
||||
## Установка на BSD
|
||||
- Руководство по сборке и настройке на английском языке [OpenBSD Guide (EN)](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md);
|
||||
- Пример rc.d скрипта: [contrib/openbsd/telemt.rcd](contrib/openbsd/telemt.rcd);
|
||||
- Поддержка sandbox с `pledge(2)` и `unveil(2)` пока не реализована.
|
||||
|
||||
## Почему Rust?
|
||||
|
||||
- Надёжность для долгоживущих процессов;
|
||||
- Детерминированное управление ресурсами (RAII);
|
||||
- Отсутствие сборщика мусора;
|
||||
- Безопасность памяти;
|
||||
- Асинхронная архитектура Tokio.
|
||||
|
||||
## Поддержать Telemt
|
||||
|
||||
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разработанное в свободное время.
|
||||
Если оно оказалось вам полезным, вы можете поддержать дальнейшую разработку.
|
||||
|
||||
Принимаемые криптовалюты (BTC, ETH, USDT, 350+ и другие):
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://nowpayments.io/images/embeds/donation-button-white.svg" alt="Cryptocurrency & Bitcoin donation button by NOWPayments" height="80">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Monero (XMR) напрямую:
|
||||
|
||||
```
|
||||
8Bk4tZEYPQWSypeD2hrUXG2rKbAKF16GqEN942ZdAP5cFdSqW6h4DwkP5cJMAdszzuPeHeHZPTyjWWFwzeFdjuci3ktfMoB
|
||||
```
|
||||
|
||||
Все пожертвования пойдут на инфраструктуру, разработку и исследования.
|
||||
|
||||

|
||||
|
||||
+8
-5
@@ -32,13 +32,13 @@ show = "*"
|
||||
port = 443
|
||||
# proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol
|
||||
# metrics_port = 9090
|
||||
# metrics_listen = "0.0.0.0:9090" # Listen address for metrics (overrides metrics_port)
|
||||
# metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"]
|
||||
# metrics_listen = "127.0.0.1:9090" # Listen address for metrics (overrides metrics_port)
|
||||
# metrics_whitelist = ["127.0.0.1/32", "::1/128"]
|
||||
|
||||
[server.api]
|
||||
enabled = true
|
||||
listen = "0.0.0.0:9091"
|
||||
whitelist = ["127.0.0.0/8"]
|
||||
listen = "127.0.0.1:9091"
|
||||
whitelist = ["127.0.0.1/32", "::1/128"]
|
||||
minimal_runtime_enabled = false
|
||||
minimal_runtime_cache_ttl_ms = 1000
|
||||
|
||||
@@ -48,9 +48,12 @@ ip = "0.0.0.0"
|
||||
|
||||
# === Anti-Censorship & Masking ===
|
||||
[censorship]
|
||||
# Fake-TLS / SNI masking domain used in generated ee-links.
|
||||
# Changing tls_domain invalidates previously generated TLS links.
|
||||
tls_domain = "petrovich.ru"
|
||||
|
||||
mask = true
|
||||
tls_emulation = true # Fetch real cert lengths and emulate TLS records
|
||||
tls_emulation = true # Fetch real cert lengths and emulate TLS records
|
||||
tls_front_dir = "tlsfront" # Cache directory for TLS emulation
|
||||
|
||||
[access.users]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
telemt:
|
||||
build:
|
||||
context: .
|
||||
target: prod-netfilter
|
||||
network_mode: host
|
||||
ports: []
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- NET_ADMIN
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
telemt:
|
||||
build:
|
||||
context: .
|
||||
target: prod-netfilter
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- NET_ADMIN
|
||||
+9
-2
@@ -1,7 +1,9 @@
|
||||
services:
|
||||
telemt:
|
||||
image: ghcr.io/telemt/telemt:latest
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
target: prod
|
||||
container_name: telemt
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -16,13 +18,18 @@ services:
|
||||
- /etc/telemt:rw,mode=1777,size=4m
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
healthcheck:
|
||||
test: [ "CMD", "/app/telemt", "healthcheck", "/etc/telemt/config.toml", "--mode", "liveness" ]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
# Uncomment this line if you want to use host network for IPv6, but bridge is default and usually better
|
||||
# network_mode: host
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- NET_ADMIN
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# High-Load Configuration & Tuning Guide
|
||||
When deploying Telemt under high-traffic load (tens or hundreds of thousands of concurrent connections), the standard OS network stack limits can lead to packet drops, high CPU context switching, and connection failures. This guide covers Linux kernel tuning, hardware configuration, and architecture optimizations required to prepare the server for high-load scenarios.
|
||||
|
||||
---
|
||||
## 1. System Limits & File Descriptors
|
||||
Every TCP connection requires a file descriptor. At 100k connections, standard Linux limits (often 1024 or 65535) will be exhausted immediately.
|
||||
### System-Wide Limits (`sysctl`)
|
||||
Increase the global file descriptor limit in `/etc/sysctl.conf`:
|
||||
```ini
|
||||
fs.file-max = 2097152
|
||||
fs.nr_open = 2097152
|
||||
```
|
||||
### User-Level Limits (`limits.conf`)
|
||||
Edit `/etc/security/limits.conf` to allow the telemt (or proxy) user to allocate them:
|
||||
```conf
|
||||
* soft nofile 1048576
|
||||
* hard nofile 1048576
|
||||
root soft nofile 1048576
|
||||
root hard nofile 1048576
|
||||
```
|
||||
### Systemd / Docker Overrides
|
||||
If using **Systemd**, add to your `telemt.service`:
|
||||
```ini
|
||||
[Service]
|
||||
LimitNOFILE=1048576
|
||||
LimitNPROC=65535
|
||||
TasksMax=infinity
|
||||
```
|
||||
If using **Docker**, configure `ulimits` in `docker-compose.yaml`:
|
||||
```yaml
|
||||
services:
|
||||
telemt:
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
```
|
||||
|
||||
---
|
||||
## 2. Kernel Network Stack Tuning (`sysctl`)
|
||||
Create a dedicated file `/etc/sysctl.d/99-telemt-highload.conf` and apply it via `sysctl -p /etc/sysctl.d/99-telemt-highload.conf`.
|
||||
### 2.1 Connection Queues & SYN Flood Protection
|
||||
Increase the size of accept queues to absorb sudden connection spikes (bursts) and mitigate SYN floods:
|
||||
```ini
|
||||
net.core.somaxconn = 65535
|
||||
net.core.netdev_max_backlog = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
```
|
||||
### 2.2 Port Exhaustion & TIME-WAIT Sockets
|
||||
High churn rates lead to ephemeral port exhaustion. Expand the range and rapidly recycle closed sockets:
|
||||
```ini
|
||||
net.ipv4.ip_local_port_range = 10000 65535
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
net.ipv4.tcp_max_tw_buckets = 2000000
|
||||
```
|
||||
### 2.3 TCP Keepalive (Aggressive Dead Connection Culling)
|
||||
By default, Linux keeps silent, dropped connections open for over 2 hours. This consumes memory at scale. Configure the system to detect and drop them in < 5 minutes:
|
||||
```ini
|
||||
net.ipv4.tcp_keepalive_time = 300
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 5
|
||||
```
|
||||
### 2.4 TCP Buffers & Congestion Control
|
||||
Optimize memory usage per socket and switch to BBR (Bottleneck Bandwidth and Round-trip propagation time) to improve latency on lossy networks:
|
||||
```ini
|
||||
# Core buffer sizes
|
||||
net.core.rmem_default = 262144
|
||||
net.core.wmem_default = 262144
|
||||
net.core.rmem_max = 16777216
|
||||
net.core.wmem_max = 16777216
|
||||
# TCP specific buffers (min, default, max)
|
||||
net.ipv4.tcp_rmem = 4096 87380 16777216
|
||||
net.ipv4.tcp_wmem = 4096 65536 16777216
|
||||
# Enable BBR
|
||||
net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
```
|
||||
|
||||
---
|
||||
## 3. Conntrack (Netfilter) Tuning
|
||||
If your server uses `iptables`, `ufw`, or `firewalld`, the Linux kernel tracks every connection state in a table (`nf_conntrack`). When this table fills up, Linux drops new packets.
|
||||
Check your current limit and usage:
|
||||
```bash
|
||||
sysctl net.netfilter.nf_conntrack_max
|
||||
sysctl net.netfilter.nf_conntrack_count
|
||||
```
|
||||
If it gets close to the limit, tune it up, and reduce the time established connections linger in the tracker:
|
||||
```ini
|
||||
# In /etc/sysctl.d/99-telemt-highload.conf
|
||||
net.netfilter.nf_conntrack_max = 2097152
|
||||
# Reduce timeout from default 5 days to 1 hour
|
||||
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
|
||||
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 12
|
||||
```
|
||||
*Note: Depending on your OS, you may need to run `modprobe nf_conntrack` before setting these parameters.*
|
||||
|
||||
---
|
||||
## 4. Multi-Tier Architecture: HAProxy Setup
|
||||
For massive traffic loads, buffering Telemt behind a reverse proxy like HAProxy can help absorb connection spikes and handle basic TCP connections before handing them off.
|
||||
### HAProxy High-Load `haproxy.cfg`
|
||||
```haproxy
|
||||
global
|
||||
# Disable detailed logging under load
|
||||
log stdout format raw local0 err
|
||||
# maxconn 250000
|
||||
|
||||
# Buffer tuning
|
||||
tune.bufsize 16384
|
||||
tune.maxaccept 64
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
option clitcpka
|
||||
option srvtcpka
|
||||
timeout connect 5s
|
||||
timeout client 1h
|
||||
timeout server 1h
|
||||
# Quick purge for dead peers
|
||||
timeout client-fin 10s
|
||||
timeout server-fin 10s
|
||||
frontend proxy_in
|
||||
bind *:443
|
||||
maxconn 250000
|
||||
option tcp-smart-accept
|
||||
default_backend telemt_backend
|
||||
backend telemt_backend
|
||||
option tcp-smart-connect
|
||||
# Send-Proxy-V2 to preserve Client IP for Telemt's internal logic
|
||||
server telemt_core 10.10.10.1:443 maxconn 250000 send-proxy-v2 check inter 5s
|
||||
```
|
||||
**Important**: Telemt must be configured to process the `PROXY` protocol on port `443` for this chain to work and preserve client IPs.
|
||||
|
||||
---
|
||||
## 5. Diagnostics & Monitoring
|
||||
When operating under load, these commands are useful for diagnostics:
|
||||
* **Checking dropped connections (Queues full)**: `netstat -s | grep "times the listen queue of a socket overflowed"`
|
||||
* **Checking Conntrack drops**: `dmesg | grep conntrack`
|
||||
* **Checking File Descriptor usage**: `cat /proc/sys/fs/file-nr`
|
||||
* **Real-time connection states**: `ss -s` (Avoid using `netstat` on heavy loads).
|
||||
@@ -0,0 +1,139 @@
|
||||
# Руководство по High-Load конфигурации и тюнингу
|
||||
При развертывании Telemt под высокой нагрузкой (десятки и сотни тысяч одновременных подключений), стандартные ограничения сетевого стека ОС могут приводить к потерям пакетов, переключениям контекста CPU и отказам в соединениях. В данном руководстве описана настройка ядра Linux, системных лимитов и аппаратной конфигурации для работы в подобных сценариях.
|
||||
|
||||
---
|
||||
## 1. Системные лимиты и файловые дескрипторы
|
||||
Каждое TCP-сосоединение требует файлового дескриптора. При 100 тысячах соединений стандартные лимиты Linux (зачастую 1024 или 65535) будут исчерпаны немедленно.
|
||||
### Общесистемные лимиты (`sysctl`)
|
||||
Увеличьте глобальный лимит файловых дескрипторов в `/etc/sysctl.conf`:
|
||||
```ini
|
||||
fs.file-max = 2097152
|
||||
fs.nr_open = 2097152
|
||||
```
|
||||
### На уровне пользователя (`limits.conf`)
|
||||
Отредактируйте `/etc/security/limits.conf`, чтобы разрешить пользователю (от которого запущен telemt) резервировать дескрипторы:
|
||||
```conf
|
||||
* soft nofile 1048576
|
||||
* hard nofile 1048576
|
||||
root soft nofile 1048576
|
||||
root hard nofile 1048576
|
||||
```
|
||||
### Переопределения для Systemd / Docker
|
||||
Если используется **Systemd**, добавьте в ваш `telemt.service`:
|
||||
```ini
|
||||
[Service]
|
||||
LimitNOFILE=1048576
|
||||
LimitNPROC=65535
|
||||
TasksMax=infinity
|
||||
```
|
||||
Если используется **Docker**, задайте `ulimits` в `docker-compose.yaml`:
|
||||
```yaml
|
||||
services:
|
||||
telemt:
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
```
|
||||
|
||||
---
|
||||
## 2. Тонкая настройка сетевого стека ядра (`sysctl`)
|
||||
Создайте выделенный файл `/etc/sysctl.d/99-telemt-highload.conf` и примените его через `sysctl -p /etc/sysctl.d/99-telemt-highload.conf`.
|
||||
### 2.1 Очереди соединений и защита от SYN-флуда
|
||||
Увеличьте размеры очередей, чтобы поглощать внезапные всплески соединений и смягчить атаки типа SYN flood:
|
||||
```ini
|
||||
net.core.somaxconn = 65535
|
||||
net.core.netdev_max_backlog = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
```
|
||||
### 2.2 Исчерпание портов и TIME-WAIT сокеты
|
||||
Высокая текучесть приводит к нехватке временных (ephemeral) портов. Расширьте диапазон портов и позвольте ядру быстро переиспользовать закрытые сокеты:
|
||||
```ini
|
||||
net.ipv4.ip_local_port_range = 10000 65535
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
net.ipv4.tcp_max_tw_buckets = 2000000
|
||||
```
|
||||
### 2.3 TCP Keepalive (Агрессивная очистка мертвых соединений)
|
||||
По умолчанию Linux держит "оборванные" TCP-сессии более 2 часов. Задайте параметры для обнаружения и сброса мертвых соединений за менее чем 5 минут:
|
||||
```ini
|
||||
net.ipv4.tcp_keepalive_time = 300
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 5
|
||||
```
|
||||
### 2.4 Буферы TCP и управление перегрузками (Congestion Control)
|
||||
Оптимизируйте использование памяти на сокет и переключитесь на алгоритм BBR (Bottleneck Bandwidth and Round-trip propagation time) для улучшения задержки на плохих сетях:
|
||||
```ini
|
||||
# Размеры буферов ядра (по умолчанию и макс)
|
||||
net.core.rmem_default = 262144
|
||||
net.core.wmem_default = 262144
|
||||
net.core.rmem_max = 16777216
|
||||
net.core.wmem_max = 16777216
|
||||
# Специфичные TCP буферы (min, default, max)
|
||||
net.ipv4.tcp_rmem = 4096 87380 16777216
|
||||
net.ipv4.tcp_wmem = 4096 65536 16777216
|
||||
# Включение BBR
|
||||
net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
```
|
||||
|
||||
---
|
||||
## 3. Тюнинг Conntrack (Netfilter)
|
||||
Если ваш сервер использует `iptables`, `ufw` или `firewalld`, ядро вынуждено отслеживать каждое соединение в таблице состояний (`nf_conntrack`). Когда эта таблица переполняется, Linux отбрасывает новые пакеты без уведомления приложения.
|
||||
Проверьте текущие лимиты и использование:
|
||||
```bash
|
||||
sysctl net.netfilter.nf_conntrack_max
|
||||
sysctl net.netfilter.nf_conntrack_count
|
||||
```
|
||||
Если вы близки к пределу, увеличьте таблицу и заставьте ядро быстрее удалять установленные соединения. Добавьте в `/etc/sysctl.d/99-telemt-highload.conf`:
|
||||
```ini
|
||||
net.netfilter.nf_conntrack_max = 2097152
|
||||
# Снижаем таймаут с дефолтных 5 дней до 1 часа
|
||||
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
|
||||
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 12
|
||||
```
|
||||
*Внимание: в зависимости от ОС, вам может потребоваться выполнить `modprobe nf_conntrack` перед установкой этих параметров.*
|
||||
|
||||
---
|
||||
## 4. Архитектура: Развертывание за HAProxy
|
||||
Для максимальных нагрузок выставление Telemt напрямую в интернет менее эффективно, чем использование оптимизированного L4-балансировщика. HAProxy эффективен в поглощении TCP атак, обработке рукопожатий и сглаживании всплесков подключений.
|
||||
### Оптимизация `haproxy.cfg` для High-Load
|
||||
```haproxy
|
||||
global
|
||||
# Отключить детальные логи соединений под нагрузкой
|
||||
log stdout format raw local0 err
|
||||
maxconn 250000
|
||||
# Тюнинг буферов и приема сокетов
|
||||
tune.bufsize 16384
|
||||
tune.maxaccept 64
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
option clitcpka
|
||||
option srvtcpka
|
||||
timeout connect 5s
|
||||
timeout client 1h
|
||||
timeout server 1h
|
||||
# Быстрая очистка мертвых пиров
|
||||
timeout client-fin 10s
|
||||
timeout server-fin 10s
|
||||
frontend proxy_in
|
||||
bind *:443
|
||||
maxconn 250000
|
||||
option tcp-smart-accept
|
||||
default_backend telemt_backend
|
||||
backend telemt_backend
|
||||
option tcp-smart-connect
|
||||
# Send-Proxy-V2 обязателен для сохранения IP клиента внутри внутренней логики Telemt
|
||||
server telemt_core 10.10.10.1:443 maxconn 250000 send-proxy-v2 check inter 5s
|
||||
```
|
||||
**Важно**: Telemt должен быть настроен на обработку протокола `PROXY` на порту `443`, чтобы получать оригинальные IP-адреса клиентов.
|
||||
|
||||
---
|
||||
## 5. Диагностика
|
||||
Команды для выявления узких мест:
|
||||
* **Проверка дропов TCP (переполнение очередей)**: `netstat -s | grep "times the listen queue of a socket overflowed"`
|
||||
* **Контроль отбрасывания пакетов Conntrack**: `dmesg | grep conntrack`
|
||||
* **Проверка использования файловых дескрипторов**: `cat /proc/sys/fs/file-nr`
|
||||
* **Отображение состояния сокетов**: `ss -s` (Избегайте использования `netstat` под высокой нагрузкой).
|
||||
@@ -9,12 +9,12 @@ API runtime is configured in `[server.api]`.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | `bool` | `false` | Enables REST API listener. |
|
||||
| `listen` | `string` (`IP:PORT`) | `127.0.0.1:9091` | API bind address. |
|
||||
| `whitelist` | `CIDR[]` | `127.0.0.1/32, ::1/128` | Source IP allowlist. Empty list means allow all. |
|
||||
| `enabled` | `bool` | `true` | Enables REST API listener. |
|
||||
| `listen` | `string` (`IP:PORT`) | `0.0.0.0:9091` | API bind address. |
|
||||
| `whitelist` | `CIDR[]` | `127.0.0.0/8` | Source IP allowlist. Empty list means allow all. |
|
||||
| `auth_header` | `string` | `""` | Exact value for `Authorization` header. Empty disables header auth. |
|
||||
| `request_body_limit_bytes` | `usize` | `65536` | Maximum request body size. Must be `> 0`. |
|
||||
| `minimal_runtime_enabled` | `bool` | `false` | Enables runtime snapshot endpoints requiring ME pool read-lock aggregation. |
|
||||
| `minimal_runtime_enabled` | `bool` | `true` | Enables runtime snapshot endpoints requiring ME pool read-lock aggregation. |
|
||||
| `minimal_runtime_cache_ttl_ms` | `u64` | `1000` | Cache TTL for minimal snapshots. `0` disables cache; valid range is `[0, 60000]`. |
|
||||
| `runtime_edge_enabled` | `bool` | `false` | Enables runtime edge endpoints with cached aggregation payloads. |
|
||||
| `runtime_edge_cache_ttl_ms` | `u64` | `1000` | Cache TTL for runtime edge summary payloads. `0` disables cache. |
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# TLS Front Profile Fidelity
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how Telemt reuses captured TLS behavior in the FakeTLS server flight and how to validate the result on a real deployment.
|
||||
|
||||
When TLS front emulation is enabled, Telemt can capture useful server-side TLS behavior from the selected origin and reuse that behavior in the emulated success path. The goal is not to reproduce the origin byte-for-byte, but to reduce stable synthetic traits and make the emitted server flight structurally closer to the captured profile.
|
||||
|
||||
## Why this change exists
|
||||
|
||||
The project already captures useful server-side TLS behavior in the TLS front fetch path:
|
||||
|
||||
- `change_cipher_spec_count`
|
||||
- `app_data_record_sizes`
|
||||
- `ticket_record_sizes`
|
||||
|
||||
Before this change, the emulator used only part of that information. This left a gap between captured origin behavior and emitted FakeTLS server flight.
|
||||
|
||||
## What is implemented
|
||||
|
||||
- The emulator now replays the observed `ChangeCipherSpec` count from the fetched behavior profile.
|
||||
- The emulator now replays observed ticket-like tail ApplicationData record sizes when raw or merged TLS profile data is available.
|
||||
- The emulator now preserves more of the profiled encrypted-flight structure instead of collapsing it into a smaller synthetic shape.
|
||||
- The emulator still falls back to the previous synthetic behavior when the cached profile does not contain raw TLS behavior information.
|
||||
- Operator-configured `tls_new_session_tickets` still works as an additive fallback when the profile does not provide enough tail records.
|
||||
|
||||
## Practical benefit
|
||||
|
||||
- Reduced distinguishability between profiled origin TLS behavior and emulated TLS behavior.
|
||||
- Lower chance of stable server-flight fingerprints caused by fixed CCS count or synthetic-only tail record sizes.
|
||||
- Better reuse of already captured TLS profile data without changing MTProto logic, KDF routing, or transport architecture.
|
||||
|
||||
## Limitations
|
||||
|
||||
This mechanism does not aim to make Telemt byte-identical to the origin server.
|
||||
|
||||
It also does not change:
|
||||
|
||||
- MTProto business logic;
|
||||
- KDF routing behavior;
|
||||
- the overall transport architecture.
|
||||
|
||||
The practical goal is narrower:
|
||||
|
||||
- reuse more captured profile data;
|
||||
- reduce fixed synthetic behavior in the server flight;
|
||||
- preserve a valid FakeTLS success path while changing the emitted shape on the wire.
|
||||
|
||||
## Validation targets
|
||||
|
||||
- Correct count of emulated `ChangeCipherSpec` records.
|
||||
- Correct replay of observed ticket-tail record sizes.
|
||||
- No regression in existing ALPN and payload-placement behavior.
|
||||
|
||||
## How to validate the result
|
||||
|
||||
Recommended validation consists of two layers:
|
||||
|
||||
- focused unit and security tests for CCS-count replay and ticket-tail replay;
|
||||
- real packet-capture comparison for a selected origin and a successful FakeTLS session.
|
||||
|
||||
When testing on the network, the expected result is:
|
||||
|
||||
- a valid FakeTLS and MTProto success path is preserved;
|
||||
- the early encrypted server flight changes shape when richer profile data is available;
|
||||
- the change is visible on the wire without changing MTProto logic or transport architecture.
|
||||
|
||||
This validation is intended to show better reuse of captured TLS profile data.
|
||||
It is not intended to prove byte-level equivalence with the real origin server.
|
||||
|
||||
## How to test on a real deployment
|
||||
|
||||
The strongest practical validation is a side-by-side trace comparison between:
|
||||
|
||||
- a real TLS origin server used as `mask_host`;
|
||||
- a Telemt FakeTLS success-path connection for the same SNI;
|
||||
- optional captures from different Telemt builds or configurations.
|
||||
|
||||
The purpose of the comparison is to inspect the shape of the server flight:
|
||||
|
||||
- record order;
|
||||
- count of `ChangeCipherSpec` records;
|
||||
- count and grouping of early encrypted `ApplicationData` records;
|
||||
- lengths of tail or continuation `ApplicationData` records.
|
||||
|
||||
## Recommended environment
|
||||
|
||||
Use a Linux host or Docker container for the cleanest reproduction.
|
||||
|
||||
Recommended setup:
|
||||
|
||||
1. One Telemt instance.
|
||||
2. One real HTTPS origin as `mask_host`.
|
||||
3. One Telegram client configured with an `ee` proxy link for the Telemt instance.
|
||||
4. `tcpdump` or Wireshark available for capture analysis.
|
||||
|
||||
## Step-by-step test procedure
|
||||
|
||||
### 1. Prepare the origin
|
||||
|
||||
1. Choose a real HTTPS origin.
|
||||
2. Set both `censorship.tls_domain` and `censorship.mask_host` to that hostname.
|
||||
3. Confirm that a direct TLS request works:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect ORIGIN_IP:443 -servername YOUR_DOMAIN </dev/null
|
||||
```
|
||||
|
||||
### 2. Configure Telemt
|
||||
|
||||
Use a configuration that enables:
|
||||
|
||||
- `censorship.mask = true`
|
||||
- `censorship.tls_emulation = true`
|
||||
- `censorship.mask_host`
|
||||
- `censorship.mask_port`
|
||||
|
||||
Recommended for cleaner testing:
|
||||
|
||||
- keep `censorship.tls_new_session_tickets = 0`, so the result depends primarily on fetched profile data rather than operator-forced synthetic tail records;
|
||||
- keep `censorship.tls_fetch.strict_route = true`, if cleaner provenance for captured profile data is important.
|
||||
|
||||
### 3. Refresh TLS profile data
|
||||
|
||||
1. Start Telemt.
|
||||
2. Let it fetch TLS front profile data for the configured domain.
|
||||
3. If `tls_front_dir` is persisted, confirm that the TLS front cache is populated.
|
||||
|
||||
Persisted cache artifacts are useful, but they are not required if packet captures already demonstrate the runtime result.
|
||||
|
||||
### 4. Capture a direct-origin trace
|
||||
|
||||
From a separate client host, connect directly to the origin:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect ORIGIN_IP:443 -servername YOUR_DOMAIN </dev/null
|
||||
```
|
||||
|
||||
Capture with:
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -w origin-direct.pcap host ORIGIN_IP and port 443
|
||||
```
|
||||
|
||||
### 5. Capture a Telemt FakeTLS success-path trace
|
||||
|
||||
Now connect to Telemt with a real Telegram client through an `ee` proxy link that targets the Telemt instance.
|
||||
|
||||
`openssl s_client` is useful for direct-origin capture and fallback sanity checks, but it does not exercise the successful FakeTLS and MTProto path.
|
||||
|
||||
Capture with:
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -w telemt-emulated.pcap host TELEMT_IP and port 443
|
||||
```
|
||||
|
||||
### 6. Decode TLS record structure
|
||||
|
||||
Use `tshark` to print record-level structure:
|
||||
|
||||
```bash
|
||||
tshark -r origin-direct.pcap -Y "tls.record" -T fields \
|
||||
-e frame.number \
|
||||
-e ip.src \
|
||||
-e ip.dst \
|
||||
-e tls.record.content_type \
|
||||
-e tls.record.length
|
||||
```
|
||||
|
||||
```bash
|
||||
tshark -r telemt-emulated.pcap -Y "tls.record" -T fields \
|
||||
-e frame.number \
|
||||
-e ip.src \
|
||||
-e ip.dst \
|
||||
-e tls.record.content_type \
|
||||
-e tls.record.length
|
||||
```
|
||||
|
||||
Focus on the server flight after ClientHello:
|
||||
|
||||
- `22` = Handshake
|
||||
- `20` = ChangeCipherSpec
|
||||
- `23` = ApplicationData
|
||||
|
||||
### 7. Build a comparison table
|
||||
|
||||
A compact table like the following is usually enough:
|
||||
|
||||
| Path | CCS count | AppData count in first encrypted flight | Tail AppData lengths |
|
||||
| --- | --- | --- | --- |
|
||||
| Origin | `N` | `M` | `[a, b, ...]` |
|
||||
| Telemt build A | `...` | `...` | `...` |
|
||||
| Telemt build B | `...` | `...` | `...` |
|
||||
|
||||
The comparison should make it easy to see that:
|
||||
|
||||
- the FakeTLS success path remains valid;
|
||||
- the early encrypted server flight changes when richer profile data is reused;
|
||||
- the result is backed by packet evidence.
|
||||
|
||||
## Example capture set
|
||||
|
||||
One practical example of this workflow uses:
|
||||
|
||||
- `origin-direct-nginx.pcap`
|
||||
- `telemt-ee-before-nginx.pcap`
|
||||
- `telemt-ee-after-nginx.pcap`
|
||||
|
||||
Practical notes:
|
||||
|
||||
- `origin` was captured as a direct TLS 1.2 connection to `nginx.org`;
|
||||
- `before` and `after` were captured on the Telemt FakeTLS success path with a real Telegram client;
|
||||
- the first server-side FakeTLS response remains valid in both cases;
|
||||
- the early encrypted server-flight segmentation differs between `before` and `after`, which is consistent with better reuse of captured profile data;
|
||||
- this kind of result shows a wire-visible effect without breaking the success path, but it does not claim full indistinguishability from the origin.
|
||||
|
||||
## Stronger validation
|
||||
|
||||
For broader confidence, repeat the same comparison on:
|
||||
|
||||
1. one CDN-backed origin;
|
||||
2. one regular nginx origin;
|
||||
3. one origin with a multi-record encrypted flight and visible ticket-like tails.
|
||||
|
||||
If the same directional improvement appears across all three, confidence in the result will be much higher than for a single-origin example.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Fidelity TLS Front Profile
|
||||
|
||||
## Обзор
|
||||
|
||||
Этот документ описывает, как Telemt переиспользует захваченное TLS-поведение в FakeTLS server flight и как проверять результат на реальной инсталляции.
|
||||
|
||||
Когда включена TLS front emulation, Telemt может собирать полезное серверное TLS-поведение выбранного origin и использовать его в emulated success path. Цель здесь не в побайтном копировании origin, а в уменьшении устойчивых synthetic признаков и в том, чтобы emitted server flight был структурно ближе к захваченному profile.
|
||||
|
||||
## Зачем нужно это изменение
|
||||
|
||||
Проект уже умеет собирать полезное серверное TLS-поведение в пути TLS front fetch:
|
||||
|
||||
- `change_cipher_spec_count`
|
||||
- `app_data_record_sizes`
|
||||
- `ticket_record_sizes`
|
||||
|
||||
До этого изменения эмулятор использовал только часть этой информации. Из-за этого оставался разрыв между захваченным поведением origin и тем FakeTLS server flight, который реально уходил на провод.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Эмулятор теперь воспроизводит наблюдаемое значение `ChangeCipherSpec` из полученного `behavior_profile`.
|
||||
- Эмулятор теперь воспроизводит наблюдаемые размеры ticket-like tail ApplicationData records, когда доступны raw или merged TLS profile data.
|
||||
- Эмулятор теперь сохраняет больше структуры профилированного encrypted flight, а не схлопывает его в более маленькую synthetic форму.
|
||||
- Для профилей без raw TLS behavior по-прежнему сохраняется прежний synthetic fallback.
|
||||
- Операторский `tls_new_session_tickets` по-прежнему работает как дополнительный fallback, если профиль не даёт достаточного количества tail records.
|
||||
|
||||
## Практическая польза
|
||||
|
||||
- Снижается различимость между профилированным origin TLS-поведением и эмулируемым TLS-поведением.
|
||||
- Уменьшается шанс устойчивых server-flight fingerprint, вызванных фиксированным CCS count или полностью synthetic tail record sizes.
|
||||
- Уже собранные TLS profile data используются лучше, без изменения MTProto logic, KDF routing или transport architecture.
|
||||
|
||||
## Ограничения
|
||||
|
||||
Этот механизм не ставит целью сделать Telemt побайтно идентичным origin server.
|
||||
|
||||
Он также не меняет:
|
||||
|
||||
- MTProto business logic;
|
||||
- поведение KDF routing;
|
||||
- общую transport architecture.
|
||||
|
||||
Практическая цель уже:
|
||||
|
||||
- использовать больше уже собранных profile data;
|
||||
- уменьшить fixed synthetic behavior в server flight;
|
||||
- сохранить валидный FakeTLS success path, одновременно меняя форму emitted traffic на проводе.
|
||||
|
||||
## Цели валидации
|
||||
|
||||
- Корректное количество эмулируемых `ChangeCipherSpec` records.
|
||||
- Корректное воспроизведение наблюдаемых ticket-tail record sizes.
|
||||
- Отсутствие регрессии в существующем ALPN и payload-placement behavior.
|
||||
|
||||
## Как проверять результат
|
||||
|
||||
Рекомендуемая валидация состоит из двух слоёв:
|
||||
|
||||
- focused unit и security tests для CCS-count replay и ticket-tail replay;
|
||||
- сравнение реальных packet capture для выбранного origin и успешной FakeTLS session.
|
||||
|
||||
При проверке на сети ожидаемый результат такой:
|
||||
|
||||
- валидный FakeTLS и MTProto success path сохраняется;
|
||||
- форма раннего encrypted server flight меняется, когда доступно более богатое profile data;
|
||||
- изменение видно на проводе без изменения MTProto logic и transport architecture.
|
||||
|
||||
Такая проверка нужна для подтверждения того, что уже собранные TLS profile data используются лучше.
|
||||
Она не предназначена для доказательства побайтной эквивалентности с реальным origin server.
|
||||
|
||||
## Как проверить на реальной инсталляции
|
||||
|
||||
Самая сильная практическая проверка — side-by-side trace comparison между:
|
||||
|
||||
- реальным TLS origin server, используемым как `mask_host`;
|
||||
- Telemt FakeTLS success-path connection для того же SNI;
|
||||
- при необходимости capture от разных Telemt builds или configurations.
|
||||
|
||||
Смысл сравнения состоит в том, чтобы посмотреть на форму server flight:
|
||||
|
||||
- порядок records;
|
||||
- количество `ChangeCipherSpec` records;
|
||||
- количество и группировку ранних encrypted `ApplicationData` records;
|
||||
- размеры tail или continuation `ApplicationData` records.
|
||||
|
||||
## Рекомендуемое окружение
|
||||
|
||||
Для самой чистой проверки лучше использовать Linux host или Docker container.
|
||||
|
||||
Рекомендуемый setup:
|
||||
|
||||
1. Один экземпляр Telemt.
|
||||
2. Один реальный HTTPS origin как `mask_host`.
|
||||
3. Один Telegram client, настроенный на `ee` proxy link для Telemt instance.
|
||||
4. `tcpdump` или Wireshark для анализа capture.
|
||||
|
||||
## Пошаговая процедура проверки
|
||||
|
||||
### 1. Подготовить origin
|
||||
|
||||
1. Выберите реальный HTTPS origin.
|
||||
2. Установите и `censorship.tls_domain`, и `censorship.mask_host` в hostname этого origin.
|
||||
3. Убедитесь, что прямой TLS request работает:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect ORIGIN_IP:443 -servername YOUR_DOMAIN </dev/null
|
||||
```
|
||||
|
||||
### 2. Настроить Telemt
|
||||
|
||||
Используйте config, где включены:
|
||||
|
||||
- `censorship.mask = true`
|
||||
- `censorship.tls_emulation = true`
|
||||
- `censorship.mask_host`
|
||||
- `censorship.mask_port`
|
||||
|
||||
Для более чистой проверки рекомендуется:
|
||||
|
||||
- держать `censorship.tls_new_session_tickets = 0`, чтобы результат в первую очередь зависел от fetched profile data, а не от операторских synthetic tail records;
|
||||
- держать `censorship.tls_fetch.strict_route = true`, если важна более чистая provenance для captured profile data.
|
||||
|
||||
### 3. Обновить TLS profile data
|
||||
|
||||
1. Запустите Telemt.
|
||||
2. Дайте ему получить TLS front profile data для выбранного домена.
|
||||
3. Если `tls_front_dir` хранится persistently, убедитесь, что TLS front cache заполнен.
|
||||
|
||||
Persisted cache artifacts полезны, но не обязательны, если packet capture уже показывают runtime result.
|
||||
|
||||
### 4. Снять direct-origin trace
|
||||
|
||||
С отдельной клиентской машины подключитесь напрямую к origin:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect ORIGIN_IP:443 -servername YOUR_DOMAIN </dev/null
|
||||
```
|
||||
|
||||
Capture:
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -w origin-direct.pcap host ORIGIN_IP and port 443
|
||||
```
|
||||
|
||||
### 5. Снять Telemt FakeTLS success-path trace
|
||||
|
||||
Теперь подключитесь к Telemt через реальный Telegram client с `ee` proxy link, который указывает на Telemt instance.
|
||||
|
||||
`openssl s_client` полезен для direct-origin capture и для fallback sanity checks, но он не проходит успешный FakeTLS и MTProto path.
|
||||
|
||||
Capture:
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -w telemt-emulated.pcap host TELEMT_IP and port 443
|
||||
```
|
||||
|
||||
### 6. Декодировать структуру TLS records
|
||||
|
||||
Используйте `tshark`, чтобы вывести record-level structure:
|
||||
|
||||
```bash
|
||||
tshark -r origin-direct.pcap -Y "tls.record" -T fields \
|
||||
-e frame.number \
|
||||
-e ip.src \
|
||||
-e ip.dst \
|
||||
-e tls.record.content_type \
|
||||
-e tls.record.length
|
||||
```
|
||||
|
||||
```bash
|
||||
tshark -r telemt-emulated.pcap -Y "tls.record" -T fields \
|
||||
-e frame.number \
|
||||
-e ip.src \
|
||||
-e ip.dst \
|
||||
-e tls.record.content_type \
|
||||
-e tls.record.length
|
||||
```
|
||||
|
||||
Смотрите на server flight после ClientHello:
|
||||
|
||||
- `22` = Handshake
|
||||
- `20` = ChangeCipherSpec
|
||||
- `23` = ApplicationData
|
||||
|
||||
### 7. Собрать сравнительную таблицу
|
||||
|
||||
Обычно достаточно короткой таблицы такого вида:
|
||||
|
||||
| Path | CCS count | AppData count in first encrypted flight | Tail AppData lengths |
|
||||
| --- | --- | --- | --- |
|
||||
| Origin | `N` | `M` | `[a, b, ...]` |
|
||||
| Telemt build A | `...` | `...` | `...` |
|
||||
| Telemt build B | `...` | `...` | `...` |
|
||||
|
||||
По такой таблице должно быть легко увидеть, что:
|
||||
|
||||
- FakeTLS success path остаётся валидным;
|
||||
- ранний encrypted server flight меняется, когда переиспользуется более богатое profile data;
|
||||
- результат подтверждён packet evidence.
|
||||
|
||||
## Пример набора capture
|
||||
|
||||
Один практический пример такой проверки использует:
|
||||
|
||||
- `origin-direct-nginx.pcap`
|
||||
- `telemt-ee-before-nginx.pcap`
|
||||
- `telemt-ee-after-nginx.pcap`
|
||||
|
||||
Практические замечания:
|
||||
|
||||
- `origin` снимался как прямое TLS 1.2 connection к `nginx.org`;
|
||||
- `before` и `after` снимались на Telemt FakeTLS success path с реальным Telegram client;
|
||||
- первый server-side FakeTLS response остаётся валидным в обоих случаях;
|
||||
- сегментация раннего encrypted server flight отличается между `before` и `after`, что согласуется с лучшим использованием captured profile data;
|
||||
- такой результат показывает заметный эффект на проводе без поломки success path, но не заявляет полной неотличимости от origin.
|
||||
|
||||
## Более сильная валидация
|
||||
|
||||
Для более широкой проверки повторите ту же процедуру ещё на:
|
||||
|
||||
1. одном CDN-backed origin;
|
||||
2. одном regular nginx origin;
|
||||
3. одном origin с multi-record encrypted flight и заметными ticket-like tails.
|
||||
|
||||
Если одно и то же направление улучшения повторится на всех трёх, уверенность в результате будет значительно выше, чем для одного origin example.
|
||||
File diff suppressed because it is too large
Load Diff
+3123
-3300
File diff suppressed because it is too large
Load Diff
+32
-3
@@ -36,8 +36,11 @@ hello2 = "ad_tag2"
|
||||
On April 1, 2026, we became aware of a method for detecting MTProxy Fake-TLS,
|
||||
based on the ECH extension and the ordering of cipher suites,
|
||||
as well as an overall unique JA3/JA4 fingerprint
|
||||
that does not occur in modern browsers:
|
||||
we have already submitted initial changes to the Telegram Desktop developers and are working on updates for other clients.
|
||||
that does not occur in modern browsers.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> TLS fingerprint has been fixed in latest version of clients for Desktop / Android / iOS.
|
||||
> Please update your client for MTProxy Fake-TLS to work correctly.
|
||||
|
||||
- We consider this a breakthrough aspect, which has no stable analogues today
|
||||
- Based on this: if `telemt` configured correctly, **TLS mode is completely identical to real-life handshake + communication** with a specified host
|
||||
@@ -154,6 +157,24 @@ Keep-Alive: timeout=60
|
||||
### Why do you need a middle proxy (ME)
|
||||
https://github.com/telemt/telemt/discussions/167
|
||||
|
||||
## How clients interact with Telegram DCs
|
||||
When you register a Telegram account, it gets permanently bound to one of Telegram's data centers (DCs).
|
||||
It is deciced beforehand by Telegram based on the phone number's region.
|
||||
This DC becomes your **home DC**: all content you upload (photos, videos, files, messages) is stored there.
|
||||
Your client authenticates on it with every connection.
|
||||
|
||||
For example, if your account is registered on **DC2**, your client will always connect to DC2 first.
|
||||
When you open a chat with another user whose home DC is **DC5**, your client opens an additional connection to DC5 to download their media.
|
||||
Those cross-DC requests are normal and happen constantly.
|
||||
|
||||
> [!WARNING]
|
||||
> Because every session is anchored to your home DC, an outage there causes other DCs to be unavaliable.
|
||||
> If your home DC is DC2 and DC2 goes down, you **cannot** reach DC5 even though DC5 itself is perfectly healthy.
|
||||
> The client has no valid session to route the request through.
|
||||
|
||||
This is also why an MTProxy only needs to reach Telegram's DC infrastructure as a whole.
|
||||
The proxy itself doesn't care which DC your account lives on. The client negotiates the correct DC through the proxy after connecting.
|
||||
|
||||
### How many people can use one link
|
||||
By default, an unlimited number of people can use a single link.
|
||||
However, you can limit the number of unique IP addresses for each user:
|
||||
@@ -161,7 +182,8 @@ However, you can limit the number of unique IP addresses for each user:
|
||||
[access.user_max_unique_ips]
|
||||
hello = 1
|
||||
```
|
||||
This parameter sets the maximum number of unique IP addresses from which a single link can be used simultaneously. If the first user disconnects, a second one can connect. At the same time, multiple users can connect from a single IP address simultaneously (for example, devices on the same Wi-Fi network).
|
||||
This parameter sets the maximum number of unique IP addresses from which a single link can be used simultaneously. If the first user disconnects, a second one can connect.
|
||||
At the same time, multiple users can connect from a single IP address simultaneously (for example, devices on the same Wi-Fi network).
|
||||
|
||||
### How to create multiple different links
|
||||
1. Generate the required number of secrets using the command: `openssl rand -hex 16`.
|
||||
@@ -188,6 +210,13 @@ If you need to allow connections with any domains (ignoring SNI mismatches), add
|
||||
unknown_sni_action = "mask"
|
||||
```
|
||||
|
||||
Alternatively, if you want telemt to behave like a vanilla nginx with `ssl_reject_handshake on;` on unknown SNI (emit a TLS `unrecognized_name` alert and close the connection), use:
|
||||
```toml
|
||||
[censorship]
|
||||
unknown_sni_action = "reject_handshake"
|
||||
```
|
||||
This does not recover stale clients, but it makes port 443 wire-indistinguishable from a stock web server that simply does not host the requested vhost.
|
||||
|
||||
### How to view metrics
|
||||
|
||||
1. Open the configuration file: `nano /etc/telemt/telemt.toml`.
|
||||
|
||||
+29
-2
@@ -33,9 +33,12 @@ hello = "ad_tag"
|
||||
hello2 = "ad_tag2"
|
||||
```
|
||||
## Распознаваемость для DPI и сканеров
|
||||
1 апреля 2026 года нам стало известно о методе обнаружения MTProxy Fake-TLS, основанном на расширении ECH и порядке набора шифров,
|
||||
а также об общем уникальном отпечатке JA3/JA4, который не встречается в современных браузерах.
|
||||
|
||||
1 апреля 2026 года нам стало известно о методе обнаружения MTProxy Fake-TLS, основанном на расширении ECH и порядке набора шифров,
|
||||
а также об общем уникальном отпечатке JA3/JA4, который не встречается в современных браузерах: мы уже отправили первоначальные изменения разработчикам Telegram Desktop и работаем над обновлениями для других клиентов.
|
||||
> [!IMPORTANT]
|
||||
> Проблема с TLS отпечатком исправлена в последних версиях клиентов Telegram для Desktop / Android / iOS.
|
||||
> Обновите свой клиент для корректной работы с MTProxy Fake-TLS!
|
||||
|
||||
- Мы считаем это прорывом, которому на сегодняшний день нет стабильных аналогов;
|
||||
- Исходя из этого: если `telemt` настроен правильно, **режим TLS полностью идентичен реальному «рукопожатию» + обмену данными** с указанным хостом;
|
||||
@@ -152,6 +155,23 @@ Keep-Alive: timeout=60
|
||||
## Зачем нужен middle proxy (ME)
|
||||
https://github.com/telemt/telemt/discussions/167
|
||||
|
||||
## Как клиенты взаимодействуют с дата-центрами Telegram
|
||||
При регистрации аккаунта Telegram он навсегда привязывается к одному из дата-центров (DC).
|
||||
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относиться номер телефона.
|
||||
Этот DC становится вашим **домашним**: именно там хранится весь контент, который вы загружаете (фото, видео, файлы, сообщения).
|
||||
И именно на нем клиент авторизуется при каждом подключении.
|
||||
|
||||
Например, если ваш аккаунт зарегистрирован на **DC2**, клиент всегда будет подключаться в первую очередь к DC2.
|
||||
Когда вы открываете переписку с пользователем, чей домашний DC — **DC5**, клиент устанавливает доп. соединение с DC5, чтобы загрузить его контент.
|
||||
Такие кросс-запросы к DC — это нормальная часть работы Telegram.
|
||||
|
||||
> [!WARNING]
|
||||
> Поскольку аккаунт всегда привязан к домашнему DC, при его падении контент с других DC будет недоступен.
|
||||
> Если ваш домашний DC — DC2, и DC2 лежит, вы **не сможете** достучаться и до DC5, даже если сам DC5 полностью исправен.
|
||||
> У клиента просто нет валидной сессии, через которую можно было бы направить запрос.
|
||||
|
||||
По той же причине MTProxy достаточно иметь доступ к инфраструктуре Telegram в целом.
|
||||
Cамому MTProxy всё равно, на каком DC живёт ваш аккаунт. Клиент cам договаривается о нужном DC через прокси уже после подключения.
|
||||
|
||||
## Что такое dd и ee в контексте MTProxy?
|
||||
|
||||
@@ -207,6 +227,13 @@ curl -s http://127.0.0.1:9091/v1/users | jq
|
||||
unknown_sni_action = "mask"
|
||||
```
|
||||
|
||||
Альтернатива: если вы хотите, чтобы telemt на неизвестный SNI вёл себя как обычный nginx с `ssl_reject_handshake on;` (отдавал TLS-alert `unrecognized_name` и закрывал соединение), используйте:
|
||||
```toml
|
||||
[censorship]
|
||||
unknown_sni_action = "reject_handshake"
|
||||
```
|
||||
Это не пропускает старых клиентов, но делает поведение на 443-м порту неотличимым от стокового веб-сервера, у которого просто нет такого виртуального хоста.
|
||||
|
||||
## Как посмотреть метрики
|
||||
|
||||
1. Откройте файл конфигурации: `nano /etc/telemt/telemt.toml`.
|
||||
|
||||
@@ -27,7 +27,8 @@ cargo build --release
|
||||
./target/release/telemt --version
|
||||
```
|
||||
|
||||
For low-RAM systems, this repository already uses `lto = "thin"` in release profile.
|
||||
For low-RAM systems, note that this repository currently uses `lto = "fat"` in release profile.
|
||||
On constrained builders, a local override to `lto = "thin"` may be more practical.
|
||||
|
||||
## 3. Install binary and config
|
||||
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
# Installation Options
|
||||
There are three options for installing Telemt:
|
||||
- [Automated installation using a script](#very-quick-start).
|
||||
- [Manual installation of Telemt as a service](#telemt-via-systemd).
|
||||
- [Installation using Docker Compose](#telemt-via-docker-compose).
|
||||
|
||||
# Very quick start
|
||||
|
||||
### One-command installation / update on re-run
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
|
||||
After starting, the script will prompt for:
|
||||
- Your language (1 - English, 2 - Russian);
|
||||
- Your TLS domain (press Enter for petrovich.ru).
|
||||
|
||||
The script checks if the port (default **443**) is free. If the port is already in use, installation will fail. You need to free up the port or use the **-p** flag with a different port to retry the installation.
|
||||
|
||||
To modify the script’s startup parameters, you can use the following flags:
|
||||
- **-d, --domain** - TLS domain;
|
||||
- **-p, --port** - server port (1–65535);
|
||||
- **-s, --secret** - 32 hex secret;
|
||||
- **-a, --ad-tag** - ad_tag;
|
||||
- **-l, --lan**g - language (1/en or 2/ru);
|
||||
|
||||
Providing all options skips interactive prompts.
|
||||
|
||||
After completion, the script will provide a link for client connections:
|
||||
```bash
|
||||
tg://proxy?server=IP&port=PORT&secret=SECRET
|
||||
```
|
||||
|
||||
### Installing a specific version
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh -s -- 3.3.39
|
||||
@@ -110,15 +137,15 @@ show = "*"
|
||||
# === Server Binding ===
|
||||
[server]
|
||||
port = 443
|
||||
# proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol
|
||||
# proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol
|
||||
# metrics_port = 9090
|
||||
# metrics_listen = "0.0.0.0:9090" # Listen address for metrics (overrides metrics_port)
|
||||
# metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"]
|
||||
# metrics_listen = "127.0.0.1:9090" # Listen address for metrics (overrides metrics_port)
|
||||
# metrics_whitelist = ["127.0.0.1/32", "::1/128"]
|
||||
|
||||
[server.api]
|
||||
enabled = true
|
||||
listen = "0.0.0.0:9091"
|
||||
whitelist = ["127.0.0.0/8"]
|
||||
listen = "127.0.0.1:9091"
|
||||
whitelist = ["127.0.0.1/32", "::1/128"]
|
||||
minimal_runtime_enabled = false
|
||||
minimal_runtime_cache_ttl_ms = 1000
|
||||
|
||||
@@ -128,9 +155,9 @@ ip = "0.0.0.0"
|
||||
|
||||
# === Anti-Censorship & Masking ===
|
||||
[censorship]
|
||||
tls_domain = "petrovich.ru"
|
||||
tls_domain = "petrovich.ru" # Fake-TLS / SNI masking domain used in generated ee-links
|
||||
mask = true
|
||||
tls_emulation = true # Fetch real cert lengths and emulate TLS records
|
||||
tls_emulation = true # Fetch real cert lengths and emulate TLS records
|
||||
tls_front_dir = "tlsfront" # Cache directory for TLS emulation
|
||||
|
||||
[access.users]
|
||||
@@ -141,9 +168,9 @@ hello = "00000000000000000000000000000000"
|
||||
then Ctrl+S -> Ctrl+X to save
|
||||
|
||||
> [!WARNING]
|
||||
> Replace the value of the hello parameter with the value you obtained in step 0.
|
||||
> Additionally, change the value of the tls_domain parameter to a different website.
|
||||
> Changing the tls_domain parameter will break all links that use the old domain!
|
||||
> Replace the value of the `hello` parameter with the value you obtained in step 0.
|
||||
> Additionally, change the value of the `tls_domain` parameter to a different website.
|
||||
> Changing the `tls_domain` parameter will break all links that use the old domain!
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
# Варианты установки
|
||||
Имеется три варианта установки Telemt:
|
||||
- [Автоматизированная установка с помощью скрипта](#очень-быстрый-старт).
|
||||
- [Ручная установка Telemt в качестве службы](#telemt-через-systemd-вручную).
|
||||
- [Установка через Docker Compose](#telemt-через-docker-compose).
|
||||
|
||||
# Очень быстрый старт
|
||||
|
||||
### Установка одной командой / обновление при повторном запуске
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
После запуска скрипт запросит:
|
||||
- ваш язык (1 - English, 2 - Русский);
|
||||
- ваш TLS-домен (нажмите Enter для petrovich.ru).
|
||||
|
||||
Во время установки скрипт проверяет, свободен ли порт (по умолчанию **443**). Если порт занят другим процессом - установка завершится с ошибкой. Для повторной установки необходимо освободить порт или указать другой через флаг **-p**.
|
||||
|
||||
Для изменения параметров запуска скрипта можно использовать следующие флаги:
|
||||
- **-d, --domain** - TLS-домен;
|
||||
- **-p, --port** - порт (1–65535);
|
||||
- **-s, --secret** - секрет (32 hex символа);
|
||||
- **-a, --ad-tag** - ad_tag;
|
||||
- **-l, --lang** - язык (1/en или 2/ru).
|
||||
|
||||
Если заданы флаги для языка и домена, интерактивных вопросов не будет.
|
||||
|
||||
После завершения установки скрипт выдаст ссылку для подключения клиентов:
|
||||
```bash
|
||||
tg://proxy?server=IP&port=PORT&secret=SECRET
|
||||
```
|
||||
|
||||
### Установка нужной версии
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh -s -- 3.3.39
|
||||
@@ -103,22 +129,22 @@ tls = true
|
||||
[general.links]
|
||||
show = "*"
|
||||
# show = ["alice", "bob"] # Показывать ссылки только для alice и bob
|
||||
# show = "*" # Показывать ссылки для всех пользователей
|
||||
# public_host = "proxy.example.com" # Хост (IP-адрес или домен) для ссылок tg://
|
||||
# public_port = 443 # Порт для ссылок tg:// (по умолчанию: server.port)
|
||||
# show = "*" # Показывать ссылки для всех пользователей
|
||||
# public_host = "proxy.example.com" # Хост (IP-адрес или домен) для ссылок tg://
|
||||
# public_port = 443 # Порт для ссылок tg:// (по умолчанию: server.port)
|
||||
|
||||
# === Привязка сервера ===
|
||||
[server]
|
||||
port = 443
|
||||
# proxy_protocol = false # Включите, если сервер находится за HAProxy/nginx с протоколом PROXY
|
||||
# proxy_protocol = false # Включите, если сервер находится за HAProxy/nginx с протоколом PROXY
|
||||
# metrics_port = 9090
|
||||
# metrics_listen = "0.0.0.0:9090" # Адрес прослушивания для метрик (переопределяет metrics_port)
|
||||
# metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"]
|
||||
# metrics_listen = "127.0.0.1:9090" # Адрес прослушивания для метрик (переопределяет metrics_port)
|
||||
# metrics_whitelist = ["127.0.0.1/32", "::1/128"]
|
||||
|
||||
[server.api]
|
||||
enabled = true
|
||||
listen = "0.0.0.0:9091"
|
||||
whitelist = ["127.0.0.0/8"]
|
||||
listen = "127.0.0.1:9091"
|
||||
whitelist = ["127.0.0.1/32", "::1/128"]
|
||||
minimal_runtime_enabled = false
|
||||
minimal_runtime_cache_ttl_ms = 1000
|
||||
|
||||
@@ -128,9 +154,9 @@ ip = "0.0.0.0"
|
||||
|
||||
# === Обход блокировок и маскировка ===
|
||||
[censorship]
|
||||
tls_domain = "petrovich.ru"
|
||||
tls_domain = "petrovich.ru" # Домен Fake-TLS / SNI, который будет использоваться в сгенерированных ee-ссылках
|
||||
mask = true
|
||||
tls_emulation = true # Получить реальную длину сертификата и эмулировать запись TLS
|
||||
tls_emulation = true # Получить реальную длину сертификата и эмулировать запись TLS
|
||||
tls_front_dir = "tlsfront" # Директория кэша для эмуляции TLS
|
||||
|
||||
[access.users]
|
||||
@@ -141,9 +167,9 @@ hello = "00000000000000000000000000000000"
|
||||
Затем нажмите Ctrl+S -> Ctrl+X, чтобы сохранить
|
||||
|
||||
> [!WARNING]
|
||||
> Замените значение параметра hello на значение, которое вы получили в пункте 0.
|
||||
> Так же замените значение параметра tls_domain на другой сайт.
|
||||
> Изменение параметра tls_domain сделает нерабочими все ссылки, использующие старый домен!
|
||||
> Замените значение параметра `hello` на значение, которое вы получили в пункте 0.
|
||||
> Так же замените значение параметра `tls_domain` на другой сайт.
|
||||
> Изменение параметра `tls_domain` сделает нерабочими все ссылки, использующие старый домен!
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ PING 10.10.10.1 (10.10.10.1) 56(84) bytes of data.
|
||||
---
|
||||
|
||||
## Step 2. Installing telemt on Server B (conditionally Netherlands)
|
||||
Installation and configuration are described [here](https://github.com/telemt/telemt/blob/main/docs/QUICK_START_GUIDE.ru.md) or [here](https://gitlab.com/An0nX/telemt-docker#-quick-start-docker-compose).\
|
||||
Installation and configuration are described [here](https://github.com/telemt/telemt/blob/main/docs/Quick_start/QUICK_START_GUIDE.en.md) or [here](https://gitlab.com/An0nX/telemt-docker#-quick-start-docker-compose).\
|
||||
It is assumed that telemt expects connections on port `443\tcp`.
|
||||
|
||||
In the telemt config, you must enable the `Proxy` protocol and restrict connections to it only through the tunnel.
|
||||
|
||||
@@ -166,7 +166,7 @@ PING 10.10.10.1 (10.10.10.1) 56(84) bytes of data.
|
||||
|
||||
## Шаг 2. Установка telemt на Сервере B (_условно Нидерланды_)
|
||||
|
||||
Установка и настройка описаны [здесь](https://github.com/telemt/telemt/blob/main/docs/QUICK_START_GUIDE.ru.md) или [здесь](https://gitlab.com/An0nX/telemt-docker#-quick-start-docker-compose).\
|
||||
Установка и настройка описаны [здесь](https://github.com/telemt/telemt/blob/main/docs/Quick_start/QUICK_START_GUIDE.ru.md) или [здесь](https://gitlab.com/An0nX/telemt-docker#-quick-start-docker-compose).\
|
||||
Подразумевается что telemt ожидает подключения на порту `443\tcp`.
|
||||
|
||||
В конфиге telemt необходимо включить протокол `Proxy` и ограничить подключения к нему только через туннель.
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<img src="https://gist.githubusercontent.com/avbor/1f8a128e628f47249aae6e058a57610b/raw/19013276c035e91058e0a9799ab145f8e70e3ff5/scheme.svg">
|
||||
|
||||
## Concept
|
||||
- **Server A** (_e.g., RU_):\
|
||||
Entry point, accepts Telegram proxy user traffic via **Xray** (port `443\tcp`)\
|
||||
and sends it through the tunnel to Server **B**.\
|
||||
Public port for Telegram clients — `443\tcp`
|
||||
- **Server B** (_e.g., NL_):\
|
||||
Exit point, runs the **Xray server** (to terminate the tunnel entry point) and **telemt**.\
|
||||
The server must have unrestricted access to Telegram Data Centers.\
|
||||
Public port for VLESS/REALITY (incoming) — `443\tcp`\
|
||||
Internal telemt port (where decrypted Xray traffic ends up) — `8443\tcp`
|
||||
|
||||
The tunnel works over the `VLESS-XTLS-Reality` (or `VLESS/xhttp/reality`) protocol. The original client IP address is preserved thanks to the PROXYv2 protocol, which Xray on Server A dynamically injects via a local loopback before wrapping the traffic into Reality, transparently delivering the real IPs to telemt on Server B.
|
||||
|
||||
---
|
||||
|
||||
## Step 1. Setup Xray Tunnel (A <-> B)
|
||||
|
||||
You must install **Xray-core** (version 1.8.4 or newer recommended) on both servers.
|
||||
Official installation script (run on both servers):
|
||||
```bash
|
||||
bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
|
||||
```
|
||||
|
||||
### Key and Parameter Generation (Run Once)
|
||||
For configuration, you need a unique UUID and Xray Reality keys. Run on any server with Xray installed:
|
||||
1. **Client UUID:**
|
||||
```bash
|
||||
xray uuid
|
||||
# Save the output (e.g.: 12345678-abcd-1234-abcd-1234567890ab) — this is <XRAY_UUID>
|
||||
```
|
||||
2. **X25519 Keypair (Private & Public) for Reality:**
|
||||
```bash
|
||||
xray x25519
|
||||
# Save the Private key (<SERVER_B_PRIVATE_KEY>) and Public key (<SERVER_B_PUBLIC_KEY>)
|
||||
```
|
||||
3. **Short ID (Reality identifier):**
|
||||
```bash
|
||||
openssl rand -hex 8
|
||||
# Save the output (e.g.: abc123def456) — this is <SHORT_ID>
|
||||
```
|
||||
4. **Random Path (for xhttp):**
|
||||
```bash
|
||||
openssl rand -hex 16
|
||||
# Save the output (e.g., 0123456789abcdef0123456789abcdef) to replace <YOUR_RANDOM_PATH> in configs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Configuration for Server B (_EU_):
|
||||
|
||||
Create or edit the file `/usr/local/etc/xray/config.json`.
|
||||
This Xray instance will listen on the public `443` port and proxy valid Reality traffic, while routing "disguised" traffic (e.g., direct web browser scans) to `yahoo.com`.
|
||||
|
||||
```bash
|
||||
nano /usr/local/etc/xray/config.json
|
||||
```
|
||||
|
||||
File content:
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"loglevel": "error",
|
||||
"access": "none"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "vless-in",
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"id": "<XRAY_UUID>"
|
||||
}
|
||||
],
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "xhttp",
|
||||
"security": "reality",
|
||||
"realitySettings": {
|
||||
"dest": "yahoo.com:443",
|
||||
"serverNames": [
|
||||
"yahoo.com"
|
||||
],
|
||||
"privateKey": "<SERVER_B_PRIVATE_KEY>",
|
||||
"shortIds": [
|
||||
"<SHORT_ID>"
|
||||
]
|
||||
},
|
||||
"xhttpSettings": {
|
||||
"path": "/<YOUR_RANDOM_PATH>",
|
||||
"mode": "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "tunnel-to-telemt",
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"destination": "127.0.0.1:8443"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"inboundTag": [
|
||||
"vless-in"
|
||||
],
|
||||
"outboundTag": "tunnel-to-telemt"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Open the firewall port (if enabled):
|
||||
```bash
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
Restart and setup Xray to run at boot:
|
||||
```bash
|
||||
sudo systemctl restart xray
|
||||
sudo systemctl enable xray
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Configuration for Server A (_RU_):
|
||||
|
||||
Similarly, edit `/usr/local/etc/xray/config.json`.
|
||||
Here Xray acts as the public entry point: it listens on `443\tcp`, uses a local loopback (via internal port `10444`) to prepend the `PROXYv2` header, and encapsulates the payload via Reality to Server B, instructing Server B to deliver it to its *local* `127.0.0.1:8443` port (where telemt will listen).
|
||||
|
||||
```bash
|
||||
nano /usr/local/etc/xray/config.json
|
||||
```
|
||||
|
||||
File content:
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"loglevel": "error",
|
||||
"access": "none"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "public-in",
|
||||
"port": 443,
|
||||
"listen": "0.0.0.0",
|
||||
"protocol": "dokodemo-door",
|
||||
"settings": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 10444,
|
||||
"network": "tcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "tunnel-in",
|
||||
"port": 10444,
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "dokodemo-door",
|
||||
"settings": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 8443,
|
||||
"network": "tcp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "local-injector",
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"proxyProtocol": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "vless-out",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "<PUBLIC_IP_SERVER_B>",
|
||||
"port": 443,
|
||||
"users": [
|
||||
{
|
||||
"id": "<XRAY_UUID>",
|
||||
"encryption": "none"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "xhttp",
|
||||
"security": "reality",
|
||||
"realitySettings": {
|
||||
"serverName": "yahoo.com",
|
||||
"publicKey": "<SERVER_B_PUBLIC_KEY>",
|
||||
"shortId": "<SHORT_ID>",
|
||||
"spiderX": "/",
|
||||
"fingerprint": "chrome"
|
||||
},
|
||||
"xhttpSettings": {
|
||||
"path": "/<YOUR_RANDOM_PATH>"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"inboundTag": ["public-in"],
|
||||
"outboundTag": "local-injector"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"inboundTag": ["tunnel-in"],
|
||||
"outboundTag": "vless-out"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
*Replace `<PUBLIC_IP_SERVER_B>` with the public IP address of Server B.*
|
||||
|
||||
Open the firewall port for clients (if enabled):
|
||||
```bash
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
|
||||
Restart and setup Xray to run at boot:
|
||||
```bash
|
||||
sudo systemctl restart xray
|
||||
sudo systemctl enable xray
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2. Install telemt on Server B (_EU_)
|
||||
|
||||
telemt installation is heavily covered in the [Quick Start Guide](../Quick_start/QUICK_START_GUIDE.en.md).
|
||||
By contrast to standard setups, telemt must listen strictly _locally_ (since Xray occupies the public `443` interface) and must expect `PROXYv2` packets.
|
||||
|
||||
Edit the configuration file (`config.toml`) on Server B accordingly:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
port = 8443
|
||||
listen_addr_ipv4 = "127.0.0.1"
|
||||
proxy_protocol = true
|
||||
|
||||
[general.links]
|
||||
show = "*"
|
||||
public_host = "<FQDN_OR_IP_SERVER_A>"
|
||||
public_port = 443
|
||||
```
|
||||
|
||||
- Address `127.0.0.1` and `port = 8443` instructs the core proxy router to process connections unpacked locally via Xray-server.
|
||||
- `proxy_protocol = true` commands telemt to parse the injected PROXY header (from Server A's Xray local loopback) and log genuine end-user IPs.
|
||||
- Under `public_host`, place Server A's public IP address or FQDN to ensure working links are generated for Telegram users.
|
||||
|
||||
Restart `telemt`. Your server is now robust against DPI scanners, passing traffic optimally.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<img src="https://gist.githubusercontent.com/avbor/1f8a128e628f47249aae6e058a57610b/raw/19013276c035e91058e0a9799ab145f8e70e3ff5/scheme.svg">
|
||||
|
||||
## Концепция
|
||||
- **Сервер A** (_РФ_):\
|
||||
Точка входа, принимает трафик пользователей Telegram-прокси напрямую через **Xray** (порт `443\tcp`)\
|
||||
и отправляет его в туннель на Сервер **B**.\
|
||||
Порт для клиентов Telegram — `443\tcp`
|
||||
- **Сервер B** (_условно Нидерланды_):\
|
||||
Точка выхода, на нем работает **Xray-сервер** (принимает подключения точки входа) и **telemt**.\
|
||||
На сервере должен быть неограниченный доступ до серверов Telegram.\
|
||||
Порт для VLESS/REALITY (вход) — `443\tcp`\
|
||||
Внутренний порт telemt (куда пробрасывается трафик) — `8443\tcp`
|
||||
|
||||
Туннель работает по протоколу VLESS-XTLS-Reality (или VLESS/xhttp/reality). Оригинальный IP-адрес клиента сохраняется благодаря протоколу PROXYv2, который Xray на Сервере А добавляет через локальный loopback перед упаковкой в туннель, благодаря чему прозрачно доходит до telemt.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1. Настройка туннеля Xray (A <-> B)
|
||||
|
||||
На обоих серверах необходимо установить **Xray-core** (рекомендуется версия 1.8.4 или новее).
|
||||
Официальный скрипт установки (выполнить на обоих серверах):
|
||||
```bash
|
||||
bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
|
||||
```
|
||||
|
||||
### Генерация ключей и параметров (выполнить один раз)
|
||||
Для конфигурации потребуются уникальные ID и ключи Xray Reality. Выполните на любом сервере с установленным Xray:
|
||||
1. **UUID клиента:**
|
||||
```bash
|
||||
xray uuid
|
||||
# Сохраните вывод (например: 12345678-abcd-1234-abcd-1234567890ab) — это <XRAY_UUID>
|
||||
```
|
||||
2. **Пара ключей X25519 (Private & Public) для Reality:**
|
||||
```bash
|
||||
xray x25519
|
||||
# Сохраните Private key (<SERVER_B_PRIVATE_KEY>) и Public key (<SERVER_B_PUBLIC_KEY>)
|
||||
```
|
||||
3. **Short ID (идентификатор Reality):**
|
||||
```bash
|
||||
openssl rand -hex 8
|
||||
# Сохраните вывод (например: abc123def456) — это <SHORT_ID>
|
||||
```
|
||||
4. **Random Path (путь для xhttp):**
|
||||
```bash
|
||||
openssl rand -hex 16
|
||||
# Сохраните вывод (например, 0123456789abcdef0123456789abcdef), чтобы заменить <YOUR_RANDOM_PATH> в конфигах
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Конфигурация Сервера B (_Нидерланды_):
|
||||
|
||||
Создаем или редактируем файл `/usr/local/etc/xray/config.json`.
|
||||
Этот Xray-сервер будет слушать порт `443` и прозрачно пропускать валидный Reality трафик дальше, а "замаскированный" трафик (например, если кто-то стучится в лоб веб-браузером) пойдет на `yahoo.com`.
|
||||
|
||||
```bash
|
||||
nano /usr/local/etc/xray/config.json
|
||||
```
|
||||
|
||||
Содержимое файла:
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"loglevel": "error",
|
||||
"access": "none"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "vless-in",
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"id": "<XRAY_UUID>"
|
||||
}
|
||||
],
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "xhttp",
|
||||
"security": "reality",
|
||||
"realitySettings": {
|
||||
"dest": "yahoo.com:443",
|
||||
"serverNames": [
|
||||
"yahoo.com"
|
||||
],
|
||||
"privateKey": "<SERVER_B_PRIVATE_KEY>",
|
||||
"shortIds": [
|
||||
"<SHORT_ID>"
|
||||
]
|
||||
},
|
||||
"xhttpSettings": {
|
||||
"path": "/<YOUR_RANDOM_PATH>",
|
||||
"mode": "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "tunnel-to-telemt",
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"destination": "127.0.0.1:8443"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"inboundTag": [
|
||||
"vless-in"
|
||||
],
|
||||
"outboundTag": "tunnel-to-telemt"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Открываем порт на фаерволе (если включен):
|
||||
```bash
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
Перезапускаем Xray:
|
||||
```bash
|
||||
sudo systemctl restart xray
|
||||
sudo systemctl enable xray
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Конфигурация Сервера A (_РФ_):
|
||||
|
||||
Аналогично, редактируем `/usr/local/etc/xray/config.json`.
|
||||
Здесь Xray выступает публичной точкой: он принимает трафик на внешний порт `443\tcp`, пропускает через локальный loopback (порт `10444`) для добавления PROXYv2-заголовка, и упаковывает в Reality до Сервера B, прося тот доставить данные на *свой локальный* порт `127.0.0.1:8443` (именно там будет слушать telemt).
|
||||
|
||||
```bash
|
||||
nano /usr/local/etc/xray/config.json
|
||||
```
|
||||
|
||||
Содержимое файла:
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"loglevel": "error",
|
||||
"access": "none"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "public-in",
|
||||
"port": 443,
|
||||
"listen": "0.0.0.0",
|
||||
"protocol": "dokodemo-door",
|
||||
"settings": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 10444,
|
||||
"network": "tcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "tunnel-in",
|
||||
"port": 10444,
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "dokodemo-door",
|
||||
"settings": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 8443,
|
||||
"network": "tcp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "local-injector",
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"proxyProtocol": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "vless-out",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "<PUBLIC_IP_SERVER_B>",
|
||||
"port": 443,
|
||||
"users": [
|
||||
{
|
||||
"id": "<XRAY_UUID>",
|
||||
"encryption": "none"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "xhttp",
|
||||
"security": "reality",
|
||||
"realitySettings": {
|
||||
"serverName": "yahoo.com",
|
||||
"publicKey": "<SERVER_B_PUBLIC_KEY>",
|
||||
"shortId": "<SHORT_ID>",
|
||||
"spiderX": "/",
|
||||
"fingerprint": "chrome"
|
||||
},
|
||||
"xhttpSettings": {
|
||||
"path": "/<YOUR_RANDOM_PATH>"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"inboundTag": ["public-in"],
|
||||
"outboundTag": "local-injector"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"inboundTag": ["tunnel-in"],
|
||||
"outboundTag": "vless-out"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
*Замените `<PUBLIC_IP_SERVER_B>` на внешний IP-адрес Сервера B.*
|
||||
|
||||
Открываем порт на фаерволе для клиентов:
|
||||
```bash
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
|
||||
Перезапускаем Xray:
|
||||
```bash
|
||||
sudo systemctl restart xray
|
||||
sudo systemctl enable xray
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2. Установка и настройка telemt на Сервере B (_Нидерланды_)
|
||||
|
||||
Установка telemt описана [в основной инструкции](../Quick_start/QUICK_START_GUIDE.ru.md).
|
||||
Отличие в том, что telemt должен слушать *внутренний* порт (так как 443 занят Xray-сервером), а также ожидать `PROXY` протокол из Xray туннеля.
|
||||
|
||||
В конфиге `config.toml` прокси (на Сервере B) укажите:
|
||||
```toml
|
||||
[server]
|
||||
port = 8443
|
||||
listen_addr_ipv4 = "127.0.0.1"
|
||||
proxy_protocol = true
|
||||
|
||||
[general.links]
|
||||
show = "*"
|
||||
public_host = "<FQDN_OR_IP_SERVER_A>"
|
||||
public_port = 443
|
||||
```
|
||||
|
||||
- `port = 8443` и `listen_addr_ipv4 = "127.0.0.1"` означают, что telemt принимает подключения только изнутри (приходящие от локального Xray-процесса).
|
||||
- `proxy_protocol = true` заставляет telemt парсить PROXYv2-заголовок (который добавил Xray на Сервере A через loopback), восстанавливая IP-адрес конечного пользователя (РФ).
|
||||
- В `public_host` укажите публичный IP-адрес или домен Сервера A, чтобы ссылки на подключение генерировались корректно.
|
||||
|
||||
Перезапустите `telemt`, и клиенты смогут подключаться по выданным ссылкам.
|
||||
|
||||
Executable → Regular
+1
-11
@@ -1,11 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="250" height="50" viewBox="0 0 250 50">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #1d98dc;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect id="Прямоугольник_скругл._углы_1" data-name="Прямоугольник, скругл. углы 1" class="cls-1" width="250" height="50" rx="25" ry="25"/>
|
||||
<image id="Join_us_" data-name="Join us!" x="53" y="12" width="144" height="24" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAAAYCAYAAAAVpXQNAAAFnElEQVRoge2aS2hdRRjHf2Pro1aRW1BBpGpLUQRbNRbxsbCSSKWCD7gVFUFEko1gdykaxQeU1NdGEBpEXLjQdOPCLqSh4koXLUhXUk1a8YFQSVqlYm3tJ1/5bpl8mTn33Nxzb25q/nDbnDMzZ+Z88/+ec4KI0EFcDdwM/A183cmJlrBAUAJ16LdOREZEZJ+IfFTRHLtkNtZ0UmoiMm6zTYpI3/+ZoyprJ3vdC5ZXOEcALgT6gacAFfgNwEXAsQrn6QpEZBio21xK1F3AHYvtPapCCGHKeasZ/acKAl0KXAM8CjwNrAMucX3+6fYLL6HjaItAy4DVwHrgceARYIW16YNPAZfbtdJ2arHtZwhhp7mtuq1/qAeW1XNolUCrzDXdbRZng2vfB3wFPAncaPdOAN8vRuGEELb2wDJ6GmUItMJigAeAe4H7jEgxfgXeM/I8B6yN2v5YrARaQnMUEUgD4NuALcDtwK2Zfp8BO4DfgXfNncX4DfhhaS/OC0yZMTmHC9xbqbXZBIwAHwNjwLMZ8hwCtgFPACeB3QnyKL6zX9chIoMiMipzoWn5cFEZIJG2Dif6zCorRPfrIrLXjR9tp+zgU+iCfoOu72CmX5/JYNr1n7b75coWVl9ZLSLPi8gnIvKziJxOCD3GByKy0cZuFpEjmX6nROQVEbmim3UgE87+Ju/QwBxi0AaBEmuMMT1fErnntEWgjFJ5pMZNxvJo1IE+BO4Crk+k3x4HgLeAzy04fsbcVi3TXyvQh4G/WpTXvGGas7+F8SrMs1lXBXOPAkmNN9SsnjTQ7lzzha0xqTQt4mwary5sI3BTE/IoAd4GHgM+tesXgPcLyKM4CkwC/3ZJOLqWcXdbXetAMFgC4MmiJOqvYAm6MRNacIzm2+769C9UVdvkExNcY5qtIYIVS3PKNONvLLfM6jrgYWCz/X2lVZC1nvMN8CrwpdV0VpqgXkrEUB4/WgZ2pjIpFGPQBXljIYRZ9ZsQggphu4jo/6NR06Btfjs4EEIYcPPtNLcVb1y/WfNuo+YUXuWz2633QMHa5hBICXAcOAi8AdwDPAS8Duyxe1usvqPkucqE/nIJ8ih+MivULdSjeWYS2h8LaqcTVBUWKKe5Y+66yGp3Ep4Ag+2eJ6bS+G/t56Ex0ptuk4pw2txXV2DmOXYNE2ZtijARjampazENnC9yFsxX4heEQCoPERmLrKGSZ9LuTXhrVAZlrAgWH420QB7Fn2bZugW/KWWIUOnG5ghbgsjdxPYE0ZVQ440UvpW1lCWQuq5bWnzJoxlL1g4WyvSfN1AyW5w2lFGgUSuBpFxbrAhnx7ZigVqFEuhIxYKf9VL6iUF06bW8DNm8kBbLoW/bihRC0AB6rWVdPlbss3KDx1b7bCc0rFhZAq10p+uvAe/Y3zkcatLeKnyMM8tFmZuISVRUj2kgDpxnHCF7DfG7FQW+LQXFGvNZQrHKJxUWVxZiPgTaZtnZiBUSU1bmZAfSVO+bUwFfnO3UrGiWhPn6vszYXkRM7r5U3SpRLpjTnotxTAG9TJvXq0oeITwoIr+IyDYRWebaNojIFyJyJipzHxeRO9s4shi244jGtS+9T6e0wwTkz3bGY2HruMzz1iSeFaP0WVhGzqWOIgrG+zVr9lSP2uvuqGHOUUb0TnvjsdbWnzj+qWXWMG3zlSbQehHZJCIXZ9p1U3aIyDGbQD9/vKzCM6+sUBKCrjcZm0JSm12/hSaQX08O4+5+ikBlMOrm93JVEpV2YQetEn0y067m70Uzn3vsfOxEq0IqiSENAHNdrZYxkKqaJjBlxxztVqA7DovPmn0VmUrR54PdIYRsETZCrYoT8vi3UkTuF5Fr23zOcEJDCj+/SMHGeI0Uu1dY7+g1CxQ9R12Nt9DnXFKz03gbn5Jv4zOObEXeudG6iPAfDgYBtHqBCrAAAAAASUVORK5CYII="/>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 150 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M150,15c0,8.279 -6.721,15 -15,15l-120,0c-8.279,0 -15,-6.721 -15,-15c0,-8.279 6.721,-15 15,-15l120,0c8.279,0 15,6.721 15,15Z" style="fill:#24a1ed;"/><g transform="matrix(20.833333,0,0,20.833333,111.464184,22.329305)"></g><text x="39.666px" y="22.329px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:20.833px;fill:#fff;">Join us!</text></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 804 B |
+81
-16
@@ -1,6 +1,6 @@
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::io::{Error as IoError, ErrorKind};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -16,7 +16,7 @@ use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, RwLock, watch};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::{ApiGrayAction, ProxyConfig};
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::proxy::route_mode::RouteRuntimeController;
|
||||
use crate::startup::StartupTracker;
|
||||
@@ -41,8 +41,8 @@ use config_store::{current_revision, load_config_from_disk, parse_if_match};
|
||||
use events::ApiEventStore;
|
||||
use http_utils::{error_response, read_json, read_optional_json, success_response};
|
||||
use model::{
|
||||
ApiFailure, CreateUserRequest, DeleteUserResponse, HealthData, PatchUserRequest,
|
||||
RotateSecretRequest, SummaryData, UserActiveIps,
|
||||
ApiFailure, ClassCount, CreateUserRequest, DeleteUserResponse, HealthData, HealthReadyData,
|
||||
PatchUserRequest, RotateSecretRequest, SummaryData, UserActiveIps,
|
||||
};
|
||||
use runtime_edge::{
|
||||
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
|
||||
@@ -184,7 +184,9 @@ pub async fn serve(
|
||||
.serve_connection(hyper_util::rt::TokioIo::new(stream), svc)
|
||||
.await
|
||||
{
|
||||
debug!(error = %error, "API connection error");
|
||||
if !error.is_user() {
|
||||
debug!(error = %error, "API connection error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -195,7 +197,7 @@ async fn handle(
|
||||
peer: SocketAddr,
|
||||
shared: Arc<ApiShared>,
|
||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
) -> Result<Response<Full<Bytes>>, IoError> {
|
||||
let request_id = shared.next_request_id();
|
||||
let cfg = config_rx.borrow().clone();
|
||||
let api_cfg = &cfg.server.api;
|
||||
@@ -213,14 +215,25 @@ async fn handle(
|
||||
|
||||
if !api_cfg.whitelist.is_empty() && !api_cfg.whitelist.iter().any(|net| net.contains(peer.ip()))
|
||||
{
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden",
|
||||
"Source IP is not allowed",
|
||||
),
|
||||
));
|
||||
return match api_cfg.gray_action {
|
||||
ApiGrayAction::Api => Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden",
|
||||
"Source IP is not allowed",
|
||||
),
|
||||
)),
|
||||
ApiGrayAction::Ok200 => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "text/html; charset=utf-8")
|
||||
.body(Full::new(Bytes::new()))
|
||||
.unwrap()),
|
||||
ApiGrayAction::Drop => Err(IoError::new(
|
||||
ErrorKind::ConnectionAborted,
|
||||
"api request dropped by gray_action=drop",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
if !api_cfg.auth_header.is_empty() {
|
||||
@@ -244,11 +257,16 @@ async fn handle(
|
||||
|
||||
let method = req.method().clone();
|
||||
let path = req.uri().path().to_string();
|
||||
let normalized_path = if path.len() > 1 {
|
||||
path.trim_end_matches('/')
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
let query = req.uri().query().map(str::to_string);
|
||||
let body_limit = api_cfg.request_body_limit_bytes;
|
||||
|
||||
let result: Result<Response<Full<Bytes>>, ApiFailure> = async {
|
||||
match (method.as_str(), path.as_str()) {
|
||||
match (method.as_str(), normalized_path) {
|
||||
("GET", "/v1/health") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = HealthData {
|
||||
@@ -257,6 +275,33 @@ async fn handle(
|
||||
};
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/health/ready") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let admission_open = shared.runtime_state.admission_open.load(Ordering::Relaxed);
|
||||
let upstream_health = shared.upstream_manager.api_health_summary().await;
|
||||
let ready = admission_open && upstream_health.healthy_total > 0;
|
||||
let reason = if ready {
|
||||
None
|
||||
} else if !admission_open {
|
||||
Some("admission_closed")
|
||||
} else {
|
||||
Some("no_healthy_upstreams")
|
||||
};
|
||||
let data = HealthReadyData {
|
||||
ready,
|
||||
status: if ready { "ready" } else { "not_ready" },
|
||||
reason,
|
||||
admission_open,
|
||||
healthy_upstreams: upstream_health.healthy_total,
|
||||
total_upstreams: upstream_health.configured_total,
|
||||
};
|
||||
let status_code = if ready {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
};
|
||||
Ok(success_response(status_code, data, revision))
|
||||
}
|
||||
("GET", "/v1/system/info") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = build_system_info_data(shared.as_ref(), cfg.as_ref(), &revision);
|
||||
@@ -289,10 +334,24 @@ async fn handle(
|
||||
}
|
||||
("GET", "/v1/stats/summary") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let connections_bad_by_class = shared
|
||||
.stats
|
||||
.get_connects_bad_class_counts()
|
||||
.into_iter()
|
||||
.map(|(class, total)| ClassCount { class, total })
|
||||
.collect();
|
||||
let handshake_failures_by_class = shared
|
||||
.stats
|
||||
.get_handshake_failure_class_counts()
|
||||
.into_iter()
|
||||
.map(|(class, total)| ClassCount { class, total })
|
||||
.collect();
|
||||
let data = SummaryData {
|
||||
uptime_seconds: shared.stats.uptime_secs(),
|
||||
connections_total: shared.stats.get_connects_all(),
|
||||
connections_bad_total: shared.stats.get_connects_bad(),
|
||||
connections_bad_by_class,
|
||||
handshake_failures_by_class,
|
||||
handshake_timeouts_total: shared.stats.get_handshake_timeouts(),
|
||||
configured_users: cfg.access.users.len(),
|
||||
};
|
||||
@@ -431,7 +490,7 @@ async fn handle(
|
||||
Ok(success_response(status, data, revision))
|
||||
}
|
||||
_ => {
|
||||
if let Some(user) = path.strip_prefix("/v1/users/")
|
||||
if let Some(user) = normalized_path.strip_prefix("/v1/users/")
|
||||
&& !user.is_empty()
|
||||
&& !user.contains('/')
|
||||
{
|
||||
@@ -600,6 +659,12 @@ async fn handle(
|
||||
),
|
||||
));
|
||||
}
|
||||
debug!(
|
||||
method = method.as_str(),
|
||||
path = %path,
|
||||
normalized_path = %normalized_path,
|
||||
"API route not found"
|
||||
);
|
||||
Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "Route not found"),
|
||||
|
||||
@@ -60,11 +60,30 @@ pub(super) struct HealthData {
|
||||
pub(super) read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct HealthReadyData {
|
||||
pub(super) ready: bool,
|
||||
pub(super) status: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) reason: Option<&'static str>,
|
||||
pub(super) admission_open: bool,
|
||||
pub(super) healthy_upstreams: usize,
|
||||
pub(super) total_upstreams: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub(super) struct ClassCount {
|
||||
pub(super) class: String,
|
||||
pub(super) total: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct SummaryData {
|
||||
pub(super) uptime_seconds: f64,
|
||||
pub(super) connections_total: u64,
|
||||
pub(super) connections_bad_total: u64,
|
||||
pub(super) connections_bad_by_class: Vec<ClassCount>,
|
||||
pub(super) handshake_failures_by_class: Vec<ClassCount>,
|
||||
pub(super) handshake_timeouts_total: u64,
|
||||
pub(super) configured_users: usize,
|
||||
}
|
||||
@@ -80,6 +99,8 @@ pub(super) struct ZeroCoreData {
|
||||
pub(super) uptime_seconds: f64,
|
||||
pub(super) connections_total: u64,
|
||||
pub(super) connections_bad_total: u64,
|
||||
pub(super) connections_bad_by_class: Vec<ClassCount>,
|
||||
pub(super) handshake_failures_by_class: Vec<ClassCount>,
|
||||
pub(super) handshake_timeouts_total: u64,
|
||||
pub(super) accept_permit_timeout_total: u64,
|
||||
pub(super) configured_users: usize,
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::transport::upstream::IpPreference;
|
||||
|
||||
use super::ApiShared;
|
||||
use super::model::{
|
||||
DcEndpointWriters, DcStatus, DcStatusData, MeWriterStatus, MeWritersData, MeWritersSummary,
|
||||
MinimalAllData, MinimalAllPayload, MinimalDcPathData, MinimalMeRuntimeData,
|
||||
ClassCount, DcEndpointWriters, DcStatus, DcStatusData, MeWriterStatus, MeWritersData,
|
||||
MeWritersSummary, MinimalAllData, MinimalAllPayload, MinimalDcPathData, MinimalMeRuntimeData,
|
||||
MinimalQuarantineData, UpstreamDcStatus, UpstreamStatus, UpstreamSummaryData, UpstreamsData,
|
||||
ZeroAllData, ZeroCodeCount, ZeroCoreData, ZeroDesyncData, ZeroMiddleProxyData, ZeroPoolData,
|
||||
ZeroUpstreamData,
|
||||
@@ -26,6 +26,16 @@ pub(crate) struct MinimalCacheEntry {
|
||||
|
||||
pub(super) fn build_zero_all_data(stats: &Stats, configured_users: usize) -> ZeroAllData {
|
||||
let telemetry = stats.telemetry_policy();
|
||||
let bad_connection_classes = stats
|
||||
.get_connects_bad_class_counts()
|
||||
.into_iter()
|
||||
.map(|(class, total)| ClassCount { class, total })
|
||||
.collect();
|
||||
let handshake_failure_classes = stats
|
||||
.get_handshake_failure_class_counts()
|
||||
.into_iter()
|
||||
.map(|(class, total)| ClassCount { class, total })
|
||||
.collect();
|
||||
let handshake_error_codes = stats
|
||||
.get_me_handshake_error_code_counts()
|
||||
.into_iter()
|
||||
@@ -38,6 +48,8 @@ pub(super) fn build_zero_all_data(stats: &Stats, configured_users: usize) -> Zer
|
||||
uptime_seconds: stats.uptime_secs(),
|
||||
connections_total: stats.get_connects_all(),
|
||||
connections_bad_total: stats.get_connects_bad(),
|
||||
connections_bad_by_class: bad_connection_classes,
|
||||
handshake_failures_by_class: handshake_failure_classes,
|
||||
handshake_timeouts_total: stats.get_handshake_timeouts(),
|
||||
accept_permit_timeout_total: stats.get_accept_permit_timeout_total(),
|
||||
configured_users,
|
||||
|
||||
+13
-1
@@ -452,7 +452,11 @@ fn build_user_links(
|
||||
startup_detected_ip_v6: Option<IpAddr>,
|
||||
) -> UserLinks {
|
||||
let hosts = resolve_link_hosts(cfg, startup_detected_ip_v4, startup_detected_ip_v6);
|
||||
let port = cfg.general.links.public_port.unwrap_or(cfg.server.port);
|
||||
let port = cfg
|
||||
.general
|
||||
.links
|
||||
.public_port
|
||||
.unwrap_or(resolve_default_link_port(cfg));
|
||||
let tls_domains = resolve_tls_domains(cfg);
|
||||
|
||||
let mut classic = Vec::new();
|
||||
@@ -490,6 +494,14 @@ fn build_user_links(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
|
||||
cfg.server
|
||||
.listeners
|
||||
.first()
|
||||
.and_then(|listener| listener.port)
|
||||
.unwrap_or(cfg.server.port)
|
||||
}
|
||||
|
||||
fn resolve_link_hosts(
|
||||
cfg: &ProxyConfig,
|
||||
startup_detected_ip_v4: Option<IpAddr>,
|
||||
|
||||
+72
-2
@@ -6,12 +6,15 @@
|
||||
//! - `reload [--pid-file PATH]` - Reload configuration (SIGHUP)
|
||||
//! - `status [--pid-file PATH]` - Check daemon status
|
||||
//! - `run [OPTIONS] [config.toml]` - Run in foreground (default behavior)
|
||||
//! - `healthcheck [OPTIONS] [config.toml]` - Run control-plane health probe
|
||||
|
||||
use rand::RngExt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::healthcheck::{self, HealthcheckMode};
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::daemon::{self, DEFAULT_PID_FILE, DaemonOptions};
|
||||
|
||||
@@ -28,6 +31,8 @@ pub enum Subcommand {
|
||||
Reload,
|
||||
/// Check daemon status (`status` subcommand).
|
||||
Status,
|
||||
/// Run health probe and exit with status code.
|
||||
Healthcheck,
|
||||
/// Fire-and-forget setup (`--init`).
|
||||
Init,
|
||||
}
|
||||
@@ -38,6 +43,8 @@ pub struct ParsedCommand {
|
||||
pub subcommand: Subcommand,
|
||||
pub pid_file: PathBuf,
|
||||
pub config_path: String,
|
||||
pub healthcheck_mode: HealthcheckMode,
|
||||
pub healthcheck_mode_invalid: Option<String>,
|
||||
#[cfg(unix)]
|
||||
pub daemon_opts: DaemonOptions,
|
||||
pub init_opts: Option<InitOptions>,
|
||||
@@ -52,6 +59,8 @@ impl Default for ParsedCommand {
|
||||
#[cfg(not(unix))]
|
||||
pid_file: PathBuf::from("/var/run/telemt.pid"),
|
||||
config_path: "config.toml".to_string(),
|
||||
healthcheck_mode: HealthcheckMode::Liveness,
|
||||
healthcheck_mode_invalid: None,
|
||||
#[cfg(unix)]
|
||||
daemon_opts: DaemonOptions::default(),
|
||||
init_opts: None,
|
||||
@@ -91,6 +100,9 @@ pub fn parse_command(args: &[String]) -> ParsedCommand {
|
||||
"status" => {
|
||||
cmd.subcommand = Subcommand::Status;
|
||||
}
|
||||
"healthcheck" => {
|
||||
cmd.subcommand = Subcommand::Healthcheck;
|
||||
}
|
||||
"run" => {
|
||||
cmd.subcommand = Subcommand::Run;
|
||||
#[cfg(unix)]
|
||||
@@ -113,7 +125,35 @@ pub fn parse_command(args: &[String]) -> ParsedCommand {
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
// Skip subcommand names
|
||||
"start" | "stop" | "reload" | "status" | "run" => {}
|
||||
"start" | "stop" | "reload" | "status" | "run" | "healthcheck" => {}
|
||||
"--mode" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
match HealthcheckMode::from_cli_arg(&args[i]) {
|
||||
Some(mode) => {
|
||||
cmd.healthcheck_mode = mode;
|
||||
cmd.healthcheck_mode_invalid = None;
|
||||
}
|
||||
None => {
|
||||
cmd.healthcheck_mode_invalid = Some(args[i].clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cmd.healthcheck_mode_invalid = Some(String::new());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--mode=") => {
|
||||
let raw = s.trim_start_matches("--mode=");
|
||||
match HealthcheckMode::from_cli_arg(raw) {
|
||||
Some(mode) => {
|
||||
cmd.healthcheck_mode = mode;
|
||||
cmd.healthcheck_mode_invalid = None;
|
||||
}
|
||||
None => {
|
||||
cmd.healthcheck_mode_invalid = Some(raw.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// PID file option (for stop/reload/status)
|
||||
"--pid-file" => {
|
||||
i += 1;
|
||||
@@ -152,6 +192,20 @@ pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
||||
Subcommand::Stop => Some(cmd_stop(&cmd.pid_file)),
|
||||
Subcommand::Reload => Some(cmd_reload(&cmd.pid_file)),
|
||||
Subcommand::Status => Some(cmd_status(&cmd.pid_file)),
|
||||
Subcommand::Healthcheck => {
|
||||
if let Some(invalid_mode) = cmd.healthcheck_mode_invalid.as_ref() {
|
||||
if invalid_mode.is_empty() {
|
||||
eprintln!("[telemt] Missing value for --mode (supported: liveness, ready)");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[telemt] Invalid --mode value '{invalid_mode}' (supported: liveness, ready)"
|
||||
);
|
||||
}
|
||||
Some(2)
|
||||
} else {
|
||||
Some(healthcheck::run(&cmd.config_path, cmd.healthcheck_mode))
|
||||
}
|
||||
}
|
||||
Subcommand::Init => {
|
||||
if let Some(opts) = cmd.init_opts.clone() {
|
||||
match run_init(opts) {
|
||||
@@ -177,6 +231,20 @@ pub fn execute_subcommand(cmd: &ParsedCommand) -> Option<i32> {
|
||||
eprintln!("[telemt] Subcommand not supported on this platform");
|
||||
Some(1)
|
||||
}
|
||||
Subcommand::Healthcheck => {
|
||||
if let Some(invalid_mode) = cmd.healthcheck_mode_invalid.as_ref() {
|
||||
if invalid_mode.is_empty() {
|
||||
eprintln!("[telemt] Missing value for --mode (supported: liveness, ready)");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[telemt] Invalid --mode value '{invalid_mode}' (supported: liveness, ready)"
|
||||
);
|
||||
}
|
||||
Some(2)
|
||||
} else {
|
||||
Some(healthcheck::run(&cmd.config_path, cmd.healthcheck_mode))
|
||||
}
|
||||
}
|
||||
Subcommand::Init => {
|
||||
if let Some(opts) = cmd.init_opts.clone() {
|
||||
match run_init(opts) {
|
||||
@@ -598,16 +666,17 @@ secure = false
|
||||
tls = true
|
||||
|
||||
[server]
|
||||
port = {port}
|
||||
listen_addr_ipv4 = "0.0.0.0"
|
||||
listen_addr_ipv6 = "::"
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = {port}
|
||||
# reuse_allow = false # Set true only when intentionally running multiple telemt instances on same port
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "::"
|
||||
port = {port}
|
||||
|
||||
[timeouts]
|
||||
client_first_byte_idle_secs = 300
|
||||
@@ -620,6 +689,7 @@ tls_domain = "{domain}"
|
||||
mask = true
|
||||
mask_port = 443
|
||||
fake_cert_len = 2048
|
||||
serverhello_compact = false
|
||||
tls_full_cert_ttl_secs = 90
|
||||
|
||||
[access]
|
||||
|
||||
@@ -21,6 +21,8 @@ const DEFAULT_ME_ADAPTIVE_FLOOR_MAX_ACTIVE_WRITERS_PER_CORE: u16 = 64;
|
||||
const DEFAULT_ME_ADAPTIVE_FLOOR_MAX_WARM_WRITERS_PER_CORE: u16 = 64;
|
||||
const DEFAULT_ME_ADAPTIVE_FLOOR_MAX_ACTIVE_WRITERS_GLOBAL: u32 = 256;
|
||||
const DEFAULT_ME_ADAPTIVE_FLOOR_MAX_WARM_WRITERS_GLOBAL: u32 = 256;
|
||||
const DEFAULT_ME_ROUTE_BACKPRESSURE_ENABLED: bool = false;
|
||||
const DEFAULT_ME_ROUTE_FAIRSHARE_ENABLED: bool = false;
|
||||
const DEFAULT_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 4096;
|
||||
const DEFAULT_ME_ROUTE_CHANNEL_CAPACITY: usize = 768;
|
||||
const DEFAULT_ME_C2ME_CHANNEL_CAPACITY: usize = 1024;
|
||||
@@ -529,6 +531,14 @@ pub(crate) fn default_me_route_backpressure_base_timeout_ms() -> u64 {
|
||||
25
|
||||
}
|
||||
|
||||
pub(crate) fn default_me_route_backpressure_enabled() -> bool {
|
||||
DEFAULT_ME_ROUTE_BACKPRESSURE_ENABLED
|
||||
}
|
||||
|
||||
pub(crate) fn default_me_route_fairshare_enabled() -> bool {
|
||||
DEFAULT_ME_ROUTE_FAIRSHARE_ENABLED
|
||||
}
|
||||
|
||||
pub(crate) fn default_me_route_backpressure_high_timeout_ms() -> u64 {
|
||||
120
|
||||
}
|
||||
@@ -565,6 +575,10 @@ pub(crate) fn default_tls_new_session_tickets() -> u8 {
|
||||
0
|
||||
}
|
||||
|
||||
pub(crate) fn default_serverhello_compact() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn default_tls_full_cert_ttl_secs() -> u64 {
|
||||
90
|
||||
}
|
||||
@@ -615,6 +629,26 @@ pub(crate) fn default_mask_relay_max_bytes() -> usize {
|
||||
32 * 1024
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn default_mask_relay_timeout_ms() -> u64 {
|
||||
60_000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn default_mask_relay_timeout_ms() -> u64 {
|
||||
200
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn default_mask_relay_idle_timeout_ms() -> u64 {
|
||||
5_000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn default_mask_relay_idle_timeout_ms() -> u64 {
|
||||
100
|
||||
}
|
||||
|
||||
pub(crate) fn default_mask_classifier_prefetch_timeout_ms() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
//! | `network` | `dns_overrides` | Applied immediately |
|
||||
//! | `access` | All user/quota fields | Effective immediately |
|
||||
//!
|
||||
//! Fields that require re-binding sockets (`server.port`, `censorship.*`,
|
||||
//! `network.*`, `use_middle_proxy`) are **not** applied; a warning is emitted.
|
||||
//! Fields that require re-binding sockets (`server.listeners`, legacy
|
||||
//! `server.port`, `censorship.*`, `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;
|
||||
@@ -85,6 +86,8 @@ pub struct HotFields {
|
||||
pub telemetry_user_enabled: bool,
|
||||
pub telemetry_me_level: MeTelemetryLevel,
|
||||
pub me_socks_kdf_policy: MeSocksKdfPolicy,
|
||||
pub me_route_backpressure_enabled: bool,
|
||||
pub me_route_fairshare_enabled: bool,
|
||||
pub me_floor_mode: MeFloorMode,
|
||||
pub me_adaptive_floor_idle_secs: u64,
|
||||
pub me_adaptive_floor_min_writers_single_endpoint: u8,
|
||||
@@ -120,6 +123,9 @@ pub struct HotFields {
|
||||
pub user_max_tcp_conns_global_each: usize,
|
||||
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
|
||||
pub user_data_quota: std::collections::HashMap<String, u64>,
|
||||
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
|
||||
pub cidr_rate_limits:
|
||||
std::collections::HashMap<ipnetwork::IpNetwork, crate::config::RateLimitBps>,
|
||||
pub 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,
|
||||
@@ -183,6 +189,8 @@ impl HotFields {
|
||||
telemetry_user_enabled: cfg.general.telemetry.user_enabled,
|
||||
telemetry_me_level: cfg.general.telemetry.me_level,
|
||||
me_socks_kdf_policy: cfg.general.me_socks_kdf_policy,
|
||||
me_route_backpressure_enabled: cfg.general.me_route_backpressure_enabled,
|
||||
me_route_fairshare_enabled: cfg.general.me_route_fairshare_enabled,
|
||||
me_floor_mode: cfg.general.me_floor_mode,
|
||||
me_adaptive_floor_idle_secs: cfg.general.me_adaptive_floor_idle_secs,
|
||||
me_adaptive_floor_min_writers_single_endpoint: cfg
|
||||
@@ -244,6 +252,8 @@ impl HotFields {
|
||||
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
|
||||
user_expirations: cfg.access.user_expirations.clone(),
|
||||
user_data_quota: cfg.access.user_data_quota.clone(),
|
||||
user_rate_limits: cfg.access.user_rate_limits.clone(),
|
||||
cidr_rate_limits: cfg.access.cidr_rate_limits.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,
|
||||
@@ -299,6 +309,7 @@ fn listeners_equal(
|
||||
}
|
||||
lhs.iter().zip(rhs.iter()).all(|(a, b)| {
|
||||
a.ip == b.ip
|
||||
&& a.port == b.port
|
||||
&& a.announce == b.announce
|
||||
&& a.announce_ip == b.announce_ip
|
||||
&& a.proxy_protocol == b.proxy_protocol
|
||||
@@ -306,6 +317,14 @@ fn listeners_equal(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
|
||||
cfg.server
|
||||
.listeners
|
||||
.first()
|
||||
.and_then(|listener| listener.port)
|
||||
.unwrap_or(cfg.server.port)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct WatchManifest {
|
||||
files: BTreeSet<PathBuf>,
|
||||
@@ -514,6 +533,8 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||
new.general.me_route_backpressure_high_timeout_ms;
|
||||
cfg.general.me_route_backpressure_high_watermark_pct =
|
||||
new.general.me_route_backpressure_high_watermark_pct;
|
||||
cfg.general.me_route_backpressure_enabled = new.general.me_route_backpressure_enabled;
|
||||
cfg.general.me_route_fairshare_enabled = new.general.me_route_fairshare_enabled;
|
||||
cfg.general.me_reader_route_data_wait_ms = new.general.me_reader_route_data_wait_ms;
|
||||
cfg.general.me_d2c_flush_batch_max_frames = new.general.me_d2c_flush_batch_max_frames;
|
||||
cfg.general.me_d2c_flush_batch_max_bytes = new.general.me_d2c_flush_batch_max_bytes;
|
||||
@@ -535,6 +556,8 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
|
||||
cfg.access.user_expirations = new.access.user_expirations.clone();
|
||||
cfg.access.user_data_quota = new.access.user_data_quota.clone();
|
||||
cfg.access.user_rate_limits = new.access.user_rate_limits.clone();
|
||||
cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.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;
|
||||
@@ -560,6 +583,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|
||||
if old.server.api.enabled != new.server.api.enabled
|
||||
|| old.server.api.listen != new.server.api.listen
|
||||
|| old.server.api.whitelist != new.server.api.whitelist
|
||||
|| old.server.api.gray_action != new.server.api.gray_action
|
||||
|| old.server.api.auth_header != new.server.api.auth_header
|
||||
|| old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes
|
||||
|| old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled
|
||||
@@ -600,6 +624,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|
||||
|| old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms
|
||||
|| old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms
|
||||
|| old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets
|
||||
|| old.censorship.serverhello_compact != new.censorship.serverhello_compact
|
||||
|| old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs
|
||||
|| old.censorship.alpn_enforce != new.censorship.alpn_enforce
|
||||
|| old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol
|
||||
@@ -611,6 +636,8 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|
||||
|| old.censorship.mask_shape_above_cap_blur_max_bytes
|
||||
!= new.censorship.mask_shape_above_cap_blur_max_bytes
|
||||
|| old.censorship.mask_relay_max_bytes != new.censorship.mask_relay_max_bytes
|
||||
|| old.censorship.mask_relay_timeout_ms != new.censorship.mask_relay_timeout_ms
|
||||
|| old.censorship.mask_relay_idle_timeout_ms != new.censorship.mask_relay_idle_timeout_ms
|
||||
|| old.censorship.mask_classifier_prefetch_timeout_ms
|
||||
!= new.censorship.mask_classifier_prefetch_timeout_ms
|
||||
|| old.censorship.mask_timing_normalization_enabled
|
||||
@@ -1033,6 +1060,8 @@ fn log_changes(
|
||||
!= new_hot.me_route_backpressure_high_timeout_ms
|
||||
|| old_hot.me_route_backpressure_high_watermark_pct
|
||||
!= new_hot.me_route_backpressure_high_watermark_pct
|
||||
|| old_hot.me_route_backpressure_enabled != new_hot.me_route_backpressure_enabled
|
||||
|| old_hot.me_route_fairshare_enabled != new_hot.me_route_fairshare_enabled
|
||||
|| old_hot.me_reader_route_data_wait_ms != new_hot.me_reader_route_data_wait_ms
|
||||
|| old_hot.me_health_interval_ms_unhealthy != new_hot.me_health_interval_ms_unhealthy
|
||||
|| old_hot.me_health_interval_ms_healthy != new_hot.me_health_interval_ms_healthy
|
||||
@@ -1040,10 +1069,12 @@ fn log_changes(
|
||||
|| old_hot.me_warn_rate_limit_ms != new_hot.me_warn_rate_limit_ms
|
||||
{
|
||||
info!(
|
||||
"config reload: me_route_backpressure: base={}ms high={}ms watermark={}%; me_reader_route_data_wait_ms={}; me_health_interval: unhealthy={}ms healthy={}ms; me_admission_poll={}ms; me_warn_rate_limit={}ms",
|
||||
"config reload: me_route_backpressure: enabled={} base={}ms high={}ms watermark={}%; me_route_fairshare_enabled={}; me_reader_route_data_wait_ms={}; me_health_interval: unhealthy={}ms healthy={}ms; me_admission_poll={}ms; me_warn_rate_limit={}ms",
|
||||
new_hot.me_route_backpressure_enabled,
|
||||
new_hot.me_route_backpressure_base_timeout_ms,
|
||||
new_hot.me_route_backpressure_high_timeout_ms,
|
||||
new_hot.me_route_backpressure_high_watermark_pct,
|
||||
new_hot.me_route_fairshare_enabled,
|
||||
new_hot.me_reader_route_data_wait_ms,
|
||||
new_hot.me_health_interval_ms_unhealthy,
|
||||
new_hot.me_health_interval_ms_healthy,
|
||||
@@ -1117,7 +1148,7 @@ fn log_changes(
|
||||
.general
|
||||
.links
|
||||
.public_port
|
||||
.unwrap_or(new_cfg.server.port);
|
||||
.unwrap_or(resolve_default_link_port(new_cfg));
|
||||
for user in &added {
|
||||
if let Some(secret) = new_hot.users.get(*user) {
|
||||
print_user_links(user, secret, &host, port, new_cfg);
|
||||
@@ -1170,6 +1201,18 @@ fn log_changes(
|
||||
new_hot.user_data_quota.len()
|
||||
);
|
||||
}
|
||||
if old_hot.user_rate_limits != new_hot.user_rate_limits {
|
||||
info!(
|
||||
"config reload: user_rate_limits updated ({} entries)",
|
||||
new_hot.user_rate_limits.len()
|
||||
);
|
||||
}
|
||||
if old_hot.cidr_rate_limits != new_hot.cidr_rate_limits {
|
||||
info!(
|
||||
"config reload: cidr_rate_limits updated ({} entries)",
|
||||
new_hot.cidr_rate_limits.len()
|
||||
);
|
||||
}
|
||||
if old_hot.user_max_unique_ips != new_hot.user_max_unique_ips {
|
||||
info!(
|
||||
"config reload: user_max_unique_ips updated ({} entries)",
|
||||
|
||||
+288
-6
@@ -253,6 +253,12 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
||||
}
|
||||
|
||||
for upstream in &config.upstreams {
|
||||
if matches!(upstream.ipv4, Some(false)) && matches!(upstream.ipv6, Some(false)) {
|
||||
return Err(ProxyError::Config(
|
||||
"upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
|
||||
let parsed = ShadowsocksServerConfig::from_url(url)
|
||||
.map_err(|error| ProxyError::Config(format!("invalid shadowsocks url: {error}")))?;
|
||||
@@ -337,27 +343,108 @@ impl ProxyConfig {
|
||||
let network_table = parsed_toml
|
||||
.get("network")
|
||||
.and_then(|value| value.as_table());
|
||||
let server_table = parsed_toml.get("server").and_then(|value| value.as_table());
|
||||
let conntrack_control_table = server_table
|
||||
.and_then(|table| table.get("conntrack_control"))
|
||||
.and_then(|value| value.as_table());
|
||||
let update_every_is_explicit = general_table
|
||||
.map(|table| table.contains_key("update_every"))
|
||||
.unwrap_or(false);
|
||||
let beobachten_is_explicit = general_table
|
||||
.map(|table| table.contains_key("beobachten"))
|
||||
.unwrap_or(false);
|
||||
let beobachten_minutes_is_explicit = general_table
|
||||
.map(|table| table.contains_key("beobachten_minutes"))
|
||||
.unwrap_or(false);
|
||||
let beobachten_flush_secs_is_explicit = general_table
|
||||
.map(|table| table.contains_key("beobachten_flush_secs"))
|
||||
.unwrap_or(false);
|
||||
let beobachten_file_is_explicit = general_table
|
||||
.map(|table| table.contains_key("beobachten_file"))
|
||||
.unwrap_or(false);
|
||||
let legacy_secret_is_explicit = general_table
|
||||
.map(|table| table.contains_key("proxy_secret_auto_reload_secs"))
|
||||
.unwrap_or(false);
|
||||
let legacy_config_is_explicit = general_table
|
||||
.map(|table| table.contains_key("proxy_config_auto_reload_secs"))
|
||||
.unwrap_or(false);
|
||||
let legacy_top_level_beobachten = parsed_toml.get("beobachten").cloned();
|
||||
let legacy_top_level_beobachten_minutes = parsed_toml.get("beobachten_minutes").cloned();
|
||||
let legacy_top_level_beobachten_flush_secs =
|
||||
parsed_toml.get("beobachten_flush_secs").cloned();
|
||||
let legacy_top_level_beobachten_file = parsed_toml.get("beobachten_file").cloned();
|
||||
let stun_servers_is_explicit = network_table
|
||||
.map(|table| table.contains_key("stun_servers"))
|
||||
.unwrap_or(false);
|
||||
let inline_conntrack_control_is_explicit = conntrack_control_table
|
||||
.map(|table| table.contains_key("inline_conntrack_control"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut config: ProxyConfig = parsed_toml
|
||||
.try_into()
|
||||
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||
config
|
||||
.server
|
||||
.conntrack_control
|
||||
.inline_conntrack_control_explicit = inline_conntrack_control_is_explicit;
|
||||
|
||||
if !update_every_is_explicit && (legacy_secret_is_explicit || legacy_config_is_explicit) {
|
||||
config.general.update_every = None;
|
||||
}
|
||||
|
||||
// Backward compatibility: legacy top-level beobachten* keys.
|
||||
// Prefer `[general].*` when both are present.
|
||||
let mut legacy_beobachten_applied = false;
|
||||
if !beobachten_is_explicit && let Some(value) = legacy_top_level_beobachten.as_ref() {
|
||||
let parsed = value.as_bool().ok_or_else(|| {
|
||||
ProxyError::Config("beobachten (top-level) must be a boolean".to_string())
|
||||
})?;
|
||||
config.general.beobachten = parsed;
|
||||
legacy_beobachten_applied = true;
|
||||
}
|
||||
if !beobachten_minutes_is_explicit
|
||||
&& let Some(value) = legacy_top_level_beobachten_minutes.as_ref()
|
||||
{
|
||||
let raw = value.as_integer().ok_or_else(|| {
|
||||
ProxyError::Config("beobachten_minutes (top-level) must be an integer".to_string())
|
||||
})?;
|
||||
let parsed = u64::try_from(raw).map_err(|_| {
|
||||
ProxyError::Config(
|
||||
"beobachten_minutes (top-level) must be within u64 range".to_string(),
|
||||
)
|
||||
})?;
|
||||
config.general.beobachten_minutes = parsed;
|
||||
legacy_beobachten_applied = true;
|
||||
}
|
||||
if !beobachten_flush_secs_is_explicit
|
||||
&& let Some(value) = legacy_top_level_beobachten_flush_secs.as_ref()
|
||||
{
|
||||
let raw = value.as_integer().ok_or_else(|| {
|
||||
ProxyError::Config(
|
||||
"beobachten_flush_secs (top-level) must be an integer".to_string(),
|
||||
)
|
||||
})?;
|
||||
let parsed = u64::try_from(raw).map_err(|_| {
|
||||
ProxyError::Config(
|
||||
"beobachten_flush_secs (top-level) must be within u64 range".to_string(),
|
||||
)
|
||||
})?;
|
||||
config.general.beobachten_flush_secs = parsed;
|
||||
legacy_beobachten_applied = true;
|
||||
}
|
||||
if !beobachten_file_is_explicit
|
||||
&& let Some(value) = legacy_top_level_beobachten_file.as_ref()
|
||||
{
|
||||
let parsed = value.as_str().ok_or_else(|| {
|
||||
ProxyError::Config("beobachten_file (top-level) must be a string".to_string())
|
||||
})?;
|
||||
config.general.beobachten_file = parsed.to_string();
|
||||
legacy_beobachten_applied = true;
|
||||
}
|
||||
if legacy_beobachten_applied {
|
||||
warn!("top-level beobachten* keys are deprecated; use general.beobachten* instead");
|
||||
}
|
||||
|
||||
let legacy_nat_stun = config.general.middle_proxy_nat_stun.take();
|
||||
let legacy_nat_stun_servers =
|
||||
std::mem::take(&mut config.general.middle_proxy_nat_stun_servers);
|
||||
@@ -553,12 +640,6 @@ impl ProxyConfig {
|
||||
));
|
||||
}
|
||||
|
||||
if config.censorship.mask_relay_max_bytes == 0 {
|
||||
return Err(ProxyError::Config(
|
||||
"censorship.mask_relay_max_bytes must be > 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if config.censorship.mask_relay_max_bytes > 67_108_864 {
|
||||
return Err(ProxyError::Config(
|
||||
"censorship.mask_relay_max_bytes must be <= 67108864".to_string(),
|
||||
@@ -785,6 +866,22 @@ impl ProxyConfig {
|
||||
));
|
||||
}
|
||||
|
||||
for (user, limit) in &config.access.user_rate_limits {
|
||||
if limit.up_bps == 0 && limit.down_bps == 0 {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"access.user_rate_limits.{user} must set at least one non-zero direction"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
for (cidr, limit) in &config.access.cidr_rate_limits {
|
||||
if limit.up_bps == 0 && limit.down_bps == 0 {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"access.cidr_rate_limits.{cidr} must set at least one non-zero direction"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if config.general.me_reinit_every_secs == 0 {
|
||||
return Err(ProxyError::Config(
|
||||
"general.me_reinit_every_secs must be > 0".to_string(),
|
||||
@@ -1250,6 +1347,7 @@ impl ProxyConfig {
|
||||
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
|
||||
config.server.listeners.push(ListenerConfig {
|
||||
ip: ipv4,
|
||||
port: Some(config.server.port),
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
@@ -1261,6 +1359,7 @@ impl ProxyConfig {
|
||||
{
|
||||
config.server.listeners.push(ListenerConfig {
|
||||
ip: ipv6,
|
||||
port: Some(config.server.port),
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
@@ -1269,6 +1368,13 @@ impl ProxyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: listeners[].port fallback to legacy server.port.
|
||||
for listener in &mut config.server.listeners {
|
||||
if listener.port.is_none() {
|
||||
listener.port = Some(config.server.port);
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: announce_ip → announce for each listener.
|
||||
for listener in &mut config.server.listeners {
|
||||
if listener.announce.is_none()
|
||||
@@ -1289,11 +1395,14 @@ impl ProxyConfig {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1385,6 +1494,21 @@ mod tests {
|
||||
const TEST_SHADOWSOCKS_URL: &str =
|
||||
"ss://2022-blake3-aes-256-gcm:MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=@127.0.0.1:8388";
|
||||
|
||||
fn load_config_from_temp_toml(toml: &str) -> ProxyConfig {
|
||||
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_cfg_{nonce}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let cfg = ProxyConfig::load(&path).unwrap();
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_remain_unchanged_for_present_sections() {
|
||||
let toml = r#"
|
||||
@@ -1481,6 +1605,7 @@ mod tests {
|
||||
cfg.general.rpc_proxy_req_every,
|
||||
default_rpc_proxy_req_every()
|
||||
);
|
||||
assert_eq!(cfg.general.beobachten_file, default_beobachten_file());
|
||||
assert_eq!(cfg.general.update_every, default_update_every());
|
||||
assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4());
|
||||
assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt());
|
||||
@@ -1491,6 +1616,7 @@ mod tests {
|
||||
assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop);
|
||||
assert_eq!(cfg.server.api.listen, default_api_listen());
|
||||
assert_eq!(cfg.server.api.whitelist, default_api_whitelist());
|
||||
assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop);
|
||||
assert_eq!(
|
||||
cfg.server.api.request_body_limit_bytes,
|
||||
default_api_request_body_limit_bytes()
|
||||
@@ -1647,6 +1773,7 @@ mod tests {
|
||||
default_upstream_connect_failfast_hard_errors()
|
||||
);
|
||||
assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every());
|
||||
assert_eq!(general.beobachten_file, default_beobachten_file());
|
||||
assert_eq!(general.update_every, default_update_every());
|
||||
|
||||
let server = ServerConfig::default();
|
||||
@@ -1661,6 +1788,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(server.api.listen, default_api_listen());
|
||||
assert_eq!(server.api.whitelist, default_api_whitelist());
|
||||
assert_eq!(server.api.gray_action, ApiGrayAction::Drop);
|
||||
assert_eq!(
|
||||
server.api.request_body_limit_bytes,
|
||||
default_api_request_body_limit_bytes()
|
||||
@@ -1758,6 +1886,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conntrack_inline_explicit_flag_is_false_when_omitted() {
|
||||
let cfg = load_config_from_temp_toml(
|
||||
r#"
|
||||
[general]
|
||||
[network]
|
||||
[server]
|
||||
[server.conntrack_control]
|
||||
[access]
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!cfg.server
|
||||
.conntrack_control
|
||||
.inline_conntrack_control_explicit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conntrack_inline_explicit_flag_is_true_when_present() {
|
||||
let cfg = load_config_from_temp_toml(
|
||||
r#"
|
||||
[general]
|
||||
[network]
|
||||
[server]
|
||||
[server.conntrack_control]
|
||||
inline_conntrack_control = true
|
||||
[access]
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
cfg.server
|
||||
.conntrack_control
|
||||
.inline_conntrack_control_explicit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_sni_action_parses_and_defaults_to_drop() {
|
||||
let cfg_default: ProxyConfig = toml::from_str(
|
||||
@@ -1806,6 +1971,123 @@ mod tests {
|
||||
cfg_accept.censorship.unknown_sni_action,
|
||||
UnknownSniAction::Accept
|
||||
);
|
||||
|
||||
let cfg_reject: ProxyConfig = toml::from_str(
|
||||
r#"
|
||||
[server]
|
||||
[general]
|
||||
[network]
|
||||
[access]
|
||||
[censorship]
|
||||
unknown_sni_action = "reject_handshake"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cfg_reject.censorship.unknown_sni_action,
|
||||
UnknownSniAction::RejectHandshake
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_gray_action_parses_and_defaults_to_drop() {
|
||||
let cfg_default: ProxyConfig = toml::from_str(
|
||||
r#"
|
||||
[server]
|
||||
[general]
|
||||
[network]
|
||||
[access]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop);
|
||||
|
||||
let cfg_api: ProxyConfig = toml::from_str(
|
||||
r#"
|
||||
[server]
|
||||
[general]
|
||||
[network]
|
||||
[access]
|
||||
[server.api]
|
||||
gray_action = "api"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api);
|
||||
|
||||
let cfg_200: ProxyConfig = toml::from_str(
|
||||
r#"
|
||||
[server]
|
||||
[general]
|
||||
[network]
|
||||
[access]
|
||||
[server.api]
|
||||
gray_action = "200"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200);
|
||||
|
||||
let cfg_drop: ProxyConfig = toml::from_str(
|
||||
r#"
|
||||
[server]
|
||||
[general]
|
||||
[network]
|
||||
[access]
|
||||
[server.api]
|
||||
gray_action = "drop"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() {
|
||||
let cfg = load_config_from_temp_toml(
|
||||
r#"
|
||||
beobachten = false
|
||||
beobachten_minutes = 7
|
||||
beobachten_flush_secs = 3
|
||||
beobachten_file = "tmp/legacy-beob.txt"
|
||||
|
||||
[server]
|
||||
[general]
|
||||
[network]
|
||||
[access]
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(!cfg.general.beobachten);
|
||||
assert_eq!(cfg.general.beobachten_minutes, 7);
|
||||
assert_eq!(cfg.general.beobachten_flush_secs, 3);
|
||||
assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_beobachten_keys_have_priority_over_legacy_top_level() {
|
||||
let cfg = load_config_from_temp_toml(
|
||||
r#"
|
||||
beobachten = true
|
||||
beobachten_minutes = 30
|
||||
beobachten_flush_secs = 30
|
||||
beobachten_file = "tmp/legacy-beob.txt"
|
||||
|
||||
[server]
|
||||
[general]
|
||||
beobachten = false
|
||||
beobachten_minutes = 5
|
||||
beobachten_flush_secs = 2
|
||||
beobachten_file = "tmp/general-beob.txt"
|
||||
[network]
|
||||
[access]
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(!cfg.general.beobachten);
|
||||
assert_eq!(cfg.general.beobachten_minutes, 5);
|
||||
assert_eq!(cfg.general.beobachten_flush_secs, 2);
|
||||
assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -238,7 +238,7 @@ mask_shape_above_cap_blur_max_bytes = 8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_zero_mask_relay_max_bytes() {
|
||||
fn load_accepts_zero_mask_relay_max_bytes_as_unlimited() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[censorship]
|
||||
@@ -246,12 +246,9 @@ mask_relay_max_bytes = 0
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path).expect_err("mask_relay_max_bytes must be > 0");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("censorship.mask_relay_max_bytes must be > 0"),
|
||||
"error must explain non-zero relay cap invariant, got: {msg}"
|
||||
);
|
||||
let cfg = ProxyConfig::load(&path)
|
||||
.expect("mask_relay_max_bytes=0 must be accepted as unlimited relay cap");
|
||||
assert_eq!(cfg.censorship.mask_relay_max_bytes, 0);
|
||||
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
+150
-1
@@ -159,6 +159,21 @@ impl MeBindStaleMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// RST-on-close mode for accepted client sockets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RstOnCloseMode {
|
||||
/// Normal FIN on all closes (default, no behaviour change).
|
||||
#[default]
|
||||
Off,
|
||||
/// SO_LINGER(0) on accept; cleared after successful auth.
|
||||
/// Pre-handshake failures (scanners, DPI, timeouts) send RST;
|
||||
/// authenticated relay sessions close gracefully with FIN.
|
||||
Errors,
|
||||
/// SO_LINGER(0) on accept, never cleared — all closes send RST.
|
||||
Always,
|
||||
}
|
||||
|
||||
/// Middle-End writer floor policy mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -377,14 +392,26 @@ pub struct GeneralConfig {
|
||||
#[serde(default = "default_proxy_secret_path")]
|
||||
pub proxy_secret_path: Option<String>,
|
||||
|
||||
/// Optional custom URL for infrastructure secret (https://core.telegram.org/getProxySecret if absent).
|
||||
#[serde(default)]
|
||||
pub proxy_secret_url: Option<String>,
|
||||
|
||||
/// Optional path to cache raw getProxyConfig (IPv4) snapshot for startup fallback.
|
||||
#[serde(default = "default_proxy_config_v4_cache_path")]
|
||||
pub proxy_config_v4_cache_path: Option<String>,
|
||||
|
||||
/// Optional custom URL for getProxyConfig (https://core.telegram.org/getProxyConfig if absent).
|
||||
#[serde(default)]
|
||||
pub proxy_config_v4_url: Option<String>,
|
||||
|
||||
/// Optional path to cache raw getProxyConfigV6 snapshot for startup fallback.
|
||||
#[serde(default = "default_proxy_config_v6_cache_path")]
|
||||
pub proxy_config_v6_cache_path: Option<String>,
|
||||
|
||||
/// Optional custom URL for getProxyConfigV6 (https://core.telegram.org/getProxyConfigV6 if absent).
|
||||
#[serde(default)]
|
||||
pub proxy_config_v6_url: Option<String>,
|
||||
|
||||
/// Global ad_tag (32 hex chars from @MTProxybot). Fallback when user has no per-user tag in access.user_ad_tags.
|
||||
#[serde(default)]
|
||||
pub ad_tag: Option<String>,
|
||||
@@ -702,6 +729,14 @@ pub struct GeneralConfig {
|
||||
#[serde(default)]
|
||||
pub me_socks_kdf_policy: MeSocksKdfPolicy,
|
||||
|
||||
/// Enable route-level ME backpressure controls in reader fairness path.
|
||||
#[serde(default = "default_me_route_backpressure_enabled")]
|
||||
pub me_route_backpressure_enabled: bool,
|
||||
|
||||
/// Enable worker-local fairshare scheduler for ME reader routing.
|
||||
#[serde(default = "default_me_route_fairshare_enabled")]
|
||||
pub me_route_fairshare_enabled: bool,
|
||||
|
||||
/// Base backpressure timeout in milliseconds for ME route channel send.
|
||||
#[serde(default = "default_me_route_backpressure_base_timeout_ms")]
|
||||
pub me_route_backpressure_base_timeout_ms: u64,
|
||||
@@ -925,6 +960,14 @@ pub struct GeneralConfig {
|
||||
/// Minimum unavailable ME DC groups before degrading.
|
||||
#[serde(default = "default_degradation_min_unavailable_dc_groups")]
|
||||
pub degradation_min_unavailable_dc_groups: u8,
|
||||
|
||||
/// RST-on-close mode for accepted client sockets.
|
||||
/// `off` — normal FIN on all closes (default).
|
||||
/// `errors` — SO_LINGER(0) on accept, cleared after successful auth;
|
||||
/// pre-handshake failures send RST, relayed sessions close gracefully.
|
||||
/// `always` — SO_LINGER(0) on accept, never cleared; all closes send RST.
|
||||
#[serde(default)]
|
||||
pub rst_on_close: RstOnCloseMode,
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
@@ -937,8 +980,11 @@ impl Default for GeneralConfig {
|
||||
use_middle_proxy: default_true(),
|
||||
ad_tag: None,
|
||||
proxy_secret_path: default_proxy_secret_path(),
|
||||
proxy_secret_url: None,
|
||||
proxy_config_v4_cache_path: default_proxy_config_v4_cache_path(),
|
||||
proxy_config_v4_url: None,
|
||||
proxy_config_v6_cache_path: default_proxy_config_v6_cache_path(),
|
||||
proxy_config_v6_url: None,
|
||||
middle_proxy_nat_ip: None,
|
||||
middle_proxy_nat_probe: default_true(),
|
||||
middle_proxy_nat_stun: default_middle_proxy_nat_stun(),
|
||||
@@ -1021,6 +1067,8 @@ impl Default for GeneralConfig {
|
||||
disable_colors: false,
|
||||
telemetry: TelemetryConfig::default(),
|
||||
me_socks_kdf_policy: MeSocksKdfPolicy::Strict,
|
||||
me_route_backpressure_enabled: default_me_route_backpressure_enabled(),
|
||||
me_route_fairshare_enabled: default_me_route_fairshare_enabled(),
|
||||
me_route_backpressure_base_timeout_ms: default_me_route_backpressure_base_timeout_ms(),
|
||||
me_route_backpressure_high_timeout_ms: default_me_route_backpressure_high_timeout_ms(),
|
||||
me_route_backpressure_high_watermark_pct:
|
||||
@@ -1086,6 +1134,7 @@ impl Default for GeneralConfig {
|
||||
ntp_servers: default_ntp_servers(),
|
||||
auto_degradation_enabled: default_true(),
|
||||
degradation_min_unavailable_dc_groups: default_degradation_min_unavailable_dc_groups(),
|
||||
rst_on_close: RstOnCloseMode::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1129,7 +1178,8 @@ pub struct LinksConfig {
|
||||
#[serde(default)]
|
||||
pub public_host: Option<String>,
|
||||
|
||||
/// Public port for tg:// link generation (overrides server.port).
|
||||
/// Public port for tg:// link generation.
|
||||
/// Overrides listener ports and legacy `server.port`.
|
||||
#[serde(default)]
|
||||
pub public_port: Option<u16>,
|
||||
}
|
||||
@@ -1159,6 +1209,13 @@ pub struct ApiConfig {
|
||||
#[serde(default = "default_api_whitelist")]
|
||||
pub whitelist: Vec<IpNetwork>,
|
||||
|
||||
/// Behavior for requests from source IPs outside `whitelist`.
|
||||
/// - `api`: return structured API forbidden response.
|
||||
/// - `200`: return `200 OK` with an empty body.
|
||||
/// - `drop`: close the connection without HTTP response.
|
||||
#[serde(default)]
|
||||
pub gray_action: ApiGrayAction,
|
||||
|
||||
/// Optional static value for `Authorization` header validation.
|
||||
/// Empty string disables header auth.
|
||||
#[serde(default)]
|
||||
@@ -1203,6 +1260,7 @@ impl Default for ApiConfig {
|
||||
enabled: default_true(),
|
||||
listen: default_api_listen(),
|
||||
whitelist: default_api_whitelist(),
|
||||
gray_action: ApiGrayAction::default(),
|
||||
auth_header: String::new(),
|
||||
request_body_limit_bytes: default_api_request_body_limit_bytes(),
|
||||
minimal_runtime_enabled: default_api_minimal_runtime_enabled(),
|
||||
@@ -1216,6 +1274,19 @@ impl Default for ApiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApiGrayAction {
|
||||
/// Preserve current API behavior for denied source IPs.
|
||||
Api,
|
||||
/// Mimic a plain web endpoint by returning `200 OK` with an empty body.
|
||||
#[serde(rename = "200")]
|
||||
Ok200,
|
||||
/// Drop connection without HTTP response for denied source IPs.
|
||||
#[default]
|
||||
Drop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConntrackMode {
|
||||
@@ -1283,6 +1354,10 @@ pub struct ConntrackControlConfig {
|
||||
#[serde(default = "default_conntrack_control_enabled")]
|
||||
pub inline_conntrack_control: bool,
|
||||
|
||||
/// Tracks whether inline_conntrack_control was explicitly set in config.
|
||||
#[serde(skip)]
|
||||
pub inline_conntrack_control_explicit: bool,
|
||||
|
||||
/// Conntrack mode for listener ingress traffic.
|
||||
#[serde(default)]
|
||||
pub mode: ConntrackMode,
|
||||
@@ -1317,6 +1392,7 @@ impl Default for ConntrackControlConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inline_conntrack_control: default_conntrack_control_enabled(),
|
||||
inline_conntrack_control_explicit: false,
|
||||
mode: ConntrackMode::default(),
|
||||
backend: ConntrackBackend::default(),
|
||||
profile: ConntrackPressureProfile::default(),
|
||||
@@ -1330,6 +1406,8 @@ impl Default for ConntrackControlConfig {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
/// Legacy listener port used for backward compatibility.
|
||||
/// For new configs prefer `[[server.listeners]].port`.
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
@@ -1503,6 +1581,13 @@ pub enum UnknownSniAction {
|
||||
Drop,
|
||||
Mask,
|
||||
Accept,
|
||||
/// Reject the TLS handshake by sending a fatal `unrecognized_name` alert
|
||||
/// (RFC 6066, AlertDescription = 112) before closing the connection.
|
||||
/// Mimics nginx `ssl_reject_handshake on;` behavior on the default vhost —
|
||||
/// the wire response indistinguishable from a stock modern web server
|
||||
/// that simply does not host the requested name.
|
||||
#[serde(rename = "reject_handshake")]
|
||||
RejectHandshake,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -1638,9 +1723,16 @@ pub struct AntiCensorshipConfig {
|
||||
#[serde(default = "default_tls_new_session_tickets")]
|
||||
pub tls_new_session_tickets: u8,
|
||||
|
||||
/// Enable compact ServerHello payload mode.
|
||||
/// When false, FakeTLS always uses full ServerHello payload behavior.
|
||||
/// When true, compact certificate payload mode can be used by TTL policy.
|
||||
#[serde(default = "default_serverhello_compact")]
|
||||
pub serverhello_compact: bool,
|
||||
|
||||
/// TTL in seconds for sending full certificate payload per client IP.
|
||||
/// First client connection per (SNI domain, client IP) gets full cert payload.
|
||||
/// Subsequent handshakes within TTL use compact cert metadata payload.
|
||||
/// Applied only when `serverhello_compact` is enabled.
|
||||
#[serde(default = "default_tls_full_cert_ttl_secs")]
|
||||
pub tls_full_cert_ttl_secs: u64,
|
||||
|
||||
@@ -1683,9 +1775,23 @@ pub struct AntiCensorshipConfig {
|
||||
pub mask_shape_above_cap_blur_max_bytes: usize,
|
||||
|
||||
/// Maximum bytes relayed per direction on unauthenticated masking fallback paths.
|
||||
/// Set to 0 to disable byte cap (unlimited within relay/idle timeouts).
|
||||
#[serde(default = "default_mask_relay_max_bytes")]
|
||||
pub mask_relay_max_bytes: usize,
|
||||
|
||||
/// Wall-clock cap for the full masking relay on non-MTProto fallback paths.
|
||||
/// Raise when the mask target is a long-lived service (e.g. WebSocket).
|
||||
/// Default: 60 000 ms (60 s).
|
||||
#[serde(default = "default_mask_relay_timeout_ms")]
|
||||
pub mask_relay_timeout_ms: u64,
|
||||
|
||||
/// Per-read idle timeout on masking relay and drain paths.
|
||||
/// Limits resource consumption by slow-loris attacks and port scanners.
|
||||
/// A read call stalling beyond this is treated as an abandoned connection.
|
||||
/// Default: 5 000 ms (5 s).
|
||||
#[serde(default = "default_mask_relay_idle_timeout_ms")]
|
||||
pub mask_relay_idle_timeout_ms: u64,
|
||||
|
||||
/// Prefetch timeout (ms) for extending fragmented masking classifier window.
|
||||
#[serde(default = "default_mask_classifier_prefetch_timeout_ms")]
|
||||
pub mask_classifier_prefetch_timeout_ms: u64,
|
||||
@@ -1721,6 +1827,7 @@ impl Default for AntiCensorshipConfig {
|
||||
server_hello_delay_min_ms: default_server_hello_delay_min_ms(),
|
||||
server_hello_delay_max_ms: default_server_hello_delay_max_ms(),
|
||||
tls_new_session_tickets: default_tls_new_session_tickets(),
|
||||
serverhello_compact: default_serverhello_compact(),
|
||||
tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(),
|
||||
alpn_enforce: default_alpn_enforce(),
|
||||
mask_proxy_protocol: 0,
|
||||
@@ -1731,6 +1838,8 @@ impl Default for AntiCensorshipConfig {
|
||||
mask_shape_above_cap_blur: default_mask_shape_above_cap_blur(),
|
||||
mask_shape_above_cap_blur_max_bytes: default_mask_shape_above_cap_blur_max_bytes(),
|
||||
mask_relay_max_bytes: default_mask_relay_max_bytes(),
|
||||
mask_relay_timeout_ms: default_mask_relay_timeout_ms(),
|
||||
mask_relay_idle_timeout_ms: default_mask_relay_idle_timeout_ms(),
|
||||
mask_classifier_prefetch_timeout_ms: default_mask_classifier_prefetch_timeout_ms(),
|
||||
mask_timing_normalization_enabled: default_mask_timing_normalization_enabled(),
|
||||
mask_timing_normalization_floor_ms: default_mask_timing_normalization_floor_ms(),
|
||||
@@ -1763,6 +1872,21 @@ pub struct AccessConfig {
|
||||
#[serde(default)]
|
||||
pub user_data_quota: HashMap<String, u64>,
|
||||
|
||||
/// Per-user transport rate limits in bits-per-second.
|
||||
///
|
||||
/// Each entry supports independent upload (`up_bps`) and download
|
||||
/// (`down_bps`) ceilings. A value of `0` in one direction means
|
||||
/// "unlimited" for that direction.
|
||||
#[serde(default)]
|
||||
pub user_rate_limits: HashMap<String, RateLimitBps>,
|
||||
|
||||
/// Per-CIDR aggregate transport rate limits in bits-per-second.
|
||||
///
|
||||
/// Matching uses longest-prefix-wins semantics. A value of `0` in one
|
||||
/// direction means "unlimited" for that direction.
|
||||
#[serde(default)]
|
||||
pub cidr_rate_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||
|
||||
#[serde(default)]
|
||||
pub user_max_unique_ips: HashMap<String, usize>,
|
||||
|
||||
@@ -1796,6 +1920,8 @@ impl Default for AccessConfig {
|
||||
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
|
||||
user_expirations: HashMap::new(),
|
||||
user_data_quota: HashMap::new(),
|
||||
user_rate_limits: HashMap::new(),
|
||||
cidr_rate_limits: 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(),
|
||||
@@ -1807,6 +1933,14 @@ impl Default for AccessConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RateLimitBps {
|
||||
#[serde(default)]
|
||||
pub up_bps: u64,
|
||||
#[serde(default)]
|
||||
pub down_bps: u64,
|
||||
}
|
||||
|
||||
// ============= Aux Structures =============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -1817,6 +1951,10 @@ pub enum UpstreamType {
|
||||
interface: Option<String>,
|
||||
#[serde(default)]
|
||||
bind_addresses: Option<Vec<String>>,
|
||||
/// Linux-only hard interface pinning via `SO_BINDTODEVICE`.
|
||||
/// Optional alias: `force_bind`.
|
||||
#[serde(default, alias = "force_bind")]
|
||||
bindtodevice: Option<String>,
|
||||
},
|
||||
Socks4 {
|
||||
address: String,
|
||||
@@ -1853,11 +1991,22 @@ pub struct UpstreamConfig {
|
||||
pub scopes: String,
|
||||
#[serde(skip)]
|
||||
pub selected_scope: String,
|
||||
/// Allow IPv4 DC targets for this upstream.
|
||||
/// `None` means auto-detect from runtime connectivity state.
|
||||
#[serde(default)]
|
||||
pub ipv4: Option<bool>,
|
||||
/// Allow IPv6 DC targets for this upstream.
|
||||
/// `None` means auto-detect from runtime connectivity state.
|
||||
#[serde(default)]
|
||||
pub ipv6: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListenerConfig {
|
||||
pub ip: IpAddr,
|
||||
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
/// IP address or hostname to announce in proxy links.
|
||||
/// Takes precedence over `announce_ip` if both are set.
|
||||
#[serde(default)]
|
||||
|
||||
+125
-54
@@ -24,6 +24,13 @@ enum NetfilterBackend {
|
||||
Iptables,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ConntrackRuntimeSupport {
|
||||
netfilter_backend: Option<NetfilterBackend>,
|
||||
has_cap_net_admin: bool,
|
||||
has_conntrack_binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PressureSample {
|
||||
conn_pct: Option<u8>,
|
||||
@@ -56,11 +63,8 @@ pub(crate) fn spawn_conntrack_controller(
|
||||
shared: Arc<ProxySharedState>,
|
||||
) {
|
||||
if !cfg!(target_os = "linux") {
|
||||
let enabled = config_rx
|
||||
.borrow()
|
||||
.server
|
||||
.conntrack_control
|
||||
.inline_conntrack_control;
|
||||
let cfg = config_rx.borrow();
|
||||
let enabled = cfg.server.conntrack_control.inline_conntrack_control;
|
||||
stats.set_conntrack_control_enabled(enabled);
|
||||
stats.set_conntrack_control_available(false);
|
||||
stats.set_conntrack_pressure_active(false);
|
||||
@@ -68,9 +72,14 @@ pub(crate) fn spawn_conntrack_controller(
|
||||
stats.set_conntrack_rule_apply_ok(false);
|
||||
shared.disable_conntrack_close_sender();
|
||||
shared.set_conntrack_pressure_active(false);
|
||||
if enabled {
|
||||
if enabled
|
||||
&& cfg
|
||||
.server
|
||||
.conntrack_control
|
||||
.inline_conntrack_control_explicit
|
||||
{
|
||||
warn!(
|
||||
"conntrack control is configured but unsupported on this OS; disabling runtime worker"
|
||||
"conntrack control explicitly enabled but unsupported on this OS; disabling runtime worker"
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -92,16 +101,17 @@ async fn run_conntrack_controller(
|
||||
let mut cfg = config_rx.borrow().clone();
|
||||
let mut pressure_state = PressureState::new(stats.as_ref());
|
||||
let mut delete_budget_tokens = cfg.server.conntrack_control.delete_budget_per_sec;
|
||||
let mut backend = pick_backend(cfg.server.conntrack_control.backend);
|
||||
let mut runtime_support = probe_runtime_support(cfg.server.conntrack_control.backend);
|
||||
let mut effective_enabled = effective_conntrack_enabled(&cfg, runtime_support);
|
||||
|
||||
apply_runtime_state(
|
||||
stats.as_ref(),
|
||||
shared.as_ref(),
|
||||
&cfg,
|
||||
backend.is_some(),
|
||||
runtime_support,
|
||||
false,
|
||||
);
|
||||
reconcile_rules(&cfg, backend, stats.as_ref()).await;
|
||||
reconcile_rules(&cfg, runtime_support, stats.as_ref()).await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -110,17 +120,18 @@ async fn run_conntrack_controller(
|
||||
break;
|
||||
}
|
||||
cfg = config_rx.borrow_and_update().clone();
|
||||
backend = pick_backend(cfg.server.conntrack_control.backend);
|
||||
runtime_support = probe_runtime_support(cfg.server.conntrack_control.backend);
|
||||
effective_enabled = effective_conntrack_enabled(&cfg, runtime_support);
|
||||
delete_budget_tokens = cfg.server.conntrack_control.delete_budget_per_sec;
|
||||
apply_runtime_state(stats.as_ref(), shared.as_ref(), &cfg, backend.is_some(), pressure_state.active);
|
||||
reconcile_rules(&cfg, backend, stats.as_ref()).await;
|
||||
apply_runtime_state(stats.as_ref(), shared.as_ref(), &cfg, runtime_support, pressure_state.active);
|
||||
reconcile_rules(&cfg, runtime_support, stats.as_ref()).await;
|
||||
}
|
||||
event = close_rx.recv() => {
|
||||
let Some(event) = event else {
|
||||
break;
|
||||
};
|
||||
stats.set_conntrack_event_queue_depth(close_rx.len() as u64);
|
||||
if !cfg.server.conntrack_control.inline_conntrack_control {
|
||||
if !effective_enabled {
|
||||
continue;
|
||||
}
|
||||
if !pressure_state.active {
|
||||
@@ -156,6 +167,7 @@ async fn run_conntrack_controller(
|
||||
stats.as_ref(),
|
||||
shared.as_ref(),
|
||||
&cfg,
|
||||
effective_enabled,
|
||||
&sample,
|
||||
&mut pressure_state,
|
||||
);
|
||||
@@ -175,20 +187,30 @@ fn apply_runtime_state(
|
||||
stats: &Stats,
|
||||
shared: &ProxySharedState,
|
||||
cfg: &ProxyConfig,
|
||||
backend_available: bool,
|
||||
runtime_support: ConntrackRuntimeSupport,
|
||||
pressure_active: bool,
|
||||
) {
|
||||
let enabled = cfg.server.conntrack_control.inline_conntrack_control;
|
||||
let available = enabled && backend_available && has_cap_net_admin();
|
||||
if enabled && !available {
|
||||
let available = effective_conntrack_enabled(cfg, runtime_support);
|
||||
if enabled
|
||||
&& !available
|
||||
&& cfg
|
||||
.server
|
||||
.conntrack_control
|
||||
.inline_conntrack_control_explicit
|
||||
{
|
||||
warn!(
|
||||
"conntrack control enabled but unavailable (missing CAP_NET_ADMIN or backend binaries)"
|
||||
has_cap_net_admin = runtime_support.has_cap_net_admin,
|
||||
backend_available = runtime_support.netfilter_backend.is_some(),
|
||||
conntrack_binary_available = runtime_support.has_conntrack_binary,
|
||||
configured_backend = ?cfg.server.conntrack_control.backend,
|
||||
"conntrack control explicitly enabled but unavailable; disabling runtime features"
|
||||
);
|
||||
}
|
||||
stats.set_conntrack_control_enabled(enabled);
|
||||
stats.set_conntrack_control_available(available);
|
||||
shared.set_conntrack_pressure_active(enabled && pressure_active);
|
||||
stats.set_conntrack_pressure_active(enabled && pressure_active);
|
||||
shared.set_conntrack_pressure_active(available && pressure_active);
|
||||
stats.set_conntrack_pressure_active(available && pressure_active);
|
||||
}
|
||||
|
||||
fn collect_pressure_sample(
|
||||
@@ -228,10 +250,11 @@ fn update_pressure_state(
|
||||
stats: &Stats,
|
||||
shared: &ProxySharedState,
|
||||
cfg: &ProxyConfig,
|
||||
effective_enabled: bool,
|
||||
sample: &PressureSample,
|
||||
state: &mut PressureState,
|
||||
) {
|
||||
if !cfg.server.conntrack_control.inline_conntrack_control {
|
||||
if !effective_enabled {
|
||||
if state.active {
|
||||
state.active = false;
|
||||
state.low_streak = 0;
|
||||
@@ -285,22 +308,26 @@ fn update_pressure_state(
|
||||
state.low_streak = 0;
|
||||
}
|
||||
|
||||
async fn reconcile_rules(cfg: &ProxyConfig, backend: Option<NetfilterBackend>, stats: &Stats) {
|
||||
async fn reconcile_rules(
|
||||
cfg: &ProxyConfig,
|
||||
runtime_support: ConntrackRuntimeSupport,
|
||||
stats: &Stats,
|
||||
) {
|
||||
if !cfg.server.conntrack_control.inline_conntrack_control {
|
||||
clear_notrack_rules_all_backends().await;
|
||||
stats.set_conntrack_rule_apply_ok(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if !has_cap_net_admin() {
|
||||
if !effective_conntrack_enabled(cfg, runtime_support) {
|
||||
clear_notrack_rules_all_backends().await;
|
||||
stats.set_conntrack_rule_apply_ok(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(backend) = backend else {
|
||||
stats.set_conntrack_rule_apply_ok(false);
|
||||
return;
|
||||
};
|
||||
let backend = runtime_support
|
||||
.netfilter_backend
|
||||
.expect("netfilter backend must be available for effective conntrack control");
|
||||
|
||||
let apply_result = match backend {
|
||||
NetfilterBackend::Nftables => apply_nft_rules(cfg).await,
|
||||
@@ -315,6 +342,24 @@ async fn reconcile_rules(cfg: &ProxyConfig, backend: Option<NetfilterBackend>, s
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_runtime_support(configured_backend: ConntrackBackend) -> ConntrackRuntimeSupport {
|
||||
ConntrackRuntimeSupport {
|
||||
netfilter_backend: pick_backend(configured_backend),
|
||||
has_cap_net_admin: has_cap_net_admin(),
|
||||
has_conntrack_binary: command_exists("conntrack"),
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_conntrack_enabled(
|
||||
cfg: &ProxyConfig,
|
||||
runtime_support: ConntrackRuntimeSupport,
|
||||
) -> bool {
|
||||
cfg.server.conntrack_control.inline_conntrack_control
|
||||
&& runtime_support.has_cap_net_admin
|
||||
&& runtime_support.netfilter_backend.is_some()
|
||||
&& runtime_support.has_conntrack_binary
|
||||
}
|
||||
|
||||
fn pick_backend(configured: ConntrackBackend) -> Option<NetfilterBackend> {
|
||||
match configured {
|
||||
ConntrackBackend::Auto => {
|
||||
@@ -343,15 +388,28 @@ fn command_exists(binary: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn notrack_targets(cfg: &ProxyConfig) -> (Vec<Option<IpAddr>>, Vec<Option<IpAddr>>) {
|
||||
fn listener_port_set(cfg: &ProxyConfig) -> Vec<u16> {
|
||||
let mut ports: BTreeSet<u16> = BTreeSet::new();
|
||||
if cfg.server.listeners.is_empty() {
|
||||
ports.insert(cfg.server.port);
|
||||
} else {
|
||||
for listener in &cfg.server.listeners {
|
||||
ports.insert(listener.port.unwrap_or(cfg.server.port));
|
||||
}
|
||||
}
|
||||
ports.into_iter().collect()
|
||||
}
|
||||
|
||||
fn notrack_targets(cfg: &ProxyConfig) -> (Vec<(Option<IpAddr>, u16)>, Vec<(Option<IpAddr>, u16)>) {
|
||||
let mode = cfg.server.conntrack_control.mode;
|
||||
let mut v4_targets: BTreeSet<Option<IpAddr>> = BTreeSet::new();
|
||||
let mut v6_targets: BTreeSet<Option<IpAddr>> = BTreeSet::new();
|
||||
let mut v4_targets: BTreeSet<(Option<IpAddr>, u16)> = BTreeSet::new();
|
||||
let mut v6_targets: BTreeSet<(Option<IpAddr>, u16)> = BTreeSet::new();
|
||||
|
||||
match mode {
|
||||
ConntrackMode::Tracked => {}
|
||||
ConntrackMode::Notrack => {
|
||||
if cfg.server.listeners.is_empty() {
|
||||
let port = cfg.server.port;
|
||||
if let Some(ipv4) = cfg
|
||||
.server
|
||||
.listen_addr_ipv4
|
||||
@@ -359,9 +417,9 @@ fn notrack_targets(cfg: &ProxyConfig) -> (Vec<Option<IpAddr>>, Vec<Option<IpAddr
|
||||
.and_then(|s| s.parse::<IpAddr>().ok())
|
||||
{
|
||||
if ipv4.is_unspecified() {
|
||||
v4_targets.insert(None);
|
||||
v4_targets.insert((None, port));
|
||||
} else {
|
||||
v4_targets.insert(Some(ipv4));
|
||||
v4_targets.insert((Some(ipv4), port));
|
||||
}
|
||||
}
|
||||
if let Some(ipv6) = cfg
|
||||
@@ -371,33 +429,39 @@ fn notrack_targets(cfg: &ProxyConfig) -> (Vec<Option<IpAddr>>, Vec<Option<IpAddr
|
||||
.and_then(|s| s.parse::<IpAddr>().ok())
|
||||
{
|
||||
if ipv6.is_unspecified() {
|
||||
v6_targets.insert(None);
|
||||
v6_targets.insert((None, port));
|
||||
} else {
|
||||
v6_targets.insert(Some(ipv6));
|
||||
v6_targets.insert((Some(ipv6), port));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for listener in &cfg.server.listeners {
|
||||
let port = listener.port.unwrap_or(cfg.server.port);
|
||||
if listener.ip.is_ipv4() {
|
||||
if listener.ip.is_unspecified() {
|
||||
v4_targets.insert(None);
|
||||
v4_targets.insert((None, port));
|
||||
} else {
|
||||
v4_targets.insert(Some(listener.ip));
|
||||
v4_targets.insert((Some(listener.ip), port));
|
||||
}
|
||||
} else if listener.ip.is_unspecified() {
|
||||
v6_targets.insert(None);
|
||||
v6_targets.insert((None, port));
|
||||
} else {
|
||||
v6_targets.insert(Some(listener.ip));
|
||||
v6_targets.insert((Some(listener.ip), port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConntrackMode::Hybrid => {
|
||||
let ports = listener_port_set(cfg);
|
||||
for ip in &cfg.server.conntrack_control.hybrid_listener_ips {
|
||||
if ip.is_ipv4() {
|
||||
v4_targets.insert(Some(*ip));
|
||||
for port in &ports {
|
||||
v4_targets.insert((Some(*ip), *port));
|
||||
}
|
||||
} else {
|
||||
v6_targets.insert(Some(*ip));
|
||||
for port in &ports {
|
||||
v6_targets.insert((Some(*ip), *port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,19 +486,19 @@ async fn apply_nft_rules(cfg: &ProxyConfig) -> Result<(), String> {
|
||||
|
||||
let (v4_targets, v6_targets) = notrack_targets(cfg);
|
||||
let mut rules = Vec::new();
|
||||
for ip in v4_targets {
|
||||
for (ip, port) in v4_targets {
|
||||
let rule = if let Some(ip) = ip {
|
||||
format!("tcp dport {} ip daddr {} notrack", cfg.server.port, ip)
|
||||
format!("tcp dport {} ip daddr {} notrack", port, ip)
|
||||
} else {
|
||||
format!("tcp dport {} notrack", cfg.server.port)
|
||||
format!("tcp dport {} notrack", port)
|
||||
};
|
||||
rules.push(rule);
|
||||
}
|
||||
for ip in v6_targets {
|
||||
for (ip, port) in v6_targets {
|
||||
let rule = if let Some(ip) = ip {
|
||||
format!("tcp dport {} ip6 daddr {} notrack", cfg.server.port, ip)
|
||||
format!("tcp dport {} ip6 daddr {} notrack", port, ip)
|
||||
} else {
|
||||
format!("tcp dport {} notrack", cfg.server.port)
|
||||
format!("tcp dport {} notrack", port)
|
||||
};
|
||||
rules.push(rule);
|
||||
}
|
||||
@@ -498,7 +562,7 @@ async fn apply_iptables_rules_for_binary(
|
||||
|
||||
let (v4_targets, v6_targets) = notrack_targets(cfg);
|
||||
let selected = if ipv4 { v4_targets } else { v6_targets };
|
||||
for ip in selected {
|
||||
for (ip, port) in selected {
|
||||
let mut args = vec![
|
||||
"-t".to_string(),
|
||||
"raw".to_string(),
|
||||
@@ -507,7 +571,7 @@ async fn apply_iptables_rules_for_binary(
|
||||
"-p".to_string(),
|
||||
"tcp".to_string(),
|
||||
"--dport".to_string(),
|
||||
cfg.server.port.to_string(),
|
||||
port.to_string(),
|
||||
];
|
||||
if let Some(ip) = ip {
|
||||
args.push("-d".to_string());
|
||||
@@ -691,7 +755,7 @@ mod tests {
|
||||
me_queue_pressure_delta: 0,
|
||||
};
|
||||
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, &sample, &mut state);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, true, &sample, &mut state);
|
||||
|
||||
assert!(state.active);
|
||||
assert!(shared.conntrack_pressure_active());
|
||||
@@ -712,7 +776,14 @@ mod tests {
|
||||
accept_timeout_delta: 0,
|
||||
me_queue_pressure_delta: 0,
|
||||
};
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, &high_sample, &mut state);
|
||||
update_pressure_state(
|
||||
&stats,
|
||||
shared.as_ref(),
|
||||
&cfg,
|
||||
true,
|
||||
&high_sample,
|
||||
&mut state,
|
||||
);
|
||||
assert!(state.active);
|
||||
|
||||
let low_sample = PressureSample {
|
||||
@@ -721,11 +792,11 @@ mod tests {
|
||||
accept_timeout_delta: 0,
|
||||
me_queue_pressure_delta: 0,
|
||||
};
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, &low_sample, &mut state);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, true, &low_sample, &mut state);
|
||||
assert!(state.active);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, &low_sample, &mut state);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, true, &low_sample, &mut state);
|
||||
assert!(state.active);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, &low_sample, &mut state);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, true, &low_sample, &mut state);
|
||||
|
||||
assert!(!state.active);
|
||||
assert!(!shared.conntrack_pressure_active());
|
||||
@@ -746,7 +817,7 @@ mod tests {
|
||||
me_queue_pressure_delta: 10,
|
||||
};
|
||||
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, &sample, &mut state);
|
||||
update_pressure_state(&stats, shared.as_ref(), &cfg, false, &sample, &mut state);
|
||||
|
||||
assert!(!state.active);
|
||||
assert!(!shared.conntrack_pressure_active());
|
||||
|
||||
+26
-4
@@ -8,6 +8,7 @@ use std::io::{self, Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nix::errno::Errno;
|
||||
use nix::fcntl::{Flock, FlockArg};
|
||||
use nix::unistd::{self, ForkResult, Gid, Pid, Uid, chdir, close, fork, getpid, setsid};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -157,15 +158,15 @@ fn redirect_stdio_to_devnull() -> Result<(), DaemonError> {
|
||||
unsafe {
|
||||
// Redirect stdin (fd 0)
|
||||
if libc::dup2(devnull_fd, 0) < 0 {
|
||||
return Err(DaemonError::RedirectFailed(nix::errno::Errno::last()));
|
||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||
}
|
||||
// Redirect stdout (fd 1)
|
||||
if libc::dup2(devnull_fd, 1) < 0 {
|
||||
return Err(DaemonError::RedirectFailed(nix::errno::Errno::last()));
|
||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||
}
|
||||
// Redirect stderr (fd 2)
|
||||
if libc::dup2(devnull_fd, 2) < 0 {
|
||||
return Err(DaemonError::RedirectFailed(nix::errno::Errno::last()));
|
||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +338,27 @@ fn is_process_running(pid: i32) -> bool {
|
||||
nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok()
|
||||
}
|
||||
|
||||
// macOS gates nix::unistd::setgroups differently in the current dependency set,
|
||||
// so call libc directly there while preserving the original nix path elsewhere.
|
||||
fn set_supplementary_groups(gid: Gid) -> Result<(), nix::Error> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let groups = [gid.as_raw()];
|
||||
let rc = unsafe {
|
||||
libc::setgroups(
|
||||
i32::try_from(groups.len()).expect("single supplementary group must fit in c_int"),
|
||||
groups.as_ptr(),
|
||||
)
|
||||
};
|
||||
if rc == 0 { Ok(()) } else { Err(Errno::last()) }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
unistd::setgroups(&[gid])
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops privileges to the specified user and group.
|
||||
///
|
||||
/// This should be called after binding privileged ports but before entering
|
||||
@@ -368,7 +390,7 @@ pub fn drop_privileges(
|
||||
|
||||
if let Some(gid) = target_gid {
|
||||
unistd::setgid(gid).map_err(DaemonError::PrivilegeDrop)?;
|
||||
unistd::setgroups(&[gid]).map_err(DaemonError::PrivilegeDrop)?;
|
||||
set_supplementary_groups(gid).map_err(DaemonError::PrivilegeDrop)?;
|
||||
info!(gid = gid.as_raw(), "Dropped group privileges");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum HealthcheckMode {
|
||||
Liveness,
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl HealthcheckMode {
|
||||
pub(crate) fn from_cli_arg(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"liveness" => Some(Self::Liveness),
|
||||
"ready" => Some(Self::Ready),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_path(self) -> &'static str {
|
||||
match self {
|
||||
Self::Liveness => "/v1/health",
|
||||
Self::Ready => "/v1/health/ready",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run(config_path: &str, mode: HealthcheckMode) -> i32 {
|
||||
match run_inner(config_path, mode) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
eprintln!("[telemt] healthcheck failed: {error}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_inner(config_path: &str, mode: HealthcheckMode) -> Result<(), String> {
|
||||
let config =
|
||||
ProxyConfig::load(config_path).map_err(|error| format!("config load failed: {error}"))?;
|
||||
let api_cfg = &config.server.api;
|
||||
if !api_cfg.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listen: SocketAddr = api_cfg
|
||||
.listen
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid API listen address: {}", api_cfg.listen))?;
|
||||
if listen.port() == 0 {
|
||||
return Err("API listen port is 0".to_string());
|
||||
}
|
||||
let target = probe_target(listen);
|
||||
|
||||
let mut stream = TcpStream::connect_timeout(&target, Duration::from_secs(2))
|
||||
.map_err(|error| format!("connect {target} failed: {error}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.map_err(|error| format!("set read timeout failed: {error}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||
.map_err(|error| format!("set write timeout failed: {error}"))?;
|
||||
|
||||
let request = build_request(target, mode.request_path(), &api_cfg.auth_header);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|error| format!("request write failed: {error}"))?;
|
||||
stream
|
||||
.flush()
|
||||
.map_err(|error| format!("request flush failed: {error}"))?;
|
||||
|
||||
let mut raw_response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut raw_response)
|
||||
.map_err(|error| format!("response read failed: {error}"))?;
|
||||
let response =
|
||||
String::from_utf8(raw_response).map_err(|_| "response is not valid UTF-8".to_string())?;
|
||||
|
||||
let (status_code, body) = split_response(&response)?;
|
||||
if status_code != 200 {
|
||||
return Err(format!("HTTP status {status_code}"));
|
||||
}
|
||||
|
||||
validate_payload(mode, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn probe_target(listen: SocketAddr) -> SocketAddr {
|
||||
match listen {
|
||||
SocketAddr::V4(addr) => {
|
||||
let ip = if addr.ip().is_unspecified() {
|
||||
Ipv4Addr::LOCALHOST
|
||||
} else {
|
||||
*addr.ip()
|
||||
};
|
||||
SocketAddr::from((ip, addr.port()))
|
||||
}
|
||||
SocketAddr::V6(addr) => {
|
||||
let ip = if addr.ip().is_unspecified() {
|
||||
Ipv6Addr::LOCALHOST
|
||||
} else {
|
||||
*addr.ip()
|
||||
};
|
||||
SocketAddr::from((ip, addr.port()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(target: SocketAddr, path: &str, auth_header: &str) -> String {
|
||||
let mut request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n",
|
||||
target
|
||||
);
|
||||
if !auth_header.is_empty() {
|
||||
request.push_str("Authorization: ");
|
||||
request.push_str(auth_header);
|
||||
request.push_str("\r\n");
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
request
|
||||
}
|
||||
|
||||
fn split_response(response: &str) -> Result<(u16, &str), String> {
|
||||
let header_end = response
|
||||
.find("\r\n\r\n")
|
||||
.ok_or_else(|| "invalid HTTP response headers".to_string())?;
|
||||
let header = &response[..header_end];
|
||||
let body = &response[header_end + 4..];
|
||||
let status_line = header
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| "missing HTTP status line".to_string())?;
|
||||
let status_code = parse_status_code(status_line)?;
|
||||
Ok((status_code, body))
|
||||
}
|
||||
|
||||
fn parse_status_code(status_line: &str) -> Result<u16, String> {
|
||||
let mut parts = status_line.split_whitespace();
|
||||
let version = parts
|
||||
.next()
|
||||
.ok_or_else(|| "missing HTTP version".to_string())?;
|
||||
if !version.starts_with("HTTP/") {
|
||||
return Err(format!("invalid HTTP status line: {status_line}"));
|
||||
}
|
||||
let code = parts
|
||||
.next()
|
||||
.ok_or_else(|| "missing HTTP status code".to_string())?;
|
||||
code.parse::<u16>()
|
||||
.map_err(|_| format!("invalid HTTP status code: {code}"))
|
||||
}
|
||||
|
||||
fn validate_payload(mode: HealthcheckMode, body: &str) -> Result<(), String> {
|
||||
let payload: Value =
|
||||
serde_json::from_str(body).map_err(|_| "response body is not valid JSON".to_string())?;
|
||||
if payload.get("ok").and_then(Value::as_bool) != Some(true) {
|
||||
return Err("response JSON has ok=false".to_string());
|
||||
}
|
||||
|
||||
let data = payload
|
||||
.get("data")
|
||||
.ok_or_else(|| "response JSON has no data field".to_string())?;
|
||||
match mode {
|
||||
HealthcheckMode::Liveness => {
|
||||
if data.get("status").and_then(Value::as_str) != Some("ok") {
|
||||
return Err("liveness status is not ok".to_string());
|
||||
}
|
||||
}
|
||||
HealthcheckMode::Ready => {
|
||||
if data.get("ready").and_then(Value::as_bool) != Some(true) {
|
||||
return Err("readiness flag is false".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HealthcheckMode, parse_status_code, split_response, validate_payload};
|
||||
|
||||
#[test]
|
||||
fn parse_status_code_reads_http_200() {
|
||||
let status = parse_status_code("HTTP/1.1 200 OK").expect("must parse status");
|
||||
assert_eq!(status, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_response_extracts_status_and_body() {
|
||||
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}";
|
||||
let (status, body) = split_response(response).expect("must split response");
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(body, "{\"ok\":true}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_payload_accepts_liveness_contract() {
|
||||
let body = "{\"ok\":true,\"data\":{\"status\":\"ok\"}}";
|
||||
validate_payload(HealthcheckMode::Liveness, body).expect("liveness payload must pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_payload_rejects_not_ready() {
|
||||
let body = "{\"ok\":true,\"data\":{\"ready\":false}}";
|
||||
let result = validate_payload(HealthcheckMode::Ready, body);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
+137
-2
@@ -231,7 +231,11 @@ fn print_help() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_runtime_config_path;
|
||||
use super::{
|
||||
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
||||
resolve_runtime_config_path,
|
||||
};
|
||||
use crate::error::{ProxyError, StreamError};
|
||||
|
||||
#[test]
|
||||
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
|
||||
@@ -299,6 +303,81 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir(&startup_cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_handshake_eof_matches_connection_reset() {
|
||||
let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
|
||||
assert!(is_expected_handshake_eof(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_handshake_eof_matches_stream_io_unexpected_eof() {
|
||||
let err = ProxyError::Stream(StreamError::Io(std::io::Error::from(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
)));
|
||||
assert!(is_expected_handshake_eof(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_description_is_human_readable_for_all_peer_close_kinds() {
|
||||
let cases = [
|
||||
(
|
||||
std::io::ErrorKind::ConnectionReset,
|
||||
"Peer reset TCP connection (RST)",
|
||||
),
|
||||
(
|
||||
std::io::ErrorKind::ConnectionAborted,
|
||||
"Peer aborted TCP connection during transport",
|
||||
),
|
||||
(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"Peer closed write side (broken pipe)",
|
||||
),
|
||||
(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"Socket was already closed by peer",
|
||||
),
|
||||
];
|
||||
|
||||
for (kind, expected) in cases {
|
||||
let err = ProxyError::Io(std::io::Error::from(kind));
|
||||
assert_eq!(peer_close_description(&err), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_close_description_is_human_readable_for_all_expected_kinds() {
|
||||
let cases = [
|
||||
(
|
||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)),
|
||||
"Peer closed before sending full 64-byte MTProto handshake",
|
||||
),
|
||||
(
|
||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
|
||||
"Peer reset TCP connection during initial MTProto handshake",
|
||||
),
|
||||
(
|
||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)),
|
||||
"Peer aborted TCP connection during initial MTProto handshake",
|
||||
),
|
||||
(
|
||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
|
||||
"Peer closed write side before MTProto handshake completed",
|
||||
),
|
||||
(
|
||||
ProxyError::Io(std::io::Error::from(std::io::ErrorKind::NotConnected)),
|
||||
"Handshake socket was already closed by peer",
|
||||
),
|
||||
(
|
||||
ProxyError::Stream(StreamError::UnexpectedEof),
|
||||
"Peer closed before sending full 64-byte MTProto handshake",
|
||||
),
|
||||
];
|
||||
|
||||
for (err, expected) in cases {
|
||||
assert_eq!(expected_handshake_close_description(&err), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
||||
@@ -428,7 +507,63 @@ pub(crate) async fn wait_until_admission_open(admission_rx: &mut watch::Receiver
|
||||
}
|
||||
|
||||
pub(crate) fn is_expected_handshake_eof(err: &crate::error::ProxyError) -> bool {
|
||||
err.to_string().contains("expected 64 bytes, got 0")
|
||||
expected_handshake_close_description(err).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn peer_close_description(err: &crate::error::ProxyError) -> Option<&'static str> {
|
||||
fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> {
|
||||
match kind {
|
||||
std::io::ErrorKind::ConnectionReset => Some("Peer reset TCP connection (RST)"),
|
||||
std::io::ErrorKind::ConnectionAborted => {
|
||||
Some("Peer aborted TCP connection during transport")
|
||||
}
|
||||
std::io::ErrorKind::BrokenPipe => Some("Peer closed write side (broken pipe)"),
|
||||
std::io::ErrorKind::NotConnected => Some("Socket was already closed by peer"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
match err {
|
||||
crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()),
|
||||
crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => {
|
||||
from_kind(ioe.kind())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expected_handshake_close_description(
|
||||
err: &crate::error::ProxyError,
|
||||
) -> Option<&'static str> {
|
||||
fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> {
|
||||
match kind {
|
||||
std::io::ErrorKind::UnexpectedEof => {
|
||||
Some("Peer closed before sending full 64-byte MTProto handshake")
|
||||
}
|
||||
std::io::ErrorKind::ConnectionReset => {
|
||||
Some("Peer reset TCP connection during initial MTProto handshake")
|
||||
}
|
||||
std::io::ErrorKind::ConnectionAborted => {
|
||||
Some("Peer aborted TCP connection during initial MTProto handshake")
|
||||
}
|
||||
std::io::ErrorKind::BrokenPipe => {
|
||||
Some("Peer closed write side before MTProto handshake completed")
|
||||
}
|
||||
std::io::ErrorKind::NotConnected => Some("Handshake socket was already closed by peer"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
match err {
|
||||
crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()),
|
||||
crate::error::ProxyError::Stream(crate::error::StreamError::UnexpectedEof) => {
|
||||
Some("Peer closed before sending full 64-byte MTProto handshake")
|
||||
}
|
||||
crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => {
|
||||
from_kind(ioe.kind())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_startup_proxy_config_snapshot(
|
||||
|
||||
+69
-39
@@ -9,7 +9,7 @@ use tokio::net::UnixListener;
|
||||
use tokio::sync::{Semaphore, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::{ProxyConfig, RstOnCloseMode};
|
||||
use crate::crypto::SecureRandom;
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::proxy::ClientHandler;
|
||||
@@ -21,15 +21,32 @@ use crate::stats::{ReplayChecker, Stats};
|
||||
use crate::stream::BufferPool;
|
||||
use crate::tls_front::TlsFrontCache;
|
||||
use crate::transport::middle_proxy::MePool;
|
||||
use crate::transport::socket::set_linger_zero;
|
||||
use crate::transport::{ListenOptions, UpstreamManager, create_listener, find_listener_processes};
|
||||
|
||||
use super::helpers::{is_expected_handshake_eof, print_proxy_links};
|
||||
use super::helpers::{
|
||||
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
||||
print_proxy_links,
|
||||
};
|
||||
|
||||
pub(crate) struct BoundListeners {
|
||||
pub(crate) listeners: Vec<(TcpListener, bool)>,
|
||||
pub(crate) has_unix_listener: bool,
|
||||
}
|
||||
|
||||
fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, config: &ProxyConfig) -> u16 {
|
||||
listener.port.unwrap_or(config.server.port)
|
||||
}
|
||||
|
||||
fn default_link_port(config: &ProxyConfig) -> u16 {
|
||||
config
|
||||
.server
|
||||
.listeners
|
||||
.first()
|
||||
.and_then(|listener| listener.port)
|
||||
.unwrap_or(config.server.port)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn bind_listeners(
|
||||
config: &Arc<ProxyConfig>,
|
||||
@@ -62,7 +79,8 @@ pub(crate) async fn bind_listeners(
|
||||
let mut listeners = Vec::new();
|
||||
|
||||
for listener_conf in &config.server.listeners {
|
||||
let addr = SocketAddr::new(listener_conf.ip, config.server.port);
|
||||
let listener_port = listener_port_or_legacy(listener_conf, config);
|
||||
let addr = SocketAddr::new(listener_conf.ip, listener_port);
|
||||
if addr.is_ipv4() && !decision_ipv4_dc {
|
||||
warn!(%addr, "Skipping IPv4 listener: IPv4 disabled by [network]");
|
||||
continue;
|
||||
@@ -105,11 +123,7 @@ pub(crate) async fn bind_listeners(
|
||||
if config.general.links.public_host.is_none()
|
||||
&& !config.general.links.show.is_empty()
|
||||
{
|
||||
let link_port = config
|
||||
.general
|
||||
.links
|
||||
.public_port
|
||||
.unwrap_or(config.server.port);
|
||||
let link_port = config.general.links.public_port.unwrap_or(listener_port);
|
||||
print_proxy_links(&public_host, link_port, config);
|
||||
}
|
||||
|
||||
@@ -157,7 +171,7 @@ pub(crate) async fn bind_listeners(
|
||||
.general
|
||||
.links
|
||||
.public_port
|
||||
.unwrap_or(config.server.port),
|
||||
.unwrap_or(default_link_port(config)),
|
||||
)
|
||||
} else {
|
||||
let ip = detected_ip_v4.or(detected_ip_v6).map(|ip| ip.to_string());
|
||||
@@ -172,7 +186,7 @@ pub(crate) async fn bind_listeners(
|
||||
.general
|
||||
.links
|
||||
.public_port
|
||||
.unwrap_or(config.server.port),
|
||||
.unwrap_or(default_link_port(config)),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -380,6 +394,15 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, peer_addr)) => {
|
||||
let rst_mode = config_rx.borrow().general.rst_on_close;
|
||||
#[cfg(unix)]
|
||||
let raw_fd = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
stream.as_raw_fd()
|
||||
};
|
||||
if matches!(rst_mode, RstOnCloseMode::Errors | RstOnCloseMode::Always) {
|
||||
let _ = set_linger_zero(&stream);
|
||||
}
|
||||
if !*admission_rx_tcp.borrow() {
|
||||
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
|
||||
drop(stream);
|
||||
@@ -454,6 +477,9 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
shared,
|
||||
proxy_protocol_enabled,
|
||||
real_peer_report_for_handler,
|
||||
#[cfg(unix)]
|
||||
raw_fd,
|
||||
rst_mode,
|
||||
)
|
||||
.run()
|
||||
.await
|
||||
@@ -462,29 +488,9 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
Ok(guard) => *guard,
|
||||
Err(_) => None,
|
||||
};
|
||||
let peer_closed = matches!(
|
||||
&e,
|
||||
crate::error::ProxyError::Io(ioe)
|
||||
if matches!(
|
||||
ioe.kind(),
|
||||
std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::NotConnected
|
||||
)
|
||||
) || matches!(
|
||||
&e,
|
||||
crate::error::ProxyError::Stream(
|
||||
crate::error::StreamError::Io(ioe)
|
||||
)
|
||||
if matches!(
|
||||
ioe.kind(),
|
||||
std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::NotConnected
|
||||
)
|
||||
);
|
||||
let peer_close_reason = peer_close_description(&e);
|
||||
let handshake_close_reason =
|
||||
expected_handshake_close_description(&e);
|
||||
|
||||
let me_closed = matches!(
|
||||
&e,
|
||||
@@ -495,12 +501,23 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
crate::error::ProxyError::Proxy(msg) if msg == ROUTE_SWITCH_ERROR_MSG
|
||||
);
|
||||
|
||||
match (peer_closed, me_closed) {
|
||||
(true, _) => {
|
||||
match (peer_close_reason, me_closed) {
|
||||
(Some(reason), _) => {
|
||||
if let Some(real_peer) = real_peer {
|
||||
debug!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed by client");
|
||||
debug!(
|
||||
peer = %peer_addr,
|
||||
real_peer = %real_peer,
|
||||
error = %e,
|
||||
close_reason = reason,
|
||||
"Connection closed by peer"
|
||||
);
|
||||
} else {
|
||||
debug!(peer = %peer_addr, error = %e, "Connection closed by client");
|
||||
debug!(
|
||||
peer = %peer_addr,
|
||||
error = %e,
|
||||
close_reason = reason,
|
||||
"Connection closed by peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
(_, true) => {
|
||||
@@ -518,10 +535,23 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
}
|
||||
}
|
||||
_ if is_expected_handshake_eof(&e) => {
|
||||
let reason = handshake_close_reason
|
||||
.unwrap_or("Peer closed during initial handshake");
|
||||
if let Some(real_peer) = real_peer {
|
||||
info!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed during initial handshake");
|
||||
info!(
|
||||
peer = %peer_addr,
|
||||
real_peer = %real_peer,
|
||||
error = %e,
|
||||
close_reason = reason,
|
||||
"Connection closed during initial handshake"
|
||||
);
|
||||
} else {
|
||||
info!(peer = %peer_addr, error = %e, "Connection closed during initial handshake");
|
||||
info!(
|
||||
peer = %peer_addr,
|
||||
error = %e,
|
||||
close_reason = reason,
|
||||
"Connection closed during initial handshake"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -66,6 +66,7 @@ pub(crate) async fn initialize_me_pool(
|
||||
match crate::transport::middle_proxy::fetch_proxy_secret_with_upstream(
|
||||
proxy_secret_path,
|
||||
config.general.proxy_secret_len_max,
|
||||
config.general.proxy_secret_url.as_deref(),
|
||||
Some(upstream_manager.clone()),
|
||||
)
|
||||
.await
|
||||
@@ -126,7 +127,11 @@ pub(crate) async fn initialize_me_pool(
|
||||
.set_me_status(StartupMeStatus::Initializing, COMPONENT_ME_PROXY_CONFIG_V4)
|
||||
.await;
|
||||
let cfg_v4 = load_startup_proxy_config_snapshot(
|
||||
"https://core.telegram.org/getProxyConfig",
|
||||
config
|
||||
.general
|
||||
.proxy_config_v4_url
|
||||
.as_deref()
|
||||
.unwrap_or("https://core.telegram.org/getProxyConfig"),
|
||||
config.general.proxy_config_v4_cache_path.as_deref(),
|
||||
me2dc_fallback,
|
||||
"getProxyConfig",
|
||||
@@ -158,7 +163,11 @@ pub(crate) async fn initialize_me_pool(
|
||||
.set_me_status(StartupMeStatus::Initializing, COMPONENT_ME_PROXY_CONFIG_V6)
|
||||
.await;
|
||||
let cfg_v6 = load_startup_proxy_config_snapshot(
|
||||
"https://core.telegram.org/getProxyConfigV6",
|
||||
config
|
||||
.general
|
||||
.proxy_config_v6_url
|
||||
.as_deref()
|
||||
.unwrap_or("https://core.telegram.org/getProxyConfigV6"),
|
||||
config.general.proxy_config_v6_cache_path.as_deref(),
|
||||
me2dc_fallback,
|
||||
"getProxyConfigV6",
|
||||
@@ -268,6 +277,8 @@ pub(crate) async fn initialize_me_pool(
|
||||
config.general.me_socks_kdf_policy,
|
||||
config.general.me_writer_cmd_channel_capacity,
|
||||
config.general.me_route_channel_capacity,
|
||||
config.general.me_route_backpressure_enabled,
|
||||
config.general.me_route_fairshare_enabled,
|
||||
config.general.me_route_backpressure_base_timeout_ms,
|
||||
config.general.me_route_backpressure_high_timeout_ms,
|
||||
config.general.me_route_backpressure_high_watermark_pct,
|
||||
|
||||
+48
-28
@@ -81,23 +81,11 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_inner(
|
||||
daemon_opts: DaemonOptions,
|
||||
// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound
|
||||
// (for privilege drop); it is a no-op on other platforms.
|
||||
async fn run_telemt_core(
|
||||
drop_after_bind: impl FnOnce(),
|
||||
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
// Acquire PID file if daemonizing or if explicitly requested
|
||||
// Keep it alive until shutdown (underscore prefix = intentionally kept for RAII cleanup)
|
||||
let _pid_file = if daemon_opts.daemonize || daemon_opts.pid_file.is_some() {
|
||||
let mut pf = PidFile::new(daemon_opts.pid_file_path());
|
||||
if let Err(e) = pf.acquire() {
|
||||
eprintln!("[telemt] {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Some(pf)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let process_started_at = Instant::now();
|
||||
let process_started_at_epoch_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -676,6 +664,11 @@ async fn run_inner(
|
||||
));
|
||||
|
||||
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
|
||||
let shared_state = ProxySharedState::new();
|
||||
shared_state.traffic_limiter.apply_policy(
|
||||
config.access.user_rate_limits.clone(),
|
||||
config.access.cidr_rate_limits.clone(),
|
||||
);
|
||||
|
||||
connectivity::run_startup_connectivity(
|
||||
&config,
|
||||
@@ -707,6 +700,7 @@ async fn run_inner(
|
||||
beobachten.clone(),
|
||||
api_config_tx.clone(),
|
||||
me_pool.clone(),
|
||||
shared_state.clone(),
|
||||
)
|
||||
.await;
|
||||
let config_rx = runtime_watches.config_rx;
|
||||
@@ -723,7 +717,6 @@ async fn run_inner(
|
||||
)
|
||||
.await;
|
||||
let _admission_tx_hold = admission_tx;
|
||||
let shared_state = ProxySharedState::new();
|
||||
conntrack_control::spawn_conntrack_controller(
|
||||
config_rx.clone(),
|
||||
stats.clone(),
|
||||
@@ -761,17 +754,8 @@ async fn run_inner(
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Drop privileges after binding sockets (which may require root for port < 1024)
|
||||
if daemon_opts.user.is_some() || daemon_opts.group.is_some() {
|
||||
if let Err(e) = drop_privileges(
|
||||
daemon_opts.user.as_deref(),
|
||||
daemon_opts.group.as_deref(),
|
||||
_pid_file.as_ref(),
|
||||
) {
|
||||
error!(error = %e, "Failed to drop privileges");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
// On Unix, caller supplies privilege drop after bind (may require root for port < 1024).
|
||||
drop_after_bind();
|
||||
|
||||
runtime_tasks::apply_runtime_log_filter(
|
||||
has_rust_log,
|
||||
@@ -819,3 +803,39 @@ async fn run_inner(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_inner(
|
||||
daemon_opts: DaemonOptions,
|
||||
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
// Acquire PID file if daemonizing or if explicitly requested
|
||||
// Keep it alive until shutdown (underscore prefix = intentionally kept for RAII cleanup)
|
||||
let _pid_file = if daemon_opts.daemonize || daemon_opts.pid_file.is_some() {
|
||||
let mut pf = PidFile::new(daemon_opts.pid_file_path());
|
||||
if let Err(e) = pf.acquire() {
|
||||
eprintln!("[telemt] {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Some(pf)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user = daemon_opts.user.clone();
|
||||
let group = daemon_opts.group.clone();
|
||||
|
||||
run_telemt_core(|| {
|
||||
if user.is_some() || group.is_some() {
|
||||
if let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) {
|
||||
error!(error = %e, "Failed to drop privileges");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn run_inner() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
run_telemt_core(|| {}).await
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(crate) async fn spawn_runtime_tasks(
|
||||
beobachten: Arc<BeobachtenStore>,
|
||||
api_config_tx: watch::Sender<Arc<ProxyConfig>>,
|
||||
me_pool_for_policy: Option<Arc<MePool>>,
|
||||
shared_state: Arc<ProxySharedState>,
|
||||
) -> RuntimeWatches {
|
||||
let um_clone = upstream_manager.clone();
|
||||
let dc_overrides_for_health = config.dc_overrides.clone();
|
||||
@@ -121,6 +122,8 @@ pub(crate) async fn spawn_runtime_tasks(
|
||||
if let Some(pool) = &me_pool_for_policy {
|
||||
pool.update_runtime_transport_policy(
|
||||
cfg.general.me_socks_kdf_policy,
|
||||
cfg.general.me_route_backpressure_enabled,
|
||||
cfg.general.me_route_fairshare_enabled,
|
||||
cfg.general.me_route_backpressure_base_timeout_ms,
|
||||
cfg.general.me_route_backpressure_high_timeout_ms,
|
||||
cfg.general.me_route_backpressure_high_watermark_pct,
|
||||
@@ -182,6 +185,41 @@ pub(crate) async fn spawn_runtime_tasks(
|
||||
}
|
||||
});
|
||||
|
||||
let limiter = shared_state.traffic_limiter.clone();
|
||||
limiter.apply_policy(
|
||||
config.access.user_rate_limits.clone(),
|
||||
config.access.cidr_rate_limits.clone(),
|
||||
);
|
||||
let mut config_rx_rate_limits = config_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut prev_user_limits = config_rx_rate_limits
|
||||
.borrow()
|
||||
.access
|
||||
.user_rate_limits
|
||||
.clone();
|
||||
let mut prev_cidr_limits = config_rx_rate_limits
|
||||
.borrow()
|
||||
.access
|
||||
.cidr_rate_limits
|
||||
.clone();
|
||||
loop {
|
||||
if config_rx_rate_limits.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
let cfg = config_rx_rate_limits.borrow_and_update().clone();
|
||||
if prev_user_limits != cfg.access.user_rate_limits
|
||||
|| prev_cidr_limits != cfg.access.cidr_rate_limits
|
||||
{
|
||||
limiter.apply_policy(
|
||||
cfg.access.user_rate_limits.clone(),
|
||||
cfg.access.cidr_rate_limits.clone(),
|
||||
);
|
||||
prev_user_limits = cfg.access.user_rate_limits.clone();
|
||||
prev_cidr_limits = cfg.access.cidr_rate_limits.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let beobachten_writer = beobachten.clone();
|
||||
let config_rx_beobachten = config_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod crypto;
|
||||
#[cfg(unix)]
|
||||
mod daemon;
|
||||
mod error;
|
||||
mod healthcheck;
|
||||
mod ip_tracker;
|
||||
#[cfg(test)]
|
||||
#[path = "tests/ip_tracker_encapsulation_adversarial_tests.rs"]
|
||||
|
||||
+270
@@ -575,6 +575,139 @@ async fn render_metrics(
|
||||
}
|
||||
);
|
||||
|
||||
let limiter_metrics = shared_state.traffic_limiter.metrics_snapshot();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_rate_limiter_throttle_total Traffic limiter throttle events by scope and direction"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_rate_limiter_throttle_total counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_throttle_total{{scope=\"user\",direction=\"up\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.user_throttle_up_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_throttle_total{{scope=\"user\",direction=\"down\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.user_throttle_down_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_throttle_total{{scope=\"cidr\",direction=\"up\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.cidr_throttle_up_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_throttle_total{{scope=\"cidr\",direction=\"down\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.cidr_throttle_down_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_rate_limiter_wait_ms_total Traffic limiter accumulated wait time in milliseconds by scope and direction"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_rate_limiter_wait_ms_total counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_wait_ms_total{{scope=\"user\",direction=\"up\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.user_wait_up_ms_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_wait_ms_total{{scope=\"user\",direction=\"down\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.user_wait_down_ms_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_wait_ms_total{{scope=\"cidr\",direction=\"up\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.cidr_wait_up_ms_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_wait_ms_total{{scope=\"cidr\",direction=\"down\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.cidr_wait_down_ms_total
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_rate_limiter_active_leases Active relay leases under rate limiting by scope"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_rate_limiter_active_leases gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_active_leases{{scope=\"user\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.user_active_leases
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_active_leases{{scope=\"cidr\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.cidr_active_leases
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_rate_limiter_policy_entries Active rate-limit policy entries by scope"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_rate_limiter_policy_entries gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_policy_entries{{scope=\"user\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.user_policy_entries
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_rate_limiter_policy_entries{{scope=\"cidr\"}} {}",
|
||||
if core_enabled {
|
||||
limiter_metrics.cidr_policy_entries
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_upstream_connect_attempt_total Upstream connect attempts across all requests"
|
||||
@@ -1177,6 +1310,143 @@ async fn render_metrics(
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_fair_pressure_state Worker-local fairness pressure state"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_me_fair_pressure_state gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_pressure_state {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_pressure_state_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_fair_active_flows Fair-scheduler active flow count"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_me_fair_active_flows gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_active_flows {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_active_flows_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_fair_queued_bytes Fair-scheduler queued bytes"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_me_fair_queued_bytes gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_queued_bytes {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_queued_bytes_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_fair_flow_state_gauge Fair-scheduler flow health classes"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_me_fair_flow_state_gauge gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_flow_state_gauge{{class=\"standing\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_standing_flows_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_flow_state_gauge{{class=\"backpressured\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_backpressured_flows_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_fair_events_total Fair-scheduler event counters"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_me_fair_events_total counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"scheduler_round\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_scheduler_rounds_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"deficit_grant\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_deficit_grants_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"deficit_skip\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_deficit_skips_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"enqueue_reject\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_enqueue_rejects_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"shed_drop\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_shed_drops_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"penalty\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_penalties_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_fair_events_total{{event=\"downstream_stall\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_fair_downstream_stalls_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
|
||||
@@ -97,6 +97,7 @@ pub async fn run_probe(
|
||||
let UpstreamType::Direct {
|
||||
interface,
|
||||
bind_addresses,
|
||||
..
|
||||
} = &upstream.upstream_type
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -1383,6 +1383,8 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
|
||||
&session_id,
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
Some(b"h2".to_vec()),
|
||||
0,
|
||||
@@ -1624,6 +1626,34 @@ fn test_extract_alpn_multiple() {
|
||||
assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_client_hello_tls_version_prefers_supported_versions_tls13() {
|
||||
let supported_versions = vec![4, 0x03, 0x04, 0x03, 0x03];
|
||||
let ch = build_client_hello_with_exts(vec![(0x002b, supported_versions)], "example.com");
|
||||
assert_eq!(
|
||||
detect_client_hello_tls_version(&ch),
|
||||
Some(ClientHelloTlsVersion::Tls13)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_client_hello_tls_version_falls_back_to_legacy_tls12() {
|
||||
let ch = build_client_hello_with_exts(Vec::new(), "example.com");
|
||||
assert_eq!(
|
||||
detect_client_hello_tls_version(&ch),
|
||||
Some(ClientHelloTlsVersion::Tls12)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_client_hello_tls_version_rejects_malformed_supported_versions() {
|
||||
// list_len=3 is invalid because version vector must contain u16 pairs.
|
||||
let malformed_supported_versions = vec![3, 0x03, 0x04, 0x03];
|
||||
let ch =
|
||||
build_client_hello_with_exts(vec![(0x002b, malformed_supported_versions)], "example.com");
|
||||
assert!(detect_client_hello_tls_version(&ch).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_sni_rejects_zero_length_host_name() {
|
||||
let mut sni_ext = Vec::new();
|
||||
|
||||
@@ -811,6 +811,122 @@ pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec<Vec<u8>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// ClientHello TLS generation inferred from handshake fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientHelloTlsVersion {
|
||||
Tls12,
|
||||
Tls13,
|
||||
}
|
||||
|
||||
/// Detect TLS generation from a ClientHello.
|
||||
///
|
||||
/// The parser prefers `supported_versions` (0x002b) when present and falls back
|
||||
/// to `legacy_version` for compatibility with TLS 1.2 style hellos.
|
||||
pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option<ClientHelloTlsVersion> {
|
||||
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
|
||||
if handshake.len() < 5 + record_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut pos = 5; // after record header
|
||||
if handshake.get(pos) != Some(&0x01) {
|
||||
return None; // not ClientHello
|
||||
}
|
||||
pos += 1; // message type
|
||||
|
||||
if pos + 3 > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
let handshake_len = ((handshake[pos] as usize) << 16)
|
||||
| ((handshake[pos + 1] as usize) << 8)
|
||||
| handshake[pos + 2] as usize;
|
||||
pos += 3; // handshake length bytes
|
||||
if pos + handshake_len > 5 + record_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
if pos + 2 + 32 > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
let legacy_version = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
|
||||
pos += 2 + 32; // version + random
|
||||
|
||||
let session_id_len = *handshake.get(pos)? as usize;
|
||||
pos += 1 + session_id_len;
|
||||
if pos + 2 > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
|
||||
pos += 2 + cipher_len;
|
||||
if pos >= handshake.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let comp_len = *handshake.get(pos)? as usize;
|
||||
pos += 1 + comp_len;
|
||||
if pos + 2 > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ext_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
let ext_end = pos + ext_len;
|
||||
if ext_end > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
while pos + 4 <= ext_end {
|
||||
let etype = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
|
||||
let elen = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize;
|
||||
pos += 4;
|
||||
if pos + elen > ext_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
if etype == extension_type::SUPPORTED_VERSIONS {
|
||||
if elen < 1 {
|
||||
return None;
|
||||
}
|
||||
let list_len = handshake[pos] as usize;
|
||||
if list_len == 0 || list_len % 2 != 0 || 1 + list_len > elen {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut has_tls12 = false;
|
||||
let mut ver_pos = pos + 1;
|
||||
let ver_end = ver_pos + list_len;
|
||||
while ver_pos + 1 < ver_end {
|
||||
let version = u16::from_be_bytes([handshake[ver_pos], handshake[ver_pos + 1]]);
|
||||
if version == 0x0304 {
|
||||
return Some(ClientHelloTlsVersion::Tls13);
|
||||
}
|
||||
if version == 0x0303 || version == 0x0302 || version == 0x0301 {
|
||||
has_tls12 = true;
|
||||
}
|
||||
ver_pos += 2;
|
||||
}
|
||||
|
||||
if has_tls12 {
|
||||
return Some(ClientHelloTlsVersion::Tls12);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
pos += elen;
|
||||
}
|
||||
|
||||
if legacy_version >= 0x0303 {
|
||||
Some(ClientHelloTlsVersion::Tls12)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if bytes look like a TLS ClientHello
|
||||
pub fn is_tls_handshake(first_bytes: &[u8]) -> bool {
|
||||
if first_bytes.len() < 3 {
|
||||
|
||||
+86
-27
@@ -324,17 +324,38 @@ fn record_beobachten_class(
|
||||
beobachten.record(class, peer_ip, beobachten_ttl(config));
|
||||
}
|
||||
|
||||
fn classify_expected_64_got_0(kind: std::io::ErrorKind) -> Option<&'static str> {
|
||||
match kind {
|
||||
std::io::ErrorKind::UnexpectedEof => Some("expected_64_got_0_unexpected_eof"),
|
||||
std::io::ErrorKind::ConnectionReset => Some("expected_64_got_0_connection_reset"),
|
||||
std::io::ErrorKind::ConnectionAborted => Some("expected_64_got_0_connection_aborted"),
|
||||
std::io::ErrorKind::BrokenPipe => Some("expected_64_got_0_broken_pipe"),
|
||||
std::io::ErrorKind::NotConnected => Some("expected_64_got_0_not_connected"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_handshake_failure_class(error: &ProxyError) -> &'static str {
|
||||
match error {
|
||||
ProxyError::Io(err) => classify_expected_64_got_0(err.kind()).unwrap_or("other"),
|
||||
ProxyError::Stream(StreamError::UnexpectedEof) => "expected_64_got_0_unexpected_eof",
|
||||
ProxyError::Stream(StreamError::Io(err)) => {
|
||||
classify_expected_64_got_0(err.kind()).unwrap_or("other")
|
||||
}
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
fn record_handshake_failure_class(
|
||||
beobachten: &BeobachtenStore,
|
||||
config: &ProxyConfig,
|
||||
peer_ip: IpAddr,
|
||||
error: &ProxyError,
|
||||
) {
|
||||
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",
|
||||
// Keep beobachten buckets stable while detailed per-kind classification
|
||||
// is tracked in API counters.
|
||||
let class = match classify_handshake_failure_class(error) {
|
||||
value if value.starts_with("expected_64_got_0_") => "expected_64_got_0",
|
||||
_ => "other",
|
||||
};
|
||||
record_beobachten_class(beobachten, config, peer_ip, class);
|
||||
@@ -343,7 +364,7 @@ fn record_handshake_failure_class(
|
||||
#[inline]
|
||||
fn increment_bad_on_unknown_tls_sni(stats: &Stats, error: &ProxyError) {
|
||||
if matches!(error, ProxyError::UnknownTlsSni) {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("unknown_tls_sni");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +465,7 @@ where
|
||||
Ok(Ok(info)) => {
|
||||
if !is_trusted_proxy_source(peer.ip(), &config.server.proxy_protocol_trusted_cidrs)
|
||||
{
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("proxy_protocol_untrusted");
|
||||
warn!(
|
||||
peer = %peer,
|
||||
trusted = ?config.server.proxy_protocol_trusted_cidrs,
|
||||
@@ -465,13 +486,13 @@ where
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("proxy_protocol_invalid_header");
|
||||
warn!(peer = %peer, error = %e, "Invalid PROXY protocol header");
|
||||
record_beobachten_class(&beobachten, &config, peer.ip(), "other");
|
||||
return Err(e);
|
||||
}
|
||||
Err(_) => {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("proxy_protocol_header_timeout");
|
||||
warn!(peer = %peer, timeout_ms = proxy_header_timeout.as_millis(), "PROXY protocol header timeout");
|
||||
record_beobachten_class(&beobachten, &config, peer.ip(), "other");
|
||||
return Err(ProxyError::InvalidProxyProtocol);
|
||||
@@ -561,7 +582,7 @@ where
|
||||
// third-party clients or future Telegram versions.
|
||||
if !tls_clienthello_len_in_bounds(tls_len) {
|
||||
debug!(peer = %real_peer, tls_len = tls_len, max_tls_len = MAX_TLS_PLAINTEXT_SIZE, "TLS handshake length out of bounds");
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_clienthello_len_out_of_bounds");
|
||||
maybe_apply_mask_reject_delay(&config).await;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
return Ok(masking_outcome(
|
||||
@@ -581,7 +602,7 @@ where
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
debug!(peer = %real_peer, error = %e, tls_len = tls_len, "TLS ClientHello body read failed; engaging masking fallback");
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_clienthello_read_error");
|
||||
maybe_apply_mask_reject_delay(&config).await;
|
||||
let initial_len = 5;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
@@ -599,7 +620,7 @@ where
|
||||
|
||||
if body_read < tls_len {
|
||||
debug!(peer = %real_peer, got = body_read, expected = tls_len, "Truncated in-range TLS ClientHello; engaging masking fallback");
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_clienthello_truncated");
|
||||
maybe_apply_mask_reject_delay(&config).await;
|
||||
let initial_len = 5 + body_read;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
@@ -623,7 +644,7 @@ where
|
||||
).await {
|
||||
HandshakeResult::Success(result) => result,
|
||||
HandshakeResult::BadClient { reader, writer } => {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
||||
return Ok(masking_outcome(
|
||||
reader,
|
||||
writer,
|
||||
@@ -663,7 +684,7 @@ where
|
||||
wrap_tls_application_record(&pending_plaintext)
|
||||
};
|
||||
let reader = tokio::io::AsyncReadExt::chain(std::io::Cursor::new(pending_record), reader);
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_mtproto_bad_client");
|
||||
debug!(
|
||||
peer = %peer,
|
||||
"Authenticated TLS session failed MTProto validation; engaging masking fallback"
|
||||
@@ -693,7 +714,7 @@ where
|
||||
} else {
|
||||
if !config.general.modes.classic && !config.general.modes.secure {
|
||||
debug!(peer = %real_peer, "Non-TLS modes disabled");
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("direct_modes_disabled");
|
||||
maybe_apply_mask_reject_delay(&config).await;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
return Ok(masking_outcome(
|
||||
@@ -720,7 +741,7 @@ where
|
||||
).await {
|
||||
HandshakeResult::Success(result) => result,
|
||||
HandshakeResult::BadClient { reader, writer } => {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("direct_mtproto_bad_client");
|
||||
return Ok(masking_outcome(
|
||||
reader,
|
||||
writer,
|
||||
@@ -757,6 +778,7 @@ where
|
||||
Ok(Ok(outcome)) => outcome,
|
||||
Ok(Err(e)) => {
|
||||
debug!(peer = %peer, error = %e, "Handshake failed");
|
||||
stats_for_timeout.increment_handshake_failure_class(classify_handshake_failure_class(&e));
|
||||
record_handshake_failure_class(
|
||||
&beobachten_for_timeout,
|
||||
&config_for_timeout,
|
||||
@@ -767,6 +789,7 @@ where
|
||||
}
|
||||
Err(_) => {
|
||||
stats_for_timeout.increment_handshake_timeouts();
|
||||
stats_for_timeout.increment_handshake_failure_class("timeout");
|
||||
debug!(peer = %peer, "Handshake timeout");
|
||||
record_beobachten_class(
|
||||
&beobachten_for_timeout,
|
||||
@@ -804,6 +827,9 @@ pub struct RunningClientHandler {
|
||||
beobachten: Arc<BeobachtenStore>,
|
||||
shared: Arc<ProxySharedState>,
|
||||
proxy_protocol_enabled: bool,
|
||||
#[cfg(unix)]
|
||||
raw_fd: std::os::unix::io::RawFd,
|
||||
rst_on_close: crate::config::RstOnCloseMode,
|
||||
}
|
||||
|
||||
impl ClientHandler {
|
||||
@@ -825,6 +851,11 @@ impl ClientHandler {
|
||||
proxy_protocol_enabled: bool,
|
||||
real_peer_report: Arc<std::sync::Mutex<Option<SocketAddr>>>,
|
||||
) -> RunningClientHandler {
|
||||
#[cfg(unix)]
|
||||
let raw_fd = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
stream.as_raw_fd()
|
||||
};
|
||||
Self::new_with_shared(
|
||||
stream,
|
||||
peer,
|
||||
@@ -842,6 +873,9 @@ impl ClientHandler {
|
||||
ProxySharedState::new(),
|
||||
proxy_protocol_enabled,
|
||||
real_peer_report,
|
||||
#[cfg(unix)]
|
||||
raw_fd,
|
||||
crate::config::RstOnCloseMode::Off,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -863,6 +897,8 @@ impl ClientHandler {
|
||||
shared: Arc<ProxySharedState>,
|
||||
proxy_protocol_enabled: bool,
|
||||
real_peer_report: Arc<std::sync::Mutex<Option<SocketAddr>>>,
|
||||
#[cfg(unix)] raw_fd: std::os::unix::io::RawFd,
|
||||
rst_on_close: crate::config::RstOnCloseMode,
|
||||
) -> RunningClientHandler {
|
||||
let normalized_peer = normalize_ip(peer);
|
||||
RunningClientHandler {
|
||||
@@ -883,6 +919,9 @@ impl ClientHandler {
|
||||
beobachten,
|
||||
shared,
|
||||
proxy_protocol_enabled,
|
||||
#[cfg(unix)]
|
||||
raw_fd,
|
||||
rst_on_close,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,6 +940,10 @@ impl RunningClientHandler {
|
||||
debug!(peer = %peer, error = %e, "Failed to configure client socket");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let raw_fd = self.raw_fd;
|
||||
let rst_on_close = self.rst_on_close;
|
||||
|
||||
let outcome = match self.do_handshake().await? {
|
||||
Some(outcome) => outcome,
|
||||
None => return Ok(()),
|
||||
@@ -908,7 +951,14 @@ impl RunningClientHandler {
|
||||
|
||||
// Phase 2: relay (WITHOUT handshake timeout — relay has its own activity timeouts)
|
||||
match outcome {
|
||||
HandshakeOutcome::NeedsRelay(fut) | HandshakeOutcome::NeedsMasking(fut) => fut.await,
|
||||
HandshakeOutcome::NeedsRelay(fut) => {
|
||||
#[cfg(unix)]
|
||||
if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) {
|
||||
let _ = crate::transport::socket::clear_linger_fd(raw_fd);
|
||||
}
|
||||
fut.await
|
||||
}
|
||||
HandshakeOutcome::NeedsMasking(fut) => fut.await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,7 +979,8 @@ impl RunningClientHandler {
|
||||
self.peer.ip(),
|
||||
&self.config.server.proxy_protocol_trusted_cidrs,
|
||||
) {
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("proxy_protocol_untrusted");
|
||||
warn!(
|
||||
peer = %self.peer,
|
||||
trusted = ?self.config.server.proxy_protocol_trusted_cidrs,
|
||||
@@ -959,7 +1010,8 @@ impl RunningClientHandler {
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("proxy_protocol_invalid_header");
|
||||
warn!(peer = %self.peer, error = %e, "Invalid PROXY protocol header");
|
||||
record_beobachten_class(
|
||||
&self.beobachten,
|
||||
@@ -970,7 +1022,8 @@ impl RunningClientHandler {
|
||||
return Err(e);
|
||||
}
|
||||
Err(_) => {
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("proxy_protocol_header_timeout");
|
||||
warn!(
|
||||
peer = %self.peer,
|
||||
timeout_ms = proxy_header_timeout.as_millis(),
|
||||
@@ -1068,6 +1121,7 @@ impl RunningClientHandler {
|
||||
Ok(Ok(outcome)) => outcome,
|
||||
Ok(Err(e)) => {
|
||||
debug!(peer = %peer_for_log, error = %e, "Handshake failed");
|
||||
stats.increment_handshake_failure_class(classify_handshake_failure_class(&e));
|
||||
record_handshake_failure_class(
|
||||
&beobachten_for_timeout,
|
||||
&config_for_timeout,
|
||||
@@ -1078,6 +1132,7 @@ impl RunningClientHandler {
|
||||
}
|
||||
Err(_) => {
|
||||
stats.increment_handshake_timeouts();
|
||||
stats.increment_handshake_failure_class("timeout");
|
||||
debug!(peer = %peer_for_log, "Handshake timeout");
|
||||
record_beobachten_class(
|
||||
&beobachten_for_timeout,
|
||||
@@ -1113,7 +1168,8 @@ impl RunningClientHandler {
|
||||
// third-party clients or future Telegram versions.
|
||||
if !tls_clienthello_len_in_bounds(tls_len) {
|
||||
debug!(peer = %peer, tls_len = tls_len, max_tls_len = MAX_TLS_PLAINTEXT_SIZE, "TLS handshake length out of bounds");
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("tls_clienthello_len_out_of_bounds");
|
||||
maybe_apply_mask_reject_delay(&self.config).await;
|
||||
let (reader, writer) = self.stream.into_split();
|
||||
return Ok(masking_outcome(
|
||||
@@ -1133,7 +1189,8 @@ impl RunningClientHandler {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
debug!(peer = %peer, error = %e, tls_len = tls_len, "TLS ClientHello body read failed; engaging masking fallback");
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("tls_clienthello_read_error");
|
||||
maybe_apply_mask_reject_delay(&self.config).await;
|
||||
let (reader, writer) = self.stream.into_split();
|
||||
return Ok(masking_outcome(
|
||||
@@ -1150,7 +1207,8 @@ impl RunningClientHandler {
|
||||
|
||||
if body_read < tls_len {
|
||||
debug!(peer = %peer, got = body_read, expected = tls_len, "Truncated in-range TLS ClientHello; engaging masking fallback");
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("tls_clienthello_truncated");
|
||||
maybe_apply_mask_reject_delay(&self.config).await;
|
||||
let initial_len = 5 + body_read;
|
||||
let (reader, writer) = self.stream.into_split();
|
||||
@@ -1187,7 +1245,7 @@ impl RunningClientHandler {
|
||||
{
|
||||
HandshakeResult::Success(result) => result,
|
||||
HandshakeResult::BadClient { reader, writer } => {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
||||
return Ok(masking_outcome(
|
||||
reader,
|
||||
writer,
|
||||
@@ -1237,7 +1295,7 @@ impl RunningClientHandler {
|
||||
};
|
||||
let reader =
|
||||
tokio::io::AsyncReadExt::chain(std::io::Cursor::new(pending_record), reader);
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("tls_mtproto_bad_client");
|
||||
debug!(
|
||||
peer = %peer,
|
||||
"Authenticated TLS session failed MTProto validation; engaging masking fallback"
|
||||
@@ -1284,7 +1342,8 @@ impl RunningClientHandler {
|
||||
|
||||
if !self.config.general.modes.classic && !self.config.general.modes.secure {
|
||||
debug!(peer = %peer, "Non-TLS modes disabled");
|
||||
self.stats.increment_connects_bad();
|
||||
self.stats
|
||||
.increment_connects_bad_with_class("direct_modes_disabled");
|
||||
maybe_apply_mask_reject_delay(&self.config).await;
|
||||
let (reader, writer) = self.stream.into_split();
|
||||
return Ok(masking_outcome(
|
||||
@@ -1324,7 +1383,7 @@ impl RunningClientHandler {
|
||||
{
|
||||
HandshakeResult::Success(result) => result,
|
||||
HandshakeResult::BadClient { reader, writer } => {
|
||||
stats.increment_connects_bad();
|
||||
stats.increment_connects_bad_with_class("direct_mtproto_bad_client");
|
||||
return Ok(masking_outcome(
|
||||
reader,
|
||||
writer,
|
||||
|
||||
@@ -316,6 +316,9 @@ where
|
||||
|
||||
stats.increment_user_connects(user);
|
||||
let _direct_connection_lease = stats.acquire_direct_connection_lease();
|
||||
let traffic_lease = shared
|
||||
.traffic_limiter
|
||||
.acquire_lease(user, success.peer.ip());
|
||||
|
||||
let buffer_pool_trim = Arc::clone(&buffer_pool);
|
||||
let relay_activity_timeout = if shared.conntrack_pressure_active() {
|
||||
@@ -329,7 +332,7 @@ where
|
||||
} else {
|
||||
Duration::from_secs(1800)
|
||||
};
|
||||
let relay_result = crate::proxy::relay::relay_bidirectional_with_activity_timeout(
|
||||
let relay_result = crate::proxy::relay::relay_bidirectional_with_activity_timeout_and_lease(
|
||||
client_reader,
|
||||
client_writer,
|
||||
tg_reader,
|
||||
@@ -340,6 +343,7 @@ where
|
||||
Arc::clone(&stats),
|
||||
config.access.user_data_quota.get(user).copied(),
|
||||
buffer_pool,
|
||||
traffic_lease,
|
||||
relay_activity_timeout,
|
||||
);
|
||||
tokio::pin!(relay_result);
|
||||
|
||||
+57
-9
@@ -1119,6 +1119,10 @@ where
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous:
|
||||
// this avoids leaking certificate payload on malformed probes.
|
||||
let client_tls_version = tls::detect_client_hello_tls_version(handshake)
|
||||
.unwrap_or(tls::ClientHelloTlsVersion::Tls13);
|
||||
|
||||
if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() {
|
||||
let sni = client_sni.as_deref().unwrap_or_default();
|
||||
@@ -1132,9 +1136,20 @@ where
|
||||
"TLS handshake accepted by unknown SNI policy"
|
||||
);
|
||||
}
|
||||
action @ (UnknownSniAction::Drop | UnknownSniAction::Mask) => {
|
||||
action @ (UnknownSniAction::Drop
|
||||
| UnknownSniAction::Mask
|
||||
| UnknownSniAction::RejectHandshake) => {
|
||||
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||
maybe_apply_server_hello_delay(config).await;
|
||||
// For Drop/Mask we apply the synthetic ServerHello delay so
|
||||
// the fail-closed path is timing-indistinguishable from the
|
||||
// success path. For RejectHandshake we deliberately skip the
|
||||
// delay: a stock modern nginx with `ssl_reject_handshake on;`
|
||||
// responds with the alert essentially immediately, so
|
||||
// injecting 8-24ms here would itself become a distinguisher
|
||||
// against the public baseline we are trying to blend into.
|
||||
if !matches!(action, UnknownSniAction::RejectHandshake) {
|
||||
maybe_apply_server_hello_delay(config).await;
|
||||
}
|
||||
let log_now = Instant::now();
|
||||
if should_emit_unknown_sni_warn_in(shared, log_now) {
|
||||
warn!(
|
||||
@@ -1153,8 +1168,33 @@ where
|
||||
"TLS handshake rejected by unknown SNI policy"
|
||||
);
|
||||
}
|
||||
if matches!(action, UnknownSniAction::RejectHandshake) {
|
||||
// TLS alert record layer:
|
||||
// 0x15 ContentType.alert
|
||||
// 0x03 0x03 legacy_record_version = TLS 1.2
|
||||
// (matches what modern nginx emits in
|
||||
// the first server -> client record,
|
||||
// per RFC 8446 5.1 guidance)
|
||||
// 0x00 0x02 length = 2
|
||||
// Alert payload:
|
||||
// 0x02 AlertLevel.fatal
|
||||
// 0x70 AlertDescription.unrecognized_name (112, RFC 6066)
|
||||
const TLS_ALERT_UNRECOGNIZED_NAME: [u8; 7] =
|
||||
[0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70];
|
||||
if let Err(e) = writer.write_all(&TLS_ALERT_UNRECOGNIZED_NAME).await {
|
||||
debug!(
|
||||
peer = %peer,
|
||||
error = %e,
|
||||
"Failed to write unrecognized_name TLS alert"
|
||||
);
|
||||
} else {
|
||||
let _ = writer.flush().await;
|
||||
}
|
||||
}
|
||||
return match action {
|
||||
UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni),
|
||||
UnknownSniAction::Drop | UnknownSniAction::RejectHandshake => {
|
||||
HandshakeResult::Error(ProxyError::UnknownTlsSni)
|
||||
}
|
||||
UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer },
|
||||
UnknownSniAction::Accept => unreachable!(),
|
||||
};
|
||||
@@ -1403,12 +1443,18 @@ where
|
||||
let selected_domain =
|
||||
matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
|
||||
let cached_entry = cache.get(selected_domain).await;
|
||||
let use_full_cert_payload = cache
|
||||
.take_full_cert_budget_for_ip(
|
||||
peer.ip(),
|
||||
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs),
|
||||
)
|
||||
.await;
|
||||
let use_full_cert_payload = if config.censorship.serverhello_compact
|
||||
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
|
||||
{
|
||||
cache
|
||||
.take_full_cert_budget_for_ip(
|
||||
peer.ip(),
|
||||
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
true
|
||||
};
|
||||
Some((cached_entry, use_full_cert_payload))
|
||||
} else {
|
||||
None
|
||||
@@ -1429,6 +1475,8 @@ where
|
||||
validation_session_id_slice,
|
||||
&cached_entry,
|
||||
use_full_cert_payload,
|
||||
config.censorship.serverhello_compact,
|
||||
client_tls_version,
|
||||
rng,
|
||||
selected_alpn.clone(),
|
||||
config.censorship.tls_new_session_tickets,
|
||||
|
||||
+84
-47
@@ -28,14 +28,10 @@ use tracing::debug;
|
||||
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);
|
||||
/// Maximum duration for the entire masking relay under test (replaced by config at runtime).
|
||||
#[cfg(test)]
|
||||
const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200);
|
||||
#[cfg(not(test))]
|
||||
const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Per-read idle timeout for masking relay and drain paths under test (replaced by config at runtime).
|
||||
#[cfg(test)]
|
||||
const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const MASK_BUFFER_SIZE: usize = 8192;
|
||||
@@ -55,6 +51,7 @@ async fn copy_with_idle_timeout<R, W>(
|
||||
writer: &mut W,
|
||||
byte_cap: usize,
|
||||
shutdown_on_eof: bool,
|
||||
idle_timeout: Duration,
|
||||
) -> CopyOutcome
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
@@ -63,22 +60,19 @@ where
|
||||
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]);
|
||||
let mut total = 0usize;
|
||||
let mut ended_by_eof = false;
|
||||
|
||||
if byte_cap == 0 {
|
||||
return CopyOutcome {
|
||||
total,
|
||||
ended_by_eof,
|
||||
};
|
||||
}
|
||||
let unlimited = byte_cap == 0;
|
||||
|
||||
loop {
|
||||
let remaining_budget = byte_cap.saturating_sub(total);
|
||||
if remaining_budget == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let read_len = remaining_budget.min(MASK_BUFFER_SIZE);
|
||||
let read_res = timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf[..read_len])).await;
|
||||
let read_len = if unlimited {
|
||||
MASK_BUFFER_SIZE
|
||||
} else {
|
||||
let remaining_budget = byte_cap.saturating_sub(total);
|
||||
if remaining_budget == 0 {
|
||||
break;
|
||||
}
|
||||
remaining_budget.min(MASK_BUFFER_SIZE)
|
||||
};
|
||||
let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await;
|
||||
let n = match read_res {
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(_)) | Err(_) => break,
|
||||
@@ -86,13 +80,13 @@ where
|
||||
if n == 0 {
|
||||
ended_by_eof = true;
|
||||
if shutdown_on_eof {
|
||||
let _ = timeout(MASK_RELAY_IDLE_TIMEOUT, writer.shutdown()).await;
|
||||
let _ = timeout(idle_timeout, writer.shutdown()).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
total = total.saturating_add(n);
|
||||
|
||||
let write_res = timeout(MASK_RELAY_IDLE_TIMEOUT, writer.write_all(&buf[..n])).await;
|
||||
let write_res = timeout(idle_timeout, writer.write_all(&buf[..n])).await;
|
||||
match write_res {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(_)) | Err(_) => break,
|
||||
@@ -230,13 +224,20 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume_client_data_with_timeout_and_cap<R>(reader: R, byte_cap: usize)
|
||||
where
|
||||
async fn consume_client_data_with_timeout_and_cap<R>(
|
||||
reader: R,
|
||||
byte_cap: usize,
|
||||
relay_timeout: Duration,
|
||||
idle_timeout: Duration,
|
||||
) where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
if timeout(MASK_RELAY_TIMEOUT, consume_client_data(reader, byte_cap))
|
||||
.await
|
||||
.is_err()
|
||||
if timeout(
|
||||
relay_timeout,
|
||||
consume_client_data(reader, byte_cap, idle_timeout),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
debug!("Timed out while consuming client data on masking fallback path");
|
||||
}
|
||||
@@ -639,10 +640,18 @@ pub async fn handle_bad_client<R, W>(
|
||||
beobachten.record(client_type, peer.ip(), ttl);
|
||||
}
|
||||
|
||||
let relay_timeout = Duration::from_millis(config.censorship.mask_relay_timeout_ms);
|
||||
let idle_timeout = Duration::from_millis(config.censorship.mask_relay_idle_timeout_ms);
|
||||
|
||||
if !config.censorship.mask {
|
||||
// Masking disabled, just consume data
|
||||
consume_client_data_with_timeout_and_cap(reader, config.censorship.mask_relay_max_bytes)
|
||||
.await;
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -674,7 +683,7 @@ pub async fn handle_bad_client<R, W>(
|
||||
return;
|
||||
}
|
||||
if timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
relay_timeout,
|
||||
relay_to_mask(
|
||||
reader,
|
||||
writer,
|
||||
@@ -688,6 +697,7 @@ pub async fn handle_bad_client<R, W>(
|
||||
config.censorship.mask_shape_above_cap_blur_max_bytes,
|
||||
config.censorship.mask_shape_hardening_aggressive_mode,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
idle_timeout,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -703,6 +713,8 @@ pub async fn handle_bad_client<R, W>(
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
@@ -712,6 +724,8 @@ pub async fn handle_bad_client<R, W>(
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
@@ -742,8 +756,13 @@ pub async fn handle_bad_client<R, W>(
|
||||
local = %local_addr,
|
||||
"Mask target resolves to local listener; refusing self-referential masking fallback"
|
||||
);
|
||||
consume_client_data_with_timeout_and_cap(reader, config.censorship.mask_relay_max_bytes)
|
||||
.await;
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
return;
|
||||
}
|
||||
@@ -777,7 +796,7 @@ pub async fn handle_bad_client<R, W>(
|
||||
return;
|
||||
}
|
||||
if timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
relay_timeout,
|
||||
relay_to_mask(
|
||||
reader,
|
||||
writer,
|
||||
@@ -791,6 +810,7 @@ pub async fn handle_bad_client<R, W>(
|
||||
config.censorship.mask_shape_above_cap_blur_max_bytes,
|
||||
config.censorship.mask_shape_hardening_aggressive_mode,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
idle_timeout,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -806,6 +826,8 @@ pub async fn handle_bad_client<R, W>(
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
@@ -815,6 +837,8 @@ pub async fn handle_bad_client<R, W>(
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
@@ -836,6 +860,7 @@ async fn relay_to_mask<R, W, MR, MW>(
|
||||
shape_above_cap_blur_max_bytes: usize,
|
||||
shape_hardening_aggressive_mode: bool,
|
||||
mask_relay_max_bytes: usize,
|
||||
idle_timeout: Duration,
|
||||
) where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
@@ -857,11 +882,19 @@ async fn relay_to_mask<R, W, MR, MW>(
|
||||
&mut mask_write,
|
||||
mask_relay_max_bytes,
|
||||
!shape_hardening_enabled,
|
||||
idle_timeout,
|
||||
)
|
||||
.await
|
||||
},
|
||||
async {
|
||||
copy_with_idle_timeout(&mut mask_read, &mut writer, mask_relay_max_bytes, true).await
|
||||
copy_with_idle_timeout(
|
||||
&mut mask_read,
|
||||
&mut writer,
|
||||
mask_relay_max_bytes,
|
||||
true,
|
||||
idle_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
);
|
||||
|
||||
@@ -889,23 +922,27 @@ async fn relay_to_mask<R, W, MR, MW>(
|
||||
}
|
||||
|
||||
/// Just consume all data from client without responding.
|
||||
async fn consume_client_data<R: AsyncRead + Unpin>(mut reader: R, byte_cap: usize) {
|
||||
if byte_cap == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
async fn consume_client_data<R: AsyncRead + Unpin>(
|
||||
mut reader: R,
|
||||
byte_cap: usize,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
// Keep drain path fail-closed under slow-loris stalls.
|
||||
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]);
|
||||
let mut total = 0usize;
|
||||
let unlimited = byte_cap == 0;
|
||||
|
||||
loop {
|
||||
let remaining_budget = byte_cap.saturating_sub(total);
|
||||
if remaining_budget == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let read_len = remaining_budget.min(MASK_BUFFER_SIZE);
|
||||
let n = match timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf[..read_len])).await {
|
||||
let read_len = if unlimited {
|
||||
MASK_BUFFER_SIZE
|
||||
} else {
|
||||
let remaining_budget = byte_cap.saturating_sub(total);
|
||||
if remaining_budget == 0 {
|
||||
break;
|
||||
}
|
||||
remaining_budget.min(MASK_BUFFER_SIZE)
|
||||
};
|
||||
let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await {
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(_)) | Err(_) => break,
|
||||
};
|
||||
@@ -915,7 +952,7 @@ async fn consume_client_data<R: AsyncRead + Unpin>(mut reader: R, byte_cap: usiz
|
||||
}
|
||||
|
||||
total = total.saturating_add(n);
|
||||
if total >= byte_cap {
|
||||
if !unlimited && total >= byte_cap {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+139
-39
@@ -28,6 +28,7 @@ use crate::proxy::route_mode::{
|
||||
use crate::proxy::shared_state::{
|
||||
ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState,
|
||||
};
|
||||
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
|
||||
use crate::stats::{
|
||||
MeD2cFlushReason, MeD2cQuotaRejectStage, MeD2cWriteMode, QuotaReserveError, Stats, UserStats,
|
||||
};
|
||||
@@ -286,6 +287,10 @@ impl RelayClientIdleState {
|
||||
self.last_client_frame_at = now;
|
||||
self.soft_idle_marked = false;
|
||||
}
|
||||
|
||||
fn on_client_tiny_frame(&mut self, now: Instant) {
|
||||
self.last_client_frame_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
impl MeD2cFlushPolicy {
|
||||
@@ -595,6 +600,41 @@ async fn reserve_user_quota_with_yield(
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_traffic_budget(
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
bytes: u64,
|
||||
) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(lease) = lease else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut remaining = bytes;
|
||||
while remaining > 0 {
|
||||
let consume = lease.try_consume(direction, remaining);
|
||||
if consume.granted > 0 {
|
||||
remaining = remaining.saturating_sub(consume.granted);
|
||||
continue;
|
||||
}
|
||||
|
||||
let wait_started_at = Instant::now();
|
||||
tokio::time::sleep(next_refill_delay()).await;
|
||||
let wait_ms = wait_started_at
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64;
|
||||
lease.observe_wait_ms(
|
||||
direction,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
wait_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_me_d2c_flush_reason(
|
||||
flush_immediately: bool,
|
||||
batch_frames: usize,
|
||||
@@ -985,6 +1025,7 @@ where
|
||||
let quota_limit = config.access.user_data_quota.get(&user).copied();
|
||||
let quota_user_stats = quota_limit.map(|_| stats.get_or_create_user_stats_handle(&user));
|
||||
let peer = success.peer;
|
||||
let traffic_lease = shared.traffic_limiter.acquire_lease(&user, peer.ip());
|
||||
let proto_tag = success.proto_tag;
|
||||
let pool_generation = me_pool.current_generation();
|
||||
|
||||
@@ -1120,6 +1161,7 @@ where
|
||||
let rng_clone = rng.clone();
|
||||
let user_clone = user.clone();
|
||||
let quota_user_stats_me_writer = quota_user_stats.clone();
|
||||
let traffic_lease_me_writer = traffic_lease.clone();
|
||||
let last_downstream_activity_ms_clone = last_downstream_activity_ms.clone();
|
||||
let bytes_me2c_clone = bytes_me2c.clone();
|
||||
let d2c_flush_policy = MeD2cFlushPolicy::from_config(&config);
|
||||
@@ -1153,7 +1195,7 @@ where
|
||||
|
||||
let first_is_downstream_activity =
|
||||
matches!(&first, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response(
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
first,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
@@ -1164,6 +1206,7 @@ where
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
@@ -1213,7 +1256,7 @@ where
|
||||
|
||||
let next_is_downstream_activity =
|
||||
matches!(&next, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response(
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
next,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
@@ -1224,6 +1267,7 @@ where
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
@@ -1276,7 +1320,7 @@ where
|
||||
Ok(Some(next)) => {
|
||||
let next_is_downstream_activity =
|
||||
matches!(&next, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response(
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
next,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
@@ -1287,6 +1331,7 @@ where
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
@@ -1341,7 +1386,7 @@ where
|
||||
|
||||
let extra_is_downstream_activity =
|
||||
matches!(&extra, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response(
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
extra,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
@@ -1352,6 +1397,7 @@ where
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
@@ -1542,6 +1588,12 @@ where
|
||||
match payload_result {
|
||||
Ok(Some((payload, quickack))) => {
|
||||
trace!(conn_id, bytes = payload.len(), "C->ME frame");
|
||||
wait_for_traffic_budget(
|
||||
traffic_lease.as_ref(),
|
||||
RateDirection::Up,
|
||||
payload.len() as u64,
|
||||
)
|
||||
.await;
|
||||
forensics.bytes_c2me = forensics
|
||||
.bytes_c2me
|
||||
.saturating_add(payload.len() as u64);
|
||||
@@ -1762,40 +1814,6 @@ where
|
||||
let downstream_ms = last_downstream_activity_ms.load(Ordering::Relaxed);
|
||||
let hard_deadline =
|
||||
hard_deadline(idle_policy, idle_state, session_started_at, downstream_ms);
|
||||
if now >= hard_deadline {
|
||||
clear_relay_idle_candidate_in(shared, forensics.conn_id);
|
||||
stats.increment_relay_idle_hard_close_total();
|
||||
let client_idle_secs = now
|
||||
.saturating_duration_since(idle_state.last_client_frame_at)
|
||||
.as_secs();
|
||||
let downstream_idle_secs = now
|
||||
.saturating_duration_since(
|
||||
session_started_at + Duration::from_millis(downstream_ms),
|
||||
)
|
||||
.as_secs();
|
||||
warn!(
|
||||
trace_id = format_args!("0x{:016x}", forensics.trace_id),
|
||||
conn_id = forensics.conn_id,
|
||||
user = %forensics.user,
|
||||
read_label,
|
||||
client_idle_secs,
|
||||
downstream_idle_secs,
|
||||
soft_idle_secs = idle_policy.soft_idle.as_secs(),
|
||||
hard_idle_secs = idle_policy.hard_idle.as_secs(),
|
||||
grace_secs = idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
"Middle-relay hard idle close"
|
||||
);
|
||||
return Err(ProxyError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"middle-relay hard idle timeout while reading {read_label}: client_idle_secs={client_idle_secs}, downstream_idle_secs={downstream_idle_secs}, soft_idle_secs={}, hard_idle_secs={}, grace_secs={}",
|
||||
idle_policy.soft_idle.as_secs(),
|
||||
idle_policy.hard_idle.as_secs(),
|
||||
idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
if !idle_state.soft_idle_marked
|
||||
&& now.saturating_duration_since(idle_state.last_client_frame_at)
|
||||
>= idle_policy.soft_idle
|
||||
@@ -1850,7 +1868,45 @@ where
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(_) => {
|
||||
let now = Instant::now();
|
||||
let downstream_ms = last_downstream_activity_ms.load(Ordering::Relaxed);
|
||||
let hard_deadline =
|
||||
hard_deadline(idle_policy, idle_state, session_started_at, downstream_ms);
|
||||
if now >= hard_deadline {
|
||||
clear_relay_idle_candidate_in(shared, forensics.conn_id);
|
||||
stats.increment_relay_idle_hard_close_total();
|
||||
let client_idle_secs = now
|
||||
.saturating_duration_since(idle_state.last_client_frame_at)
|
||||
.as_secs();
|
||||
let downstream_idle_secs = now
|
||||
.saturating_duration_since(
|
||||
session_started_at + Duration::from_millis(downstream_ms),
|
||||
)
|
||||
.as_secs();
|
||||
warn!(
|
||||
trace_id = format_args!("0x{:016x}", forensics.trace_id),
|
||||
conn_id = forensics.conn_id,
|
||||
user = %forensics.user,
|
||||
read_label,
|
||||
client_idle_secs,
|
||||
downstream_idle_secs,
|
||||
soft_idle_secs = idle_policy.soft_idle.as_secs(),
|
||||
hard_idle_secs = idle_policy.hard_idle.as_secs(),
|
||||
grace_secs = idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
"Middle-relay hard idle close"
|
||||
);
|
||||
return Err(ProxyError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"middle-relay hard idle timeout while reading {read_label}: client_idle_secs={client_idle_secs}, downstream_idle_secs={downstream_idle_secs}, soft_idle_secs={}, hard_idle_secs={}, grace_secs={}",
|
||||
idle_policy.soft_idle.as_secs(),
|
||||
idle_policy.hard_idle.as_secs(),
|
||||
idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1941,6 +1997,7 @@ where
|
||||
};
|
||||
|
||||
if len == 0 {
|
||||
idle_state.on_client_tiny_frame(Instant::now());
|
||||
idle_state.tiny_frame_debt = idle_state
|
||||
.tiny_frame_debt
|
||||
.saturating_add(TINY_FRAME_DEBT_PER_TINY);
|
||||
@@ -2144,6 +2201,7 @@ enum MeWriterResponseOutcome {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn process_me_writer_response<W>(
|
||||
response: MeResponse,
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
@@ -2160,6 +2218,46 @@ async fn process_me_writer_response<W>(
|
||||
ack_flush_immediate: bool,
|
||||
batched: bool,
|
||||
) -> Result<MeWriterResponseOutcome>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
process_me_writer_response_with_traffic_lease(
|
||||
response,
|
||||
client_writer,
|
||||
proto_tag,
|
||||
rng,
|
||||
frame_buf,
|
||||
stats,
|
||||
user,
|
||||
quota_user_stats,
|
||||
quota_limit,
|
||||
quota_soft_overshoot_bytes,
|
||||
None,
|
||||
bytes_me2c,
|
||||
conn_id,
|
||||
ack_flush_immediate,
|
||||
batched,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_me_writer_response_with_traffic_lease<W>(
|
||||
response: MeResponse,
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
proto_tag: ProtoTag,
|
||||
rng: &SecureRandom,
|
||||
frame_buf: &mut Vec<u8>,
|
||||
stats: &Stats,
|
||||
user: &str,
|
||||
quota_user_stats: Option<&UserStats>,
|
||||
quota_limit: Option<u64>,
|
||||
quota_soft_overshoot_bytes: u64,
|
||||
traffic_lease: Option<&Arc<TrafficLease>>,
|
||||
bytes_me2c: &AtomicU64,
|
||||
conn_id: u64,
|
||||
ack_flush_immediate: bool,
|
||||
batched: bool,
|
||||
) -> Result<MeWriterResponseOutcome>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
@@ -2183,6 +2281,7 @@ where
|
||||
});
|
||||
}
|
||||
}
|
||||
wait_for_traffic_budget(traffic_lease, RateDirection::Down, data_len).await;
|
||||
|
||||
let write_mode =
|
||||
match write_client_payload(client_writer, proto_tag, flags, &data, rng, frame_buf)
|
||||
@@ -2220,6 +2319,7 @@ where
|
||||
} else {
|
||||
trace!(conn_id, confirm, "ME->C quickack");
|
||||
}
|
||||
wait_for_traffic_budget(traffic_lease, RateDirection::Down, 4).await;
|
||||
write_client_ack(client_writer, proto_tag, confirm).await?;
|
||||
stats.increment_me_d2c_ack_frames_total();
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ pub mod relay;
|
||||
pub mod route_mode;
|
||||
pub mod session_eviction;
|
||||
pub mod shared_state;
|
||||
pub mod traffic_limiter;
|
||||
|
||||
pub use client::ClientHandler;
|
||||
#[allow(unused_imports)]
|
||||
|
||||
+251
-47
@@ -52,6 +52,7 @@
|
||||
//! - `SharedCounters` (atomics) let the watchdog read stats without locking
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
|
||||
use crate::stats::{Stats, UserStats};
|
||||
use crate::stream::BufferPool;
|
||||
use std::io;
|
||||
@@ -61,7 +62,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, copy_bidirectional_with_sizes};
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::{Instant, Sleep};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
// ============= Constants =============
|
||||
@@ -210,13 +211,26 @@ struct StatsIo<S> {
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
user_stats: Arc<UserStats>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
c2s_rate_debt_bytes: u64,
|
||||
c2s_wait: RateWaitState,
|
||||
s2c_wait: RateWaitState,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
quota_bytes_since_check: u64,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateWaitState {
|
||||
sleep: Option<Pin<Box<Sleep>>>,
|
||||
started_at: Option<Instant>,
|
||||
blocked_user: bool,
|
||||
blocked_cidr: bool,
|
||||
}
|
||||
|
||||
impl<S> StatsIo<S> {
|
||||
#[cfg(test)]
|
||||
fn new(
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
@@ -225,6 +239,28 @@ impl<S> StatsIo<S> {
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
epoch: Instant,
|
||||
) -> Self {
|
||||
Self::new_with_traffic_lease(
|
||||
inner,
|
||||
counters,
|
||||
stats,
|
||||
user,
|
||||
None,
|
||||
quota_limit,
|
||||
quota_exceeded,
|
||||
epoch,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_traffic_lease(
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
epoch: Instant,
|
||||
) -> Self {
|
||||
// Mark initial activity so the watchdog doesn't fire before data flows
|
||||
counters.touch(Instant::now(), epoch);
|
||||
@@ -235,12 +271,88 @@ impl<S> StatsIo<S> {
|
||||
stats,
|
||||
user,
|
||||
user_stats,
|
||||
traffic_lease,
|
||||
c2s_rate_debt_bytes: 0,
|
||||
c2s_wait: RateWaitState::default(),
|
||||
s2c_wait: RateWaitState::default(),
|
||||
quota_limit,
|
||||
quota_exceeded,
|
||||
quota_bytes_since_check: 0,
|
||||
epoch,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_wait(
|
||||
wait: &mut RateWaitState,
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
) {
|
||||
let Some(started_at) = wait.started_at.take() else {
|
||||
return;
|
||||
};
|
||||
let wait_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
|
||||
if let Some(lease) = lease {
|
||||
lease.observe_wait_ms(direction, wait.blocked_user, wait.blocked_cidr, wait_ms);
|
||||
}
|
||||
wait.blocked_user = false;
|
||||
wait.blocked_cidr = false;
|
||||
}
|
||||
|
||||
fn arm_wait(wait: &mut RateWaitState, blocked_user: bool, blocked_cidr: bool) {
|
||||
if wait.sleep.is_none() {
|
||||
wait.sleep = Some(Box::pin(tokio::time::sleep(next_refill_delay())));
|
||||
wait.started_at = Some(Instant::now());
|
||||
}
|
||||
wait.blocked_user |= blocked_user;
|
||||
wait.blocked_cidr |= blocked_cidr;
|
||||
}
|
||||
|
||||
fn poll_wait(
|
||||
wait: &mut RateWaitState,
|
||||
cx: &mut Context<'_>,
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
) -> Poll<()> {
|
||||
let Some(sleep) = wait.sleep.as_mut() else {
|
||||
return Poll::Ready(());
|
||||
};
|
||||
if sleep.as_mut().poll(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
wait.sleep = None;
|
||||
Self::record_wait(wait, lease, direction);
|
||||
Poll::Ready(())
|
||||
}
|
||||
|
||||
fn settle_c2s_rate_debt(&mut self, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let Some(lease) = self.traffic_lease.as_ref() else {
|
||||
self.c2s_rate_debt_bytes = 0;
|
||||
return Poll::Ready(());
|
||||
};
|
||||
|
||||
while self.c2s_rate_debt_bytes > 0 {
|
||||
let consume = lease.try_consume(RateDirection::Up, self.c2s_rate_debt_bytes);
|
||||
if consume.granted > 0 {
|
||||
self.c2s_rate_debt_bytes = self.c2s_rate_debt_bytes.saturating_sub(consume.granted);
|
||||
continue;
|
||||
}
|
||||
Self::arm_wait(
|
||||
&mut self.c2s_wait,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
);
|
||||
if Self::poll_wait(&mut self.c2s_wait, cx, Some(lease), RateDirection::Up).is_pending()
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
|
||||
if Self::poll_wait(&mut self.c2s_wait, cx, Some(lease), RateDirection::Up).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
Poll::Ready(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -286,6 +398,25 @@ fn should_immediate_quota_check(remaining_before: u64, charge_bytes: u64) -> boo
|
||||
remaining_before <= QUOTA_NEAR_LIMIT_BYTES || charge_bytes >= QUOTA_LARGE_CHARGE_BYTES
|
||||
}
|
||||
|
||||
fn refund_reserved_quota_bytes(user_stats: &UserStats, reserved_bytes: u64) {
|
||||
if reserved_bytes == 0 {
|
||||
return;
|
||||
}
|
||||
let mut current = user_stats.quota_used.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(reserved_bytes);
|
||||
match user_stats.quota_used.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
@@ -296,6 +427,9 @@ impl<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
|
||||
if this.quota_exceeded.load(Ordering::Acquire) {
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
if this.settle_c2s_rate_debt(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
let mut remaining_before = None;
|
||||
if let Some(limit) = this.quota_limit {
|
||||
@@ -377,6 +511,11 @@ impl<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
|
||||
.add_user_octets_from_handle(this.user_stats.as_ref(), n_to_charge);
|
||||
this.stats
|
||||
.increment_user_msgs_from_handle(this.user_stats.as_ref());
|
||||
if this.traffic_lease.is_some() {
|
||||
this.c2s_rate_debt_bytes =
|
||||
this.c2s_rate_debt_bytes.saturating_add(n_to_charge);
|
||||
let _ = this.settle_c2s_rate_debt(cx);
|
||||
}
|
||||
|
||||
trace!(user = %this.user, bytes = n, "C->S");
|
||||
}
|
||||
@@ -398,28 +537,66 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
|
||||
let mut shaper_reserved_bytes = 0u64;
|
||||
let mut write_buf = buf;
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
if !buf.is_empty() {
|
||||
loop {
|
||||
let consume = lease.try_consume(RateDirection::Down, buf.len() as u64);
|
||||
if consume.granted > 0 {
|
||||
shaper_reserved_bytes = consume.granted;
|
||||
if consume.granted < buf.len() as u64 {
|
||||
write_buf = &buf[..consume.granted as usize];
|
||||
}
|
||||
let _ = Self::poll_wait(
|
||||
&mut this.s2c_wait,
|
||||
cx,
|
||||
Some(lease),
|
||||
RateDirection::Down,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
Self::arm_wait(
|
||||
&mut this.s2c_wait,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
);
|
||||
if Self::poll_wait(&mut this.s2c_wait, cx, Some(lease), RateDirection::Down)
|
||||
.is_pending()
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = Self::poll_wait(&mut this.s2c_wait, cx, Some(lease), RateDirection::Down);
|
||||
}
|
||||
}
|
||||
|
||||
let mut remaining_before = None;
|
||||
let mut reserved_bytes = 0u64;
|
||||
let mut write_buf = buf;
|
||||
if let Some(limit) = this.quota_limit {
|
||||
if !buf.is_empty() {
|
||||
if !write_buf.is_empty() {
|
||||
let mut reserve_rounds = 0usize;
|
||||
while reserved_bytes == 0 {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
|
||||
let desired = remaining.min(buf.len() as u64);
|
||||
let desired = remaining.min(write_buf.len() as u64);
|
||||
let mut saw_contention = false;
|
||||
for _ in 0..QUOTA_RESERVE_SPIN_RETRIES {
|
||||
match this.user_stats.quota_try_reserve(desired, limit) {
|
||||
Ok(_) => {
|
||||
reserved_bytes = desired;
|
||||
write_buf = &buf[..desired as usize];
|
||||
write_buf = &write_buf[..desired as usize];
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::LimitExceeded) => {
|
||||
@@ -434,6 +611,9 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
if reserved_bytes == 0 {
|
||||
reserve_rounds = reserve_rounds.saturating_add(1);
|
||||
if reserve_rounds >= QUOTA_RESERVE_MAX_ROUNDS {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
@@ -446,6 +626,9 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
@@ -456,23 +639,20 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
match Pin::new(&mut this.inner).poll_write(cx, write_buf) {
|
||||
Poll::Ready(Ok(n)) => {
|
||||
if reserved_bytes > n as u64 {
|
||||
let refund = reserved_bytes - n as u64;
|
||||
let mut current = this.user_stats.quota_used.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(refund);
|
||||
match this.user_stats.quota_used.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
refund_reserved_quota_bytes(
|
||||
this.user_stats.as_ref(),
|
||||
reserved_bytes - n as u64,
|
||||
);
|
||||
}
|
||||
if shaper_reserved_bytes > n as u64
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes - n as u64);
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
Self::record_wait(&mut this.s2c_wait, Some(lease), RateDirection::Down);
|
||||
}
|
||||
let n_to_charge = n as u64;
|
||||
|
||||
// S→C: data written to client
|
||||
@@ -512,37 +692,23 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
}
|
||||
Poll::Ready(Err(err)) => {
|
||||
if reserved_bytes > 0 {
|
||||
let mut current = this.user_stats.quota_used.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(reserved_bytes);
|
||||
match this.user_stats.quota_used.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > 0
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
Poll::Ready(Err(err))
|
||||
}
|
||||
Poll::Pending => {
|
||||
if reserved_bytes > 0 {
|
||||
let mut current = this.user_stats.quota_used.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(reserved_bytes);
|
||||
match this.user_stats.quota_used.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > 0
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
@@ -627,6 +793,43 @@ pub async fn relay_bidirectional_with_activity_timeout<CR, CW, SR, SW>(
|
||||
_buffer_pool: Arc<BufferPool>,
|
||||
activity_timeout: Duration,
|
||||
) -> Result<()>
|
||||
where
|
||||
CR: AsyncRead + Unpin + Send + 'static,
|
||||
CW: AsyncWrite + Unpin + Send + 'static,
|
||||
SR: AsyncRead + Unpin + Send + 'static,
|
||||
SW: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
relay_bidirectional_with_activity_timeout_and_lease(
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
c2s_buf_size,
|
||||
s2c_buf_size,
|
||||
user,
|
||||
stats,
|
||||
quota_limit,
|
||||
_buffer_pool,
|
||||
None,
|
||||
activity_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn relay_bidirectional_with_activity_timeout_and_lease<CR, CW, SR, SW>(
|
||||
client_reader: CR,
|
||||
client_writer: CW,
|
||||
server_reader: SR,
|
||||
server_writer: SW,
|
||||
c2s_buf_size: usize,
|
||||
s2c_buf_size: usize,
|
||||
user: &str,
|
||||
stats: Arc<Stats>,
|
||||
quota_limit: Option<u64>,
|
||||
_buffer_pool: Arc<BufferPool>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
activity_timeout: Duration,
|
||||
) -> Result<()>
|
||||
where
|
||||
CR: AsyncRead + Unpin + Send + 'static,
|
||||
CW: AsyncWrite + Unpin + Send + 'static,
|
||||
@@ -644,11 +847,12 @@ where
|
||||
let mut server = CombinedStream::new(server_reader, server_writer);
|
||||
|
||||
// Wrap client with stats/activity tracking
|
||||
let mut client = StatsIo::new(
|
||||
let mut client = StatsIo::new_with_traffic_lease(
|
||||
client_combined,
|
||||
Arc::clone(&counters),
|
||||
Arc::clone(&stats),
|
||||
user_owned.clone(),
|
||||
traffic_lease,
|
||||
quota_limit,
|
||||
Arc::clone("a_exceeded),
|
||||
epoch,
|
||||
|
||||
@@ -10,6 +10,7 @@ use tokio::sync::mpsc;
|
||||
|
||||
use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState};
|
||||
use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry};
|
||||
use crate::proxy::traffic_limiter::TrafficLimiter;
|
||||
|
||||
const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64;
|
||||
|
||||
@@ -65,6 +66,7 @@ pub(crate) struct MiddleRelaySharedState {
|
||||
pub(crate) struct ProxySharedState {
|
||||
pub(crate) handshake: HandshakeSharedState,
|
||||
pub(crate) middle_relay: MiddleRelaySharedState,
|
||||
pub(crate) traffic_limiter: Arc<TrafficLimiter>,
|
||||
pub(crate) conntrack_pressure_active: AtomicBool,
|
||||
pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>,
|
||||
}
|
||||
@@ -98,6 +100,7 @@ impl ProxySharedState {
|
||||
relay_idle_registry: Mutex::new(RelayIdleCandidateRegistry::default()),
|
||||
relay_idle_mark_seq: AtomicU64::new(0),
|
||||
},
|
||||
traffic_limiter: TrafficLimiter::new(),
|
||||
conntrack_pressure_active: AtomicBool::new(false),
|
||||
conntrack_close_tx: Mutex::new(None),
|
||||
})
|
||||
|
||||
@@ -31,11 +31,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -27,11 +27,14 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -25,11 +25,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -38,11 +38,14 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -16,11 +16,14 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -39,11 +39,14 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -229,11 +232,14 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -470,11 +476,14 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -544,11 +553,14 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -13,11 +13,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -11,11 +11,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -25,11 +25,14 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -332,11 +332,14 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -446,11 +449,14 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -570,11 +576,14 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -740,11 +749,14 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() {
|
||||
upstream_type: crate::config::UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -817,11 +829,14 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load(
|
||||
upstream_type: crate::config::UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -977,11 +992,14 @@ async fn short_tls_probe_is_masked_through_client_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1065,11 +1083,14 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1151,11 +1172,14 @@ async fn handle_client_stream_increments_connects_all_exactly_once() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1244,11 +1268,14 @@ async fn running_client_handler_increments_connects_all_exactly_once() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1334,11 +1361,14 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1405,11 +1435,14 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1491,11 +1524,14 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1816,11 +1852,14 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1925,11 +1964,14 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2032,11 +2074,14 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2154,11 +2199,14 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2247,11 +2295,14 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2346,11 +2397,14 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2439,6 +2493,46 @@ fn unexpected_eof_is_classified_without_string_matching() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_reset_is_classified_as_expected_handshake_close() {
|
||||
let beobachten = BeobachtenStore::new();
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = true;
|
||||
config.general.beobachten_minutes = 1;
|
||||
|
||||
let reset = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
|
||||
let peer_ip: IpAddr = "198.51.100.202".parse().unwrap();
|
||||
|
||||
record_handshake_failure_class(&beobachten, &config, peer_ip, &reset);
|
||||
|
||||
let snapshot = beobachten.snapshot_text(Duration::from_secs(60));
|
||||
assert!(
|
||||
snapshot.contains("[expected_64_got_0]"),
|
||||
"ConnectionReset must be classified as expected handshake close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_io_unexpected_eof_is_classified_without_string_matching() {
|
||||
let beobachten = BeobachtenStore::new();
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = true;
|
||||
config.general.beobachten_minutes = 1;
|
||||
|
||||
let eof = ProxyError::Stream(StreamError::Io(std::io::Error::from(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
)));
|
||||
let peer_ip: IpAddr = "198.51.100.203".parse().unwrap();
|
||||
|
||||
record_handshake_failure_class(&beobachten, &config, peer_ip, &eof);
|
||||
|
||||
let snapshot = beobachten.snapshot_text(Duration::from_secs(60));
|
||||
assert!(
|
||||
snapshot.contains("[expected_64_got_0]"),
|
||||
"StreamError::Io(UnexpectedEof) must be classified as expected handshake close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_eof_error_is_classified_as_other() {
|
||||
let beobachten = BeobachtenStore::new();
|
||||
@@ -3251,11 +3345,14 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -3812,11 +3909,14 @@ async fn untrusted_proxy_header_source_is_rejected() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -3882,11 +3982,14 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -3979,11 +4082,14 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4082,11 +4188,14 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4199,11 +4308,14 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4302,11 +4414,14 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4408,11 +4523,14 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4509,11 +4627,14 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -24,11 +24,14 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -26,11 +26,14 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -27,11 +27,14 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -41,11 +41,14 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -1293,11 +1293,14 @@ async fn direct_relay_abort_midflight_releases_route_gauge() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1400,11 +1403,14 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1522,11 +1528,14 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1758,8 +1767,11 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
100,
|
||||
@@ -1849,8 +1861,11 @@ async fn adversarial_direct_relay_cutover_integrity() {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
100,
|
||||
|
||||
@@ -1007,6 +1007,55 @@ async fn tls_unknown_sni_mask_policy_falls_back_to_bad_client() {
|
||||
assert!(matches!(result, HandshakeResult::BadClient { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_unknown_sni_reject_handshake_policy_emits_unrecognized_name_alert() {
|
||||
use tokio::io::{AsyncReadExt, duplex};
|
||||
|
||||
let secret = [0x4Au8; 16];
|
||||
let mut config = test_config_with_secret_hex("4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a");
|
||||
config.censorship.unknown_sni_action = UnknownSniAction::RejectHandshake;
|
||||
|
||||
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
|
||||
let rng = SecureRandom::new();
|
||||
let peer: SocketAddr = "198.51.100.192:44326".parse().unwrap();
|
||||
let handshake =
|
||||
make_valid_tls_client_hello_with_sni_and_alpn(&secret, 0, "unknown.example", &[b"h2"]);
|
||||
|
||||
// Wire up a duplex so we can inspect what the server writes towards the
|
||||
// client. We own the "peer side" half to read from it.
|
||||
let (server_side, mut peer_side) = duplex(1024);
|
||||
let (server_read, server_write) = tokio::io::split(server_side);
|
||||
|
||||
let result = handle_tls_handshake(
|
||||
&handshake,
|
||||
server_read,
|
||||
server_write,
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
&rng,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
HandshakeResult::Error(ProxyError::UnknownTlsSni)
|
||||
));
|
||||
|
||||
// Drain what the server wrote. We expect exactly one TLS alert record:
|
||||
// 0x15 0x03 0x03 0x00 0x02 0x02 0x70
|
||||
// (ContentType.alert, TLS 1.2, length=2, fatal, unrecognized_name)
|
||||
drop(result); // drops the server-side writer so peer_side sees EOF
|
||||
let mut buf = Vec::new();
|
||||
peer_side.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(
|
||||
buf,
|
||||
[0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70],
|
||||
"reject_handshake must emit a fatal unrecognized_name TLS alert"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_unknown_sni_accept_policy_continues_auth_path() {
|
||||
let secret = [0x4Bu8; 16];
|
||||
|
||||
@@ -47,7 +47,7 @@ async fn consume_client_data_stops_after_byte_cap_without_eof() {
|
||||
};
|
||||
let cap = 10_000usize;
|
||||
|
||||
consume_client_data(reader, cap).await;
|
||||
consume_client_data(reader, cap, MASK_RELAY_IDLE_TIMEOUT).await;
|
||||
|
||||
let total = produced.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
|
||||
@@ -31,7 +31,7 @@ async fn stalling_client_terminates_at_idle_not_relay_timeout() {
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
consume_client_data(reader, MASK_BUFFER_SIZE * 4),
|
||||
consume_client_data(reader, MASK_BUFFER_SIZE * 4, MASK_RELAY_IDLE_TIMEOUT),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -57,9 +57,12 @@ async fn fast_reader_drains_to_eof() {
|
||||
let data = vec![0xAAu8; 32 * 1024];
|
||||
let reader = std::io::Cursor::new(data);
|
||||
|
||||
tokio::time::timeout(MASK_RELAY_TIMEOUT, consume_client_data(reader, usize::MAX))
|
||||
.await
|
||||
.expect("consume_client_data did not complete for fast EOF reader");
|
||||
tokio::time::timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
consume_client_data(reader, usize::MAX, MASK_RELAY_IDLE_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.expect("consume_client_data did not complete for fast EOF reader");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -81,7 +84,7 @@ async fn io_error_terminates_cleanly() {
|
||||
|
||||
tokio::time::timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
consume_client_data(ErrReader, usize::MAX),
|
||||
consume_client_data(ErrReader, usize::MAX, MASK_RELAY_IDLE_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.expect("consume_client_data did not return on I/O error");
|
||||
|
||||
@@ -34,7 +34,11 @@ async fn consume_stall_stress_finishes_within_idle_budget() {
|
||||
set.spawn(async {
|
||||
tokio::time::timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
consume_client_data(OneByteThenStall { sent: false }, usize::MAX),
|
||||
consume_client_data(
|
||||
OneByteThenStall { sent: false },
|
||||
usize::MAX,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("consume_client_data exceeded relay timeout under stall load");
|
||||
@@ -54,11 +58,22 @@ async fn consume_stall_stress_finishes_within_idle_budget() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_zero_cap_returns_immediately() {
|
||||
async fn consume_zero_cap_is_idle_bounded_on_stall() {
|
||||
let started = Instant::now();
|
||||
consume_client_data(tokio::io::empty(), 0).await;
|
||||
tokio::time::timeout(
|
||||
MASK_RELAY_TIMEOUT,
|
||||
consume_client_data(OneByteThenStall { sent: false }, 0, MASK_RELAY_IDLE_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.expect("zero-cap consume path must remain bounded by timeout guards");
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
started.elapsed() < MASK_RELAY_IDLE_TIMEOUT,
|
||||
"zero byte cap must return immediately"
|
||||
elapsed >= (MASK_RELAY_IDLE_TIMEOUT / 2),
|
||||
"zero cap must not short-circuit before idle timeout path, got {elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < MASK_RELAY_TIMEOUT,
|
||||
"zero-cap consume path must complete before relay timeout, got {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,14 @@ async fn positive_copy_with_production_cap_stops_exactly_at_budget() {
|
||||
let mut reader = FinitePatternReader::new(PROD_CAP_BYTES + (256 * 1024), 4096, read_calls);
|
||||
let mut writer = CountingWriter::default();
|
||||
|
||||
let outcome = copy_with_idle_timeout(&mut reader, &mut writer, PROD_CAP_BYTES, true).await;
|
||||
let outcome = copy_with_idle_timeout(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
PROD_CAP_BYTES,
|
||||
true,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome.total, PROD_CAP_BYTES,
|
||||
@@ -141,16 +148,41 @@ async fn positive_copy_with_production_cap_stops_exactly_at_budget() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn negative_consume_with_zero_cap_performs_no_reads() {
|
||||
let read_calls = Arc::new(AtomicUsize::new(0));
|
||||
let reader = FinitePatternReader::new(1024, 64, Arc::clone(&read_calls));
|
||||
async fn consume_with_zero_cap_drains_until_eof() {
|
||||
let payload = 256 * 1024;
|
||||
let total_read = Arc::new(AtomicUsize::new(0));
|
||||
let reader = BudgetProbeReader::new(payload, Arc::clone(&total_read));
|
||||
|
||||
consume_client_data_with_timeout_and_cap(reader, 0).await;
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
0,
|
||||
MASK_RELAY_TIMEOUT,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
read_calls.load(Ordering::Relaxed),
|
||||
0,
|
||||
"zero cap must return before reading attacker-controlled bytes"
|
||||
total_read.load(Ordering::Relaxed),
|
||||
payload,
|
||||
"zero cap must disable byte budget and drain finite payload to EOF"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_with_zero_cap_drains_until_eof() {
|
||||
let read_calls = Arc::new(AtomicUsize::new(0));
|
||||
let payload = 73 * 1024;
|
||||
let mut reader = FinitePatternReader::new(payload, 3072, read_calls);
|
||||
let mut writer = CountingWriter::default();
|
||||
|
||||
let outcome =
|
||||
copy_with_idle_timeout(&mut reader, &mut writer, 0, true, MASK_RELAY_IDLE_TIMEOUT).await;
|
||||
|
||||
assert_eq!(outcome.total, payload);
|
||||
assert_eq!(writer.written, payload);
|
||||
assert!(
|
||||
outcome.ended_by_eof,
|
||||
"zero cap must not terminate relay early on byte budget"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +193,14 @@ async fn edge_copy_below_cap_reports_eof_without_overread() {
|
||||
let mut reader = FinitePatternReader::new(payload, 3072, read_calls);
|
||||
let mut writer = CountingWriter::default();
|
||||
|
||||
let outcome = copy_with_idle_timeout(&mut reader, &mut writer, PROD_CAP_BYTES, true).await;
|
||||
let outcome = copy_with_idle_timeout(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
PROD_CAP_BYTES,
|
||||
true,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.total, payload);
|
||||
assert_eq!(writer.written, payload);
|
||||
@@ -175,7 +214,13 @@ async fn edge_copy_below_cap_reports_eof_without_overread() {
|
||||
async fn adversarial_blackhat_never_ready_reader_is_bounded_by_timeout_guards() {
|
||||
let started = Instant::now();
|
||||
|
||||
consume_client_data_with_timeout_and_cap(NeverReadyReader, PROD_CAP_BYTES).await;
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
NeverReadyReader,
|
||||
PROD_CAP_BYTES,
|
||||
MASK_RELAY_TIMEOUT,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(350),
|
||||
@@ -190,7 +235,12 @@ async fn integration_consume_path_honors_production_cap_for_large_payload() {
|
||||
|
||||
let bounded = timeout(
|
||||
Duration::from_millis(350),
|
||||
consume_client_data_with_timeout_and_cap(reader, PROD_CAP_BYTES),
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
PROD_CAP_BYTES,
|
||||
MASK_RELAY_TIMEOUT,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -206,7 +256,13 @@ async fn adversarial_consume_path_never_reads_beyond_declared_byte_cap() {
|
||||
let total_read = Arc::new(AtomicUsize::new(0));
|
||||
let reader = BudgetProbeReader::new(256 * 1024, Arc::clone(&total_read));
|
||||
|
||||
consume_client_data_with_timeout_and_cap(reader, byte_cap).await;
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
byte_cap,
|
||||
MASK_RELAY_TIMEOUT,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
total_read.load(Ordering::Relaxed) <= byte_cap,
|
||||
@@ -231,7 +287,9 @@ async fn light_fuzz_cap_and_payload_matrix_preserves_min_budget_invariant() {
|
||||
let mut reader = FinitePatternReader::new(payload, chunk, read_calls);
|
||||
let mut writer = CountingWriter::default();
|
||||
|
||||
let outcome = copy_with_idle_timeout(&mut reader, &mut writer, cap, true).await;
|
||||
let outcome =
|
||||
copy_with_idle_timeout(&mut reader, &mut writer, cap, true, MASK_RELAY_IDLE_TIMEOUT)
|
||||
.await;
|
||||
let expected = payload.min(cap);
|
||||
|
||||
assert_eq!(
|
||||
@@ -261,7 +319,14 @@ async fn stress_parallel_copy_tasks_with_production_cap_complete_without_leaks()
|
||||
read_calls,
|
||||
);
|
||||
let mut writer = CountingWriter::default();
|
||||
copy_with_idle_timeout(&mut reader, &mut writer, PROD_CAP_BYTES, true).await
|
||||
copy_with_idle_timeout(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
PROD_CAP_BYTES,
|
||||
true,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ async fn relay_to_mask_enforces_masking_session_byte_cap() {
|
||||
0,
|
||||
false,
|
||||
32 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -81,6 +82,7 @@ async fn relay_to_mask_propagates_client_half_close_without_waiting_for_other_di
|
||||
0,
|
||||
false,
|
||||
32 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -1377,6 +1377,7 @@ async fn relay_to_mask_keeps_backend_to_client_flow_when_client_to_backend_stall
|
||||
0,
|
||||
false,
|
||||
5 * 1024 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -1508,6 +1509,7 @@ async fn relay_to_mask_timeout_cancels_and_drops_all_io_endpoints() {
|
||||
0,
|
||||
false,
|
||||
5 * 1024 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -228,6 +228,7 @@ async fn relay_path_idle_timeout_eviction_remains_effective() {
|
||||
0,
|
||||
false,
|
||||
5 * 1024 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn run_relay_case(
|
||||
above_cap_blur_max_bytes,
|
||||
false,
|
||||
5 * 1024 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn relay_to_mask_applies_cap_clamped_padding_for_non_power_of_two_cap() {
|
||||
0,
|
||||
false,
|
||||
5 * 1024 * 1024,
|
||||
MASK_RELAY_IDLE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -53,11 +53,14 @@ fn new_client_harness() -> ClientHarness {
|
||||
upstream_type: UpstreamType::Direct {
|
||||
interface: None,
|
||||
bind_addresses: None,
|
||||
bindtodevice: None,
|
||||
},
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scopes: String::new(),
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use dashmap::DashMap;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::RateLimitBps;
|
||||
|
||||
const REGISTRY_SHARDS: usize = 64;
|
||||
const FAIR_EPOCH_MS: u64 = 20;
|
||||
const MAX_BORROW_CHUNK_BYTES: u64 = 32 * 1024;
|
||||
const CLEANUP_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RateDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TrafficConsumeResult {
|
||||
pub granted: u64,
|
||||
pub blocked_user: bool,
|
||||
pub blocked_cidr: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TrafficLimiterMetricsSnapshot {
|
||||
pub user_throttle_up_total: u64,
|
||||
pub user_throttle_down_total: u64,
|
||||
pub cidr_throttle_up_total: u64,
|
||||
pub cidr_throttle_down_total: u64,
|
||||
pub user_wait_up_ms_total: u64,
|
||||
pub user_wait_down_ms_total: u64,
|
||||
pub cidr_wait_up_ms_total: u64,
|
||||
pub cidr_wait_down_ms_total: u64,
|
||||
pub user_active_leases: u64,
|
||||
pub cidr_active_leases: u64,
|
||||
pub user_policy_entries: u64,
|
||||
pub cidr_policy_entries: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScopeMetrics {
|
||||
throttle_up_total: AtomicU64,
|
||||
throttle_down_total: AtomicU64,
|
||||
wait_up_ms_total: AtomicU64,
|
||||
wait_down_ms_total: AtomicU64,
|
||||
active_leases: AtomicU64,
|
||||
policy_entries: AtomicU64,
|
||||
}
|
||||
|
||||
impl ScopeMetrics {
|
||||
fn throttle(&self, direction: RateDirection) {
|
||||
match direction {
|
||||
RateDirection::Up => {
|
||||
self.throttle_up_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
RateDirection::Down => {
|
||||
self.throttle_down_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_ms(&self, direction: RateDirection, wait_ms: u64) {
|
||||
match direction {
|
||||
RateDirection::Up => {
|
||||
self.wait_up_ms_total.fetch_add(wait_ms, Ordering::Relaxed);
|
||||
}
|
||||
RateDirection::Down => {
|
||||
self.wait_down_ms_total
|
||||
.fetch_add(wait_ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AtomicRatePair {
|
||||
up_bps: AtomicU64,
|
||||
down_bps: AtomicU64,
|
||||
}
|
||||
|
||||
impl AtomicRatePair {
|
||||
fn set(&self, limits: RateLimitBps) {
|
||||
self.up_bps.store(limits.up_bps, Ordering::Relaxed);
|
||||
self.down_bps.store(limits.down_bps, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn get(&self, direction: RateDirection) -> u64 {
|
||||
match direction {
|
||||
RateDirection::Up => self.up_bps.load(Ordering::Relaxed),
|
||||
RateDirection::Down => self.down_bps.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DirectionBucket {
|
||||
epoch: AtomicU64,
|
||||
used: AtomicU64,
|
||||
}
|
||||
|
||||
impl DirectionBucket {
|
||||
fn sync_epoch(&self, epoch: u64) {
|
||||
let current = self.epoch.load(Ordering::Relaxed);
|
||||
if current == epoch {
|
||||
return;
|
||||
}
|
||||
if current < epoch
|
||||
&& self
|
||||
.epoch
|
||||
.compare_exchange(current, epoch, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
self.used.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_consume(&self, cap_bps: u64, requested: u64) -> u64 {
|
||||
if requested == 0 {
|
||||
return 0;
|
||||
}
|
||||
if cap_bps == 0 {
|
||||
return requested;
|
||||
}
|
||||
|
||||
let epoch = current_epoch();
|
||||
self.sync_epoch(epoch);
|
||||
let cap_epoch = bytes_per_epoch(cap_bps);
|
||||
|
||||
loop {
|
||||
let used = self.used.load(Ordering::Relaxed);
|
||||
if used >= cap_epoch {
|
||||
return 0;
|
||||
}
|
||||
let remaining = cap_epoch.saturating_sub(used);
|
||||
let grant = requested.min(remaining);
|
||||
if grant == 0 {
|
||||
return 0;
|
||||
}
|
||||
let next = used.saturating_add(grant);
|
||||
if self
|
||||
.used
|
||||
.compare_exchange_weak(used, next, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
return grant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refund(&self, bytes: u64) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
decrement_atomic_saturating(&self.used, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
struct UserBucket {
|
||||
rates: AtomicRatePair,
|
||||
up: DirectionBucket,
|
||||
down: DirectionBucket,
|
||||
active_leases: AtomicU64,
|
||||
}
|
||||
|
||||
impl UserBucket {
|
||||
fn new(limits: RateLimitBps) -> Self {
|
||||
let rates = AtomicRatePair::default();
|
||||
rates.set(limits);
|
||||
Self {
|
||||
rates,
|
||||
up: DirectionBucket::default(),
|
||||
down: DirectionBucket::default(),
|
||||
active_leases: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rates(&self, limits: RateLimitBps) {
|
||||
self.rates.set(limits);
|
||||
}
|
||||
|
||||
fn try_consume(&self, direction: RateDirection, requested: u64) -> u64 {
|
||||
let cap_bps = self.rates.get(direction);
|
||||
match direction {
|
||||
RateDirection::Up => self.up.try_consume(cap_bps, requested),
|
||||
RateDirection::Down => self.down.try_consume(cap_bps, requested),
|
||||
}
|
||||
}
|
||||
|
||||
fn refund(&self, direction: RateDirection, bytes: u64) {
|
||||
match direction {
|
||||
RateDirection::Up => self.up.refund(bytes),
|
||||
RateDirection::Down => self.down.refund(bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CidrDirectionBucket {
|
||||
epoch: AtomicU64,
|
||||
used: AtomicU64,
|
||||
active_users: AtomicU64,
|
||||
}
|
||||
|
||||
impl CidrDirectionBucket {
|
||||
fn sync_epoch(&self, epoch: u64) {
|
||||
let current = self.epoch.load(Ordering::Relaxed);
|
||||
if current == epoch {
|
||||
return;
|
||||
}
|
||||
if current < epoch
|
||||
&& self
|
||||
.epoch
|
||||
.compare_exchange(current, epoch, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
self.used.store(0, Ordering::Relaxed);
|
||||
self.active_users.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_consume(
|
||||
&self,
|
||||
user_state: &CidrUserDirectionState,
|
||||
cap_epoch: u64,
|
||||
requested: u64,
|
||||
) -> u64 {
|
||||
if requested == 0 || cap_epoch == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let epoch = current_epoch();
|
||||
self.sync_epoch(epoch);
|
||||
user_state.sync_epoch_and_mark_active(epoch, &self.active_users);
|
||||
let active_users = self.active_users.load(Ordering::Relaxed).max(1);
|
||||
let fair_share = cap_epoch.saturating_div(active_users).max(1);
|
||||
|
||||
loop {
|
||||
let total_used = self.used.load(Ordering::Relaxed);
|
||||
if total_used >= cap_epoch {
|
||||
return 0;
|
||||
}
|
||||
let total_remaining = cap_epoch.saturating_sub(total_used);
|
||||
let user_used = user_state.used.load(Ordering::Relaxed);
|
||||
let guaranteed_remaining = fair_share.saturating_sub(user_used);
|
||||
|
||||
let grant = if guaranteed_remaining > 0 {
|
||||
requested.min(guaranteed_remaining).min(total_remaining)
|
||||
} else {
|
||||
requested.min(total_remaining).min(MAX_BORROW_CHUNK_BYTES)
|
||||
};
|
||||
|
||||
if grant == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let next_total = total_used.saturating_add(grant);
|
||||
if self
|
||||
.used
|
||||
.compare_exchange_weak(total_used, next_total, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
user_state.used.fetch_add(grant, Ordering::Relaxed);
|
||||
return grant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refund(&self, bytes: u64) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
decrement_atomic_saturating(&self.used, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CidrUserDirectionState {
|
||||
epoch: AtomicU64,
|
||||
used: AtomicU64,
|
||||
}
|
||||
|
||||
impl CidrUserDirectionState {
|
||||
fn sync_epoch_and_mark_active(&self, epoch: u64, active_users: &AtomicU64) {
|
||||
let current = self.epoch.load(Ordering::Relaxed);
|
||||
if current == epoch {
|
||||
return;
|
||||
}
|
||||
if current < epoch
|
||||
&& self
|
||||
.epoch
|
||||
.compare_exchange(current, epoch, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
self.used.store(0, Ordering::Relaxed);
|
||||
active_users.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn refund(&self, bytes: u64) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
decrement_atomic_saturating(&self.used, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
struct CidrUserShare {
|
||||
active_conns: AtomicU64,
|
||||
up: CidrUserDirectionState,
|
||||
down: CidrUserDirectionState,
|
||||
}
|
||||
|
||||
impl CidrUserShare {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
active_conns: AtomicU64::new(0),
|
||||
up: CidrUserDirectionState::default(),
|
||||
down: CidrUserDirectionState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CidrBucket {
|
||||
rates: AtomicRatePair,
|
||||
up: CidrDirectionBucket,
|
||||
down: CidrDirectionBucket,
|
||||
users: ShardedRegistry<CidrUserShare>,
|
||||
active_leases: AtomicU64,
|
||||
}
|
||||
|
||||
impl CidrBucket {
|
||||
fn new(limits: RateLimitBps) -> Self {
|
||||
let rates = AtomicRatePair::default();
|
||||
rates.set(limits);
|
||||
Self {
|
||||
rates,
|
||||
up: CidrDirectionBucket::default(),
|
||||
down: CidrDirectionBucket::default(),
|
||||
users: ShardedRegistry::new(REGISTRY_SHARDS),
|
||||
active_leases: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rates(&self, limits: RateLimitBps) {
|
||||
self.rates.set(limits);
|
||||
}
|
||||
|
||||
fn acquire_user_share(&self, user: &str) -> Arc<CidrUserShare> {
|
||||
let share = self.users.get_or_insert_with(user, CidrUserShare::new);
|
||||
share.active_conns.fetch_add(1, Ordering::Relaxed);
|
||||
share
|
||||
}
|
||||
|
||||
fn release_user_share(&self, user: &str, share: &Arc<CidrUserShare>) {
|
||||
decrement_atomic_saturating(&share.active_conns, 1);
|
||||
let share_for_remove = Arc::clone(share);
|
||||
let _ = self.users.remove_if(user, |candidate| {
|
||||
Arc::ptr_eq(candidate, &share_for_remove)
|
||||
&& candidate.active_conns.load(Ordering::Relaxed) == 0
|
||||
});
|
||||
}
|
||||
|
||||
fn try_consume_for_user(
|
||||
&self,
|
||||
direction: RateDirection,
|
||||
share: &CidrUserShare,
|
||||
requested: u64,
|
||||
) -> u64 {
|
||||
let cap_bps = self.rates.get(direction);
|
||||
if cap_bps == 0 {
|
||||
return requested;
|
||||
}
|
||||
let cap_epoch = bytes_per_epoch(cap_bps);
|
||||
match direction {
|
||||
RateDirection::Up => self.up.try_consume(&share.up, cap_epoch, requested),
|
||||
RateDirection::Down => self.down.try_consume(&share.down, cap_epoch, requested),
|
||||
}
|
||||
}
|
||||
|
||||
fn refund_for_user(&self, direction: RateDirection, share: &CidrUserShare, bytes: u64) {
|
||||
match direction {
|
||||
RateDirection::Up => {
|
||||
self.up.refund(bytes);
|
||||
share.up.refund(bytes);
|
||||
}
|
||||
RateDirection::Down => {
|
||||
self.down.refund(bytes);
|
||||
share.down.refund(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_idle_users(&self) {
|
||||
self.users
|
||||
.retain(|_, share| share.active_conns.load(Ordering::Relaxed) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CidrRule {
|
||||
key: String,
|
||||
cidr: IpNetwork,
|
||||
limits: RateLimitBps,
|
||||
prefix_len: u8,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicySnapshot {
|
||||
user_limits: HashMap<String, RateLimitBps>,
|
||||
cidr_rules_v4: Vec<CidrRule>,
|
||||
cidr_rules_v6: Vec<CidrRule>,
|
||||
cidr_rule_keys: HashSet<String>,
|
||||
}
|
||||
|
||||
impl PolicySnapshot {
|
||||
fn match_cidr(&self, ip: IpAddr) -> Option<&CidrRule> {
|
||||
match ip {
|
||||
IpAddr::V4(_) => self
|
||||
.cidr_rules_v4
|
||||
.iter()
|
||||
.find(|rule| rule.cidr.contains(ip)),
|
||||
IpAddr::V6(_) => self
|
||||
.cidr_rules_v6
|
||||
.iter()
|
||||
.find(|rule| rule.cidr.contains(ip)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShardedRegistry<T> {
|
||||
shards: Box<[DashMap<String, Arc<T>>]>,
|
||||
mask: usize,
|
||||
}
|
||||
|
||||
impl<T> ShardedRegistry<T> {
|
||||
fn new(shards: usize) -> Self {
|
||||
let shard_count = shards.max(1).next_power_of_two();
|
||||
let mut items = Vec::with_capacity(shard_count);
|
||||
for _ in 0..shard_count {
|
||||
items.push(DashMap::<String, Arc<T>>::new());
|
||||
}
|
||||
Self {
|
||||
shards: items.into_boxed_slice(),
|
||||
mask: shard_count.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn shard_index(&self, key: &str) -> usize {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
(hasher.finish() as usize) & self.mask
|
||||
}
|
||||
|
||||
fn get_or_insert_with<F>(&self, key: &str, make: F) -> Arc<T>
|
||||
where
|
||||
F: FnOnce() -> T,
|
||||
{
|
||||
let shard = &self.shards[self.shard_index(key)];
|
||||
match shard.entry(key.to_string()) {
|
||||
dashmap::mapref::entry::Entry::Occupied(entry) => Arc::clone(entry.get()),
|
||||
dashmap::mapref::entry::Entry::Vacant(slot) => {
|
||||
let value = Arc::new(make());
|
||||
slot.insert(Arc::clone(&value));
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retain<F>(&self, predicate: F)
|
||||
where
|
||||
F: Fn(&String, &Arc<T>) -> bool + Copy,
|
||||
{
|
||||
for shard in &*self.shards {
|
||||
shard.retain(|key, value| predicate(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_if<F>(&self, key: &str, predicate: F) -> bool
|
||||
where
|
||||
F: Fn(&Arc<T>) -> bool,
|
||||
{
|
||||
let shard = &self.shards[self.shard_index(key)];
|
||||
let should_remove = match shard.get(key) {
|
||||
Some(entry) => predicate(entry.value()),
|
||||
None => false,
|
||||
};
|
||||
if !should_remove {
|
||||
return false;
|
||||
}
|
||||
shard.remove(key).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TrafficLease {
|
||||
limiter: Arc<TrafficLimiter>,
|
||||
user_bucket: Option<Arc<UserBucket>>,
|
||||
cidr_bucket: Option<Arc<CidrBucket>>,
|
||||
cidr_user_key: Option<String>,
|
||||
cidr_user_share: Option<Arc<CidrUserShare>>,
|
||||
}
|
||||
|
||||
impl TrafficLease {
|
||||
pub fn try_consume(&self, direction: RateDirection, requested: u64) -> TrafficConsumeResult {
|
||||
if requested == 0 {
|
||||
return TrafficConsumeResult {
|
||||
granted: 0,
|
||||
blocked_user: false,
|
||||
blocked_cidr: false,
|
||||
};
|
||||
}
|
||||
|
||||
let mut granted = requested;
|
||||
if let Some(user_bucket) = self.user_bucket.as_ref() {
|
||||
let user_granted = user_bucket.try_consume(direction, granted);
|
||||
if user_granted == 0 {
|
||||
self.limiter.observe_throttle(direction, true, false);
|
||||
return TrafficConsumeResult {
|
||||
granted: 0,
|
||||
blocked_user: true,
|
||||
blocked_cidr: false,
|
||||
};
|
||||
}
|
||||
granted = user_granted;
|
||||
}
|
||||
|
||||
if let (Some(cidr_bucket), Some(cidr_user_share)) =
|
||||
(self.cidr_bucket.as_ref(), self.cidr_user_share.as_ref())
|
||||
{
|
||||
let cidr_granted =
|
||||
cidr_bucket.try_consume_for_user(direction, cidr_user_share, granted);
|
||||
if cidr_granted < granted
|
||||
&& let Some(user_bucket) = self.user_bucket.as_ref()
|
||||
{
|
||||
user_bucket.refund(direction, granted.saturating_sub(cidr_granted));
|
||||
}
|
||||
if cidr_granted == 0 {
|
||||
self.limiter.observe_throttle(direction, false, true);
|
||||
return TrafficConsumeResult {
|
||||
granted: 0,
|
||||
blocked_user: false,
|
||||
blocked_cidr: true,
|
||||
};
|
||||
}
|
||||
granted = cidr_granted;
|
||||
}
|
||||
|
||||
TrafficConsumeResult {
|
||||
granted,
|
||||
blocked_user: false,
|
||||
blocked_cidr: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refund(&self, direction: RateDirection, bytes: u64) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(user_bucket) = self.user_bucket.as_ref() {
|
||||
user_bucket.refund(direction, bytes);
|
||||
}
|
||||
if let (Some(cidr_bucket), Some(cidr_user_share)) =
|
||||
(self.cidr_bucket.as_ref(), self.cidr_user_share.as_ref())
|
||||
{
|
||||
cidr_bucket.refund_for_user(direction, cidr_user_share, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observe_wait_ms(
|
||||
&self,
|
||||
direction: RateDirection,
|
||||
blocked_user: bool,
|
||||
blocked_cidr: bool,
|
||||
wait_ms: u64,
|
||||
) {
|
||||
if wait_ms == 0 {
|
||||
return;
|
||||
}
|
||||
self.limiter
|
||||
.observe_wait(direction, blocked_user, blocked_cidr, wait_ms);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TrafficLease {
|
||||
fn drop(&mut self) {
|
||||
if let Some(bucket) = self.user_bucket.as_ref() {
|
||||
decrement_atomic_saturating(&bucket.active_leases, 1);
|
||||
decrement_atomic_saturating(&self.limiter.user_scope.active_leases, 1);
|
||||
}
|
||||
|
||||
if let Some(bucket) = self.cidr_bucket.as_ref() {
|
||||
if let (Some(user_key), Some(share)) =
|
||||
(self.cidr_user_key.as_ref(), self.cidr_user_share.as_ref())
|
||||
{
|
||||
bucket.release_user_share(user_key, share);
|
||||
}
|
||||
decrement_atomic_saturating(&bucket.active_leases, 1);
|
||||
decrement_atomic_saturating(&self.limiter.cidr_scope.active_leases, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TrafficLimiter {
|
||||
policy: ArcSwap<PolicySnapshot>,
|
||||
user_buckets: ShardedRegistry<UserBucket>,
|
||||
cidr_buckets: ShardedRegistry<CidrBucket>,
|
||||
user_scope: ScopeMetrics,
|
||||
cidr_scope: ScopeMetrics,
|
||||
last_cleanup_epoch_secs: AtomicU64,
|
||||
}
|
||||
|
||||
impl TrafficLimiter {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
policy: ArcSwap::from_pointee(PolicySnapshot::default()),
|
||||
user_buckets: ShardedRegistry::new(REGISTRY_SHARDS),
|
||||
cidr_buckets: ShardedRegistry::new(REGISTRY_SHARDS),
|
||||
user_scope: ScopeMetrics::default(),
|
||||
cidr_scope: ScopeMetrics::default(),
|
||||
last_cleanup_epoch_secs: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_policy(
|
||||
&self,
|
||||
user_limits: HashMap<String, RateLimitBps>,
|
||||
cidr_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||
) {
|
||||
let filtered_users = user_limits
|
||||
.into_iter()
|
||||
.filter(|(_, limit)| limit.up_bps > 0 || limit.down_bps > 0)
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut cidr_rules_v4 = Vec::new();
|
||||
let mut cidr_rules_v6 = Vec::new();
|
||||
let mut cidr_rule_keys = HashSet::new();
|
||||
for (cidr, limits) in cidr_limits {
|
||||
if limits.up_bps == 0 && limits.down_bps == 0 {
|
||||
continue;
|
||||
}
|
||||
let key = cidr.to_string();
|
||||
let rule = CidrRule {
|
||||
key: key.clone(),
|
||||
cidr,
|
||||
limits,
|
||||
prefix_len: cidr.prefix(),
|
||||
};
|
||||
cidr_rule_keys.insert(key);
|
||||
match rule.cidr {
|
||||
IpNetwork::V4(_) => cidr_rules_v4.push(rule),
|
||||
IpNetwork::V6(_) => cidr_rules_v6.push(rule),
|
||||
}
|
||||
}
|
||||
|
||||
cidr_rules_v4.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
cidr_rules_v6.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
|
||||
self.user_scope
|
||||
.policy_entries
|
||||
.store(filtered_users.len() as u64, Ordering::Relaxed);
|
||||
self.cidr_scope
|
||||
.policy_entries
|
||||
.store(cidr_rule_keys.len() as u64, Ordering::Relaxed);
|
||||
|
||||
self.policy.store(Arc::new(PolicySnapshot {
|
||||
user_limits: filtered_users,
|
||||
cidr_rules_v4,
|
||||
cidr_rules_v6,
|
||||
cidr_rule_keys,
|
||||
}));
|
||||
|
||||
self.maybe_cleanup();
|
||||
}
|
||||
|
||||
pub fn acquire_lease(
|
||||
self: &Arc<Self>,
|
||||
user: &str,
|
||||
client_ip: IpAddr,
|
||||
) -> Option<Arc<TrafficLease>> {
|
||||
let policy = self.policy.load_full();
|
||||
let mut user_bucket = None;
|
||||
if let Some(limit) = policy.user_limits.get(user).copied() {
|
||||
let bucket = self
|
||||
.user_buckets
|
||||
.get_or_insert_with(user, || UserBucket::new(limit));
|
||||
bucket.set_rates(limit);
|
||||
bucket.active_leases.fetch_add(1, Ordering::Relaxed);
|
||||
self.user_scope
|
||||
.active_leases
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
user_bucket = Some(bucket);
|
||||
}
|
||||
|
||||
let mut cidr_bucket = None;
|
||||
let mut cidr_user_key = None;
|
||||
let mut cidr_user_share = None;
|
||||
if let Some(rule) = policy.match_cidr(client_ip) {
|
||||
let bucket = self
|
||||
.cidr_buckets
|
||||
.get_or_insert_with(rule.key.as_str(), || CidrBucket::new(rule.limits));
|
||||
bucket.set_rates(rule.limits);
|
||||
bucket.active_leases.fetch_add(1, Ordering::Relaxed);
|
||||
self.cidr_scope
|
||||
.active_leases
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let share = bucket.acquire_user_share(user);
|
||||
cidr_user_key = Some(user.to_string());
|
||||
cidr_user_share = Some(share);
|
||||
cidr_bucket = Some(bucket);
|
||||
}
|
||||
|
||||
if user_bucket.is_none() && cidr_bucket.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.maybe_cleanup();
|
||||
Some(Arc::new(TrafficLease {
|
||||
limiter: Arc::clone(self),
|
||||
user_bucket,
|
||||
cidr_bucket,
|
||||
cidr_user_key,
|
||||
cidr_user_share,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn metrics_snapshot(&self) -> TrafficLimiterMetricsSnapshot {
|
||||
TrafficLimiterMetricsSnapshot {
|
||||
user_throttle_up_total: self.user_scope.throttle_up_total.load(Ordering::Relaxed),
|
||||
user_throttle_down_total: self.user_scope.throttle_down_total.load(Ordering::Relaxed),
|
||||
cidr_throttle_up_total: self.cidr_scope.throttle_up_total.load(Ordering::Relaxed),
|
||||
cidr_throttle_down_total: self.cidr_scope.throttle_down_total.load(Ordering::Relaxed),
|
||||
user_wait_up_ms_total: self.user_scope.wait_up_ms_total.load(Ordering::Relaxed),
|
||||
user_wait_down_ms_total: self.user_scope.wait_down_ms_total.load(Ordering::Relaxed),
|
||||
cidr_wait_up_ms_total: self.cidr_scope.wait_up_ms_total.load(Ordering::Relaxed),
|
||||
cidr_wait_down_ms_total: self.cidr_scope.wait_down_ms_total.load(Ordering::Relaxed),
|
||||
user_active_leases: self.user_scope.active_leases.load(Ordering::Relaxed),
|
||||
cidr_active_leases: self.cidr_scope.active_leases.load(Ordering::Relaxed),
|
||||
user_policy_entries: self.user_scope.policy_entries.load(Ordering::Relaxed),
|
||||
cidr_policy_entries: self.cidr_scope.policy_entries.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_throttle(&self, direction: RateDirection, blocked_user: bool, blocked_cidr: bool) {
|
||||
if blocked_user {
|
||||
self.user_scope.throttle(direction);
|
||||
}
|
||||
if blocked_cidr {
|
||||
self.cidr_scope.throttle(direction);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_wait(
|
||||
&self,
|
||||
direction: RateDirection,
|
||||
blocked_user: bool,
|
||||
blocked_cidr: bool,
|
||||
wait_ms: u64,
|
||||
) {
|
||||
if blocked_user {
|
||||
self.user_scope.wait_ms(direction, wait_ms);
|
||||
}
|
||||
if blocked_cidr {
|
||||
self.cidr_scope.wait_ms(direction, wait_ms);
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_cleanup(&self) {
|
||||
let now_epoch_secs = now_epoch_secs();
|
||||
let last = self.last_cleanup_epoch_secs.load(Ordering::Relaxed);
|
||||
if now_epoch_secs.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.last_cleanup_epoch_secs
|
||||
.compare_exchange(last, now_epoch_secs, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = self.policy.load_full();
|
||||
self.user_buckets.retain(|user, bucket| {
|
||||
bucket.active_leases.load(Ordering::Relaxed) > 0
|
||||
|| policy.user_limits.contains_key(user)
|
||||
});
|
||||
self.cidr_buckets.retain(|cidr_key, bucket| {
|
||||
bucket.cleanup_idle_users();
|
||||
bucket.active_leases.load(Ordering::Relaxed) > 0
|
||||
|| policy.cidr_rule_keys.contains(cidr_key)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_refill_delay() -> Duration {
|
||||
let start = limiter_epoch_start();
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
let epoch_pos = elapsed_ms % FAIR_EPOCH_MS;
|
||||
let wait_ms = FAIR_EPOCH_MS.saturating_sub(epoch_pos).max(1);
|
||||
Duration::from_millis(wait_ms)
|
||||
}
|
||||
|
||||
fn decrement_atomic_saturating(counter: &AtomicU64, by: u64) {
|
||||
if by == 0 {
|
||||
return;
|
||||
}
|
||||
let mut current = counter.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current == 0 {
|
||||
return;
|
||||
}
|
||||
let next = current.saturating_sub(by);
|
||||
match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn bytes_per_epoch(bps: u64) -> u64 {
|
||||
if bps == 0 {
|
||||
return 0;
|
||||
}
|
||||
let numerator = bps.saturating_mul(FAIR_EPOCH_MS);
|
||||
let bytes = numerator.saturating_div(8_000);
|
||||
bytes.max(1)
|
||||
}
|
||||
|
||||
fn current_epoch() -> u64 {
|
||||
let start = limiter_epoch_start();
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
elapsed_ms / FAIR_EPOCH_MS
|
||||
}
|
||||
|
||||
fn limiter_epoch_start() -> &'static Instant {
|
||||
static START: OnceLock<Instant> = OnceLock::new();
|
||||
START.get_or_init(Instant::now)
|
||||
}
|
||||
+179
-3
@@ -88,6 +88,8 @@ impl Drop for RouteConnectionLease {
|
||||
pub struct Stats {
|
||||
connects_all: AtomicU64,
|
||||
connects_bad: AtomicU64,
|
||||
connects_bad_classes: DashMap<&'static str, AtomicU64>,
|
||||
handshake_failure_classes: DashMap<&'static str, AtomicU64>,
|
||||
current_connections_direct: AtomicU64,
|
||||
current_connections_me: AtomicU64,
|
||||
handshake_timeouts: AtomicU64,
|
||||
@@ -175,6 +177,18 @@ pub struct Stats {
|
||||
me_route_drop_queue_full: AtomicU64,
|
||||
me_route_drop_queue_full_base: AtomicU64,
|
||||
me_route_drop_queue_full_high: AtomicU64,
|
||||
me_fair_pressure_state_gauge: AtomicU64,
|
||||
me_fair_active_flows_gauge: AtomicU64,
|
||||
me_fair_queued_bytes_gauge: AtomicU64,
|
||||
me_fair_standing_flows_gauge: AtomicU64,
|
||||
me_fair_backpressured_flows_gauge: AtomicU64,
|
||||
me_fair_scheduler_rounds_total: AtomicU64,
|
||||
me_fair_deficit_grants_total: AtomicU64,
|
||||
me_fair_deficit_skips_total: AtomicU64,
|
||||
me_fair_enqueue_rejects_total: AtomicU64,
|
||||
me_fair_shed_drops_total: AtomicU64,
|
||||
me_fair_penalties_total: AtomicU64,
|
||||
me_fair_downstream_stalls_total: AtomicU64,
|
||||
me_d2c_batches_total: AtomicU64,
|
||||
me_d2c_batch_frames_total: AtomicU64,
|
||||
me_d2c_batch_bytes_total: AtomicU64,
|
||||
@@ -506,10 +520,32 @@ impl Stats {
|
||||
self.connects_all.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_connects_bad(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.connects_bad.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
pub fn increment_connects_bad_with_class(&self, class: &'static str) {
|
||||
if !self.telemetry_core_enabled() {
|
||||
return;
|
||||
}
|
||||
self.connects_bad.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = self
|
||||
.connects_bad_classes
|
||||
.entry(class)
|
||||
.or_insert_with(|| AtomicU64::new(0));
|
||||
entry.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn increment_connects_bad(&self) {
|
||||
self.increment_connects_bad_with_class("other");
|
||||
}
|
||||
|
||||
pub fn increment_handshake_failure_class(&self, class: &'static str) {
|
||||
if !self.telemetry_core_enabled() {
|
||||
return;
|
||||
}
|
||||
let entry = self
|
||||
.handshake_failure_classes
|
||||
.entry(class)
|
||||
.or_insert_with(|| AtomicU64::new(0));
|
||||
entry.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn increment_current_connections_direct(&self) {
|
||||
self.current_connections_direct
|
||||
@@ -856,6 +892,78 @@ impl Stats {
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_pressure_state_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_pressure_state_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_active_flows_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_active_flows_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_queued_bytes_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_queued_bytes_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_standing_flows_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_standing_flows_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_backpressured_flows_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_backpressured_flows_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_scheduler_rounds_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_scheduler_rounds_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_deficit_grants_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_deficit_grants_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_deficit_skips_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_deficit_skips_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_enqueue_rejects_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_enqueue_rejects_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_shed_drops_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_shed_drops_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_penalties_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_penalties_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_downstream_stalls_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_downstream_stalls_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_batches_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_batches_total.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -1556,6 +1664,37 @@ impl Stats {
|
||||
pub fn get_connects_bad(&self) -> u64 {
|
||||
self.connects_bad.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_connects_bad_class_counts(&self) -> Vec<(String, u64)> {
|
||||
let mut out: Vec<(String, u64)> = self
|
||||
.connects_bad_classes
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.key().to_string(),
|
||||
entry.value().load(Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get_handshake_failure_class_counts(&self) -> Vec<(String, u64)> {
|
||||
let mut out: Vec<(String, u64)> = self
|
||||
.handshake_failure_classes
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.key().to_string(),
|
||||
entry.value().load(Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get_accept_permit_timeout_total(&self) -> u64 {
|
||||
self.accept_permit_timeout_total.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -1806,6 +1945,43 @@ impl Stats {
|
||||
pub fn get_me_route_drop_queue_full_high(&self) -> u64 {
|
||||
self.me_route_drop_queue_full_high.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_pressure_state_gauge(&self) -> u64 {
|
||||
self.me_fair_pressure_state_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_active_flows_gauge(&self) -> u64 {
|
||||
self.me_fair_active_flows_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_queued_bytes_gauge(&self) -> u64 {
|
||||
self.me_fair_queued_bytes_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_standing_flows_gauge(&self) -> u64 {
|
||||
self.me_fair_standing_flows_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_backpressured_flows_gauge(&self) -> u64 {
|
||||
self.me_fair_backpressured_flows_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_scheduler_rounds_total(&self) -> u64 {
|
||||
self.me_fair_scheduler_rounds_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_deficit_grants_total(&self) -> u64 {
|
||||
self.me_fair_deficit_grants_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_deficit_skips_total(&self) -> u64 {
|
||||
self.me_fair_deficit_skips_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_enqueue_rejects_total(&self) -> u64 {
|
||||
self.me_fair_enqueue_rejects_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_shed_drops_total(&self) -> u64 {
|
||||
self.me_fair_shed_drops_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_penalties_total(&self) -> u64 {
|
||||
self.me_fair_penalties_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_downstream_stalls_total(&self) -> u64 {
|
||||
self.me_fair_downstream_stalls_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batches_total(&self) -> u64 {
|
||||
self.me_d2c_batches_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
+193
-53
@@ -5,12 +5,15 @@ use crate::protocol::constants::{
|
||||
MAX_TLS_CIPHERTEXT_SIZE, 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::protocol::tls::{
|
||||
ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key,
|
||||
};
|
||||
use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource};
|
||||
use crc32fast::Hasher;
|
||||
|
||||
const MIN_APP_DATA: usize = 64;
|
||||
const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE;
|
||||
const MAX_TICKET_RECORDS: usize = 4;
|
||||
|
||||
fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec<usize> {
|
||||
sizes
|
||||
@@ -62,6 +65,64 @@ fn ensure_payload_capacity(mut sizes: Vec<usize>, payload_len: usize) -> Vec<usi
|
||||
sizes
|
||||
}
|
||||
|
||||
fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
|
||||
match cached.behavior_profile.source {
|
||||
TlsProfileSource::Raw | TlsProfileSource::Merged => {
|
||||
return cached
|
||||
.app_data_records_sizes
|
||||
.first()
|
||||
.copied()
|
||||
.or_else(|| {
|
||||
cached
|
||||
.behavior_profile
|
||||
.app_data_record_sizes
|
||||
.first()
|
||||
.copied()
|
||||
})
|
||||
.map(|size| vec![size])
|
||||
.unwrap_or_else(|| vec![cached.total_app_data_len.max(1024)]);
|
||||
}
|
||||
TlsProfileSource::Default | TlsProfileSource::Rustls => {}
|
||||
}
|
||||
|
||||
let mut sizes = cached.app_data_records_sizes.clone();
|
||||
if sizes.is_empty() {
|
||||
sizes.push(cached.total_app_data_len.max(1024));
|
||||
}
|
||||
sizes
|
||||
}
|
||||
|
||||
fn emulated_change_cipher_spec_count(_cached: &CachedTlsData) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn emulated_ticket_record_sizes(
|
||||
cached: &CachedTlsData,
|
||||
new_session_tickets: u8,
|
||||
rng: &SecureRandom,
|
||||
) -> Vec<usize> {
|
||||
let target_count = usize::from(new_session_tickets.min(MAX_TICKET_RECORDS as u8));
|
||||
if target_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let profiled_sizes = match cached.behavior_profile.source {
|
||||
TlsProfileSource::Raw | TlsProfileSource::Merged => {
|
||||
cached.behavior_profile.ticket_record_sizes.as_slice()
|
||||
}
|
||||
TlsProfileSource::Default | TlsProfileSource::Rustls => &[],
|
||||
};
|
||||
|
||||
let mut sizes = Vec::with_capacity(target_count);
|
||||
sizes.extend(profiled_sizes.iter().copied().take(target_count));
|
||||
|
||||
while sizes.len() < target_count {
|
||||
sizes.push(rng.range(48) + 48);
|
||||
}
|
||||
|
||||
sizes
|
||||
}
|
||||
|
||||
fn build_compact_cert_info_payload(cert_info: &ParsedCertificateInfo) -> Option<Vec<u8>> {
|
||||
let mut fields = Vec::new();
|
||||
|
||||
@@ -131,6 +192,8 @@ pub fn build_emulated_server_hello(
|
||||
session_id: &[u8],
|
||||
cached: &CachedTlsData,
|
||||
use_full_cert_payload: bool,
|
||||
serverhello_compact: bool,
|
||||
client_tls_version: ClientHelloTlsVersion,
|
||||
rng: &SecureRandom,
|
||||
alpn: Option<Vec<u8>>,
|
||||
new_session_tickets: u8,
|
||||
@@ -180,53 +243,59 @@ pub fn build_emulated_server_hello(
|
||||
server_hello.extend_from_slice(&message);
|
||||
|
||||
// --- ChangeCipherSpec ---
|
||||
let change_cipher_spec = [
|
||||
TLS_RECORD_CHANGE_CIPHER,
|
||||
TLS_VERSION[0],
|
||||
TLS_VERSION[1],
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
];
|
||||
let change_cipher_spec_count = emulated_change_cipher_spec_count(cached);
|
||||
let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6);
|
||||
for _ in 0..change_cipher_spec_count {
|
||||
change_cipher_spec.extend_from_slice(&[
|
||||
TLS_RECORD_CHANGE_CIPHER,
|
||||
TLS_VERSION[0],
|
||||
TLS_VERSION[1],
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- ApplicationData (fake encrypted records) ---
|
||||
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));
|
||||
let mut sizes = {
|
||||
let base_sizes = emulated_app_data_sizes(cached);
|
||||
match cached.behavior_profile.source {
|
||||
TlsProfileSource::Raw | TlsProfileSource::Merged => base_sizes
|
||||
.into_iter()
|
||||
.map(|size| size.clamp(MIN_APP_DATA, MAX_APP_DATA))
|
||||
.collect(),
|
||||
TlsProfileSource::Default | TlsProfileSource::Rustls => {
|
||||
jitter_and_clamp_sizes(&base_sizes, rng)
|
||||
}
|
||||
sizes
|
||||
}
|
||||
};
|
||||
let mut sizes = jitter_and_clamp_sizes(&sizes, rng);
|
||||
let compact_payload = cached
|
||||
.cert_info
|
||||
.as_ref()
|
||||
.and_then(build_compact_cert_info_payload)
|
||||
.and_then(hash_compact_cert_info_payload);
|
||||
let selected_payload: Option<&[u8]> = if use_full_cert_payload {
|
||||
let compact_payload = if serverhello_compact {
|
||||
cached
|
||||
.cert_payload
|
||||
.cert_info
|
||||
.as_ref()
|
||||
.map(|payload| payload.certificate_message.as_slice())
|
||||
.filter(|payload| !payload.is_empty())
|
||||
.or(compact_payload.as_deref())
|
||||
.and_then(build_compact_cert_info_payload)
|
||||
.and_then(hash_compact_cert_info_payload)
|
||||
} else {
|
||||
compact_payload.as_deref()
|
||||
None
|
||||
};
|
||||
let full_payload = cached
|
||||
.cert_payload
|
||||
.as_ref()
|
||||
.map(|payload| payload.certificate_message.as_slice())
|
||||
.filter(|payload| !payload.is_empty());
|
||||
let selected_payload: Option<&[u8]> = match client_tls_version {
|
||||
ClientHelloTlsVersion::Tls13 => None,
|
||||
ClientHelloTlsVersion::Tls12 => {
|
||||
if serverhello_compact {
|
||||
if use_full_cert_payload {
|
||||
full_payload.or(compact_payload.as_deref())
|
||||
} else {
|
||||
compact_payload.as_deref()
|
||||
}
|
||||
} else {
|
||||
full_payload
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(payload) = selected_payload {
|
||||
@@ -299,17 +368,13 @@ pub fn build_emulated_server_hello(
|
||||
// --- Combine ---
|
||||
// Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint).
|
||||
let mut tickets = Vec::new();
|
||||
let ticket_count = new_session_tickets.min(4);
|
||||
if ticket_count > 0 {
|
||||
for _ in 0..ticket_count {
|
||||
let ticket_len: usize = rng.range(48) + 48;
|
||||
let mut rec = Vec::with_capacity(5 + ticket_len);
|
||||
rec.push(TLS_RECORD_APPLICATION);
|
||||
rec.extend_from_slice(&TLS_VERSION);
|
||||
rec.extend_from_slice(&(ticket_len as u16).to_be_bytes());
|
||||
rec.extend_from_slice(&rng.bytes(ticket_len));
|
||||
tickets.extend_from_slice(&rec);
|
||||
}
|
||||
for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) {
|
||||
let mut rec = Vec::with_capacity(5 + ticket_len);
|
||||
rec.push(TLS_RECORD_APPLICATION);
|
||||
rec.extend_from_slice(&TLS_VERSION);
|
||||
rec.extend_from_slice(&(ticket_len as u16).to_be_bytes());
|
||||
rec.extend_from_slice(&rng.bytes(ticket_len));
|
||||
tickets.extend_from_slice(&rec);
|
||||
}
|
||||
|
||||
let mut response = Vec::with_capacity(
|
||||
@@ -334,6 +399,10 @@ pub fn build_emulated_server_hello(
|
||||
#[path = "tests/emulator_security_tests.rs"]
|
||||
mod security_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/emulator_profile_fidelity_security_tests.rs"]
|
||||
mod emulator_profile_fidelity_security_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::SystemTime;
|
||||
@@ -350,6 +419,7 @@ mod tests {
|
||||
use crate::protocol::constants::{
|
||||
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
|
||||
};
|
||||
use crate::protocol::tls::ClientHelloTlsVersion;
|
||||
|
||||
fn first_app_data_payload(response: &[u8]) -> &[u8] {
|
||||
let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize;
|
||||
@@ -396,6 +466,8 @@ mod tests {
|
||||
&[0x22; 16],
|
||||
&cached,
|
||||
true,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls12,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
@@ -422,6 +494,8 @@ mod tests {
|
||||
&[0x33; 16],
|
||||
&cached,
|
||||
true,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls12,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
@@ -454,6 +528,8 @@ mod tests {
|
||||
&[0x55; 16],
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls12,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
@@ -478,7 +554,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_emulated_server_hello_ignores_tail_records_for_raw_profile() {
|
||||
fn test_build_emulated_server_hello_tls13_never_uses_cert_payload() {
|
||||
let cert_msg = vec![0x0b, 0x00, 0x00, 0x05, 0x00, 0xaa, 0xbb, 0xcc, 0xdd];
|
||||
let cached = make_cached(Some(TlsCertPayload {
|
||||
cert_chain_der: vec![vec![0x30, 0x01, 0x00]],
|
||||
certificate_message: cert_msg.clone(),
|
||||
}));
|
||||
|
||||
let rng = SecureRandom::new();
|
||||
let response = build_emulated_server_hello(
|
||||
b"secret",
|
||||
&[0x56; 32],
|
||||
&[0x78; 16],
|
||||
&cached,
|
||||
true,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
);
|
||||
|
||||
let payload = first_app_data_payload(&response);
|
||||
assert!(
|
||||
!payload.starts_with(&cert_msg),
|
||||
"TLS 1.3 response path must not expose certificate payload bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_emulated_server_hello_compact_disabled_skips_compact_payload() {
|
||||
let mut cached = make_cached(None);
|
||||
cached.cert_info = Some(crate::tls_front::types::ParsedCertificateInfo {
|
||||
not_after_unix: Some(1_900_000_000),
|
||||
not_before_unix: Some(1_700_000_000),
|
||||
issuer_cn: Some("Issuer".to_string()),
|
||||
subject_cn: Some("example.com".to_string()),
|
||||
san_names: vec!["example.com".to_string()],
|
||||
});
|
||||
|
||||
let rng = SecureRandom::new();
|
||||
let response = build_emulated_server_hello(
|
||||
b"secret",
|
||||
&[0x90; 32],
|
||||
&[0x91; 16],
|
||||
&cached,
|
||||
false,
|
||||
false,
|
||||
ClientHelloTlsVersion::Tls12,
|
||||
&rng,
|
||||
Some(b"h2".to_vec()),
|
||||
0,
|
||||
);
|
||||
|
||||
let payload = first_app_data_payload(&response);
|
||||
let expected_alpn_marker = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2'];
|
||||
assert!(
|
||||
payload.starts_with(&expected_alpn_marker),
|
||||
"when compact mode is disabled and no full cert payload exists, the random/alpn path must be used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_emulated_server_hello_ignores_tail_records_for_profiled_tls() {
|
||||
let mut cached = make_cached(None);
|
||||
cached.app_data_records_sizes = vec![27, 3905, 537, 69];
|
||||
cached.total_app_data_len = 4538;
|
||||
@@ -493,6 +631,8 @@ mod tests {
|
||||
&[0x34; 16],
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
@@ -503,8 +643,8 @@ mod tests {
|
||||
let app_start = ccs_start + 6;
|
||||
let app_len =
|
||||
u16::from_be_bytes([response[app_start + 3], response[app_start + 4]]) as usize;
|
||||
|
||||
assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
|
||||
assert_eq!(app_len, 64);
|
||||
assert_eq!(app_start + 5 + app_len, response.len());
|
||||
}
|
||||
}
|
||||
|
||||
+507
-61
@@ -1,6 +1,7 @@
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use dashmap::DashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -19,6 +20,7 @@ use rustls::client::ClientConfig;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{DigitallySignedStruct, Error as RustlsError};
|
||||
use x25519_dalek::{X25519_BASEPOINT_BYTES, x25519};
|
||||
|
||||
use x509_parser::certificate::X509Certificate;
|
||||
use x509_parser::prelude::FromDer;
|
||||
@@ -274,7 +276,7 @@ fn remember_profile_success(
|
||||
);
|
||||
}
|
||||
|
||||
fn build_client_config() -> Arc<ClientConfig> {
|
||||
fn build_client_config(alpn_protocols: &[&[u8]]) -> Arc<ClientConfig> {
|
||||
let root = rustls::RootCertStore::empty();
|
||||
|
||||
let provider = rustls::crypto::ring::default_provider();
|
||||
@@ -287,6 +289,7 @@ fn build_client_config() -> Arc<ClientConfig> {
|
||||
config
|
||||
.dangerous()
|
||||
.set_certificate_verifier(Arc::new(NoVerify));
|
||||
config.alpn_protocols = alpn_protocols.iter().map(|proto| proto.to_vec()).collect();
|
||||
|
||||
Arc::new(config)
|
||||
}
|
||||
@@ -358,6 +361,22 @@ fn profile_alpn(profile: TlsFetchProfile) -> &'static [&'static [u8]] {
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_alpn_labels(profile: TlsFetchProfile) -> &'static [&'static str] {
|
||||
const H2_HTTP11: &[&str] = &["h2", "http/1.1"];
|
||||
const HTTP11: &[&str] = &["http/1.1"];
|
||||
match profile {
|
||||
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => H2_HTTP11,
|
||||
TlsFetchProfile::CompatTls12 | TlsFetchProfile::LegacyMinimal => HTTP11,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_session_id_len(profile: TlsFetchProfile) -> usize {
|
||||
match profile {
|
||||
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => 32,
|
||||
TlsFetchProfile::CompatTls12 | TlsFetchProfile::LegacyMinimal => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_supported_versions(profile: TlsFetchProfile) -> &'static [u16] {
|
||||
const MODERN: &[u16] = &[0x0304, 0x0303];
|
||||
const COMPAT: &[u16] = &[0x0303, 0x0304];
|
||||
@@ -412,8 +431,20 @@ fn build_client_hello(
|
||||
body.extend_from_slice(&rng.bytes(32));
|
||||
}
|
||||
|
||||
// Session ID: empty
|
||||
body.push(0);
|
||||
// Use non-empty Session ID for modern TLS 1.3-like profiles to reduce middlebox friction.
|
||||
let session_id_len = profile_session_id_len(profile);
|
||||
let session_id = if session_id_len == 0 {
|
||||
Vec::new()
|
||||
} else if deterministic {
|
||||
deterministic_bytes(
|
||||
&format!("tls-fetch-session:{sni}:{}", profile.as_str()),
|
||||
session_id_len,
|
||||
)
|
||||
} else {
|
||||
rng.bytes(session_id_len)
|
||||
};
|
||||
body.push(session_id.len() as u8);
|
||||
body.extend_from_slice(&session_id);
|
||||
|
||||
let mut cipher_suites = profile_cipher_suites(profile).to_vec();
|
||||
if grease_enabled {
|
||||
@@ -432,16 +463,26 @@ fn build_client_hello(
|
||||
// === Extensions ===
|
||||
let mut exts = Vec::new();
|
||||
|
||||
let mut push_extension = |ext_type: u16, data: &[u8]| {
|
||||
exts.extend_from_slice(&ext_type.to_be_bytes());
|
||||
exts.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
||||
exts.extend_from_slice(data);
|
||||
};
|
||||
|
||||
// server_name (SNI)
|
||||
let sni_bytes = sni.as_bytes();
|
||||
let mut sni_ext = Vec::with_capacity(5 + sni_bytes.len());
|
||||
sni_ext.extend_from_slice(&(sni_bytes.len() as u16 + 3).to_be_bytes());
|
||||
sni_ext.push(0); // host_name
|
||||
sni_ext.push(0);
|
||||
sni_ext.extend_from_slice(&(sni_bytes.len() as u16).to_be_bytes());
|
||||
sni_ext.extend_from_slice(sni_bytes);
|
||||
exts.extend_from_slice(&0x0000u16.to_be_bytes());
|
||||
exts.extend_from_slice(&(sni_ext.len() as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&sni_ext);
|
||||
push_extension(0x0000, &sni_ext);
|
||||
|
||||
// Chrome-like profile keeps browser-like ordering and extension set.
|
||||
if matches!(profile, TlsFetchProfile::ModernChromeLike) {
|
||||
// ec_point_formats: uncompressed only.
|
||||
push_extension(0x000b, &[0x01, 0x00]);
|
||||
}
|
||||
|
||||
// supported_groups
|
||||
let mut groups = profile_groups(profile).to_vec();
|
||||
@@ -449,11 +490,16 @@ fn build_client_hello(
|
||||
let grease = grease_value(rng, deterministic, &format!("group:{sni}"));
|
||||
groups.insert(0, grease);
|
||||
}
|
||||
exts.extend_from_slice(&0x000au16.to_be_bytes());
|
||||
exts.extend_from_slice(&((2 + groups.len() * 2) as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&(groups.len() as u16 * 2).to_be_bytes());
|
||||
let mut groups_ext = Vec::with_capacity(2 + groups.len() * 2);
|
||||
groups_ext.extend_from_slice(&(groups.len() as u16 * 2).to_be_bytes());
|
||||
for g in groups {
|
||||
exts.extend_from_slice(&g.to_be_bytes());
|
||||
groups_ext.extend_from_slice(&g.to_be_bytes());
|
||||
}
|
||||
push_extension(0x000a, &groups_ext);
|
||||
|
||||
if matches!(profile, TlsFetchProfile::ModernChromeLike) {
|
||||
// session_ticket
|
||||
push_extension(0x0023, &[]);
|
||||
}
|
||||
|
||||
// signature_algorithms
|
||||
@@ -462,12 +508,12 @@ fn build_client_hello(
|
||||
let grease = grease_value(rng, deterministic, &format!("sigalg:{sni}"));
|
||||
sig_algs.insert(0, grease);
|
||||
}
|
||||
exts.extend_from_slice(&0x000du16.to_be_bytes());
|
||||
exts.extend_from_slice(&((2 + sig_algs.len() * 2) as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&(sig_algs.len() as u16 * 2).to_be_bytes());
|
||||
let mut sig_algs_ext = Vec::with_capacity(2 + sig_algs.len() * 2);
|
||||
sig_algs_ext.extend_from_slice(&(sig_algs.len() as u16 * 2).to_be_bytes());
|
||||
for a in sig_algs {
|
||||
exts.extend_from_slice(&a.to_be_bytes());
|
||||
sig_algs_ext.extend_from_slice(&a.to_be_bytes());
|
||||
}
|
||||
push_extension(0x000d, &sig_algs_ext);
|
||||
|
||||
// supported_versions
|
||||
let mut versions = profile_supported_versions(profile).to_vec();
|
||||
@@ -475,30 +521,32 @@ fn build_client_hello(
|
||||
let grease = grease_value(rng, deterministic, &format!("version:{sni}"));
|
||||
versions.insert(0, grease);
|
||||
}
|
||||
exts.extend_from_slice(&0x002bu16.to_be_bytes());
|
||||
exts.extend_from_slice(&((1 + versions.len() * 2) as u16).to_be_bytes());
|
||||
exts.push((versions.len() * 2) as u8);
|
||||
let mut versions_ext = Vec::with_capacity(1 + versions.len() * 2);
|
||||
versions_ext.push((versions.len() * 2) as u8);
|
||||
for v in versions {
|
||||
exts.extend_from_slice(&v.to_be_bytes());
|
||||
versions_ext.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
push_extension(0x002b, &versions_ext);
|
||||
|
||||
if matches!(profile, TlsFetchProfile::ModernChromeLike) {
|
||||
// psk_key_exchange_modes: psk_dhe_ke
|
||||
push_extension(0x002d, &[0x01, 0x01]);
|
||||
}
|
||||
|
||||
// key_share (x25519)
|
||||
let key = if deterministic {
|
||||
let det = deterministic_bytes(&format!("keyshare:{sni}"), 32);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&det);
|
||||
key
|
||||
} else {
|
||||
gen_key_share(rng)
|
||||
};
|
||||
let key = gen_key_share(
|
||||
rng,
|
||||
deterministic,
|
||||
&format!("tls-fetch-keyshare:{sni}:{}", profile.as_str()),
|
||||
);
|
||||
let mut keyshare = Vec::with_capacity(4 + key.len());
|
||||
keyshare.extend_from_slice(&0x001du16.to_be_bytes()); // group
|
||||
keyshare.extend_from_slice(&0x001du16.to_be_bytes());
|
||||
keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes());
|
||||
keyshare.extend_from_slice(&key);
|
||||
exts.extend_from_slice(&0x0033u16.to_be_bytes());
|
||||
exts.extend_from_slice(&((2 + keyshare.len()) as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&(keyshare.len() as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&keyshare);
|
||||
let mut keyshare_ext = Vec::with_capacity(2 + keyshare.len());
|
||||
keyshare_ext.extend_from_slice(&(keyshare.len() as u16).to_be_bytes());
|
||||
keyshare_ext.extend_from_slice(&keyshare);
|
||||
push_extension(0x0033, &keyshare_ext);
|
||||
|
||||
// ALPN
|
||||
let mut alpn_list = Vec::new();
|
||||
@@ -507,16 +555,15 @@ fn build_client_hello(
|
||||
alpn_list.extend_from_slice(proto);
|
||||
}
|
||||
if !alpn_list.is_empty() {
|
||||
exts.extend_from_slice(&0x0010u16.to_be_bytes());
|
||||
exts.extend_from_slice(&((2 + alpn_list.len()) as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&(alpn_list.len() as u16).to_be_bytes());
|
||||
exts.extend_from_slice(&alpn_list);
|
||||
let mut alpn_ext = Vec::with_capacity(2 + alpn_list.len());
|
||||
alpn_ext.extend_from_slice(&(alpn_list.len() as u16).to_be_bytes());
|
||||
alpn_ext.extend_from_slice(&alpn_list);
|
||||
push_extension(0x0010, &alpn_ext);
|
||||
}
|
||||
|
||||
if grease_enabled {
|
||||
let grease = grease_value(rng, deterministic, &format!("ext:{sni}"));
|
||||
exts.extend_from_slice(&grease.to_be_bytes());
|
||||
exts.extend_from_slice(&0u16.to_be_bytes());
|
||||
push_extension(grease, &[]);
|
||||
}
|
||||
|
||||
// padding to reduce recognizability and keep length ~500 bytes
|
||||
@@ -552,10 +599,14 @@ fn build_client_hello(
|
||||
record
|
||||
}
|
||||
|
||||
fn gen_key_share(rng: &SecureRandom) -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&rng.bytes(32));
|
||||
key
|
||||
fn gen_key_share(rng: &SecureRandom, deterministic: bool, seed: &str) -> [u8; 32] {
|
||||
let mut scalar = [0u8; 32];
|
||||
if deterministic {
|
||||
scalar.copy_from_slice(&deterministic_bytes(seed, 32));
|
||||
} else {
|
||||
scalar.copy_from_slice(&rng.bytes(32));
|
||||
}
|
||||
x25519(scalar, X25519_BASEPOINT_BYTES)
|
||||
}
|
||||
|
||||
async fn read_tls_record<S>(stream: &mut S) -> Result<(u8, Vec<u8>)>
|
||||
@@ -793,6 +844,51 @@ async fn connect_tcp_with_upstream(
|
||||
))
|
||||
}
|
||||
|
||||
fn socket_addrs_from_upstream_stream(
|
||||
stream: &UpstreamStream,
|
||||
) -> (Option<SocketAddr>, Option<SocketAddr>) {
|
||||
match stream {
|
||||
UpstreamStream::Tcp(tcp) => (tcp.local_addr().ok(), tcp.peer_addr().ok()),
|
||||
UpstreamStream::Shadowsocks(_) => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tls_fetch_proxy_header(
|
||||
proxy_protocol: u8,
|
||||
src_addr: Option<SocketAddr>,
|
||||
dst_addr: Option<SocketAddr>,
|
||||
) -> Option<Vec<u8>> {
|
||||
match proxy_protocol {
|
||||
0 => None,
|
||||
2 => {
|
||||
let header = match (src_addr, dst_addr) {
|
||||
(Some(src @ SocketAddr::V4(_)), Some(dst @ SocketAddr::V4(_)))
|
||||
| (Some(src @ SocketAddr::V6(_)), Some(dst @ SocketAddr::V6(_))) => {
|
||||
ProxyProtocolV2Builder::new().with_addrs(src, dst).build()
|
||||
}
|
||||
_ => ProxyProtocolV2Builder::new().build(),
|
||||
};
|
||||
Some(header)
|
||||
}
|
||||
_ => {
|
||||
let header = match (src_addr, dst_addr) {
|
||||
(Some(SocketAddr::V4(src)), Some(SocketAddr::V4(dst))) => {
|
||||
ProxyProtocolV1Builder::new()
|
||||
.tcp4(src.into(), dst.into())
|
||||
.build()
|
||||
}
|
||||
(Some(SocketAddr::V6(src)), Some(SocketAddr::V6(dst))) => {
|
||||
ProxyProtocolV1Builder::new()
|
||||
.tcp6(src.into(), dst.into())
|
||||
.build()
|
||||
}
|
||||
_ => ProxyProtocolV1Builder::new().build(),
|
||||
};
|
||||
Some(header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_tls13_certificate_message(cert_chain_der: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
if cert_chain_der.is_empty() {
|
||||
return None;
|
||||
@@ -824,7 +920,7 @@ async fn fetch_via_raw_tls_stream<S>(
|
||||
mut stream: S,
|
||||
sni: &str,
|
||||
connect_timeout: Duration,
|
||||
proxy_protocol: u8,
|
||||
proxy_header: Option<Vec<u8>>,
|
||||
profile: TlsFetchProfile,
|
||||
grease_enabled: bool,
|
||||
deterministic: bool,
|
||||
@@ -835,11 +931,7 @@ where
|
||||
let rng = SecureRandom::new();
|
||||
let client_hello = build_client_hello(sni, &rng, profile, grease_enabled, deterministic);
|
||||
timeout(connect_timeout, async {
|
||||
if proxy_protocol > 0 {
|
||||
let header = match proxy_protocol {
|
||||
2 => ProxyProtocolV2Builder::new().build(),
|
||||
_ => ProxyProtocolV1Builder::new().build(),
|
||||
};
|
||||
if let Some(header) = proxy_header.as_ref() {
|
||||
stream.write_all(&header).await?;
|
||||
}
|
||||
stream.write_all(&client_hello).await?;
|
||||
@@ -921,11 +1013,12 @@ async fn fetch_via_raw_tls(
|
||||
sock = %sock_path,
|
||||
"Raw TLS fetch using mask unix socket"
|
||||
);
|
||||
let proxy_header = build_tls_fetch_proxy_header(proxy_protocol, None, None);
|
||||
return fetch_via_raw_tls_stream(
|
||||
stream,
|
||||
sni,
|
||||
connect_timeout,
|
||||
proxy_protocol,
|
||||
proxy_header,
|
||||
profile,
|
||||
grease_enabled,
|
||||
deterministic,
|
||||
@@ -956,11 +1049,13 @@ async fn fetch_via_raw_tls(
|
||||
let stream =
|
||||
connect_tcp_with_upstream(host, port, connect_timeout, upstream, scope, strict_route)
|
||||
.await?;
|
||||
let (src_addr, dst_addr) = socket_addrs_from_upstream_stream(&stream);
|
||||
let proxy_header = build_tls_fetch_proxy_header(proxy_protocol, src_addr, dst_addr);
|
||||
fetch_via_raw_tls_stream(
|
||||
stream,
|
||||
sni,
|
||||
connect_timeout,
|
||||
proxy_protocol,
|
||||
proxy_header,
|
||||
profile,
|
||||
grease_enabled,
|
||||
deterministic,
|
||||
@@ -972,22 +1067,19 @@ async fn fetch_via_rustls_stream<S>(
|
||||
mut stream: S,
|
||||
host: &str,
|
||||
sni: &str,
|
||||
proxy_protocol: u8,
|
||||
proxy_header: Option<Vec<u8>>,
|
||||
alpn_protocols: &[&[u8]],
|
||||
) -> Result<TlsFetchResult>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
// rustls handshake path for certificate and basic negotiated metadata.
|
||||
if proxy_protocol > 0 {
|
||||
let header = match proxy_protocol {
|
||||
2 => ProxyProtocolV2Builder::new().build(),
|
||||
_ => ProxyProtocolV1Builder::new().build(),
|
||||
};
|
||||
if let Some(header) = proxy_header.as_ref() {
|
||||
stream.write_all(&header).await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
|
||||
let config = build_client_config();
|
||||
let config = build_client_config(alpn_protocols);
|
||||
let connector = TlsConnector::from(config);
|
||||
|
||||
let server_name = ServerName::try_from(sni.to_owned())
|
||||
@@ -1072,6 +1164,7 @@ async fn fetch_via_rustls(
|
||||
proxy_protocol: u8,
|
||||
unix_sock: Option<&str>,
|
||||
strict_route: bool,
|
||||
alpn_protocols: &[&[u8]],
|
||||
) -> Result<TlsFetchResult> {
|
||||
#[cfg(unix)]
|
||||
if let Some(sock_path) = unix_sock {
|
||||
@@ -1082,7 +1175,9 @@ async fn fetch_via_rustls(
|
||||
sock = %sock_path,
|
||||
"Rustls fetch using mask unix socket"
|
||||
);
|
||||
return fetch_via_rustls_stream(stream, host, sni, proxy_protocol).await;
|
||||
let proxy_header = build_tls_fetch_proxy_header(proxy_protocol, None, None);
|
||||
return fetch_via_rustls_stream(stream, host, sni, proxy_header, alpn_protocols)
|
||||
.await;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(
|
||||
@@ -1108,7 +1203,9 @@ async fn fetch_via_rustls(
|
||||
let stream =
|
||||
connect_tcp_with_upstream(host, port, connect_timeout, upstream, scope, strict_route)
|
||||
.await?;
|
||||
fetch_via_rustls_stream(stream, host, sni, proxy_protocol).await
|
||||
let (src_addr, dst_addr) = socket_addrs_from_upstream_stream(&stream);
|
||||
let proxy_header = build_tls_fetch_proxy_header(proxy_protocol, src_addr, dst_addr);
|
||||
fetch_via_rustls_stream(stream, host, sni, proxy_header, alpn_protocols).await
|
||||
}
|
||||
|
||||
/// Fetch real TLS metadata with an adaptive multi-profile strategy.
|
||||
@@ -1147,6 +1244,14 @@ pub async fn fetch_real_tls_with_strategy(
|
||||
break;
|
||||
}
|
||||
let timeout_for_attempt = attempt_timeout.min(total_budget - elapsed);
|
||||
debug!(
|
||||
sni = %sni,
|
||||
profile = profile.as_str(),
|
||||
alpn = ?profile_alpn_labels(profile),
|
||||
grease_enabled = strategy.grease_enabled,
|
||||
deterministic = strategy.deterministic,
|
||||
"TLS fetch ClientHello params (raw)"
|
||||
);
|
||||
|
||||
match fetch_via_raw_tls(
|
||||
host,
|
||||
@@ -1212,6 +1317,16 @@ pub async fn fetch_real_tls_with_strategy(
|
||||
}
|
||||
|
||||
let rustls_timeout = attempt_timeout.min(total_budget - elapsed);
|
||||
let rustls_profile = selected_profile.unwrap_or(TlsFetchProfile::ModernChromeLike);
|
||||
let rustls_alpn_protocols = profile_alpn(rustls_profile);
|
||||
debug!(
|
||||
sni = %sni,
|
||||
profile = rustls_profile.as_str(),
|
||||
alpn = ?profile_alpn_labels(rustls_profile),
|
||||
grease_enabled = strategy.grease_enabled,
|
||||
deterministic = strategy.deterministic,
|
||||
"TLS fetch ClientHello params (rustls)"
|
||||
);
|
||||
let rustls_result = fetch_via_rustls(
|
||||
host,
|
||||
port,
|
||||
@@ -1222,6 +1337,7 @@ pub async fn fetch_real_tls_with_strategy(
|
||||
proxy_protocol,
|
||||
unix_sock,
|
||||
strategy.strict_route,
|
||||
rustls_alpn_protocols,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1278,11 +1394,13 @@ pub async fn fetch_real_tls(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{
|
||||
ProfileCacheValue, TlsFetchStrategy, build_client_hello, derive_behavior_profile,
|
||||
encode_tls13_certificate_message, order_profiles, profile_cache, profile_cache_key,
|
||||
ProfileCacheValue, TlsFetchStrategy, build_client_hello, build_tls_fetch_proxy_header,
|
||||
derive_behavior_profile, encode_tls13_certificate_message, fetch_via_rustls_stream,
|
||||
order_profiles, profile_alpn, profile_cache, profile_cache_key,
|
||||
};
|
||||
use crate::config::TlsFetchProfile;
|
||||
use crate::crypto::SecureRandom;
|
||||
@@ -1290,11 +1408,115 @@ mod tests {
|
||||
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
|
||||
};
|
||||
use crate::tls_front::types::TlsProfileSource;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
struct ParsedClientHelloForTest {
|
||||
session_id: Vec<u8>,
|
||||
extensions: Vec<(u16, Vec<u8>)>,
|
||||
}
|
||||
|
||||
fn read_u24(bytes: &[u8]) -> usize {
|
||||
((bytes[0] as usize) << 16) | ((bytes[1] as usize) << 8) | (bytes[2] as usize)
|
||||
}
|
||||
|
||||
fn parse_client_hello_for_test(record: &[u8]) -> ParsedClientHelloForTest {
|
||||
assert!(record.len() >= 9, "record too short");
|
||||
assert_eq!(record[0], TLS_RECORD_HANDSHAKE, "not a handshake record");
|
||||
let record_len = u16::from_be_bytes([record[3], record[4]]) as usize;
|
||||
assert_eq!(record.len(), 5 + record_len, "record length mismatch");
|
||||
|
||||
let handshake = &record[5..];
|
||||
assert_eq!(handshake[0], 0x01, "not a ClientHello handshake");
|
||||
let hello_len = read_u24(&handshake[1..4]);
|
||||
assert_eq!(handshake.len(), 4 + hello_len, "handshake length mismatch");
|
||||
let hello = &handshake[4..];
|
||||
|
||||
let mut pos = 0usize;
|
||||
pos += 2;
|
||||
pos += 32;
|
||||
|
||||
let session_len = hello[pos] as usize;
|
||||
pos += 1;
|
||||
let session_id = hello[pos..pos + session_len].to_vec();
|
||||
pos += session_len;
|
||||
|
||||
let cipher_len = u16::from_be_bytes([hello[pos], hello[pos + 1]]) as usize;
|
||||
pos += 2 + cipher_len;
|
||||
|
||||
let compression_len = hello[pos] as usize;
|
||||
pos += 1 + compression_len;
|
||||
|
||||
let ext_len = u16::from_be_bytes([hello[pos], hello[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
let ext_end = pos + ext_len;
|
||||
assert_eq!(ext_end, hello.len(), "extensions length mismatch");
|
||||
|
||||
let mut extensions = Vec::new();
|
||||
while pos + 4 <= ext_end {
|
||||
let ext_type = u16::from_be_bytes([hello[pos], hello[pos + 1]]);
|
||||
let data_len = u16::from_be_bytes([hello[pos + 2], hello[pos + 3]]) as usize;
|
||||
pos += 4;
|
||||
let data = hello[pos..pos + data_len].to_vec();
|
||||
pos += data_len;
|
||||
extensions.push((ext_type, data));
|
||||
}
|
||||
assert_eq!(pos, ext_end, "extension parse did not consume all bytes");
|
||||
|
||||
ParsedClientHelloForTest {
|
||||
session_id,
|
||||
extensions,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_alpn_protocols(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
assert!(data.len() >= 2, "ALPN extension is too short");
|
||||
let protocols_len = u16::from_be_bytes([data[0], data[1]]) as usize;
|
||||
assert_eq!(protocols_len + 2, data.len(), "ALPN list length mismatch");
|
||||
let mut pos = 2usize;
|
||||
let mut out = Vec::new();
|
||||
while pos < data.len() {
|
||||
let len = data[pos] as usize;
|
||||
pos += 1;
|
||||
out.push(data[pos..pos + len].to_vec());
|
||||
pos += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn capture_rustls_client_hello_record(
|
||||
alpn_protocols: &'static [&'static [u8]],
|
||||
) -> Vec<u8> {
|
||||
let (client, mut server) = tokio::io::duplex(32 * 1024);
|
||||
let fetch_task = tokio::spawn(async move {
|
||||
fetch_via_rustls_stream(client, "example.com", "example.com", None, alpn_protocols)
|
||||
.await
|
||||
});
|
||||
|
||||
let mut header = [0u8; 5];
|
||||
server
|
||||
.read_exact(&mut header)
|
||||
.await
|
||||
.expect("must read client hello record header");
|
||||
let body_len = u16::from_be_bytes([header[3], header[4]]) as usize;
|
||||
let mut body = vec![0u8; body_len];
|
||||
server
|
||||
.read_exact(&mut body)
|
||||
.await
|
||||
.expect("must read client hello record body");
|
||||
drop(server);
|
||||
|
||||
let result = fetch_task.await.expect("fetch task must join");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"capture task should end with handshake error"
|
||||
);
|
||||
|
||||
let mut record = Vec::with_capacity(5 + body_len);
|
||||
record.extend_from_slice(&header);
|
||||
record.extend_from_slice(&body);
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_tls13_certificate_message_single_cert() {
|
||||
let cert = vec![0x30, 0x03, 0x02, 0x01, 0x01];
|
||||
@@ -1423,4 +1645,228 @@ mod tests {
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_client_hello_alpn_matches_profile() {
|
||||
let rng = SecureRandom::new();
|
||||
for profile in [
|
||||
TlsFetchProfile::ModernChromeLike,
|
||||
TlsFetchProfile::ModernFirefoxLike,
|
||||
TlsFetchProfile::CompatTls12,
|
||||
TlsFetchProfile::LegacyMinimal,
|
||||
] {
|
||||
let hello = build_client_hello("alpn.example", &rng, profile, false, true);
|
||||
let parsed = parse_client_hello_for_test(&hello);
|
||||
let alpn_ext = parsed
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|(ext_type, _)| *ext_type == 0x0010)
|
||||
.expect("ALPN extension must exist");
|
||||
let parsed_alpn = parse_alpn_protocols(&alpn_ext.1);
|
||||
let expected_alpn = profile_alpn(profile)
|
||||
.iter()
|
||||
.map(|proto| proto.to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
parsed_alpn,
|
||||
expected_alpn,
|
||||
"ALPN mismatch for {}",
|
||||
profile.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modern_chrome_like_browser_extension_layout() {
|
||||
let rng = SecureRandom::new();
|
||||
let hello = build_client_hello(
|
||||
"chrome.example",
|
||||
&rng,
|
||||
TlsFetchProfile::ModernChromeLike,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let parsed = parse_client_hello_for_test(&hello);
|
||||
assert_eq!(
|
||||
parsed.session_id.len(),
|
||||
32,
|
||||
"modern chrome must use non-empty session id"
|
||||
);
|
||||
|
||||
let extension_ids = parsed
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|(ext_type, _)| *ext_type)
|
||||
.collect::<Vec<_>>();
|
||||
let expected_prefix = [
|
||||
0x0000, 0x000b, 0x000a, 0x0023, 0x000d, 0x002b, 0x002d, 0x0033, 0x0010,
|
||||
];
|
||||
assert!(
|
||||
extension_ids.as_slice().starts_with(&expected_prefix),
|
||||
"unexpected extension order: {extension_ids:?}"
|
||||
);
|
||||
assert!(
|
||||
extension_ids.contains(&0x0015),
|
||||
"modern chrome profile should include padding extension"
|
||||
);
|
||||
|
||||
let key_share = parsed
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|(ext_type, _)| *ext_type == 0x0033)
|
||||
.expect("key_share extension must exist");
|
||||
let key_share_data = &key_share.1;
|
||||
assert!(
|
||||
key_share_data.len() >= 2 + 4 + 32,
|
||||
"key_share payload is too short"
|
||||
);
|
||||
let entry_len = u16::from_be_bytes([key_share_data[0], key_share_data[1]]) as usize;
|
||||
assert_eq!(
|
||||
entry_len,
|
||||
key_share_data.len() - 2,
|
||||
"key_share list length mismatch"
|
||||
);
|
||||
let group = u16::from_be_bytes([key_share_data[2], key_share_data[3]]);
|
||||
let key_len = u16::from_be_bytes([key_share_data[4], key_share_data[5]]) as usize;
|
||||
let key = &key_share_data[6..6 + key_len];
|
||||
assert_eq!(group, 0x001d, "key_share group must be x25519");
|
||||
assert_eq!(key_len, 32, "x25519 key length must be 32");
|
||||
assert!(
|
||||
key.iter().any(|b| *b != 0),
|
||||
"x25519 key must not be all zero"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_profiles_keep_compat_extension_set() {
|
||||
let rng = SecureRandom::new();
|
||||
for profile in [
|
||||
TlsFetchProfile::ModernFirefoxLike,
|
||||
TlsFetchProfile::CompatTls12,
|
||||
TlsFetchProfile::LegacyMinimal,
|
||||
] {
|
||||
let hello = build_client_hello("fallback.example", &rng, profile, false, true);
|
||||
let parsed = parse_client_hello_for_test(&hello);
|
||||
let extension_ids = parsed
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|(ext_type, _)| *ext_type)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(extension_ids.contains(&0x0000), "SNI extension must exist");
|
||||
assert!(
|
||||
extension_ids.contains(&0x000a),
|
||||
"supported_groups extension must exist"
|
||||
);
|
||||
assert!(
|
||||
extension_ids.contains(&0x000d),
|
||||
"signature_algorithms extension must exist"
|
||||
);
|
||||
assert!(
|
||||
extension_ids.contains(&0x002b),
|
||||
"supported_versions extension must exist"
|
||||
);
|
||||
assert!(
|
||||
extension_ids.contains(&0x0033),
|
||||
"key_share extension must exist"
|
||||
);
|
||||
assert!(extension_ids.contains(&0x0010), "ALPN extension must exist");
|
||||
assert!(
|
||||
!extension_ids.contains(&0x000b),
|
||||
"ec_point_formats must stay chrome-only"
|
||||
);
|
||||
assert!(
|
||||
!extension_ids.contains(&0x0023),
|
||||
"session_ticket must stay chrome-only"
|
||||
);
|
||||
assert!(
|
||||
!extension_ids.contains(&0x002d),
|
||||
"psk_key_exchange_modes must stay chrome-only"
|
||||
);
|
||||
|
||||
let expected_session_len = if matches!(profile, TlsFetchProfile::ModernFirefoxLike) {
|
||||
32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
assert_eq!(
|
||||
parsed.session_id.len(),
|
||||
expected_session_len,
|
||||
"unexpected session id length for {}",
|
||||
profile.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_rustls_client_hello_alpn_matches_selected_profile() {
|
||||
for profile in [
|
||||
TlsFetchProfile::ModernChromeLike,
|
||||
TlsFetchProfile::CompatTls12,
|
||||
TlsFetchProfile::LegacyMinimal,
|
||||
] {
|
||||
let record = capture_rustls_client_hello_record(profile_alpn(profile)).await;
|
||||
let parsed = parse_client_hello_for_test(&record);
|
||||
let alpn_ext = parsed
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|(ext_type, _)| *ext_type == 0x0010)
|
||||
.expect("ALPN extension must exist");
|
||||
let parsed_alpn = parse_alpn_protocols(&alpn_ext.1);
|
||||
let expected_alpn = profile_alpn(profile)
|
||||
.iter()
|
||||
.map(|proto| proto.to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
parsed_alpn,
|
||||
expected_alpn,
|
||||
"rustls ALPN mismatch for {}",
|
||||
profile.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tls_fetch_proxy_header_v2_with_tcp_addrs() {
|
||||
let src: SocketAddr = "198.51.100.10:42000".parse().expect("valid src");
|
||||
let dst: SocketAddr = "203.0.113.20:443".parse().expect("valid dst");
|
||||
let header = build_tls_fetch_proxy_header(2, Some(src), Some(dst)).expect("header");
|
||||
|
||||
assert_eq!(
|
||||
&header[..12],
|
||||
&[
|
||||
0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a
|
||||
]
|
||||
);
|
||||
assert_eq!(header[12], 0x21);
|
||||
assert_eq!(header[13], 0x11);
|
||||
assert_eq!(u16::from_be_bytes([header[14], header[15]]), 12);
|
||||
assert_eq!(&header[16..20], &[198, 51, 100, 10]);
|
||||
assert_eq!(&header[20..24], &[203, 0, 113, 20]);
|
||||
assert_eq!(u16::from_be_bytes([header[24], header[25]]), 42000);
|
||||
assert_eq!(u16::from_be_bytes([header[26], header[27]]), 443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tls_fetch_proxy_header_v2_mixed_family_falls_back_to_local_command() {
|
||||
let src: SocketAddr = "198.51.100.10:42000".parse().expect("valid src");
|
||||
let dst: SocketAddr = "[2001:db8::20]:443".parse().expect("valid dst");
|
||||
let header = build_tls_fetch_proxy_header(2, Some(src), Some(dst)).expect("header");
|
||||
|
||||
assert_eq!(header[12], 0x20);
|
||||
assert_eq!(header[13], 0x00);
|
||||
assert_eq!(u16::from_be_bytes([header[14], header[15]]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tls_fetch_proxy_header_v1_with_tcp_addrs() {
|
||||
let src: SocketAddr = "198.51.100.10:42000".parse().expect("valid src");
|
||||
let dst: SocketAddr = "203.0.113.20:443".parse().expect("valid dst");
|
||||
let header = build_tls_fetch_proxy_header(1, Some(src), Some(dst)).expect("header");
|
||||
|
||||
assert_eq!(
|
||||
header,
|
||||
b"PROXY TCP4 198.51.100.10 203.0.113.20 42000 443\r\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::crypto::SecureRandom;
|
||||
use crate::protocol::constants::{
|
||||
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
|
||||
};
|
||||
use crate::protocol::tls::ClientHelloTlsVersion;
|
||||
use crate::tls_front::emulator::build_emulated_server_hello;
|
||||
use crate::tls_front::types::{
|
||||
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource,
|
||||
};
|
||||
|
||||
fn make_cached() -> CachedTlsData {
|
||||
CachedTlsData {
|
||||
server_hello_template: ParsedServerHello {
|
||||
version: [0x03, 0x03],
|
||||
random: [0u8; 32],
|
||||
session_id: Vec::new(),
|
||||
cipher_suite: [0x13, 0x01],
|
||||
compression: 0,
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
cert_info: None,
|
||||
cert_payload: None,
|
||||
app_data_records_sizes: vec![1200, 900, 220, 180],
|
||||
total_app_data_len: 2500,
|
||||
behavior_profile: TlsBehaviorProfile {
|
||||
change_cipher_spec_count: 2,
|
||||
app_data_record_sizes: vec![1200, 900],
|
||||
ticket_record_sizes: vec![220, 180],
|
||||
source: TlsProfileSource::Merged,
|
||||
},
|
||||
fetched_at: SystemTime::now(),
|
||||
domain: "example.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_lengths_by_type(response: &[u8], wanted_type: u8) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while pos + 5 <= response.len() {
|
||||
let record_type = response[pos];
|
||||
let record_len = u16::from_be_bytes([response[pos + 3], response[pos + 4]]) as usize;
|
||||
if pos + 5 + record_len > response.len() {
|
||||
break;
|
||||
}
|
||||
if record_type == wanted_type {
|
||||
out.push(record_len);
|
||||
}
|
||||
pos += 5 + record_len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibility() {
|
||||
let cached = make_cached();
|
||||
let rng = SecureRandom::new();
|
||||
|
||||
let response = build_emulated_server_hello(
|
||||
b"secret",
|
||||
&[0x71; 32],
|
||||
&[0x72; 16],
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(response[0], TLS_RECORD_HANDSHAKE);
|
||||
let ccs_records = record_lengths_by_type(&response, TLS_RECORD_CHANGE_CIPHER);
|
||||
assert_eq!(ccs_records.len(), 1);
|
||||
assert!(ccs_records.iter().all(|len| *len == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() {
|
||||
let cached = make_cached();
|
||||
let rng = SecureRandom::new();
|
||||
|
||||
let response = build_emulated_server_hello(
|
||||
b"secret",
|
||||
&[0x81; 32],
|
||||
&[0x82; 16],
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
None,
|
||||
0,
|
||||
);
|
||||
|
||||
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
|
||||
assert_eq!(app_records, vec![1200]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() {
|
||||
let cached = make_cached();
|
||||
let rng = SecureRandom::new();
|
||||
|
||||
let response = build_emulated_server_hello(
|
||||
b"secret",
|
||||
&[0x91; 32],
|
||||
&[0x92; 16],
|
||||
&cached,
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
None,
|
||||
2,
|
||||
);
|
||||
|
||||
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
|
||||
assert_eq!(app_records, vec![1200, 220, 180]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use crate::crypto::SecureRandom;
|
||||
use crate::protocol::constants::{
|
||||
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
|
||||
};
|
||||
use crate::protocol::tls::ClientHelloTlsVersion;
|
||||
use crate::tls_front::emulator::build_emulated_server_hello;
|
||||
use crate::tls_front::types::{
|
||||
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
|
||||
@@ -55,6 +56,8 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
|
||||
&[0x22; 16],
|
||||
&cached,
|
||||
true,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
Some(oversized_alpn),
|
||||
0,
|
||||
@@ -91,6 +94,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() {
|
||||
&[0x41; 16],
|
||||
&cached,
|
||||
true,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
&rng,
|
||||
Some(b"h2".to_vec()),
|
||||
0,
|
||||
@@ -119,6 +124,8 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() {
|
||||
&[0x42; 16],
|
||||
&cached,
|
||||
true,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls12,
|
||||
&rng,
|
||||
Some(b"h2".to_vec()),
|
||||
0,
|
||||
|
||||
@@ -321,7 +321,14 @@ async fn run_update_cycle(
|
||||
let mut maps_changed = false;
|
||||
|
||||
let mut ready_v4: Option<(ProxyConfigData, u64)> = None;
|
||||
let cfg_v4 = retry_fetch("https://core.telegram.org/getProxyConfig", upstream.clone()).await;
|
||||
let cfg_v4 = retry_fetch(
|
||||
cfg.general
|
||||
.proxy_config_v4_url
|
||||
.as_deref()
|
||||
.unwrap_or("https://core.telegram.org/getProxyConfig"),
|
||||
upstream.clone(),
|
||||
)
|
||||
.await;
|
||||
if let Some(cfg_v4) = cfg_v4
|
||||
&& snapshot_passes_guards(cfg, &cfg_v4, "getProxyConfig")
|
||||
{
|
||||
@@ -346,7 +353,10 @@ async fn run_update_cycle(
|
||||
|
||||
let mut ready_v6: Option<(ProxyConfigData, u64)> = None;
|
||||
let cfg_v6 = retry_fetch(
|
||||
"https://core.telegram.org/getProxyConfigV6",
|
||||
cfg.general
|
||||
.proxy_config_v6_url
|
||||
.as_deref()
|
||||
.unwrap_or("https://core.telegram.org/getProxyConfigV6"),
|
||||
upstream.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -430,6 +440,7 @@ async fn run_update_cycle(
|
||||
match download_proxy_secret_with_max_len_via_upstream(
|
||||
cfg.general.proxy_secret_len_max,
|
||||
upstream,
|
||||
cfg.general.proxy_secret_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Backpressure-driven fairness control for ME reader routing.
|
||||
//!
|
||||
//! This module keeps fairness decisions worker-local:
|
||||
//! each reader loop owns one scheduler instance and mutates it without locks.
|
||||
|
||||
mod model;
|
||||
mod pressure;
|
||||
mod scheduler;
|
||||
|
||||
pub(crate) use model::PressureState;
|
||||
pub(crate) use model::{AdmissionDecision, DispatchAction, DispatchFeedback, SchedulerDecision};
|
||||
pub(crate) use scheduler::{WorkerFairnessConfig, WorkerFairnessSnapshot, WorkerFairnessState};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user