mirror of
https://github.com/telemt/telemt.git
synced 2026-07-15 16:30:20 +03:00
Compare commits
64 Commits
3.4.18
..
flow-aescr
| Author | SHA1 | Date | |
|---|---|---|---|
| 73afeccae1 | |||
| feb51cbf57 | |||
| 8c65cd868c | |||
| ea296bbdc8 | |||
| fb042f826e | |||
| 96425f15c8 | |||
| d4c4980e5a | |||
| 893ce0cf36 | |||
| 2ac93c6d49 | |||
| a51e58009b | |||
| d523406c0a | |||
| 5b3ad0096b | |||
| b587fdbf94 | |||
| 77a45e509a | |||
| b8be805aed | |||
| a1ebd44cee | |||
| 25d02a8e0e | |||
| 3375017460 | |||
| 25e0abae8a | |||
| 50538d234e | |||
| e3a7be6786 | |||
| 3fc2877205 | |||
| cd1dc2f4c9 | |||
| 451227da60 | |||
| f55d8479e3 | |||
| 81ae483201 | |||
| ed1895d6df | |||
| 88d161a5e9 | |||
| a0ac108807 | |||
| 809352fac5 | |||
| 22627b498d | |||
| b9c5c71dbc | |||
| 7aee991416 | |||
| 9a9fd3f55d | |||
| 3a5fe31262 | |||
| 82f63d0d8a | |||
| fce75163b0 | |||
| fe56621a83 | |||
| 1f2910f5bc | |||
| d67e7c5a6f | |||
| 558f352a57 | |||
| 1ee9a234d7 | |||
| 2e13f89f6d | |||
| 5eaccee68f | |||
| f56895feac | |||
| 87c82c2a63 | |||
| 7e5a1841b1 | |||
| e994ddea00 | |||
| 5e5c82a0ad | |||
| 840713a359 | |||
| 50b67a93d6 | |||
| 72800e4aa7 | |||
| 49742d38a7 | |||
| 869d8517a0 | |||
| e82ce634d6 | |||
| f1f46fac42 | |||
| 37d0184a0b | |||
| d81d7dba62 | |||
| 04b8d8365c | |||
| 2e26bfb86e | |||
| d414c73c9b | |||
| d1a97fe10f | |||
| b153782597 | |||
| b95956d141 |
+13
-11
@@ -1,18 +1,20 @@
|
||||
# Issues
|
||||
## Warnung
|
||||
### Warnung
|
||||
|
||||
Before opening Issue, if it is more question than problem or bug - ask about that [in our chat](https://t.me/telemtrs)
|
||||
|
||||
## What it is not
|
||||
- NOT Question and Answer
|
||||
- NOT Helpdesk
|
||||
|
||||
***Each of your Issues triggers attempts to reproduce problems and analyze them, which are done manually by people***
|
||||
|
||||
Issues is **NOT** about:
|
||||
- Question and Answer
|
||||
- Helpdesk
|
||||
- Configuration or Intergraion Support
|
||||
|
||||
---
|
||||
|
||||
# Pull Requests
|
||||
|
||||
## General
|
||||
### General
|
||||
- ONLY signed and verified commits
|
||||
- ONLY from your name
|
||||
- DO NOT commit with `codex`, `claude`, or other AI tools as author/committer
|
||||
@@ -20,7 +22,7 @@ Before opening Issue, if it is more question than problem or bug - ask about tha
|
||||
|
||||
---
|
||||
|
||||
## Definition of Ready (MANDATORY)
|
||||
### Definition of Ready (MANDATORY)
|
||||
|
||||
A Pull Request WILL be ignored or closed if:
|
||||
|
||||
@@ -32,14 +34,14 @@ A Pull Request WILL be ignored or closed if:
|
||||
|
||||
---
|
||||
|
||||
## Blessed Principles
|
||||
### Blessed Principles
|
||||
- PR must build
|
||||
- PR must pass tests
|
||||
- PR must be understood by author
|
||||
|
||||
---
|
||||
|
||||
## AI Usage Policy
|
||||
### AI Usage Policy
|
||||
|
||||
AI tools (Claude, ChatGPT, Codex, DeepSeek, etc.) are allowed as **assistants**, NOT as decision-makers.
|
||||
|
||||
@@ -60,7 +62,7 @@ PRs that look like unverified AI dumps WILL be closed
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Policy
|
||||
### Maintainer Policy
|
||||
|
||||
Maintainers reserve the right to:
|
||||
|
||||
@@ -72,7 +74,7 @@ Respect the reviewers time
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
### Enforcement
|
||||
|
||||
Pull Requests that violate project standards may be closed without review.
|
||||
|
||||
|
||||
Generated
+94
-270
@@ -21,6 +21,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -105,9 +106,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
@@ -222,9 +223,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
@@ -251,9 +252,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -281,9 +282,9 @@ checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
@@ -302,9 +303,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
version = "1.2.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -361,9 +362,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -745,9 +746,6 @@ name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
@@ -766,7 +764,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
@@ -875,12 +873,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1038,16 +1030,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1062,9 +1052,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1101,9 +1091,6 @@ name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1113,7 +1100,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1200,9 +1187,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1256,9 +1243,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1420,12 +1407,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1455,15 +1436,13 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
||||
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"inotify-sys",
|
||||
@@ -1593,13 +1572,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
version = "0.3.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1625,9 +1603,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
@@ -1649,12 +1627,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -1684,9 +1656,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.30"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
@@ -1730,9 +1702,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
@@ -2101,16 +2073,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -2147,9 +2109,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
@@ -2167,9 +2129,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
@@ -2203,9 +2165,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -2239,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20 0.10.0",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
@@ -2317,9 +2279,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -2340,9 +2302,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
@@ -2451,9 +2413,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -2466,9 +2428,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
@@ -2832,9 +2794,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -2901,9 +2863,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2938,7 +2900,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.4.18"
|
||||
version = "3.4.24"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
@@ -3007,7 +2969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -3044,12 +3006,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
version = "0.3.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
@@ -3059,15 +3020,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
version = "0.2.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
@@ -3380,9 +3341,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
@@ -3396,12 +3357,6 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -3438,11 +3393,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3495,27 +3450,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
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 0.51.0",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.122"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -3526,9 +3472,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.72"
|
||||
version = "0.4.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3536,9 +3482,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.122"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -3546,9 +3492,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.122"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -3559,52 +3505,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.122"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.99"
|
||||
version = "0.3.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3622,18 +3534,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -3907,100 +3819,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
@@ -4038,9 +3862,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -4061,18 +3885,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.49"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.49"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4102,18 +3926,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+57
-57
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.4.18"
|
||||
version = "3.4.24"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
@@ -8,89 +8,89 @@ redteam_offline_expected_fail = []
|
||||
|
||||
[dependencies]
|
||||
# C
|
||||
libc = "0.2"
|
||||
libc = "0.2.186"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.42", features = ["full", "tracing"] }
|
||||
tokio-util = { version = "0.7", features = ["full"] }
|
||||
tokio = { version = "1.52.3", features = ["full", "tracing"] }
|
||||
tokio-util = { version = "0.7.18", features = ["full"] }
|
||||
|
||||
# Crypto
|
||||
aes = "0.8"
|
||||
ctr = "0.9"
|
||||
cbc = "0.1"
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
md-5 = "0.10"
|
||||
hmac = "0.12"
|
||||
crc32fast = "1.4"
|
||||
crc32c = "0.6"
|
||||
zeroize = { version = "1.8", features = ["derive"] }
|
||||
subtle = "2.6"
|
||||
static_assertions = "1.1"
|
||||
aes = { version = "0.8.4", features = ["zeroize"] }
|
||||
ctr = { version = "0.9.2", features = ["zeroize"] }
|
||||
cbc = "0.1.2"
|
||||
sha2 = "0.10.9"
|
||||
sha1 = "0.10.6"
|
||||
md-5 = "0.10.6"
|
||||
hmac = "0.12.1"
|
||||
crc32fast = "1.5.0"
|
||||
crc32c = "0.6.8"
|
||||
zeroize = { version = "1.9.0", features = ["derive"] }
|
||||
subtle = "2.6.1"
|
||||
static_assertions = "1.1.0"
|
||||
ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] }
|
||||
|
||||
# Network
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
nix = { version = "0.31", default-features = false, features = [
|
||||
socket2 = { version = "0.6.4", features = ["all"] }
|
||||
nix = { version = "0.31.3", default-features = false, features = [
|
||||
"net",
|
||||
"user",
|
||||
"process",
|
||||
"fs",
|
||||
"signal",
|
||||
] }
|
||||
shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] }
|
||||
shadowsocks = { version = "1.24.0", features = ["aead-cipher-2022"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "1.0"
|
||||
x509-parser = "0.18"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
toml = "1.1"
|
||||
x509-parser = "0.18.1"
|
||||
|
||||
# Utils
|
||||
bytes = "1.9"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
parking_lot = "0.12"
|
||||
dashmap = "6.1"
|
||||
arc-swap = "1.7"
|
||||
lru = "0.16"
|
||||
rand = "0.10"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
url = "2.5"
|
||||
regex = "1.11"
|
||||
crossbeam-queue = "0.3"
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
x25519-dalek = "2"
|
||||
anyhow = "1.0"
|
||||
bytes = "1.12.0"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
tracing-appender = "0.2.5"
|
||||
parking_lot = "0.12.5"
|
||||
dashmap = "6.2.1"
|
||||
arc-swap = "1.9.1"
|
||||
lru = "0.16.4"
|
||||
rand = "0.10.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
hex = "0.4.3"
|
||||
base64 = "0.22.1"
|
||||
url = "2.5.8"
|
||||
regex = "1.12.4"
|
||||
crossbeam-queue = "0.3.12"
|
||||
num-bigint = "0.4.6"
|
||||
num-traits = "0.2.19"
|
||||
x25519-dalek = "2.0.1"
|
||||
anyhow = "1.0.102"
|
||||
|
||||
# HTTP
|
||||
reqwest = { version = "0.13", features = ["rustls"], default-features = false }
|
||||
notify = "8.2"
|
||||
ipnetwork = { version = "0.21", features = ["serde"] }
|
||||
hyper = { version = "1", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] }
|
||||
http-body-util = "0.1"
|
||||
httpdate = "1.0"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = [
|
||||
reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
|
||||
notify = "8.2.0"
|
||||
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
||||
hyper = { version = "1.10.1", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
|
||||
http-body-util = "0.1.3"
|
||||
httpdate = "1.0.3"
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
"tls12",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
rustls = { version = "0.23.41", default-features = false, features = [
|
||||
"std",
|
||||
"tls12",
|
||||
"ring",
|
||||
] }
|
||||
webpki-roots = "1.0"
|
||||
webpki-roots = "1.0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
criterion = "0.8"
|
||||
proptest = "1.4"
|
||||
futures = "0.3"
|
||||
tokio-test = "0.4.5"
|
||||
criterion = "0.8.2"
|
||||
proptest = "1.11.0"
|
||||
futures = "0.3.32"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[[bench]]
|
||||
|
||||
+13
-3
@@ -55,6 +55,16 @@ RUN set -eux; \
|
||||
strip --strip-unneeded /telemt || true; \
|
||||
rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt
|
||||
|
||||
RUN --mount=type=bind,target=/tmp \
|
||||
mkdir -p /app && \
|
||||
if [ -f /tmp/config.toml ]; then \
|
||||
cp /tmp/config.toml /app/config.toml; \
|
||||
elif [ -f /tmp/config/config.toml ]; then \
|
||||
cp /tmp/config/config.toml /app/config.toml; \
|
||||
else \
|
||||
echo "Config file not found" && exit 1; \
|
||||
fi
|
||||
|
||||
# ==========================
|
||||
# Debug Image
|
||||
# ==========================
|
||||
@@ -73,7 +83,7 @@ RUN set -eux; \
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=minimal /telemt /app/telemt
|
||||
COPY config.toml /app/config.toml
|
||||
COPY ./config/config.toml /app/config.toml
|
||||
|
||||
EXPOSE 443 9090 9091
|
||||
|
||||
@@ -99,7 +109,7 @@ RUN set -eux; \
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=minimal /telemt /app/telemt
|
||||
COPY config.toml /app/config.toml
|
||||
COPY --from=minimal /app/config.toml /app/config.toml
|
||||
|
||||
EXPOSE 443 9090 9091
|
||||
|
||||
@@ -116,7 +126,7 @@ FROM gcr.io/distroless/static-debian12 AS prod
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=minimal /telemt /app/telemt
|
||||
COPY config.toml /app/config.toml
|
||||
COPY --from=minimal /app/config.toml /app/config.toml
|
||||
|
||||
USER nonroot:nonroot
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
> From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions"
|
||||
>
|
||||
> Telegram Clients TLS ClientHello has been banned by JA3 Fingerprint: we are already looking for ways to solve this problem
|
||||
> Telegram Clients TLS ClientHello has been banned by JA4/JA4+ Fingerprint: we are already looking for ways to solve this problem
|
||||
>
|
||||
> You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf)
|
||||
|
||||
|
||||
+15
-3
@@ -1,12 +1,24 @@
|
||||
// Cryptobench
|
||||
use criterion::{Criterion, black_box, criterion_group};
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use std::hint::black_box;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
#[path = "../src/crypto/aes.rs"]
|
||||
mod aes_impl;
|
||||
#[allow(unused_imports)]
|
||||
#[path = "../src/error.rs"]
|
||||
mod error;
|
||||
|
||||
use aes_impl::AesCtr;
|
||||
|
||||
fn bench_aes_ctr(c: &mut Criterion) {
|
||||
c.bench_function("aes_ctr_encrypt_64kb", |b| {
|
||||
let data = vec![0u8; 65536];
|
||||
b.iter(|| {
|
||||
let mut enc = AesCtr::new(&[0u8; 32], 0);
|
||||
black_box(enc.encrypt(&data))
|
||||
black_box(enc.encrypt(black_box(data.as_slice())))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_aes_ctr);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -4,7 +4,6 @@ services:
|
||||
context: .
|
||||
target: prod-netfilter
|
||||
network_mode: host
|
||||
ports: []
|
||||
ports: !reset []
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- NET_ADMIN
|
||||
- NET_ADMIN
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
|
||||
# Table of contents
|
||||
- [Top-level keys](#top-level-keys)
|
||||
- [logging](#logging)
|
||||
- [general](#general)
|
||||
- [general.modes](#generalmodes)
|
||||
- [general.links](#generallinks)
|
||||
@@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`include`](#include) | `String` (special directive) | — | `✔` |
|
||||
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
|
||||
| [`logging`](#logging) | Table | default values | `✘` |
|
||||
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
|
||||
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
|
||||
@@ -83,6 +85,84 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
default_dc = 2
|
||||
```
|
||||
|
||||
# [logging]
|
||||
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `✘` |
|
||||
| [`path`](#loggingpath) | `String` | — | `✘` |
|
||||
| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `✘` |
|
||||
| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `✘` |
|
||||
| [`max_files`](#loggingmax_files) | `usize` | `0` | `✘` |
|
||||
| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `✘` |
|
||||
|
||||
## logging.destination
|
||||
- **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`.
|
||||
- **Description**: Selects the runtime log destination. CLI flags override this value.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
```
|
||||
## logging.path
|
||||
- **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty.
|
||||
- **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
```
|
||||
## logging.rotation
|
||||
- **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`.
|
||||
- **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
rotation = "daily"
|
||||
```
|
||||
## logging.max_size_bytes
|
||||
- **Constraints / validation**: `0` disables size rotation.
|
||||
- **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
max_size_bytes = 104857600
|
||||
```
|
||||
## logging.max_files
|
||||
- **Constraints / validation**: `0` disables count-based retention.
|
||||
- **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
rotation = "daily"
|
||||
max_files = 14
|
||||
```
|
||||
## logging.max_age_secs
|
||||
- **Constraints / validation**: `0` disables age-based retention.
|
||||
- **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
rotation = "daily"
|
||||
max_age_secs = 1209600
|
||||
```
|
||||
|
||||
# [general]
|
||||
|
||||
|
||||
@@ -1806,6 +1886,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` |
|
||||
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` |
|
||||
| [`client_mss`](#client_mss) | `String` | `""` | `✘` |
|
||||
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `✘` |
|
||||
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `✘` |
|
||||
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `✘` |
|
||||
@@ -1891,6 +1972,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
## client_mss
|
||||
- **Constraints / validation**: `String`. Empty or omitted means do not change kernel MSS. Presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Custom decimal strings must be within `88..=4096`.
|
||||
- **Description**: Client-facing TCP MSS applied to TCP listener sockets before `listen(2)`, so Linux can announce it in SYN/ACK. This affects only proxy client TCP listeners, not API, metrics, Unix sockets, Telegram upstreams, ME sockets, or mask backend connections. Changes require listener restart/rebind.
|
||||
- **Operator note**: The two-tier `synlimit` profile does not require Telemt to disable MSS automatically. Operators that follow external host-tuning recipes should decide explicitly whether to leave MSS shaping enabled for handshake fragmentation or disable it for higher media throughput.
|
||||
- **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`.
|
||||
- **Example**:
|
||||
|
||||
@@ -1898,6 +1980,16 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
```
|
||||
## client_mss_bulk
|
||||
- **Constraints / validation**: `String`. Same grammar as [`client_mss`](#client_mss) (empty/omitted, presets `"extreme-low"`/`"tspu"`/`"2in8"`, or a decimal in `88..=4096`).
|
||||
- **Description**: Optional bulk-phase MSS. When set, the low `client_mss` is applied only while the TLS handshake (including the DPI-inspected ServerHello) is sent; once the connection transitions to relaying, the client socket MSS is raised to `client_mss_bulk` for the bulk data phase. This keeps the anti-DPI handshake fragmentation but restores normal-size packets for payload, cutting outgoing packets-per-second by roughly the `client_mss` segment multiplier (e.g. ~10x with `"tspu"`). Useful on hosts whose abuse detection counts packets-per-second rather than bandwidth. When empty/omitted, the handshake MSS is kept for the whole connection (previous behavior). Linux only; a no-op elsewhere.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
client_mss_bulk = "1400"
|
||||
```
|
||||
## proxy_protocol
|
||||
- **Constraints / validation**: `bool`.
|
||||
- **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header.
|
||||
@@ -2220,9 +2312,14 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✔` |
|
||||
| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✔` |
|
||||
| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✔` |
|
||||
| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✔` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
@@ -2260,7 +2357,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
```
|
||||
## synlimit (server.listeners)
|
||||
- **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener.
|
||||
- **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind.
|
||||
- **Description**: Installs per-listener Linux netfilter two-tier SYN-fix rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit`, `length`, and TTL/hop-limit matches. `"nftables"` uses Telemt-owned tables with per-source `meter` rules and equivalent IPv4/IPv6 classifiers. Rules are inserted early in `INPUT`, accept under-limit SYN packets, and reject over-limit SYN packets with TCP RST so clients retry promptly instead of waiting for a silent DROP timeout. The generic bucket is controlled by `synlimit_seconds`, `synlimit_hitcount`, and `synlimit_burst`; the iOS-like TTL/length bucket is controlled by `synlimit_ios_*`. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind.
|
||||
- **Operator note**: Telemt does not persist rules with `iptables-persistent`, write `/etc/sysctl.d`, edit systemd limits, or modify `client_mss`. Apply host-level tuning manually if your deployment policy requires it.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2275,8 +2373,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
synlimit = "nftables"
|
||||
```
|
||||
## synlimit_seconds (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Token-bucket interval for both SYN limiter backends. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`).
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60`.
|
||||
- **Description**: Generic SYN-fix token-bucket interval. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). This bucket handles SYN packets that do not match the iOS-like TTL/length classifier.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2284,11 +2382,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_seconds = 1
|
||||
synlimit_seconds = 60
|
||||
```
|
||||
## synlimit_hitcount (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Token-bucket rate amount for both SYN limiter backends. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets are dropped.
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `48`.
|
||||
- **Description**: Generic SYN-fix token-bucket rate amount. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets receive TCP RST.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2296,11 +2394,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hitcount = 1
|
||||
synlimit_hitcount = 48
|
||||
```
|
||||
## synlimit_burst (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `2`.
|
||||
- **Description**: Token-bucket burst size for both SYN limiter backends. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced.
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Generic SYN-fix token-bucket burst size. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2308,7 +2406,67 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_burst = 2
|
||||
synlimit_burst = 1
|
||||
```
|
||||
## synlimit_ios_seconds (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
|
||||
- **Description**: Token-bucket interval for SYN packets matching the iOS-like classifier. IPv4 matches packet length `64` and TTL `< 65`; IPv6 matches packet length `84` and hop limit `< 65`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_seconds = 1
|
||||
```
|
||||
## synlimit_ios_hitcount (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `12`.
|
||||
- **Description**: Token-bucket rate amount for the iOS-like SYN classifier.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_hitcount = 12
|
||||
```
|
||||
## synlimit_ios_burst (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `24`.
|
||||
- **Description**: Token-bucket burst size for the iOS-like SYN classifier.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_burst = 24
|
||||
```
|
||||
## synlimit_hashlimit_expire_ms (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60000`.
|
||||
- **Description**: Entry expiration in milliseconds for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_expire_ms = 60000
|
||||
```
|
||||
## synlimit_hashlimit_size (server.listeners)
|
||||
- **Constraints / validation**: `u32`, must be `> 0`. Default is `32768`.
|
||||
- **Description**: Hash table size for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_size = 32768
|
||||
```
|
||||
## announce
|
||||
- **Constraints / validation**: `String` (optional). Must not be empty when set.
|
||||
@@ -3028,7 +3186,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | `✘` |
|
||||
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | `✘` |
|
||||
| [`user_rate_limits`](#user_rate_limits) | `Map<String, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<IpNetwork, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<CidrRateLimitKey, RateLimitBps>` | `{}` | `✔` |
|
||||
|
||||
## users
|
||||
- **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**.
|
||||
@@ -3191,13 +3349,15 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
alice = { up_bps = 1048576, down_bps = 2097152 }
|
||||
```
|
||||
## cidr_rate_limits
|
||||
- **Constraints / validation**: Table `CIDR -> { up_bps, down_bps }`. CIDR must parse as `IpNetwork`; at least one direction must be non-zero.
|
||||
- **Description**: Source-subnet bandwidth caps applied alongside per-user limits.
|
||||
- **Constraints / validation**: Table `CIDR or auto-template -> { up_bps, down_bps }`. Explicit CIDR keys must parse as `IpNetwork`; auto-template keys must be `*4/N` (`N=0..32`), `*6/N` (`N=0..128`), or `*/N` (`N=0..32`). At least one direction must be non-zero. Duplicate normalized auto-templates are rejected.
|
||||
- **Description**: Source-subnet bandwidth caps applied alongside per-user limits. Explicit CIDR rules use longest-prefix-wins and take priority over auto-templates. Auto-templates create buckets lazily per matched source subnet: `*4/N` for IPv4, `*6/N` for IPv6, and `*/N` as a dual-stack shorthand where IPv4 uses `/N` and IPv6 uses `/(N * 4)`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[access.cidr_rate_limits]
|
||||
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
|
||||
"*4/32" = { up_bps = 262144, down_bps = 1048576 }
|
||||
"*6/64" = { up_bps = 262144, down_bps = 1048576 }
|
||||
```
|
||||
# [[upstreams]]
|
||||
|
||||
|
||||
@@ -1808,6 +1808,7 @@
|
||||
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` |
|
||||
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` |
|
||||
| [`client_mss`](#client_mss) | `String` | `""` | `✘` |
|
||||
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `✘` |
|
||||
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `✘` |
|
||||
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `✘` |
|
||||
@@ -1893,6 +1894,7 @@
|
||||
## client_mss
|
||||
- **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`.
|
||||
- **Описание**: MSS для входящих TCP-соединений клиентов. Значение применяется к TCP listener-сокетам до `listen(2)`, чтобы Linux мог объявить его в SYN/ACK. Параметр влияет только на proxy client TCP listeners и не применяется к API, metrics, Unix sockets, Telegram upstreams, ME sockets или mask backend connections. Изменение требует restart/rebind listener’ов.
|
||||
- **Operator note**: Two-tier `synlimit` profile больше не требует автоматического отключения MSS внутри Telemt. Оператор должен сам решить, оставлять MSS shaping для handshake fragmentation или отключать его ради более высокой скорости media.
|
||||
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
|
||||
- **Пример**:
|
||||
|
||||
@@ -1900,6 +1902,16 @@
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
```
|
||||
## client_mss_bulk
|
||||
- **Ограничения / валидация**: `String`. Грамматика та же, что у [`client_mss`](#client_mss) (пусто/не задано, пресеты `"extreme-low"`/`"tspu"`/`"2in8"` либо десятичное число в диапазоне `88..=4096`).
|
||||
- **Описание**: Необязательный MSS для bulk-фазы. Если задан, низкий `client_mss` применяется только на время TLS-handshake (включая инспектируемый DPI ServerHello); как только соединение переходит в фазу relay, MSS клиентского сокета поднимается до `client_mss_bulk` для передачи полезной нагрузки. Так сохраняется anti-DPI фрагментация handshake, но для данных возвращаются пакеты нормального размера — это снижает исходящий packets-per-second примерно во столько раз, каков segment multiplier у `client_mss` (например, ~10x для `"tspu"`). Полезно на хостингах, где abuse-детекция считает packets-per-second, а не полосу. Если пусто/не задано — MSS handshake сохраняется на всё соединение (прежнее поведение). Только Linux; на прочих платформах — no-op.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
client_mss_bulk = "1400"
|
||||
```
|
||||
## proxy_protocol
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
- **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка.
|
||||
@@ -2226,9 +2238,14 @@
|
||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` |
|
||||
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` |
|
||||
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` |
|
||||
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✔` |
|
||||
| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✔` |
|
||||
| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✔` |
|
||||
| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✔` |
|
||||
| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✔` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
@@ -2266,7 +2283,8 @@
|
||||
```
|
||||
## synlimit (server.listeners)
|
||||
- **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен.
|
||||
- **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind.
|
||||
- **Описание**: Устанавливает per-listener Linux netfilter two-tier SYN-fix rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit`, `length` и TTL/hop-limit matches. `"nftables"` использует Telemt-owned tables с per-source `meter` rules и эквивалентными IPv4/IPv6 classifiers. Rules вставляются рано в `INPUT`, принимают under-limit SYN packets и отвечают TCP RST на over-limit SYN packets, чтобы клиент быстро переподключался вместо ожидания silent DROP timeout. Generic bucket управляется `synlimit_seconds`, `synlimit_hitcount` и `synlimit_burst`; iOS-like TTL/length bucket управляется `synlimit_ios_*`. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind.
|
||||
- **Operator note**: Telemt не сохраняет rules через `iptables-persistent`, не пишет `/etc/sysctl.d`, не меняет systemd limits и не модифицирует `client_mss`. Host-level tuning применяется оператором вручную.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2281,8 +2299,8 @@
|
||||
synlimit = "nftables"
|
||||
```
|
||||
## synlimit_seconds (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`).
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60`.
|
||||
- **Описание**: Generic SYN-fix token-bucket interval. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`). Этот bucket обрабатывает SYN packets, которые не совпали с iOS-like TTL/length classifier.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2290,11 +2308,11 @@
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_seconds = 1
|
||||
synlimit_seconds = 60
|
||||
```
|
||||
## synlimit_hitcount (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться.
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `48`.
|
||||
- **Описание**: Generic SYN-fix token-bucket rate amount. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets получат TCP RST.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2302,11 +2320,11 @@
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hitcount = 1
|
||||
synlimit_hitcount = 48
|
||||
```
|
||||
## synlimit_burst (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`.
|
||||
- **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Generic SYN-fix token-bucket burst size. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2314,7 +2332,67 @@
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_burst = 2
|
||||
synlimit_burst = 1
|
||||
```
|
||||
## synlimit_ios_seconds (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
|
||||
- **Описание**: Token-bucket interval для SYN packets, совпавших с iOS-like classifier. IPv4 match: packet length `64` и TTL `< 65`; IPv6 match: packet length `84` и hop limit `< 65`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_seconds = 1
|
||||
```
|
||||
## synlimit_ios_hitcount (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `12`.
|
||||
- **Описание**: Token-bucket rate amount для iOS-like SYN classifier.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_hitcount = 12
|
||||
```
|
||||
## synlimit_ios_burst (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `24`.
|
||||
- **Описание**: Token-bucket burst size для iOS-like SYN classifier.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_ios_burst = 24
|
||||
```
|
||||
## synlimit_hashlimit_expire_ms (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60000`.
|
||||
- **Описание**: Entry expiration в миллисекундах для iptables/ip6tables hashlimit buckets. nftables meters используют kernel-managed state и не имеют точного аналога этого knob.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_expire_ms = 60000
|
||||
```
|
||||
## synlimit_hashlimit_size (server.listeners)
|
||||
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `32768`.
|
||||
- **Описание**: Hash table size для iptables/ip6tables hashlimit buckets. nftables meters используют kernel-managed state и не имеют точного аналога этого knob.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
synlimit = "iptables"
|
||||
synlimit_hashlimit_size = 32768
|
||||
```
|
||||
## announce
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
|
||||
@@ -3034,7 +3112,7 @@
|
||||
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | `✘` |
|
||||
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | `✘` |
|
||||
| [`user_rate_limits`](#user_rate_limits) | `Map<String, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<IpNetwork, RateLimitBps>` | `{}` | `✔` |
|
||||
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<CidrRateLimitKey, RateLimitBps>` | `{}` | `✔` |
|
||||
|
||||
## users
|
||||
- **Ограничения / валидация**: Не должно быть пустым (должен существовать хотя бы один пользователь). Каждое значение должно состоять **ровно из 32 шестнадцатеричных символов**.
|
||||
@@ -3187,13 +3265,15 @@
|
||||
alice = { up_bps = 1048576, down_bps = 2097152 }
|
||||
```
|
||||
## cidr_rate_limits
|
||||
- **Ограничения / валидация**: Таблица `CIDR -> { up_bps, down_bps }`. CIDR должен корректно разбираться как `IpNetwork`; хотя бы одно направление должно быть ненулевым.
|
||||
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений.
|
||||
- **Ограничения / валидация**: Таблица `CIDR или auto-template -> { up_bps, down_bps }`. Explicit CIDR-ключи должны корректно разбираться как `IpNetwork`; auto-template ключи должны иметь вид `*4/N` (`N=0..32`), `*6/N` (`N=0..128`) или `*/N` (`N=0..32`). Хотя бы одно направление должно быть ненулевым. Дублирующиеся нормализованные auto-template отклоняются.
|
||||
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений. Explicit CIDR-правила используют longest-prefix-wins и имеют приоритет над auto-template. Auto-template создают bucket’ы лениво по matched source subnet: `*4/N` для IPv4, `*6/N` для IPv6, а `*/N` является dual-stack shorthand, где IPv4 использует `/N`, а IPv6 — `/(N * 4)`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[access.cidr_rate_limits]
|
||||
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
|
||||
"*4/32" = { up_bps = 262144, down_bps = 1048576 }
|
||||
"*6/64" = { up_bps = 262144, down_bps = 1048576 }
|
||||
```
|
||||
# [[upstreams]]
|
||||
|
||||
|
||||
@@ -313,6 +313,83 @@ mod tests {
|
||||
assert_eq!(err.code, "section_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_show_link_section() {
|
||||
// show_link is a legacy top-level scalar/array (not a [table]); it cannot
|
||||
// be upserted safely and is superseded by the editable general.links.show.
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"show_link": "*"});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "section_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_general_links_show_is_editable() {
|
||||
// The supported replacement path: edit show via the general.links sub-table.
|
||||
let (path, _d) = temp_config(
|
||||
"[general]\nprefer_ipv6 = false\n[general.links]\nshow = \"*\"\n\
|
||||
[censorship]\ntls_domain = \"a\"\n",
|
||||
);
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"show": ["alice"]}}});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(resp.changed.iter().any(|c| c == "general"));
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||
assert_eq!(
|
||||
parsed["general"]["links"]["show"][0].as_str(),
|
||||
Some("alice"),
|
||||
"{written}"
|
||||
);
|
||||
// No leaked top-level [links]/[modes] and no duplicate sub-tables.
|
||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_links_public_port_written_as_integer_not_float_or_string() {
|
||||
// A JSON integer must land on disk as a bare TOML integer (443), never
|
||||
// 443.0 nor "443". The write re-renders from the typed config, so the
|
||||
// u16 field dictates the output format regardless of JSON quirks.
|
||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443}}});
|
||||
apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert!(written.contains("public_port = 443"), "{written}");
|
||||
assert!(
|
||||
!written.contains("443.0"),
|
||||
"must not be a float:\n{written}"
|
||||
);
|
||||
assert!(
|
||||
!written.contains("\"443\""),
|
||||
"must not be a string:\n{written}"
|
||||
);
|
||||
|
||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||
assert_eq!(
|
||||
parsed["general"]["links"]["public_port"].as_integer(),
|
||||
Some(443),
|
||||
"{written}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_links_public_port_rejects_float() {
|
||||
// 443.0 cannot deserialize into u16 -> rejected, not silently coerced.
|
||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443.0}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_links_public_port_rejects_string() {
|
||||
// "443" is a string, not a u16 -> rejected.
|
||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": "443"}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_empty_is_rejected() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
|
||||
+204
-19
@@ -102,9 +102,14 @@ pub(super) async fn save_config_to_disk(
|
||||
/// Intentionally excluded (defense-in-depth, enforces the spec's per-node
|
||||
/// identity invariant at the Telemt layer too):
|
||||
///
|
||||
/// - `access` : owned by the users API.
|
||||
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
|
||||
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
|
||||
/// - `access` : owned by the users API.
|
||||
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
|
||||
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
|
||||
/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded
|
||||
/// by the editable `general.links.show` sub-table. The
|
||||
/// section-upsert machinery here only handles `[table]` /
|
||||
/// `[[array-of-tables]]` blocks; a bare top-level key cannot be
|
||||
/// located or replaced safely, so it is edited via `general`.
|
||||
///
|
||||
/// A future field-level allowlist can re-admit specific safe fields
|
||||
/// (e.g. `network.dns_overrides`) without opening the whole section.
|
||||
@@ -113,7 +118,6 @@ pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
||||
"timeouts",
|
||||
"censorship",
|
||||
"upstreams",
|
||||
"show_link",
|
||||
"dc_overrides",
|
||||
];
|
||||
|
||||
@@ -162,10 +166,15 @@ fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String,
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let body = toml::to_string(table)
|
||||
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
|
||||
// crate emits correctly dotted headers for nested sub-tables, e.g.
|
||||
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
|
||||
// inner table alone would render bare `[modes]`/`[links]` headers, which
|
||||
// would leak as duplicate top-level tables and break config load.
|
||||
let mut wrapper = toml::value::Table::new();
|
||||
wrapper.insert(section.to_string(), table.clone());
|
||||
let mut out = toml::to_string(&toml::Value::Table(wrapper))
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
||||
let mut out = format!("[{}]\n", section);
|
||||
out.push_str(&body);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
@@ -328,11 +337,22 @@ fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
||||
}
|
||||
|
||||
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
|
||||
if let Some((start, end)) = find_toml_table_bounds(source, table_name) {
|
||||
let blocks = find_all_table_blocks(source, table_name);
|
||||
if let Some(&(first_start, first_end)) = blocks.first() {
|
||||
// Replace the first block in place and delete any further blocks that
|
||||
// also belong to this table. Telemt writes a section's sub-tables
|
||||
// contiguously, but a hand-edited config may scatter them; dropping the
|
||||
// extras here prevents the duplicate-table corruption that would
|
||||
// otherwise break config load.
|
||||
let mut out = String::with_capacity(source.len() + replacement.len());
|
||||
out.push_str(&source[..start]);
|
||||
out.push_str(&source[..first_start]);
|
||||
out.push_str(replacement);
|
||||
out.push_str(&source[end..]);
|
||||
let mut cursor = first_end;
|
||||
for &(start, end) in &blocks[1..] {
|
||||
out.push_str(&source[cursor..start]);
|
||||
cursor = end;
|
||||
}
|
||||
out.push_str(&source[cursor..]);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -347,29 +367,62 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
|
||||
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
|
||||
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
|
||||
/// prefixes — `access.users` must not match `access.user_enabled`.
|
||||
fn header_belongs_to(header: &str, table_name: &str) -> bool {
|
||||
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
|
||||
Some(body) => body,
|
||||
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
|
||||
Some(body) => body,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
let body = body.trim();
|
||||
body == table_name
|
||||
|| body
|
||||
.strip_prefix(table_name)
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// Locate the first contiguous byte range covering `table_name` and the nested
|
||||
/// sub-tables immediately following it. Used for existence checks; see
|
||||
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
|
||||
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
|
||||
let single = format!("[{}]", table_name);
|
||||
let array = format!("[[{}]]", table_name);
|
||||
find_all_table_blocks(source, table_name).into_iter().next()
|
||||
}
|
||||
|
||||
/// Locate every byte range that belongs to `table_name`: the table header and
|
||||
/// its nested sub-tables. Returns one range per contiguous run, so a config
|
||||
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
|
||||
/// ranges — letting the caller collapse them into a single rendered block.
|
||||
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
let mut start = None;
|
||||
let mut start: Option<usize> = None;
|
||||
|
||||
for line in source.split_inclusive('\n') {
|
||||
// Drop any inline comment so a hand-edited header like
|
||||
// `[censorship] # note` still matches. Section names never contain `#`.
|
||||
let header = line.trim().split('#').next().unwrap_or("").trim();
|
||||
let is_header = header.starts_with('[');
|
||||
if let Some(start_offset) = start {
|
||||
let is_same_array = header == array;
|
||||
let is_new_header = header.starts_with('[');
|
||||
if is_new_header && !is_same_array {
|
||||
return Some((start_offset, offset));
|
||||
if is_header && !header_belongs_to(header, table_name) {
|
||||
blocks.push((start_offset, offset));
|
||||
start = None;
|
||||
}
|
||||
} else if header == single || header == array {
|
||||
}
|
||||
if start.is_none() && header_belongs_to(header, table_name) {
|
||||
start = Some(offset);
|
||||
}
|
||||
offset = offset.saturating_add(line.len());
|
||||
}
|
||||
|
||||
start.map(|start_offset| (start_offset, source.len()))
|
||||
if let Some(start_offset) = start {
|
||||
blocks.push((start_offset, source.len()));
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
|
||||
@@ -467,6 +520,138 @@ mod tests {
|
||||
assert!(!slice.contains("[server]")); // terminates at the next header
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
|
||||
// No bare top-level [modes] / [links] headers leaked.
|
||||
for line in written.lines() {
|
||||
let header = line.trim();
|
||||
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
|
||||
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
|
||||
}
|
||||
|
||||
// Sub-tables kept their dotted prefix exactly once each.
|
||||
assert_eq!(
|
||||
written.matches("[general.modes]").count(),
|
||||
1,
|
||||
"[general.modes] must appear exactly once:\n{written}"
|
||||
);
|
||||
assert_eq!(
|
||||
written.matches("[general.links]").count(),
|
||||
1,
|
||||
"[general.links] must appear exactly once:\n{written}"
|
||||
);
|
||||
|
||||
// Result parses (duplicate tables would error here).
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
|
||||
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_section_is_idempotent_across_repeated_saves() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"old.example\"\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
|
||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||
assert_eq!(written.matches("[general]").count(), 1, "{written}");
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_spans_dotted_subtables() {
|
||||
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
|
||||
let bounds = find_toml_table_bounds(src, "general");
|
||||
assert!(bounds.is_some(), "should locate [general] block");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[general]"));
|
||||
assert!(slice.contains("[general.modes]")); // spans nested sub-tables
|
||||
assert!(slice.contains("[general.links]"));
|
||||
assert!(!slice.contains("[server]")); // terminates at the next unrelated header
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_does_not_overrun_sibling_prefix() {
|
||||
// access.users must not swallow access.user_enabled (dot guards the prefix).
|
||||
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
|
||||
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
|
||||
let slice = &src[bounds.0..bounds.1];
|
||||
assert!(slice.starts_with("[access.users]"));
|
||||
assert!(!slice.contains("[access.user_enabled]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_handles_non_contiguous_subtables() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
|
||||
[general.modes]\ntls = true\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(
|
||||
written.matches("[general.modes]").count(),
|
||||
1,
|
||||
"non-contiguous [general.modes] must not duplicate:\n{written}"
|
||||
);
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
assert!(written.contains("[server]")); // unrelated section preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_user_rate_limits_section() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
|
||||
+50
-3
@@ -24,6 +24,10 @@ 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;
|
||||
pub(crate) const ME_WRITER_BYTE_PERMIT_UNIT_BYTES: usize = 16 * 1024;
|
||||
pub(crate) const ME_WRITER_FRAME_OVERHEAD_RESERVE_BYTES: usize = 256;
|
||||
const DEFAULT_ME_WRITER_BYTE_BUDGET_BYTES: usize =
|
||||
32 * 1024 * 1024 + ME_WRITER_BYTE_PERMIT_UNIT_BYTES;
|
||||
const DEFAULT_ME_ROUTE_CHANNEL_CAPACITY: usize = 768;
|
||||
const DEFAULT_ME_C2ME_CHANNEL_CAPACITY: usize = 1024;
|
||||
const DEFAULT_ME_READER_ROUTE_DATA_WAIT_MS: u64 = 2;
|
||||
@@ -35,6 +39,8 @@ const DEFAULT_ME_QUOTA_SOFT_OVERSHOOT_BYTES: u64 = 64 * 1024;
|
||||
const DEFAULT_ME_D2C_FRAME_BUF_SHRINK_THRESHOLD_BYTES: usize = 256 * 1024;
|
||||
const DEFAULT_DIRECT_RELAY_COPY_BUF_C2S_BYTES: usize = 64 * 1024;
|
||||
const DEFAULT_DIRECT_RELAY_COPY_BUF_S2C_BYTES: usize = 256 * 1024;
|
||||
pub(crate) const DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES: usize = 4 * 1024;
|
||||
const DEFAULT_DIRECT_RELAY_BUFFER_BUDGET_MAX_BYTES: usize = 0;
|
||||
const DEFAULT_ME_WRITER_PICK_SAMPLE_SIZE: u8 = 3;
|
||||
const DEFAULT_ME_HEALTH_INTERVAL_MS_UNHEALTHY: u64 = 1000;
|
||||
const DEFAULT_ME_HEALTH_INTERVAL_MS_HEALTHY: u64 = 3000;
|
||||
@@ -54,9 +60,14 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true;
|
||||
const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85;
|
||||
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
|
||||
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
|
||||
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_BURST: u32 = 2;
|
||||
const DEFAULT_SYNLIMIT_SECONDS: u32 = 60;
|
||||
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 48;
|
||||
const DEFAULT_SYNLIMIT_BURST: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_IOS_SECONDS: u32 = 1;
|
||||
const DEFAULT_SYNLIMIT_IOS_HITCOUNT: u32 = 12;
|
||||
const DEFAULT_SYNLIMIT_IOS_BURST: u32 = 24;
|
||||
const DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS: u32 = 60_000;
|
||||
const DEFAULT_SYNLIMIT_HASHLIMIT_SIZE: u32 = 32_768;
|
||||
const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
|
||||
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
|
||||
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
|
||||
@@ -258,6 +269,26 @@ pub(crate) fn default_synlimit_burst() -> u32 {
|
||||
DEFAULT_SYNLIMIT_BURST
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_ios_seconds() -> u32 {
|
||||
DEFAULT_SYNLIMIT_IOS_SECONDS
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_ios_hitcount() -> u32 {
|
||||
DEFAULT_SYNLIMIT_IOS_HITCOUNT
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_ios_burst() -> u32 {
|
||||
DEFAULT_SYNLIMIT_IOS_BURST
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_hashlimit_expire_ms() -> u32 {
|
||||
DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS
|
||||
}
|
||||
|
||||
pub(crate) fn default_synlimit_hashlimit_size() -> u32 {
|
||||
DEFAULT_SYNLIMIT_HASHLIMIT_SIZE
|
||||
}
|
||||
|
||||
pub(crate) fn default_prefer_4() -> u8 {
|
||||
4
|
||||
}
|
||||
@@ -430,6 +461,18 @@ pub(crate) fn default_me_writer_cmd_channel_capacity() -> usize {
|
||||
DEFAULT_ME_WRITER_CMD_CHANNEL_CAPACITY
|
||||
}
|
||||
|
||||
pub(crate) fn default_me_writer_byte_budget_bytes() -> usize {
|
||||
DEFAULT_ME_WRITER_BYTE_BUDGET_BYTES
|
||||
}
|
||||
|
||||
pub(crate) fn minimum_me_writer_byte_budget_bytes(max_client_frame: usize) -> usize {
|
||||
max_client_frame
|
||||
.saturating_mul(2)
|
||||
.saturating_add(ME_WRITER_FRAME_OVERHEAD_RESERVE_BYTES)
|
||||
.div_ceil(ME_WRITER_BYTE_PERMIT_UNIT_BYTES)
|
||||
.saturating_mul(ME_WRITER_BYTE_PERMIT_UNIT_BYTES)
|
||||
}
|
||||
|
||||
pub(crate) fn default_me_route_channel_capacity() -> usize {
|
||||
DEFAULT_ME_ROUTE_CHANNEL_CAPACITY
|
||||
}
|
||||
@@ -474,6 +517,10 @@ pub(crate) fn default_direct_relay_copy_buf_s2c_bytes() -> usize {
|
||||
DEFAULT_DIRECT_RELAY_COPY_BUF_S2C_BYTES
|
||||
}
|
||||
|
||||
pub(crate) fn default_direct_relay_buffer_budget_max_bytes() -> usize {
|
||||
DEFAULT_DIRECT_RELAY_BUFFER_BUDGET_MAX_BYTES
|
||||
}
|
||||
|
||||
pub(crate) fn default_me_writer_pick_sample_size() -> u8 {
|
||||
DEFAULT_ME_WRITER_PICK_SAMPLE_SIZE
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ use tracing::{error, info, warn};
|
||||
|
||||
use super::load::{LoadedConfig, ProxyConfig};
|
||||
use crate::config::{
|
||||
ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
|
||||
MeWriterPickMode, SynLimitMode,
|
||||
CidrRateLimitKey, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy,
|
||||
MeTelemetryLevel, MeWriterPickMode, SynLimitMode,
|
||||
};
|
||||
|
||||
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||
@@ -128,8 +128,7 @@ pub struct HotFields {
|
||||
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
|
||||
pub user_data_quota: std::collections::HashMap<String, u64>,
|
||||
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
|
||||
pub cidr_rate_limits:
|
||||
std::collections::HashMap<ipnetwork::IpNetwork, crate::config::RateLimitBps>,
|
||||
pub cidr_rate_limits: std::collections::HashMap<CidrRateLimitKey, 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,
|
||||
@@ -145,6 +144,11 @@ pub struct ListenerSynLimitHotFields {
|
||||
pub synlimit_seconds: u32,
|
||||
pub synlimit_hitcount: u32,
|
||||
pub synlimit_burst: u32,
|
||||
pub synlimit_ios_seconds: u32,
|
||||
pub synlimit_ios_hitcount: u32,
|
||||
pub synlimit_ios_burst: u32,
|
||||
pub synlimit_hashlimit_expire_ms: u32,
|
||||
pub synlimit_hashlimit_size: u32,
|
||||
}
|
||||
|
||||
impl HotFields {
|
||||
@@ -293,6 +297,11 @@ impl ListenerSynLimitHotFields {
|
||||
synlimit_seconds: listener.synlimit_seconds,
|
||||
synlimit_hitcount: listener.synlimit_hitcount,
|
||||
synlimit_burst: listener.synlimit_burst,
|
||||
synlimit_ios_seconds: listener.synlimit_ios_seconds,
|
||||
synlimit_ios_hitcount: listener.synlimit_ios_hitcount,
|
||||
synlimit_ios_burst: listener.synlimit_ios_burst,
|
||||
synlimit_hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms,
|
||||
synlimit_hashlimit_size: listener.synlimit_hashlimit_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -620,6 +629,11 @@ fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerC
|
||||
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
|
||||
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
|
||||
old_listener.synlimit_burst = new_listener.synlimit_burst;
|
||||
old_listener.synlimit_ios_seconds = new_listener.synlimit_ios_seconds;
|
||||
old_listener.synlimit_ios_hitcount = new_listener.synlimit_ios_hitcount;
|
||||
old_listener.synlimit_ios_burst = new_listener.synlimit_ios_burst;
|
||||
old_listener.synlimit_hashlimit_expire_ms = new_listener.synlimit_hashlimit_expire_ms;
|
||||
old_listener.synlimit_hashlimit_size = new_listener.synlimit_hashlimit_size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1710,6 +1724,51 @@ mod tests {
|
||||
assert!(!config_equal(&applied, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_synlimit_extended_fields_are_hot() {
|
||||
let mut old = sample_config();
|
||||
old.server.listeners.push(ListenerConfig {
|
||||
ip: "0.0.0.0".parse().unwrap(),
|
||||
port: Some(443),
|
||||
client_mss: None,
|
||||
synlimit: SynLimitMode::Iptables,
|
||||
synlimit_seconds: 60,
|
||||
synlimit_hitcount: 48,
|
||||
synlimit_burst: 1,
|
||||
synlimit_ios_seconds: 1,
|
||||
synlimit_ios_hitcount: 12,
|
||||
synlimit_ios_burst: 24,
|
||||
synlimit_hashlimit_expire_ms: 60_000,
|
||||
synlimit_hashlimit_size: 32_768,
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
reuse_allow: false,
|
||||
});
|
||||
let mut new = old.clone();
|
||||
new.server.port = 8443;
|
||||
new.server.listeners[0].synlimit_seconds = 120;
|
||||
new.server.listeners[0].synlimit_hitcount = 96;
|
||||
new.server.listeners[0].synlimit_burst = 2;
|
||||
new.server.listeners[0].synlimit_ios_seconds = 2;
|
||||
new.server.listeners[0].synlimit_ios_hitcount = 18;
|
||||
new.server.listeners[0].synlimit_ios_burst = 36;
|
||||
new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000;
|
||||
new.server.listeners[0].synlimit_hashlimit_size = 65_536;
|
||||
|
||||
let applied = overlay_hot_fields(&old, &new);
|
||||
let listener = &applied.server.listeners[0];
|
||||
assert_eq!(applied.server.port, old.server.port);
|
||||
assert_eq!(listener.synlimit_seconds, 120);
|
||||
assert_eq!(listener.synlimit_hitcount, 96);
|
||||
assert_eq!(listener.synlimit_burst, 2);
|
||||
assert_eq!(listener.synlimit_ios_seconds, 2);
|
||||
assert_eq!(listener.synlimit_ios_hitcount, 18);
|
||||
assert_eq!(listener.synlimit_ios_burst, 36);
|
||||
assert_eq!(listener.synlimit_hashlimit_expire_ms, 90_000);
|
||||
assert_eq!(listener.synlimit_hashlimit_size, 65_536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_applies_hot_change_on_first_observed_snapshot() {
|
||||
let initial_tag = "11111111111111111111111111111111";
|
||||
|
||||
+137
-3228
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
|
||||
pub(super) fn normalize_config_path(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map(|cwd| cwd.join(path))
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
rendered.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_includes(
|
||||
content: &str,
|
||||
base_dir: &Path,
|
||||
depth: u8,
|
||||
source_files: &mut BTreeSet<PathBuf>,
|
||||
) -> Result<String> {
|
||||
if depth > 10 {
|
||||
return Err(ProxyError::Config("Include depth > 10".into()));
|
||||
}
|
||||
let mut output = String::with_capacity(content.len());
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("include") {
|
||||
let rest = rest.trim();
|
||||
if let Some(rest) = rest.strip_prefix('=') {
|
||||
let path_str = rest.trim().trim_matches('"');
|
||||
let resolved = base_dir.join(path_str);
|
||||
source_files.insert(normalize_config_path(&resolved));
|
||||
let included = std::fs::read_to_string(&resolved)
|
||||
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||
let included_dir = resolved.parent().unwrap_or(base_dir);
|
||||
output.push_str(&preprocess_includes(
|
||||
&included,
|
||||
included_dir,
|
||||
depth + 1,
|
||||
source_files,
|
||||
)?);
|
||||
output.push('\n');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output.push_str(line);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use crate::error::{ProxyError, Result};
|
||||
use tracing::warn;
|
||||
|
||||
pub(super) fn is_valid_tls_domain_name(domain: &str) -> bool {
|
||||
!domain.is_empty()
|
||||
&& !domain
|
||||
.chars()
|
||||
.any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\'))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_domain_to_ascii(domain: &str, field: &str) -> Result<String> {
|
||||
let domain = domain.trim();
|
||||
if !is_valid_tls_domain_name(domain) {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"Invalid {field}: '{}'. Must be a valid domain name",
|
||||
domain
|
||||
)));
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(&format!("https://{domain}/")).map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"Invalid {field}: '{}'. IDNA conversion failed: {error}",
|
||||
domain
|
||||
))
|
||||
})?;
|
||||
let host = parsed.host_str().ok_or_else(|| {
|
||||
ProxyError::Config(format!("Invalid {field}: '{}'. Host is empty", domain))
|
||||
})?;
|
||||
Ok(host.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_mask_host_to_ascii(host: &str, field: &str) -> Result<String> {
|
||||
let host = host.trim();
|
||||
if host.starts_with('[') && host.ends_with(']') {
|
||||
let inner = &host[1..host.len() - 1];
|
||||
let ip = inner.parse::<std::net::IpAddr>().map_err(|_| {
|
||||
ProxyError::Config(format!(
|
||||
"Invalid {field}: '{}'. IPv6 literal is invalid",
|
||||
host
|
||||
))
|
||||
})?;
|
||||
return match ip {
|
||||
std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")),
|
||||
std::net::IpAddr::V4(v4) => Ok(v4.to_string()),
|
||||
};
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(v4) => Ok(v4.to_string()),
|
||||
std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")),
|
||||
};
|
||||
}
|
||||
|
||||
normalize_domain_to_ascii(host, field)
|
||||
}
|
||||
|
||||
pub(super) fn parse_exclusive_mask_target(target: &str) -> Option<(&str, u16)> {
|
||||
let target = target.trim();
|
||||
if target.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if target.starts_with('[') {
|
||||
let end = target.find(']')?;
|
||||
if target.get(end + 1..end + 2)? != ":" {
|
||||
return None;
|
||||
}
|
||||
let host = &target[..=end];
|
||||
let port = target[end + 2..].parse::<u16>().ok()?;
|
||||
return (port > 0).then_some((host, port));
|
||||
}
|
||||
|
||||
let (host, port) = target.rsplit_once(':')?;
|
||||
if host.is_empty() || host.contains(':') {
|
||||
return None;
|
||||
}
|
||||
let port = port.parse::<u16>().ok()?;
|
||||
(port > 0).then_some((host, port))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String> {
|
||||
let (host, port) = parse_exclusive_mask_target(target).ok_or_else(|| {
|
||||
ProxyError::Config(format!(
|
||||
"Invalid {field}: '{}'. Expected host:port with port > 0",
|
||||
target
|
||||
))
|
||||
})?;
|
||||
let host = normalize_mask_host_to_ascii(host, field)?;
|
||||
Ok(format!("{host}:{port}"))
|
||||
}
|
||||
|
||||
pub(super) fn push_unique_nonempty(target: &mut Vec<String>, value: String) {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !target.iter().any(|existing| existing == trimmed) {
|
||||
target.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_valid_ad_tag(tag: &str) -> bool {
|
||||
tag.len() == 32 && tag.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
|
||||
let Some(tag) = ad_tag.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !is_valid_ad_tag(tag) {
|
||||
warn!("Invalid general.ad_tag value, expected exactly 32 hex chars; ad_tag is disabled");
|
||||
*ad_tag = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
|
||||
const ACCESS_SECRET_BYTES: usize = 16;
|
||||
|
||||
/// Precomputed, immutable user authentication data used by handshake hot paths.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct UserAuthSnapshot {
|
||||
entries: Vec<UserAuthEntry>,
|
||||
by_name: HashMap<String, u32>,
|
||||
sni_index: HashMap<u64, Vec<u32>>,
|
||||
sni_initial_index: HashMap<u8, Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct UserAuthEntry {
|
||||
pub(crate) user: String,
|
||||
pub(crate) secret: [u8; ACCESS_SECRET_BYTES],
|
||||
}
|
||||
|
||||
impl UserAuthSnapshot {
|
||||
pub(super) fn from_users(users: &HashMap<String, String>) -> Result<Self> {
|
||||
let mut entries = Vec::with_capacity(users.len());
|
||||
let mut by_name = HashMap::with_capacity(users.len());
|
||||
let mut sni_index = HashMap::with_capacity(users.len());
|
||||
let mut sni_initial_index = HashMap::with_capacity(users.len());
|
||||
|
||||
for (user, secret_hex) in users {
|
||||
let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret {
|
||||
user: user.clone(),
|
||||
reason: "Must be 32 hex characters".to_string(),
|
||||
})?;
|
||||
if decoded.len() != ACCESS_SECRET_BYTES {
|
||||
return Err(ProxyError::InvalidSecret {
|
||||
user: user.clone(),
|
||||
reason: "Must be 32 hex characters".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let user_id = u32::try_from(entries.len()).map_err(|_| {
|
||||
ProxyError::Config("Too many users for runtime auth snapshot".to_string())
|
||||
})?;
|
||||
|
||||
let mut secret = [0u8; ACCESS_SECRET_BYTES];
|
||||
secret.copy_from_slice(&decoded);
|
||||
entries.push(UserAuthEntry {
|
||||
user: user.clone(),
|
||||
secret,
|
||||
});
|
||||
by_name.insert(user.clone(), user_id);
|
||||
sni_index
|
||||
.entry(Self::sni_lookup_hash(user))
|
||||
.or_insert_with(Vec::new)
|
||||
.push(user_id);
|
||||
if let Some(initial) = user
|
||||
.as_bytes()
|
||||
.first()
|
||||
.map(|byte| byte.to_ascii_lowercase())
|
||||
{
|
||||
sni_initial_index
|
||||
.entry(initial)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
entries,
|
||||
by_name,
|
||||
sni_index,
|
||||
sni_initial_index,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn entries(&self) -> &[UserAuthEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub(crate) fn user_id_by_name(&self, user: &str) -> Option<u32> {
|
||||
self.by_name.get(user).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn entry_by_id(&self, user_id: u32) -> Option<&UserAuthEntry> {
|
||||
let idx = usize::try_from(user_id).ok()?;
|
||||
self.entries.get(idx)
|
||||
}
|
||||
|
||||
pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> {
|
||||
self.sni_index
|
||||
.get(&Self::sni_lookup_hash(sni))
|
||||
.map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub(crate) fn sni_initial_candidates(&self, sni: &str) -> Option<&[u32]> {
|
||||
let initial = sni
|
||||
.as_bytes()
|
||||
.first()
|
||||
.map(|byte| byte.to_ascii_lowercase())?;
|
||||
self.sni_initial_index.get(&initial).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
fn sni_lookup_hash(value: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
for byte in value.bytes() {
|
||||
hasher.write_u8(byte.to_ascii_lowercase());
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
|
||||
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
||||
"general",
|
||||
"logging",
|
||||
"network",
|
||||
"server",
|
||||
"timeouts",
|
||||
"censorship",
|
||||
"access",
|
||||
"upstreams",
|
||||
"show_link",
|
||||
"dc_overrides",
|
||||
"default_dc",
|
||||
"beobachten",
|
||||
"beobachten_minutes",
|
||||
"beobachten_flush_secs",
|
||||
"beobachten_file",
|
||||
"include",
|
||||
];
|
||||
|
||||
const GENERAL_CONFIG_KEYS: &[&str] = &[
|
||||
"data_path",
|
||||
"quota_state_path",
|
||||
"config_strict",
|
||||
"modes",
|
||||
"prefer_ipv6",
|
||||
"fast_mode",
|
||||
"use_middle_proxy",
|
||||
"proxy_secret_path",
|
||||
"proxy_secret_url",
|
||||
"proxy_config_v4_cache_path",
|
||||
"proxy_config_v4_url",
|
||||
"proxy_config_v6_cache_path",
|
||||
"proxy_config_v6_url",
|
||||
"ad_tag",
|
||||
"middle_proxy_nat_ip",
|
||||
"middle_proxy_nat_probe",
|
||||
"middle_proxy_nat_stun",
|
||||
"middle_proxy_nat_stun_servers",
|
||||
"stun_nat_probe_concurrency",
|
||||
"middle_proxy_pool_size",
|
||||
"middle_proxy_warm_standby",
|
||||
"me_init_retry_attempts",
|
||||
"me2dc_fallback",
|
||||
"me2dc_fast",
|
||||
"me_keepalive_enabled",
|
||||
"me_keepalive_interval_secs",
|
||||
"me_keepalive_jitter_secs",
|
||||
"me_keepalive_payload_random",
|
||||
"rpc_proxy_req_every",
|
||||
"me_writer_cmd_channel_capacity",
|
||||
"me_writer_byte_budget_bytes",
|
||||
"me_route_channel_capacity",
|
||||
"me_c2me_channel_capacity",
|
||||
"me_c2me_send_timeout_ms",
|
||||
"me_reader_route_data_wait_ms",
|
||||
"me_d2c_flush_batch_max_frames",
|
||||
"me_d2c_flush_batch_max_bytes",
|
||||
"me_d2c_flush_batch_max_delay_us",
|
||||
"me_d2c_ack_flush_immediate",
|
||||
"me_quota_soft_overshoot_bytes",
|
||||
"me_d2c_frame_buf_shrink_threshold_bytes",
|
||||
"direct_relay_copy_buf_c2s_bytes",
|
||||
"direct_relay_copy_buf_s2c_bytes",
|
||||
"direct_relay_buffer_budget_max_bytes",
|
||||
"crypto_pending_buffer",
|
||||
"max_client_frame",
|
||||
"desync_all_full",
|
||||
"beobachten",
|
||||
"beobachten_minutes",
|
||||
"beobachten_flush_secs",
|
||||
"beobachten_file",
|
||||
"hardswap",
|
||||
"me_warmup_stagger_enabled",
|
||||
"me_warmup_step_delay_ms",
|
||||
"me_warmup_step_jitter_ms",
|
||||
"me_reconnect_max_concurrent_per_dc",
|
||||
"me_reconnect_backoff_base_ms",
|
||||
"me_reconnect_backoff_cap_ms",
|
||||
"me_reconnect_fast_retry_count",
|
||||
"me_single_endpoint_shadow_writers",
|
||||
"me_single_endpoint_outage_mode_enabled",
|
||||
"me_single_endpoint_outage_disable_quarantine",
|
||||
"me_single_endpoint_outage_backoff_min_ms",
|
||||
"me_single_endpoint_outage_backoff_max_ms",
|
||||
"me_single_endpoint_shadow_rotate_every_secs",
|
||||
"me_floor_mode",
|
||||
"me_adaptive_floor_idle_secs",
|
||||
"me_adaptive_floor_min_writers_single_endpoint",
|
||||
"me_adaptive_floor_min_writers_multi_endpoint",
|
||||
"me_adaptive_floor_recover_grace_secs",
|
||||
"me_adaptive_floor_writers_per_core_total",
|
||||
"me_adaptive_floor_cpu_cores_override",
|
||||
"me_adaptive_floor_max_extra_writers_single_per_core",
|
||||
"me_adaptive_floor_max_extra_writers_multi_per_core",
|
||||
"me_adaptive_floor_max_active_writers_per_core",
|
||||
"me_adaptive_floor_max_warm_writers_per_core",
|
||||
"me_adaptive_floor_max_active_writers_global",
|
||||
"me_adaptive_floor_max_warm_writers_global",
|
||||
"upstream_connect_retry_attempts",
|
||||
"upstream_connect_retry_backoff_ms",
|
||||
"upstream_connect_budget_ms",
|
||||
"tg_connect",
|
||||
"upstream_unhealthy_fail_threshold",
|
||||
"upstream_connect_failfast_hard_errors",
|
||||
"stun_iface_mismatch_ignore",
|
||||
"unknown_dc_log_path",
|
||||
"unknown_dc_file_log_enabled",
|
||||
"log_level",
|
||||
"disable_colors",
|
||||
"telemetry",
|
||||
"me_socks_kdf_policy",
|
||||
"me_route_backpressure_enabled",
|
||||
"me_route_fairshare_enabled",
|
||||
"me_route_backpressure_base_timeout_ms",
|
||||
"me_route_backpressure_high_timeout_ms",
|
||||
"me_route_backpressure_high_watermark_pct",
|
||||
"me_health_interval_ms_unhealthy",
|
||||
"me_health_interval_ms_healthy",
|
||||
"me_admission_poll_ms",
|
||||
"me_warn_rate_limit_ms",
|
||||
"me_route_no_writer_mode",
|
||||
"me_route_no_writer_wait_ms",
|
||||
"me_route_hybrid_max_wait_ms",
|
||||
"me_route_blocking_send_timeout_ms",
|
||||
"me_route_inline_recovery_attempts",
|
||||
"me_route_inline_recovery_wait_ms",
|
||||
"links",
|
||||
"fast_mode_min_tls_record",
|
||||
"update_every",
|
||||
"me_reinit_every_secs",
|
||||
"me_hardswap_warmup_delay_min_ms",
|
||||
"me_hardswap_warmup_delay_max_ms",
|
||||
"me_hardswap_warmup_extra_passes",
|
||||
"me_hardswap_warmup_pass_backoff_base_ms",
|
||||
"me_config_stable_snapshots",
|
||||
"me_config_apply_cooldown_secs",
|
||||
"me_snapshot_require_http_2xx",
|
||||
"me_snapshot_reject_empty_map",
|
||||
"me_snapshot_min_proxy_for_lines",
|
||||
"proxy_secret_stable_snapshots",
|
||||
"proxy_secret_rotate_runtime",
|
||||
"me_secret_atomic_snapshot",
|
||||
"proxy_secret_len_max",
|
||||
"me_pool_drain_ttl_secs",
|
||||
"me_instadrain",
|
||||
"me_pool_drain_threshold",
|
||||
"me_pool_drain_soft_evict_enabled",
|
||||
"me_pool_drain_soft_evict_grace_secs",
|
||||
"me_pool_drain_soft_evict_per_writer",
|
||||
"me_pool_drain_soft_evict_budget_per_core",
|
||||
"me_pool_drain_soft_evict_cooldown_ms",
|
||||
"me_bind_stale_mode",
|
||||
"me_bind_stale_ttl_secs",
|
||||
"me_pool_min_fresh_ratio",
|
||||
"me_reinit_drain_timeout_secs",
|
||||
"proxy_secret_auto_reload_secs",
|
||||
"proxy_config_auto_reload_secs",
|
||||
"me_reinit_singleflight",
|
||||
"me_reinit_trigger_channel",
|
||||
"me_reinit_coalesce_window_ms",
|
||||
"me_deterministic_writer_sort",
|
||||
"me_writer_pick_mode",
|
||||
"me_writer_pick_sample_size",
|
||||
"ntp_check",
|
||||
"ntp_servers",
|
||||
"auto_degradation_enabled",
|
||||
"degradation_min_unavailable_dc_groups",
|
||||
"rst_on_close",
|
||||
];
|
||||
|
||||
const NETWORK_CONFIG_KEYS: &[&str] = &[
|
||||
"ipv4",
|
||||
"ipv6",
|
||||
"prefer",
|
||||
"multipath",
|
||||
"stun_use",
|
||||
"stun_servers",
|
||||
"stun_tcp_fallback",
|
||||
"http_ip_detect_urls",
|
||||
"cache_public_ip_path",
|
||||
"dns_overrides",
|
||||
];
|
||||
|
||||
const SERVER_CONFIG_KEYS: &[&str] = &[
|
||||
"port",
|
||||
"listen_addr_ipv4",
|
||||
"listen_addr_ipv6",
|
||||
"listen_unix_sock",
|
||||
"listen_unix_sock_perm",
|
||||
"listen_tcp",
|
||||
"client_mss",
|
||||
"client_mss_bulk",
|
||||
"proxy_protocol",
|
||||
"proxy_protocol_header_timeout_ms",
|
||||
"proxy_protocol_trusted_cidrs",
|
||||
"metrics_port",
|
||||
"metrics_listen",
|
||||
"metrics_whitelist",
|
||||
"api",
|
||||
"admin_api",
|
||||
"listeners",
|
||||
"listen_backlog",
|
||||
"max_connections",
|
||||
"accept_permit_timeout_ms",
|
||||
"conntrack_control",
|
||||
];
|
||||
|
||||
const API_CONFIG_KEYS: &[&str] = &[
|
||||
"enabled",
|
||||
"listen",
|
||||
"whitelist",
|
||||
"gray_action",
|
||||
"auth_header",
|
||||
"request_body_limit_bytes",
|
||||
"minimal_runtime_enabled",
|
||||
"minimal_runtime_cache_ttl_ms",
|
||||
"runtime_edge_enabled",
|
||||
"runtime_edge_cache_ttl_ms",
|
||||
"runtime_edge_top_n",
|
||||
"runtime_edge_events_capacity",
|
||||
"read_only",
|
||||
];
|
||||
|
||||
const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
|
||||
"inline_conntrack_control",
|
||||
"mode",
|
||||
"backend",
|
||||
"profile",
|
||||
"hybrid_listener_ips",
|
||||
"pressure_high_watermark_pct",
|
||||
"pressure_low_watermark_pct",
|
||||
"delete_budget_per_sec",
|
||||
];
|
||||
|
||||
const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||
"ip",
|
||||
"port",
|
||||
"client_mss",
|
||||
"synlimit",
|
||||
"synlimit_seconds",
|
||||
"synlimit_hitcount",
|
||||
"synlimit_burst",
|
||||
"synlimit_ios_seconds",
|
||||
"synlimit_ios_hitcount",
|
||||
"synlimit_ios_burst",
|
||||
"synlimit_hashlimit_expire_ms",
|
||||
"synlimit_hashlimit_size",
|
||||
"announce",
|
||||
"announce_ip",
|
||||
"proxy_protocol",
|
||||
"reuse_allow",
|
||||
];
|
||||
|
||||
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||
"client_first_byte_idle_secs",
|
||||
"client_handshake",
|
||||
"relay_idle_policy_v2_enabled",
|
||||
"relay_client_idle_soft_secs",
|
||||
"relay_client_idle_hard_secs",
|
||||
"relay_idle_grace_after_downstream_activity_secs",
|
||||
"client_keepalive",
|
||||
"client_ack",
|
||||
"me_one_retry",
|
||||
"me_one_timeout_ms",
|
||||
];
|
||||
|
||||
const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
|
||||
"tls_domain",
|
||||
"tls_domains",
|
||||
"unknown_sni_action",
|
||||
"tls_fetch_scope",
|
||||
"tls_fetch",
|
||||
"mask",
|
||||
"mask_dynamic",
|
||||
"mask_host",
|
||||
"mask_port",
|
||||
"exclusive_mask",
|
||||
"mask_unix_sock",
|
||||
"fake_cert_len",
|
||||
"tls_emulation",
|
||||
"tls_front_dir",
|
||||
"server_hello_delay_min_ms",
|
||||
"server_hello_delay_max_ms",
|
||||
"tls_new_session_tickets",
|
||||
"serverhello_compact",
|
||||
"tls_full_cert_ttl_secs",
|
||||
"alpn_enforce",
|
||||
"mask_proxy_protocol",
|
||||
"mask_shape_hardening",
|
||||
"mask_shape_hardening_aggressive_mode",
|
||||
"mask_shape_bucket_floor_bytes",
|
||||
"mask_shape_bucket_cap_bytes",
|
||||
"mask_shape_above_cap_blur",
|
||||
"mask_shape_above_cap_blur_max_bytes",
|
||||
"mask_relay_max_bytes",
|
||||
"mask_relay_timeout_ms",
|
||||
"mask_relay_idle_timeout_ms",
|
||||
"mask_classifier_prefetch_timeout_ms",
|
||||
"mask_timing_normalization_enabled",
|
||||
"mask_timing_normalization_floor_ms",
|
||||
"mask_timing_normalization_ceiling_ms",
|
||||
];
|
||||
|
||||
const TLS_FETCH_CONFIG_KEYS: &[&str] = &[
|
||||
"profiles",
|
||||
"strict_route",
|
||||
"attempt_timeout_ms",
|
||||
"total_budget_ms",
|
||||
"grease_enabled",
|
||||
"deterministic",
|
||||
"profile_cache_ttl_secs",
|
||||
];
|
||||
|
||||
const ACCESS_CONFIG_KEYS: &[&str] = &[
|
||||
"users",
|
||||
"user_enabled",
|
||||
"user_ad_tags",
|
||||
"user_max_tcp_conns",
|
||||
"user_max_tcp_conns_global_each",
|
||||
"user_expirations",
|
||||
"user_data_quota",
|
||||
"user_rate_limits",
|
||||
"cidr_rate_limits",
|
||||
"user_max_unique_ips",
|
||||
"user_max_unique_ips_global_each",
|
||||
"user_max_unique_ips_mode",
|
||||
"user_max_unique_ips_window_secs",
|
||||
"replay_check_len",
|
||||
"replay_window_secs",
|
||||
"ignore_time_skew",
|
||||
];
|
||||
|
||||
const RATE_LIMIT_BPS_CONFIG_KEYS: &[&str] = &["up_bps", "down_bps"];
|
||||
|
||||
const UPSTREAM_CONFIG_KEYS: &[&str] = &[
|
||||
"type",
|
||||
"interface",
|
||||
"bind_addresses",
|
||||
"bindtodevice",
|
||||
"force_bind",
|
||||
"address",
|
||||
"user_id",
|
||||
"username",
|
||||
"password",
|
||||
"url",
|
||||
"weight",
|
||||
"enabled",
|
||||
"scopes",
|
||||
"ipv4",
|
||||
"ipv6",
|
||||
];
|
||||
|
||||
const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"];
|
||||
const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"];
|
||||
const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"];
|
||||
const LOGGING_CONFIG_KEYS: &[&str] = &[
|
||||
"destination",
|
||||
"path",
|
||||
"rotation",
|
||||
"max_size_bytes",
|
||||
"max_files",
|
||||
"max_age_secs",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UnknownConfigKey {
|
||||
path: String,
|
||||
suggestion: Option<String>,
|
||||
}
|
||||
|
||||
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
|
||||
let mut current = value;
|
||||
for segment in path {
|
||||
current = current.get(*segment)?;
|
||||
}
|
||||
current.as_table()
|
||||
}
|
||||
|
||||
fn is_strict_config(parsed_toml: &toml::Value) -> bool {
|
||||
table_at(parsed_toml, &["general"])
|
||||
.and_then(|table| table.get("config_strict"))
|
||||
.and_then(toml::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
|
||||
let mut keys = Vec::new();
|
||||
for group in [
|
||||
TOP_LEVEL_CONFIG_KEYS,
|
||||
GENERAL_CONFIG_KEYS,
|
||||
NETWORK_CONFIG_KEYS,
|
||||
SERVER_CONFIG_KEYS,
|
||||
API_CONFIG_KEYS,
|
||||
CONNTRACK_CONTROL_CONFIG_KEYS,
|
||||
LISTENER_CONFIG_KEYS,
|
||||
TIMEOUTS_CONFIG_KEYS,
|
||||
CENSORSHIP_CONFIG_KEYS,
|
||||
TLS_FETCH_CONFIG_KEYS,
|
||||
ACCESS_CONFIG_KEYS,
|
||||
RATE_LIMIT_BPS_CONFIG_KEYS,
|
||||
UPSTREAM_CONFIG_KEYS,
|
||||
PROXY_MODES_CONFIG_KEYS,
|
||||
TELEMETRY_CONFIG_KEYS,
|
||||
LINKS_CONFIG_KEYS,
|
||||
LOGGING_CONFIG_KEYS,
|
||||
] {
|
||||
keys.extend_from_slice(group);
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
||||
let mut curr = vec![0usize; b_chars.len() + 1];
|
||||
|
||||
for (i, ca) in a.chars().enumerate() {
|
||||
curr[0] = i + 1;
|
||||
for (j, cb) in b_chars.iter().enumerate() {
|
||||
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
|
||||
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut curr);
|
||||
}
|
||||
|
||||
prev[b_chars.len()]
|
||||
}
|
||||
|
||||
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
|
||||
let normalized = key.to_ascii_lowercase();
|
||||
let mut best: Option<(&str, usize)> = None;
|
||||
for known in known_keys {
|
||||
let distance = levenshtein_distance(&normalized, known);
|
||||
let is_better = match best {
|
||||
Some((_, best_distance)) => distance < best_distance,
|
||||
None => true,
|
||||
};
|
||||
if distance <= 4 && is_better {
|
||||
best = Some((known, distance));
|
||||
}
|
||||
}
|
||||
best.map(|(known, _)| known.to_string())
|
||||
}
|
||||
|
||||
fn push_unknown_keys(
|
||||
unknown: &mut Vec<UnknownConfigKey>,
|
||||
known_for_suggestion: &[&'static str],
|
||||
path: &str,
|
||||
table: &toml::Table,
|
||||
allowed: &[&str],
|
||||
) {
|
||||
for key in table.keys() {
|
||||
if !allowed.contains(&key.as_str()) {
|
||||
let full_path = if path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{path}.{key}")
|
||||
};
|
||||
unknown.push(UnknownConfigKey {
|
||||
path: full_path,
|
||||
suggestion: unknown_key_suggestion(key, known_for_suggestion),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_known_table(
|
||||
parsed_toml: &toml::Value,
|
||||
unknown: &mut Vec<UnknownConfigKey>,
|
||||
known_for_suggestion: &[&'static str],
|
||||
path: &[&str],
|
||||
allowed: &[&str],
|
||||
) {
|
||||
if let Some(table) = table_at(parsed_toml, path) {
|
||||
push_unknown_keys(
|
||||
unknown,
|
||||
known_for_suggestion,
|
||||
&path.join("."),
|
||||
table,
|
||||
allowed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_nested_table_value(
|
||||
unknown: &mut Vec<UnknownConfigKey>,
|
||||
known_for_suggestion: &[&'static str],
|
||||
path: String,
|
||||
value: &toml::Value,
|
||||
allowed: &[&str],
|
||||
) {
|
||||
if let Some(table) = value.as_table() {
|
||||
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
|
||||
let known_for_suggestion = known_config_keys_for_suggestion();
|
||||
let mut unknown = Vec::new();
|
||||
|
||||
if let Some(root) = parsed_toml.as_table() {
|
||||
push_unknown_keys(
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
"",
|
||||
root,
|
||||
TOP_LEVEL_CONFIG_KEYS,
|
||||
);
|
||||
}
|
||||
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["general"],
|
||||
GENERAL_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["general", "modes"],
|
||||
PROXY_MODES_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["general", "telemetry"],
|
||||
TELEMETRY_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["general", "links"],
|
||||
LINKS_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["logging"],
|
||||
LOGGING_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["network"],
|
||||
NETWORK_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["server"],
|
||||
SERVER_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["server", "api"],
|
||||
API_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["server", "admin_api"],
|
||||
API_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["server", "conntrack_control"],
|
||||
CONNTRACK_CONTROL_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["timeouts"],
|
||||
TIMEOUTS_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["censorship"],
|
||||
CENSORSHIP_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["censorship", "tls_fetch"],
|
||||
TLS_FETCH_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["access"],
|
||||
ACCESS_CONFIG_KEYS,
|
||||
);
|
||||
|
||||
if let Some(listeners) = table_at(parsed_toml, &["server"])
|
||||
.and_then(|table| table.get("listeners"))
|
||||
.and_then(toml::Value::as_array)
|
||||
{
|
||||
for (idx, listener) in listeners.iter().enumerate() {
|
||||
check_nested_table_value(
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
format!("server.listeners[{idx}]"),
|
||||
listener,
|
||||
LISTENER_CONFIG_KEYS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
|
||||
for (idx, upstream) in upstreams.iter().enumerate() {
|
||||
check_nested_table_value(
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
format!("upstreams[{idx}]"),
|
||||
upstream,
|
||||
UPSTREAM_CONFIG_KEYS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
|
||||
if let Some(table) = table_at(parsed_toml, &["access"])
|
||||
.and_then(|access| access.get(access_map))
|
||||
.and_then(toml::Value::as_table)
|
||||
{
|
||||
for (entry_name, value) in table {
|
||||
check_nested_table_value(
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
format!("access.{access_map}.{entry_name}"),
|
||||
value,
|
||||
RATE_LIMIT_BPS_CONFIG_KEYS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unknown
|
||||
}
|
||||
|
||||
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
|
||||
let unknown = collect_unknown_config_keys(parsed_toml);
|
||||
if unknown.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for item in &unknown {
|
||||
if let Some(suggestion) = item.suggestion.as_deref() {
|
||||
warn!(
|
||||
key = %item.path,
|
||||
suggestion = %suggestion,
|
||||
"Unknown config key ignored; did you mean the suggested key?"
|
||||
);
|
||||
} else {
|
||||
warn!(key = %item.path, "Unknown config key ignored");
|
||||
}
|
||||
}
|
||||
|
||||
if is_strict_config(parsed_toml) {
|
||||
let mut paths = Vec::with_capacity(unknown.len());
|
||||
for item in unknown {
|
||||
if let Some(suggestion) = item.suggestion {
|
||||
paths.push(format!("{} (did you mean `{}`?)", item.path, suggestion));
|
||||
} else {
|
||||
paths.push(item.path);
|
||||
}
|
||||
}
|
||||
return Err(ProxyError::Config(format!(
|
||||
"unknown config keys are not allowed when general.config_strict=true: {}",
|
||||
paths.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use shadowsocks::config::ServerConfig as ShadowsocksServerConfig;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
|
||||
use super::super::types::{LoggingConfig, LoggingDestination, NetworkConfig, UpstreamType};
|
||||
use super::ProxyConfig;
|
||||
|
||||
pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> {
|
||||
if !net.ipv4 && matches!(net.ipv6, Some(false)) {
|
||||
return Err(ProxyError::Config(
|
||||
"Both ipv4 and ipv6 are disabled in [network]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if net.prefer != 4 && net.prefer != 6 {
|
||||
return Err(ProxyError::Config(
|
||||
"network.prefer must be 4 or 6".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !net.ipv4 && net.prefer == 4 {
|
||||
warn!("prefer=4 but ipv4=false; forcing prefer=6");
|
||||
net.prefer = 6;
|
||||
}
|
||||
|
||||
if matches!(net.ipv6, Some(false)) && net.prefer == 6 {
|
||||
warn!("prefer=6 but ipv6=false; forcing prefer=4");
|
||||
net.prefer = 4;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_logging_config(logging: &LoggingConfig) -> Result<()> {
|
||||
if let Some(path) = logging.path.as_ref()
|
||||
&& path.trim().is_empty()
|
||||
{
|
||||
return Err(ProxyError::Config(
|
||||
"logging.path cannot be empty when provided".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() {
|
||||
return Err(ProxyError::Config(
|
||||
"logging.path must be set when logging.destination=\"file\"".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
||||
let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| {
|
||||
upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. })
|
||||
});
|
||||
|
||||
if has_enabled_shadowsocks && config.general.use_middle_proxy {
|
||||
return Err(ProxyError::Config(
|
||||
"shadowsocks upstreams require general.use_middle_proxy = false".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
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 Some(prefer) = upstream.prefer
|
||||
&& prefer != 4
|
||||
&& prefer != 6
|
||||
{
|
||||
return Err(ProxyError::Config(
|
||||
"upstream.prefer must be 4 or 6".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
|
||||
let parsed = ShadowsocksServerConfig::from_url(url)
|
||||
.map_err(|error| ProxyError::Config(format!("invalid shadowsocks url: {error}")))?;
|
||||
if parsed.plugin().is_some() {
|
||||
return Err(ProxyError::Config(
|
||||
"shadowsocks plugins are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
|
||||
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
|
||||
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
|
||||
warn!(
|
||||
upstream = idx,
|
||||
"upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6"
|
||||
);
|
||||
upstream.prefer = Some(6);
|
||||
}
|
||||
|
||||
if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) {
|
||||
warn!(
|
||||
upstream = idx,
|
||||
"upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4"
|
||||
);
|
||||
upstream.prefer = Some(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,22 +95,162 @@ max_client_frame = 16777217
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_writer_byte_budget_below_frame_residency_minimum() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[general]
|
||||
max_client_frame = 16777216
|
||||
me_writer_byte_budget_bytes = 33554432
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path)
|
||||
.expect_err("writer byte budget below frame residency minimum must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("general.me_writer_byte_budget_bytes must be within [33570816, 268435456]"),
|
||||
"error must explain writer byte budget minimum, got: {msg}"
|
||||
);
|
||||
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_unaligned_writer_byte_budget() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[general]
|
||||
me_writer_byte_budget_bytes = 33570817
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path)
|
||||
.expect_err("writer byte budget outside permit granularity must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("general.me_writer_byte_budget_bytes must be a multiple of 16384"),
|
||||
"error must explain writer byte budget alignment, got: {msg}"
|
||||
);
|
||||
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_writer_byte_budget_above_hard_cap() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[general]
|
||||
me_writer_byte_budget_bytes = 268451840
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path).expect_err("writer byte budget above hard cap must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("general.me_writer_byte_budget_bytes must be within [33570816, 268435456]"),
|
||||
"error must explain writer byte budget hard cap, got: {msg}"
|
||||
);
|
||||
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_unaligned_direct_relay_buffer_budget() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[general]
|
||||
direct_relay_buffer_budget_max_bytes = 16777217
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path).expect_err("unaligned direct relay buffer budget must fail");
|
||||
assert!(
|
||||
err.to_string().contains(
|
||||
"general.direct_relay_buffer_budget_max_bytes must be 0 or a multiple of 4096"
|
||||
)
|
||||
);
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_direct_relay_buffer_budget_above_hard_cap() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[general]
|
||||
direct_relay_buffer_budget_max_bytes = 2147487744
|
||||
"#,
|
||||
);
|
||||
|
||||
let err =
|
||||
ProxyConfig::load(&path).expect_err("direct relay buffer budget above hard cap must fail");
|
||||
assert!(err.to_string().contains(
|
||||
"general.direct_relay_buffer_budget_max_bytes must be 0 or within [16777216, 2147483648]"
|
||||
));
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_listen_backlog_above_i32_upper_bound() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[server]
|
||||
listen_backlog = 2147483648
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path).expect_err("listen_backlog above socket cap must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
|
||||
"error must explain listen_backlog hard cap, got: {msg}"
|
||||
);
|
||||
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_zero_listen_backlog() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[server]
|
||||
listen_backlog = 0
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = ProxyConfig::load(&path).expect_err("zero listen_backlog must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
|
||||
"error must explain listen_backlog lower bound, got: {msg}"
|
||||
);
|
||||
|
||||
remove_temp_config(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_accepts_memory_limits_at_hard_upper_bounds() {
|
||||
let path = write_temp_config(
|
||||
r#"
|
||||
[general]
|
||||
me_writer_cmd_channel_capacity = 16384
|
||||
me_writer_byte_budget_bytes = 268435456
|
||||
me_route_channel_capacity = 8192
|
||||
me_c2me_channel_capacity = 8192
|
||||
direct_relay_buffer_budget_max_bytes = 2147483648
|
||||
max_client_frame = 16777216
|
||||
"#,
|
||||
);
|
||||
|
||||
let cfg = ProxyConfig::load(&path).expect("hard upper bound values must be accepted");
|
||||
assert_eq!(cfg.general.me_writer_cmd_channel_capacity, 16384);
|
||||
assert_eq!(cfg.general.me_writer_byte_budget_bytes, 256 * 1024 * 1024);
|
||||
assert_eq!(cfg.general.me_route_channel_capacity, 8192);
|
||||
assert_eq!(cfg.general.me_c2me_channel_capacity, 8192);
|
||||
assert_eq!(
|
||||
cfg.general.direct_relay_buffer_budget_max_bytes,
|
||||
2 * 1024 * 1024 * 1024
|
||||
);
|
||||
assert_eq!(cfg.general.max_client_frame, 16 * 1024 * 1024);
|
||||
|
||||
remove_temp_config(&path);
|
||||
|
||||
+282
-12
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -63,6 +64,86 @@ impl std::fmt::Display for LogLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Logging output destination.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LoggingDestination {
|
||||
/// Write logs to stderr.
|
||||
#[default]
|
||||
Stderr,
|
||||
/// Write logs to syslog on Unix platforms.
|
||||
Syslog,
|
||||
/// Write logs to a file.
|
||||
File,
|
||||
}
|
||||
|
||||
/// Time-based log rotation interval for file logging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogRotation {
|
||||
/// Do not rotate logs by time.
|
||||
#[default]
|
||||
Never,
|
||||
/// Rotate once per minute.
|
||||
Minutely,
|
||||
/// Rotate once per hour.
|
||||
Hourly,
|
||||
/// Rotate once per day.
|
||||
Daily,
|
||||
/// Rotate once per week.
|
||||
Weekly,
|
||||
}
|
||||
|
||||
impl LogRotation {
|
||||
/// Parse a CLI rotation value.
|
||||
pub fn from_cli_arg(value: &str) -> Option<Self> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"never" | "none" | "off" => Some(Self::Never),
|
||||
"minutely" | "minute" => Some(Self::Minutely),
|
||||
"hourly" | "hour" => Some(Self::Hourly),
|
||||
"daily" | "day" => Some(Self::Daily),
|
||||
"weekly" | "week" => Some(Self::Weekly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File logging and retention settings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
/// Effective logging destination.
|
||||
#[serde(default)]
|
||||
pub destination: LoggingDestination,
|
||||
/// File path used when `destination = "file"`.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Time rotation interval for file logs.
|
||||
#[serde(default)]
|
||||
pub rotation: LogRotation,
|
||||
/// Maximum active log file size before rotating. `0` disables size rotation.
|
||||
#[serde(default)]
|
||||
pub max_size_bytes: u64,
|
||||
/// Maximum number of matching log files to keep. `0` disables count retention.
|
||||
#[serde(default)]
|
||||
pub max_files: usize,
|
||||
/// Maximum age for rotated log files in seconds. `0` disables age retention.
|
||||
#[serde(default)]
|
||||
pub max_age_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
destination: LoggingDestination::Stderr,
|
||||
path: None,
|
||||
rotation: LogRotation::Never,
|
||||
max_size_bytes: 0,
|
||||
max_files: 0,
|
||||
max_age_secs: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Middle-End telemetry verbosity level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -429,7 +510,7 @@ pub struct GeneralConfig {
|
||||
pub ad_tag: Option<String>,
|
||||
|
||||
/// Public IP override for middle-proxy NAT environments.
|
||||
/// When set, this IP is used in ME key derivation and RPC_PROXY_REQ "our_addr".
|
||||
/// When set, this IP is used in ME key derivation and local address translation.
|
||||
#[serde(default)]
|
||||
pub middle_proxy_nat_ip: Option<IpAddr>,
|
||||
|
||||
@@ -498,6 +579,10 @@ pub struct GeneralConfig {
|
||||
#[serde(default = "default_me_writer_cmd_channel_capacity")]
|
||||
pub me_writer_cmd_channel_capacity: usize,
|
||||
|
||||
/// Resident-memory budget in bytes for each ME writer data queue.
|
||||
#[serde(default = "default_me_writer_byte_budget_bytes")]
|
||||
pub me_writer_byte_budget_bytes: usize,
|
||||
|
||||
/// Capacity of per-connection ME response route channel.
|
||||
#[serde(default = "default_me_route_channel_capacity")]
|
||||
pub me_route_channel_capacity: usize,
|
||||
@@ -541,7 +626,7 @@ pub struct GeneralConfig {
|
||||
#[serde(default = "default_me_d2c_frame_buf_shrink_threshold_bytes")]
|
||||
pub me_d2c_frame_buf_shrink_threshold_bytes: usize,
|
||||
|
||||
/// Copy buffer size for client->DC direction in direct relay.
|
||||
/// Copy buffer ceiling for client->DC direction in direct relay.
|
||||
///
|
||||
/// This is also the upper bound for one amortized upload rate-limit burst:
|
||||
/// upload debt is settled before the next relay read instead of blocking
|
||||
@@ -549,13 +634,18 @@ pub struct GeneralConfig {
|
||||
#[serde(default = "default_direct_relay_copy_buf_c2s_bytes")]
|
||||
pub direct_relay_copy_buf_c2s_bytes: usize,
|
||||
|
||||
/// Copy buffer size for DC->client direction in direct relay.
|
||||
/// Copy buffer ceiling for DC->client direction in direct relay.
|
||||
///
|
||||
/// This bounds one direct download rate-limit grant because writes are
|
||||
/// clipped to the currently available shaper budget.
|
||||
#[serde(default = "default_direct_relay_copy_buf_s2c_bytes")]
|
||||
pub direct_relay_copy_buf_s2c_bytes: usize,
|
||||
|
||||
/// Process-wide hard ceiling for Direct relay copy buffers.
|
||||
/// `0` derives the ceiling from host and cgroup memory limits.
|
||||
#[serde(default = "default_direct_relay_buffer_budget_max_bytes")]
|
||||
pub direct_relay_buffer_budget_max_bytes: usize,
|
||||
|
||||
/// Max pending ciphertext buffer per client writer (bytes).
|
||||
/// Controls FakeTLS backpressure vs throughput.
|
||||
#[serde(default = "default_crypto_pending_buffer")]
|
||||
@@ -1022,6 +1112,7 @@ impl Default for GeneralConfig {
|
||||
me_keepalive_payload_random: default_true(),
|
||||
rpc_proxy_req_every: default_rpc_proxy_req_every(),
|
||||
me_writer_cmd_channel_capacity: default_me_writer_cmd_channel_capacity(),
|
||||
me_writer_byte_budget_bytes: default_me_writer_byte_budget_bytes(),
|
||||
me_route_channel_capacity: default_me_route_channel_capacity(),
|
||||
me_c2me_channel_capacity: default_me_c2me_channel_capacity(),
|
||||
me_c2me_send_timeout_ms: default_me_c2me_send_timeout_ms(),
|
||||
@@ -1035,6 +1126,7 @@ impl Default for GeneralConfig {
|
||||
default_me_d2c_frame_buf_shrink_threshold_bytes(),
|
||||
direct_relay_copy_buf_c2s_bytes: default_direct_relay_copy_buf_c2s_bytes(),
|
||||
direct_relay_copy_buf_s2c_bytes: default_direct_relay_copy_buf_s2c_bytes(),
|
||||
direct_relay_buffer_budget_max_bytes: default_direct_relay_buffer_budget_max_bytes(),
|
||||
me_warmup_stagger_enabled: default_true(),
|
||||
me_warmup_step_delay_ms: default_warmup_step_delay_ms(),
|
||||
me_warmup_step_jitter_ms: default_warmup_step_jitter_ms(),
|
||||
@@ -1375,9 +1467,9 @@ pub enum SynLimitMode {
|
||||
/// Disable SYN limiting for this listener.
|
||||
#[default]
|
||||
Off,
|
||||
/// Use iptables/ip6tables filter rules with the hashlimit match.
|
||||
/// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match.
|
||||
Iptables,
|
||||
/// Use nftables rules with per-source token-bucket meters.
|
||||
/// Use nftables two-tier SYN-fix rules with per-source token-bucket meters.
|
||||
Nftables,
|
||||
}
|
||||
|
||||
@@ -1527,6 +1619,15 @@ pub struct ServerConfig {
|
||||
#[serde(default)]
|
||||
pub client_mss: Option<String>,
|
||||
|
||||
/// Client-facing TCP MSS to switch to AFTER the TLS handshake (ServerHello)
|
||||
/// is sent. Lets `client_mss` fragment ONLY the handshake (the DPI-inspected
|
||||
/// part) while the bulk transfer uses normal-size packets — avoids the ~10x
|
||||
/// packets-per-second blowup that triggers anti-DDoS abuse blocks on
|
||||
/// pps-policing hosts. Empty/omitted = keep the handshake MSS for the whole
|
||||
/// connection (previous behavior). Same preset/int grammar as `client_mss`.
|
||||
#[serde(default)]
|
||||
pub client_mss_bulk: Option<String>,
|
||||
|
||||
/// Accept HAProxy PROXY protocol headers on incoming connections.
|
||||
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
|
||||
#[serde(default)]
|
||||
@@ -1594,6 +1695,7 @@ impl Default for ServerConfig {
|
||||
listen_unix_sock_perm: None,
|
||||
listen_tcp: None,
|
||||
client_mss: None,
|
||||
client_mss_bulk: None,
|
||||
proxy_protocol: false,
|
||||
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
|
||||
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
|
||||
@@ -2008,11 +2110,13 @@ pub struct AccessConfig {
|
||||
|
||||
/// 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. Limits are amortized
|
||||
/// with the same bounded-burst contract as per-user rate limits.
|
||||
/// Explicit CIDR keys use longest-prefix-wins semantics. Auto-template
|
||||
/// keys (`*4/N`, `*6/N`, `*/N`) lazily create per-source-subnet buckets
|
||||
/// after explicit CIDR matching misses. A value of `0` in one direction
|
||||
/// means "unlimited" for that direction. Limits are amortized with the
|
||||
/// same bounded-burst contract as per-user rate limits.
|
||||
#[serde(default)]
|
||||
pub cidr_rate_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||
pub cidr_rate_limits: HashMap<CidrRateLimitKey, RateLimitBps>,
|
||||
|
||||
/// Per-username client source IP/CIDR deny list. Checked after successful
|
||||
/// authentication; matching IPs get the same rejection path as invalid auth
|
||||
@@ -2081,10 +2185,156 @@ impl AccessConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Key used by `access.cidr_rate_limits`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum CidrRateLimitKey {
|
||||
/// Explicit source CIDR rule.
|
||||
Network(IpNetwork),
|
||||
/// IPv4 auto-template that creates one bucket for each matching `/N`.
|
||||
AutoV4(u8),
|
||||
/// IPv6 auto-template that creates one bucket for each matching `/N`.
|
||||
AutoV6(u8),
|
||||
/// Dual-stack auto-template; IPv4 uses `/N`, IPv6 uses `/(N * 4)`.
|
||||
AutoDual(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum CidrAutoTemplateFamily {
|
||||
V4,
|
||||
V6,
|
||||
}
|
||||
|
||||
impl CidrAutoTemplateFamily {
|
||||
pub(crate) fn marker(self) -> &'static str {
|
||||
match self {
|
||||
Self::V4 => "*4",
|
||||
Self::V6 => "*6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct CidrAutoTemplate {
|
||||
pub(crate) family: CidrAutoTemplateFamily,
|
||||
pub(crate) prefix_len: u8,
|
||||
}
|
||||
|
||||
impl fmt::Display for CidrAutoTemplate {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{}/{}", self.family.marker(), self.prefix_len)
|
||||
}
|
||||
}
|
||||
|
||||
impl CidrRateLimitKey {
|
||||
pub(crate) fn auto_templates(&self) -> [Option<CidrAutoTemplate>; 2] {
|
||||
match *self {
|
||||
Self::Network(_) => [None, None],
|
||||
Self::AutoV4(prefix_len) => [
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V4,
|
||||
prefix_len,
|
||||
}),
|
||||
None,
|
||||
],
|
||||
Self::AutoV6(prefix_len) => [
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V6,
|
||||
prefix_len,
|
||||
}),
|
||||
None,
|
||||
],
|
||||
Self::AutoDual(prefix_len) => [
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V4,
|
||||
prefix_len,
|
||||
}),
|
||||
Some(CidrAutoTemplate {
|
||||
family: CidrAutoTemplateFamily::V6,
|
||||
prefix_len: prefix_len.saturating_mul(4),
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CidrRateLimitKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Network(cidr) => write!(formatter, "{cidr}"),
|
||||
Self::AutoV4(prefix_len) => write!(formatter, "*4/{prefix_len}"),
|
||||
Self::AutoV6(prefix_len) => write!(formatter, "*6/{prefix_len}"),
|
||||
Self::AutoDual(prefix_len) => write!(formatter, "*/{prefix_len}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for CidrRateLimitKey {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CidrRateLimitKey {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
parse_cidr_rate_limit_key(&value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cidr_rate_limit_key(value: &str) -> std::result::Result<CidrRateLimitKey, String> {
|
||||
if let Some(prefix) = value.strip_prefix("*4/") {
|
||||
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoV4);
|
||||
}
|
||||
if let Some(prefix) = value.strip_prefix("*6/") {
|
||||
return parse_cidr_auto_prefix(value, prefix, 128).map(CidrRateLimitKey::AutoV6);
|
||||
}
|
||||
if let Some(prefix) = value.strip_prefix("*/") {
|
||||
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoDual);
|
||||
}
|
||||
if value.starts_with('*') {
|
||||
return Err(format!(
|
||||
"invalid CIDR rate limit key {value:?}; expected CIDR, *4/N, *6/N, or */N"
|
||||
));
|
||||
}
|
||||
value
|
||||
.parse::<IpNetwork>()
|
||||
.map(CidrRateLimitKey::Network)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"invalid CIDR rate limit key {value:?}: {error}; expected CIDR, *4/N, *6/N, or */N"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cidr_auto_prefix(
|
||||
key: &str,
|
||||
prefix: &str,
|
||||
max_prefix: u8,
|
||||
) -> std::result::Result<u8, String> {
|
||||
let prefix = prefix.parse::<u8>().map_err(|_| {
|
||||
format!("invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}")
|
||||
})?;
|
||||
if prefix > max_prefix {
|
||||
return Err(format!(
|
||||
"invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}"
|
||||
));
|
||||
}
|
||||
Ok(prefix)
|
||||
}
|
||||
|
||||
/// Transport rate limit in bits-per-second.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RateLimitBps {
|
||||
/// Upload direction limit in bits-per-second; `0` means unlimited.
|
||||
#[serde(default)]
|
||||
pub up_bps: u64,
|
||||
/// Download direction limit in bits-per-second; `0` means unlimited.
|
||||
#[serde(default)]
|
||||
pub down_bps: u64,
|
||||
}
|
||||
@@ -2176,15 +2426,30 @@ pub struct ListenerConfig {
|
||||
/// Per-listener SYN limiter mode.
|
||||
#[serde(default)]
|
||||
pub synlimit: SynLimitMode,
|
||||
/// Token-bucket rate interval for the per-listener SYN limiter.
|
||||
/// Generic SYN-fix token-bucket rate interval.
|
||||
#[serde(default = "default_synlimit_seconds")]
|
||||
pub synlimit_seconds: u32,
|
||||
/// Token-bucket rate amount for the per-listener SYN limiter.
|
||||
/// Generic SYN-fix token-bucket rate amount.
|
||||
#[serde(default = "default_synlimit_hitcount")]
|
||||
pub synlimit_hitcount: u32,
|
||||
/// Token-bucket burst size for the per-listener SYN limiter.
|
||||
/// Generic SYN-fix token-bucket burst size.
|
||||
#[serde(default = "default_synlimit_burst")]
|
||||
pub synlimit_burst: u32,
|
||||
/// iOS-like SYN-fix token-bucket rate interval.
|
||||
#[serde(default = "default_synlimit_ios_seconds")]
|
||||
pub synlimit_ios_seconds: u32,
|
||||
/// iOS-like SYN-fix token-bucket rate amount.
|
||||
#[serde(default = "default_synlimit_ios_hitcount")]
|
||||
pub synlimit_ios_hitcount: u32,
|
||||
/// iOS-like SYN-fix token-bucket burst size.
|
||||
#[serde(default = "default_synlimit_ios_burst")]
|
||||
pub synlimit_ios_burst: u32,
|
||||
/// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules.
|
||||
#[serde(default = "default_synlimit_hashlimit_expire_ms")]
|
||||
pub synlimit_hashlimit_expire_ms: u32,
|
||||
/// Hashlimit table size for iptables/ip6tables rules.
|
||||
#[serde(default = "default_synlimit_hashlimit_size")]
|
||||
pub synlimit_hashlimit_size: u32,
|
||||
/// IP address or hostname to announce in proxy links.
|
||||
/// Takes precedence over `announce_ip` if both are set.
|
||||
#[serde(default)]
|
||||
@@ -2218,6 +2483,11 @@ impl ServerConfig {
|
||||
pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> {
|
||||
parse_client_mss(self.client_mss.as_deref())
|
||||
}
|
||||
|
||||
/// Resolves the post-handshake (bulk transfer) client MSS, if configured.
|
||||
pub fn client_mss_bulk_value(&self) -> std::result::Result<Option<u16>, String> {
|
||||
parse_client_mss(self.client_mss_bulk.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
impl ListenerConfig {
|
||||
|
||||
+18
-12
@@ -4,12 +4,12 @@
|
||||
//!
|
||||
//! ## Zeroize policy
|
||||
//!
|
||||
//! - `AesCbc` stores raw key/IV bytes and zeroizes them on drop.
|
||||
//! - `AesCtr` wraps an opaque `Aes256Ctr` cipher from the `ctr` crate.
|
||||
//! The expanded key schedule lives inside that type and cannot be
|
||||
//! zeroized from outside. Callers that hold raw key material (e.g.
|
||||
//! `HandshakeSuccess`, `ObfuscationParams`) are responsible for
|
||||
//! zeroizing their own copies.
|
||||
//! - `AesCbc` stores raw key/IV bytes and zeroizes them on drop. Temporary
|
||||
//! expanded key schedules are also zeroized by the RustCrypto backend.
|
||||
//! - `AesCtr` uses the RustCrypto `zeroize` contract to clear its expanded
|
||||
//! key schedule, counter, and buffered keystream on drop.
|
||||
//! - Callers that hold raw key material (e.g. `HandshakeSuccess`,
|
||||
//! `ObfuscationParams`) remain responsible for zeroizing their own copies.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
@@ -19,24 +19,28 @@ use ctr::{
|
||||
Ctr128BE,
|
||||
cipher::{KeyIvInit, StreamCipher},
|
||||
};
|
||||
use zeroize::Zeroize;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
type Aes256Ctr = Ctr128BE<Aes256>;
|
||||
|
||||
static_assertions::assert_impl_all!(Aes256: ZeroizeOnDrop);
|
||||
static_assertions::assert_impl_all!(Aes256Ctr: ZeroizeOnDrop);
|
||||
|
||||
// ============= AES-256-CTR =============
|
||||
|
||||
/// AES-256-CTR encryptor/decryptor
|
||||
///
|
||||
/// CTR mode is symmetric — encryption and decryption are the same operation.
|
||||
///
|
||||
/// **Zeroize note:** The inner `Aes256Ctr` cipher state (expanded key schedule
|
||||
/// + counter) is opaque and cannot be zeroized. If you need to protect key
|
||||
/// material, zeroize the `[u8; 32]` key and `u128` IV at the call site
|
||||
/// before dropping them.
|
||||
/// **Zeroize note:** The inner `Aes256Ctr` zeroizes its expanded key schedule,
|
||||
/// counter, and buffered keystream on drop. Callers remain responsible for
|
||||
/// zeroizing their own raw key and IV copies.
|
||||
pub struct AesCtr {
|
||||
cipher: Aes256Ctr,
|
||||
}
|
||||
|
||||
impl ZeroizeOnDrop for AesCtr {}
|
||||
|
||||
impl AesCtr {
|
||||
/// Create new AES-CTR cipher with key and IV
|
||||
pub fn new(key: &[u8; 32], iv: u128) -> Self {
|
||||
@@ -92,7 +96,7 @@ impl AesCtr {
|
||||
/// are different operations. This implementation handles CBC chaining
|
||||
/// correctly across multiple blocks.
|
||||
///
|
||||
/// Key and IV are zeroized on drop.
|
||||
/// Key, IV, and temporary expanded key schedules are zeroized on drop.
|
||||
pub struct AesCbc {
|
||||
key: [u8; 32],
|
||||
iv: [u8; 16],
|
||||
@@ -105,6 +109,8 @@ impl Drop for AesCbc {
|
||||
}
|
||||
}
|
||||
|
||||
impl ZeroizeOnDrop for AesCbc {}
|
||||
|
||||
impl AesCbc {
|
||||
/// AES block size
|
||||
const BLOCK_SIZE: usize = 16;
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ pub mod hash;
|
||||
pub mod random;
|
||||
|
||||
pub use aes::{AesCbc, AesCtr};
|
||||
pub use hash::{
|
||||
build_middleproxy_prekey, crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use hash::build_middleproxy_prekey;
|
||||
pub use hash::{crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac};
|
||||
pub use random::SecureRandom;
|
||||
|
||||
+222
-101
@@ -5,16 +5,41 @@
|
||||
//! - syslog (Unix only, for traditional init systems)
|
||||
//! - file (with optional rotation)
|
||||
|
||||
#![allow(dead_code)] // Infrastructure module - used via CLI flags
|
||||
// Infrastructure module used via CLI flags.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, fmt, reload};
|
||||
|
||||
// Submodules:
|
||||
// - file: bounded file appender for size and retention controls.
|
||||
mod file;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// File logging and retention options resolved from config and CLI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileLogOptions {
|
||||
/// Log file path or rolling filename prefix path.
|
||||
pub path: String,
|
||||
/// Time rotation interval.
|
||||
pub rotation: LogRotation,
|
||||
/// Maximum active file size before size rotation. `0` disables it.
|
||||
pub max_size_bytes: u64,
|
||||
/// Maximum number of matching log files to keep. `0` disables it.
|
||||
pub max_files: usize,
|
||||
/// Maximum rotated file age in seconds. `0` disables it.
|
||||
pub max_age_secs: u64,
|
||||
}
|
||||
|
||||
/// Log destination configuration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum LogDestination {
|
||||
/// Log to stderr (default, captured by systemd journald).
|
||||
#[default]
|
||||
@@ -24,12 +49,29 @@ pub enum LogDestination {
|
||||
Syslog,
|
||||
/// Log to a file with optional rotation.
|
||||
File {
|
||||
path: String,
|
||||
/// Rotate daily if true.
|
||||
rotate_daily: bool,
|
||||
/// Resolved file logging options.
|
||||
options: FileLogOptions,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LogCliDestination {
|
||||
Stderr,
|
||||
Syslog,
|
||||
File,
|
||||
}
|
||||
|
||||
/// Logging-related CLI overrides.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LogCliOptions {
|
||||
destination: Option<LogCliDestination>,
|
||||
path: Option<String>,
|
||||
rotation: Option<LogRotation>,
|
||||
max_size_bytes: Option<u64>,
|
||||
max_files: Option<usize>,
|
||||
max_age_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Logging options parsed from CLI/config.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoggingOptions {
|
||||
@@ -101,23 +143,29 @@ pub fn init_logging(
|
||||
(filter_handle, LoggingGuard::noop())
|
||||
}
|
||||
|
||||
LogDestination::File { path, rotate_daily } => {
|
||||
let (non_blocking, guard) = if *rotate_daily {
|
||||
// Extract directory and filename prefix
|
||||
let path = Path::new(path);
|
||||
let dir = path.parent().unwrap_or(Path::new("/var/log"));
|
||||
let prefix = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("telemt");
|
||||
|
||||
let file_appender = tracing_appender::rolling::daily(dir, prefix);
|
||||
LogDestination::File { options } => {
|
||||
let (non_blocking, guard) = if options.max_size_bytes > 0
|
||||
|| options.max_files > 0
|
||||
|| options.max_age_secs > 0
|
||||
{
|
||||
let file_appender = file::BoundedFileAppender::new(options.clone())
|
||||
.expect("Failed to open log file");
|
||||
tracing_appender::non_blocking(file_appender)
|
||||
} else if !matches!(options.rotation, LogRotation::Never) {
|
||||
let path = Path::new(&options.path);
|
||||
let dir = log_file_dir(path);
|
||||
let prefix = log_file_name(path);
|
||||
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
|
||||
.rotation(to_tracing_rotation(options.rotation))
|
||||
.filename_prefix(prefix)
|
||||
.build(dir)
|
||||
.expect("Failed to open log file");
|
||||
tracing_appender::non_blocking(file_appender)
|
||||
} else {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.open(&options.path)
|
||||
.expect("Failed to open log file");
|
||||
tracing_appender::non_blocking(file)
|
||||
};
|
||||
@@ -137,6 +185,28 @@ pub fn init_logging(
|
||||
}
|
||||
}
|
||||
|
||||
fn log_file_dir(path: &Path) -> &Path {
|
||||
path.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
}
|
||||
|
||||
fn log_file_name(path: &Path) -> &str {
|
||||
path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("telemt")
|
||||
}
|
||||
|
||||
fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation {
|
||||
match rotation {
|
||||
LogRotation::Never => tracing_appender::rolling::Rotation::NEVER,
|
||||
LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY,
|
||||
LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
|
||||
LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
|
||||
LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Syslog writer for tracing.
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse log destination from CLI arguments.
|
||||
pub fn parse_log_destination(args: &[String]) -> LogDestination {
|
||||
/// Parse logging overrides from CLI arguments.
|
||||
pub fn parse_log_cli_options(args: &[String]) -> Result<LogCliOptions, String> {
|
||||
let mut options = LogCliOptions::default();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
#[cfg(unix)]
|
||||
"--syslog" => {
|
||||
return LogDestination::Syslog;
|
||||
options.destination = Some(LogCliDestination::Syslog);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
"--syslog" => {
|
||||
options.destination = Some(LogCliDestination::Syslog);
|
||||
}
|
||||
"--log-file" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
return LogDestination::File {
|
||||
path: args[i].clone(),
|
||||
rotate_daily: false,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(args[i].clone());
|
||||
} else {
|
||||
return Err("Missing value for --log-file".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-file=") => {
|
||||
return LogDestination::File {
|
||||
path: s.trim_start_matches("--log-file=").to_string(),
|
||||
rotate_daily: false,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(s.trim_start_matches("--log-file=").to_string());
|
||||
}
|
||||
"--log-file-daily" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
return LogDestination::File {
|
||||
path: args[i].clone(),
|
||||
rotate_daily: true,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(args[i].clone());
|
||||
options.rotation = Some(LogRotation::Daily);
|
||||
} else {
|
||||
return Err("Missing value for --log-file-daily".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-file-daily=") => {
|
||||
return LogDestination::File {
|
||||
path: s.trim_start_matches("--log-file-daily=").to_string(),
|
||||
rotate_daily: true,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(s.trim_start_matches("--log-file-daily=").to_string());
|
||||
options.rotation = Some(LogRotation::Daily);
|
||||
}
|
||||
"--log-rotation" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.rotation = Some(parse_rotation_cli_value(&args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-rotation".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-rotation=") => {
|
||||
options.rotation = Some(parse_rotation_cli_value(
|
||||
s.trim_start_matches("--log-rotation="),
|
||||
)?);
|
||||
}
|
||||
"--log-max-size-bytes" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.max_size_bytes =
|
||||
Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-max-size-bytes".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-max-size-bytes=") => {
|
||||
options.max_size_bytes = Some(parse_u64_cli_value(
|
||||
"--log-max-size-bytes",
|
||||
s.trim_start_matches("--log-max-size-bytes="),
|
||||
)?);
|
||||
}
|
||||
"--log-max-files" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-max-files".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-max-files=") => {
|
||||
options.max_files = Some(parse_usize_cli_value(
|
||||
"--log-max-files",
|
||||
s.trim_start_matches("--log-max-files="),
|
||||
)?);
|
||||
}
|
||||
"--log-max-age-secs" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.max_age_secs =
|
||||
Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-max-age-secs".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-max-age-secs=") => {
|
||||
options.max_age_secs = Some(parse_u64_cli_value(
|
||||
"--log-max-age-secs",
|
||||
s.trim_start_matches("--log-max-age-secs="),
|
||||
)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
LogDestination::Stderr
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn parse_rotation_cli_value(value: &str) -> Result<LogRotation, String> {
|
||||
LogRotation::from_cli_arg(value).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_destination_default() {
|
||||
let args: Vec<String> = vec![];
|
||||
assert!(matches!(
|
||||
parse_log_destination(&args),
|
||||
LogDestination::Stderr
|
||||
));
|
||||
}
|
||||
fn parse_u64_cli_value(flag: &str, value: &str) -> Result<u64, String> {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_destination_file() {
|
||||
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
|
||||
match parse_log_destination(&args) {
|
||||
LogDestination::File { path, rotate_daily } => {
|
||||
assert_eq!(path, "/var/log/telemt.log");
|
||||
assert!(!rotate_daily);
|
||||
fn parse_usize_cli_value(flag: &str, value: &str) -> Result<usize, String> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
|
||||
}
|
||||
|
||||
/// Resolve effective logging destination from config and CLI overrides.
|
||||
pub fn resolve_log_destination(
|
||||
config: &LoggingConfig,
|
||||
cli: &LogCliOptions,
|
||||
) -> Result<LogDestination, String> {
|
||||
let destination = cli.destination.unwrap_or(match config.destination {
|
||||
LoggingDestination::Stderr => LogCliDestination::Stderr,
|
||||
LoggingDestination::Syslog => LogCliDestination::Syslog,
|
||||
LoggingDestination::File => LogCliDestination::File,
|
||||
});
|
||||
|
||||
match destination {
|
||||
LogCliDestination::Stderr => Ok(LogDestination::Stderr),
|
||||
LogCliDestination::Syslog => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(LogDestination::Syslog)
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_destination_file_daily() {
|
||||
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
|
||||
match parse_log_destination(&args) {
|
||||
LogDestination::File { path, rotate_daily } => {
|
||||
assert_eq!(path, "/var/log/telemt");
|
||||
assert!(rotate_daily);
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Err("Syslog logging is only supported on Unix platforms".to_string())
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
LogCliDestination::File => {
|
||||
let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| {
|
||||
"logging.path or --log-file must be set when file logging is enabled".to_string()
|
||||
})?;
|
||||
if path.trim().is_empty() {
|
||||
return Err("Log file path cannot be empty".to_string());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_parse_log_destination_syslog() {
|
||||
let args = vec!["--syslog".to_string()];
|
||||
assert!(matches!(
|
||||
parse_log_destination(&args),
|
||||
LogDestination::Syslog
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_syslog_priority_for_level_mapping() {
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::ERROR),
|
||||
libc::LOG_ERR
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::WARN),
|
||||
libc::LOG_WARNING
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::INFO),
|
||||
libc::LOG_INFO
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::DEBUG),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::TRACE),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
Ok(LogDestination::File {
|
||||
options: FileLogOptions {
|
||||
path: path.clone(),
|
||||
rotation: cli.rotation.unwrap_or(config.rotation),
|
||||
max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes),
|
||||
max_files: cli.max_files.unwrap_or(config.max_files),
|
||||
max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc};
|
||||
|
||||
use crate::config::LogRotation;
|
||||
|
||||
use super::FileLogOptions;
|
||||
|
||||
const CLEANUP_INTERVAL_SECS: i64 = 60;
|
||||
|
||||
/// File appender with size rotation and local retention cleanup.
|
||||
pub(crate) struct BoundedFileAppender {
|
||||
options: FileLogOptions,
|
||||
dir: PathBuf,
|
||||
base_name: String,
|
||||
current_path: PathBuf,
|
||||
current_size: u64,
|
||||
last_cleanup: DateTime<Utc>,
|
||||
file: Option<File>,
|
||||
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl BoundedFileAppender {
|
||||
pub(crate) fn new(options: FileLogOptions) -> io::Result<Self> {
|
||||
Self::with_now(options, Box::new(Utc::now))
|
||||
}
|
||||
|
||||
fn with_now(
|
||||
options: FileLogOptions,
|
||||
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
|
||||
) -> io::Result<Self> {
|
||||
let path = Path::new(&options.path);
|
||||
let dir = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
let base_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("telemt")
|
||||
.to_string();
|
||||
|
||||
let start = now();
|
||||
let current_path = active_path_for(&dir, &base_name, options.rotation, &start);
|
||||
let (file, current_size) = open_append_file(¤t_path)?;
|
||||
let mut appender = Self {
|
||||
options,
|
||||
dir,
|
||||
base_name,
|
||||
current_path,
|
||||
current_size,
|
||||
last_cleanup: start,
|
||||
file: Some(file),
|
||||
now,
|
||||
};
|
||||
appender.cleanup(&start);
|
||||
Ok(appender)
|
||||
}
|
||||
|
||||
fn now(&self) -> DateTime<Utc> {
|
||||
(self.now)()
|
||||
}
|
||||
|
||||
fn refresh_active_path(&mut self, now: &DateTime<Utc>) -> io::Result<bool> {
|
||||
let next_path = active_path_for(&self.dir, &self.base_name, self.options.rotation, now);
|
||||
if next_path == self.current_path {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.close_current()?;
|
||||
self.current_path = next_path;
|
||||
self.open_current()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn rotate_for_size(&mut self, now: &DateTime<Utc>) -> io::Result<()> {
|
||||
self.close_current()?;
|
||||
if self.current_path.exists() {
|
||||
let archive_path = self.archive_path(now);
|
||||
fs::rename(&self.current_path, archive_path)?;
|
||||
}
|
||||
self.open_current()
|
||||
}
|
||||
|
||||
fn archive_path(&self, now: &DateTime<Utc>) -> PathBuf {
|
||||
let file_name = self
|
||||
.current_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&self.base_name);
|
||||
let stamp = now.format("%Y%m%d%H%M%S");
|
||||
for seq in 0..1000 {
|
||||
let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
self.dir.join(format!("{file_name}.{stamp}.overflow"))
|
||||
}
|
||||
|
||||
fn open_current(&mut self) -> io::Result<()> {
|
||||
let (file, current_size) = open_append_file(&self.current_path)?;
|
||||
self.file = Some(file);
|
||||
self.current_size = current_size;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_current(&mut self) -> io::Result<()> {
|
||||
if let Some(mut file) = self.file.take() {
|
||||
file.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_rotate_for_size(&self, incoming_len: usize) -> bool {
|
||||
self.options.max_size_bytes > 0
|
||||
&& self.current_size > 0
|
||||
&& self.current_size.saturating_add(incoming_len as u64) > self.options.max_size_bytes
|
||||
}
|
||||
|
||||
fn cleanup_due(&self, now: &DateTime<Utc>) -> bool {
|
||||
self.options.max_age_secs > 0
|
||||
&& now.signed_duration_since(self.last_cleanup)
|
||||
>= ChronoDuration::seconds(CLEANUP_INTERVAL_SECS)
|
||||
}
|
||||
|
||||
fn cleanup(&mut self, now: &DateTime<Utc>) {
|
||||
self.last_cleanup = now.clone();
|
||||
let Ok(entries) = fs::read_dir(&self.dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let prefix = format!("{}.", self.base_name);
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_current = path == self.current_path;
|
||||
let Some(name) = entry.file_name().to_str().map(|name| name.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_current && !name.starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
|
||||
candidates.push(LogFileCandidate {
|
||||
path,
|
||||
modified,
|
||||
is_current,
|
||||
});
|
||||
}
|
||||
|
||||
if self.options.max_age_secs > 0 {
|
||||
let cutoff = system_time_from_utc(now)
|
||||
.checked_sub(Duration::from_secs(self.options.max_age_secs))
|
||||
.unwrap_or(UNIX_EPOCH);
|
||||
candidates.retain(|candidate| {
|
||||
if candidate.is_current || candidate.modified >= cutoff {
|
||||
true
|
||||
} else {
|
||||
let _ = fs::remove_file(&candidate.path);
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if self.options.max_files > 0 && candidates.len() > self.options.max_files {
|
||||
let mut archives: Vec<_> = candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| !candidate.is_current)
|
||||
.collect();
|
||||
archives.sort_by_key(|candidate| candidate.modified);
|
||||
let mut total = archives.len() + 1;
|
||||
for candidate in archives {
|
||||
if total <= self.options.max_files {
|
||||
break;
|
||||
}
|
||||
let _ = fs::remove_file(candidate.path);
|
||||
total -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for BoundedFileAppender {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let now = self.now();
|
||||
let rotated_by_time = self.refresh_active_path(&now)?;
|
||||
if self.should_rotate_for_size(buf.len()) {
|
||||
self.rotate_for_size(&now)?;
|
||||
self.cleanup(&now);
|
||||
} else if rotated_by_time || self.cleanup_due(&now) {
|
||||
self.cleanup(&now);
|
||||
}
|
||||
|
||||
let Some(file) = self.file.as_mut() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"bounded log file is not open",
|
||||
));
|
||||
};
|
||||
file.write_all(buf)?;
|
||||
self.current_size = self.current_size.saturating_add(buf.len() as u64);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Some(file) = self.file.as_mut() {
|
||||
file.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LogFileCandidate {
|
||||
path: PathBuf,
|
||||
modified: SystemTime,
|
||||
is_current: bool,
|
||||
}
|
||||
|
||||
fn open_append_file(path: &Path) -> io::Result<(File, u64)> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).append(true);
|
||||
|
||||
let file = match options.open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) => {
|
||||
let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
else {
|
||||
return Err(error);
|
||||
};
|
||||
fs::create_dir_all(parent)?;
|
||||
options.open(path)?
|
||||
}
|
||||
};
|
||||
let current_size = file.metadata()?.len();
|
||||
Ok((file, current_size))
|
||||
}
|
||||
|
||||
fn active_path_for(
|
||||
dir: &Path,
|
||||
base_name: &str,
|
||||
rotation: LogRotation,
|
||||
now: &DateTime<Utc>,
|
||||
) -> PathBuf {
|
||||
match rotation {
|
||||
LogRotation::Never => dir.join(base_name),
|
||||
LogRotation::Minutely | LogRotation::Hourly | LogRotation::Daily | LogRotation::Weekly => {
|
||||
dir.join(format!("{base_name}.{}", period_suffix_for(rotation, now)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn period_suffix_for(rotation: LogRotation, now: &DateTime<Utc>) -> String {
|
||||
match rotation {
|
||||
LogRotation::Never | LogRotation::Daily => now.format("%Y-%m-%d").to_string(),
|
||||
LogRotation::Hourly => now.format("%Y-%m-%d-%H").to_string(),
|
||||
LogRotation::Minutely => now.format("%Y-%m-%d-%H-%M").to_string(),
|
||||
LogRotation::Weekly => {
|
||||
let days_since_sunday = now.weekday().num_days_from_sunday() as i64;
|
||||
let week_start = now.date_naive() - ChronoDuration::days(days_since_sunday);
|
||||
week_start.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn system_time_from_utc(now: &DateTime<Utc>) -> SystemTime {
|
||||
let duration = Duration::new(now.timestamp().unsigned_abs(), now.timestamp_subsec_nanos());
|
||||
if now.timestamp() >= 0 {
|
||||
UNIX_EPOCH + duration
|
||||
} else {
|
||||
UNIX_EPOCH - duration
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixed_now() -> DateTime<Utc> {
|
||||
DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(10))
|
||||
}
|
||||
|
||||
fn options(path: PathBuf) -> FileLogOptions {
|
||||
FileLogOptions {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
rotation: LogRotation::Never,
|
||||
max_size_bytes: 0,
|
||||
max_files: 0,
|
||||
max_age_secs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn matching_logs(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("telemt.log"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_rotation_keeps_latest_write_in_active_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("telemt.log");
|
||||
let mut options = options(path.clone());
|
||||
options.max_size_bytes = 6;
|
||||
|
||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||
appender.write_all(b"abc\n").unwrap();
|
||||
appender.write_all(b"def\n").unwrap();
|
||||
appender.flush().unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "def\n");
|
||||
assert_eq!(matching_logs(dir.path()).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_files_retention_removes_oldest_archives() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("telemt.log");
|
||||
let mut options = options(path);
|
||||
options.max_size_bytes = 4;
|
||||
options.max_files = 2;
|
||||
|
||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||
for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] {
|
||||
appender.write_all(line).unwrap();
|
||||
}
|
||||
appender.flush().unwrap();
|
||||
|
||||
assert!(matching_logs(dir.path()).len() <= 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn max_age_retention_removes_old_archives() {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("telemt.log");
|
||||
let old_archive = dir.path().join("telemt.log.20000101000000.0");
|
||||
fs::write(&old_archive, "old").unwrap();
|
||||
|
||||
let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap();
|
||||
let times = [
|
||||
libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
];
|
||||
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
|
||||
assert_eq!(rc, 0);
|
||||
|
||||
let mut options = options(path);
|
||||
options.max_age_secs = 1;
|
||||
let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||
|
||||
assert!(!old_archive.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_default() {
|
||||
let args: Vec<String> = vec![];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
assert_eq!(
|
||||
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
|
||||
LogDestination::Stderr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_file() {
|
||||
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
|
||||
LogDestination::File { options } => {
|
||||
assert_eq!(options.path, "/var/log/telemt.log");
|
||||
assert_eq!(options.rotation, LogRotation::Never);
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_file_daily() {
|
||||
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
|
||||
LogDestination::File { options } => {
|
||||
assert_eq!(options.path, "/var/log/telemt");
|
||||
assert_eq!(options.rotation, LogRotation::Daily);
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_bounds() {
|
||||
let args = vec![
|
||||
"--log-file=/var/log/telemt.log".to_string(),
|
||||
"--log-rotation=hourly".to_string(),
|
||||
"--log-max-size-bytes=1024".to_string(),
|
||||
"--log-max-files=3".to_string(),
|
||||
"--log-max-age-secs=60".to_string(),
|
||||
];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
|
||||
LogDestination::File { options } => {
|
||||
assert_eq!(options.rotation, LogRotation::Hourly);
|
||||
assert_eq!(options.max_size_bytes, 1024);
|
||||
assert_eq!(options.max_files, 3);
|
||||
assert_eq!(options.max_age_secs, 60);
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_rejects_bad_rotation() {
|
||||
let args = vec!["--log-rotation=yearly".to_string()];
|
||||
assert!(parse_log_cli_options(&args).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_syslog() {
|
||||
let args = vec!["--syslog".to_string()];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
assert_eq!(
|
||||
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
|
||||
LogDestination::Syslog
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_syslog_priority_for_level_mapping() {
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::ERROR),
|
||||
libc::LOG_ERR
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::WARN),
|
||||
libc::LOG_WARNING
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::INFO),
|
||||
libc::LOG_INFO
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::DEBUG),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::TRACE),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
}
|
||||
+31
-6
@@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::cli;
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::logging::LogDestination;
|
||||
use crate::logging::LogCliOptions;
|
||||
use crate::transport::UpstreamManager;
|
||||
use crate::transport::middle_proxy::{
|
||||
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
|
||||
@@ -113,7 +113,7 @@ pub(crate) struct CliArgs {
|
||||
pub data_path: Option<PathBuf>,
|
||||
pub silent: bool,
|
||||
pub log_level: Option<String>,
|
||||
pub log_destination: LogDestination,
|
||||
pub log_cli_options: LogCliOptions,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cli() -> CliArgs {
|
||||
@@ -125,8 +125,13 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// Parse log destination
|
||||
let log_destination = crate::logging::parse_log_destination(&args);
|
||||
let log_cli_options = match crate::logging::parse_log_cli_options(&args) {
|
||||
Ok(options) => options,
|
||||
Err(error) => {
|
||||
eprintln!("[telemt] {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for --init first (handled before tokio)
|
||||
if let Some(init_opts) = cli::parse_init_args(&args) {
|
||||
@@ -180,6 +185,21 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
s if s.starts_with("--log-level=") => {
|
||||
log_level = Some(s.trim_start_matches("--log-level=").to_string());
|
||||
}
|
||||
"--log-file" | "--log-file-daily" => {
|
||||
i += 1;
|
||||
}
|
||||
s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {}
|
||||
"--log-rotation"
|
||||
| "--log-max-size-bytes"
|
||||
| "--log-max-files"
|
||||
| "--log-max-age-secs" => {
|
||||
i += 1;
|
||||
}
|
||||
s if s.starts_with("--log-rotation=")
|
||||
|| s.starts_with("--log-max-size-bytes=")
|
||||
|| s.starts_with("--log-max-files=")
|
||||
|| s.starts_with("--log-max-age-secs=") => {}
|
||||
"--syslog" => {}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
@@ -192,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
"--daemon" | "-d" | "--foreground" | "-f" => {}
|
||||
s if s.starts_with("--pid-file") => {
|
||||
if !s.contains('=') {
|
||||
i += 1; // skip value
|
||||
// Skip the pid-file value consumed by daemon argument parsing.
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--run-as-user") => {
|
||||
@@ -224,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
data_path,
|
||||
silent,
|
||||
log_level,
|
||||
log_destination,
|
||||
log_cli_options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +275,10 @@ fn print_help() {
|
||||
eprintln!("Logging options:");
|
||||
eprintln!(" --log-file <PATH> Log to file (default: stderr)");
|
||||
eprintln!(" --log-file-daily <PATH> Log to file with daily rotation");
|
||||
eprintln!(" --log-rotation <MODE> never|minutely|hourly|daily|weekly");
|
||||
eprintln!(" --log-max-size-bytes N Rotate file logs when active file exceeds N bytes");
|
||||
eprintln!(" --log-max-files N Keep at most N matching file logs (0 disables)");
|
||||
eprintln!(" --log-max-age-secs N Remove rotated file logs older than N seconds");
|
||||
#[cfg(unix)]
|
||||
eprintln!(" --syslog Log to syslog (Unix only)");
|
||||
eprintln!();
|
||||
|
||||
@@ -208,6 +208,8 @@ pub(crate) async fn initialize_me_pool(
|
||||
me_nat_probe,
|
||||
None,
|
||||
config.network.stun_servers.clone(),
|
||||
config.network.stun_tcp_fallback,
|
||||
config.network.http_ip_detect_urls.clone(),
|
||||
config.general.stun_nat_probe_concurrency,
|
||||
probe.detected_ipv6,
|
||||
config.timeouts.me_one_retry,
|
||||
@@ -277,6 +279,7 @@ pub(crate) async fn initialize_me_pool(
|
||||
config.general.me_writer_pick_sample_size,
|
||||
config.general.me_socks_kdf_policy,
|
||||
config.general.me_writer_cmd_channel_capacity,
|
||||
config.general.me_writer_byte_budget_bytes,
|
||||
config.general.me_route_channel_capacity,
|
||||
config.general.me_route_backpressure_enabled,
|
||||
config.general.me_route_fairshare_enabled,
|
||||
|
||||
+29
-2
@@ -33,6 +33,9 @@ use crate::conntrack_control;
|
||||
use crate::crypto::SecureRandom;
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::network::probe::{decide_network_capabilities, log_probe_result, run_probe};
|
||||
use crate::proxy::direct_buffer_budget::{
|
||||
DirectBufferBudget, resolve_direct_buffer_hard_limit, spawn_direct_buffer_budget_controller,
|
||||
};
|
||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||
use crate::proxy::shared_state::ProxySharedState;
|
||||
use crate::startup::{
|
||||
@@ -108,7 +111,7 @@ async fn run_telemt_core(
|
||||
let data_path = cli_args.data_path;
|
||||
let cli_silent = cli_args.silent;
|
||||
let cli_log_level = cli_args.log_level;
|
||||
let log_destination = cli_args.log_destination;
|
||||
let log_cli_options = cli_args.log_cli_options;
|
||||
let startup_cwd = match std::env::current_dir() {
|
||||
Ok(cwd) => cwd,
|
||||
Err(e) => {
|
||||
@@ -331,6 +334,14 @@ async fn run_telemt_core(
|
||||
};
|
||||
|
||||
let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level);
|
||||
let log_destination =
|
||||
match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) {
|
||||
Ok(destination) => destination,
|
||||
Err(error) => {
|
||||
eprintln!("[telemt] {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let (filter_layer, filter_handle) =
|
||||
reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
|
||||
startup_tracker
|
||||
@@ -465,7 +476,16 @@ async fn run_telemt_core(
|
||||
config.network.dns_overrides.len()
|
||||
);
|
||||
}
|
||||
let shared_state = ProxySharedState::new();
|
||||
let direct_buffer_hard_limit =
|
||||
resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await;
|
||||
let direct_buffer_budget = DirectBufferBudget::new(direct_buffer_hard_limit);
|
||||
info!(
|
||||
hard_limit_bytes = direct_buffer_hard_limit,
|
||||
configured_override_bytes = config.general.direct_relay_buffer_budget_max_bytes,
|
||||
"Direct relay buffer budget initialized"
|
||||
);
|
||||
let shared_state =
|
||||
ProxySharedState::new_with_direct_buffer_budget(direct_buffer_budget.clone());
|
||||
shared_state.apply_user_enabled_config(&config.access.user_enabled);
|
||||
shared_state.traffic_limiter.apply_policy(
|
||||
config.access.user_rate_limits.clone(),
|
||||
@@ -874,6 +894,13 @@ async fn run_telemt_core(
|
||||
stats.clone(),
|
||||
shared_state.clone(),
|
||||
);
|
||||
spawn_direct_buffer_budget_controller(
|
||||
direct_buffer_budget,
|
||||
buffer_pool.clone(),
|
||||
stats.clone(),
|
||||
shared_state.clone(),
|
||||
config.server.max_connections,
|
||||
);
|
||||
|
||||
let bound = listeners::bind_listeners(
|
||||
&config,
|
||||
|
||||
+151
@@ -595,6 +595,81 @@ async fn render_metrics(
|
||||
"telemt_buffer_pool_buffers_total{{kind=\"in_use\"}} {}",
|
||||
stats.get_buffer_pool_in_use_gauge()
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_buffer_pool_events_total Buffer-pool allocation lifecycle events"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_buffer_pool_events_total counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_buffer_pool_events_total{{event=\"replaced_nonstandard\"}} {}",
|
||||
stats.get_buffer_pool_replaced_nonstandard_total()
|
||||
);
|
||||
|
||||
let direct_budget = shared_state.direct_buffer_budget.snapshot();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_direct_relay_buffer_budget_bytes Direct relay copy-buffer budget and memory inputs"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_direct_relay_buffer_budget_bytes gauge");
|
||||
for (kind, value) in [
|
||||
("hard_limit", direct_budget.hard_limit_bytes),
|
||||
("target", direct_budget.target_bytes),
|
||||
("reserved", direct_budget.reserved_bytes),
|
||||
("memory_total", direct_budget.memory_total_bytes),
|
||||
("memory_available", direct_budget.memory_available_bytes),
|
||||
("process_rss", direct_budget.process_rss_bytes),
|
||||
] {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_direct_relay_buffer_budget_bytes{{kind=\"{}\"}} {}",
|
||||
kind, value
|
||||
);
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_direct_relay_buffer_budget_events_total Direct relay buffer-budget lifecycle events"
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# TYPE telemt_direct_relay_buffer_budget_events_total counter"
|
||||
);
|
||||
for (result, value) in [
|
||||
("promotion", direct_budget.promotion_total),
|
||||
("promotion_denied", direct_budget.promotion_denied_total),
|
||||
("minimum_fallback", direct_budget.minimum_fallback_total),
|
||||
("admission_rejected", direct_budget.admission_rejected_total),
|
||||
("quiet_demotion", direct_budget.quiet_demotion_total),
|
||||
(
|
||||
"write_pressure_demotion",
|
||||
direct_budget.write_pressure_demotion_total,
|
||||
),
|
||||
(
|
||||
"global_pressure_demotion",
|
||||
direct_budget.global_pressure_demotion_total,
|
||||
),
|
||||
] {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_direct_relay_buffer_budget_events_total{{result=\"{}\"}} {}",
|
||||
result, value
|
||||
);
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_direct_relay_buffer_sessions Current Direct relay sessions by adaptive tier"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_direct_relay_buffer_sessions gauge");
|
||||
for (tier, value) in ["base", "tier1", "tier2", "tier3"]
|
||||
.into_iter()
|
||||
.zip(direct_budget.tier_sessions)
|
||||
{
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_direct_relay_buffer_sessions{{tier=\"{}\"}} {}",
|
||||
tier, value
|
||||
);
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
@@ -2435,6 +2510,82 @@ async fn render_metrics(
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_writer_byte_budget_limit_bytes Configured resident-memory budget per ME writer"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_me_writer_byte_budget_limit_bytes gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_writer_byte_budget_limit_bytes {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_writer_byte_budget_limit_bytes_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_writer_byte_budget_reserved_bytes Aggregate ME writer memory reservations by lifecycle state"
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# TYPE telemt_me_writer_byte_budget_reserved_bytes gauge"
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_writer_byte_budget_reserved_bytes{{state=\"queued\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_writer_byte_budget_queued_bytes_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_writer_byte_budget_reserved_bytes{{state=\"inflight\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_writer_byte_budget_inflight_bytes_gauge()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_writer_byte_budget_events_total ME writer byte-budget outcomes"
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# TYPE telemt_me_writer_byte_budget_events_total counter"
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_writer_byte_budget_events_total{{result=\"wait\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_writer_byte_budget_wait_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_writer_byte_budget_events_total{{result=\"timeout\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_writer_byte_budget_timeout_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_me_writer_byte_budget_events_total{{result=\"oversize\"}} {}",
|
||||
if me_allows_normal {
|
||||
stats.get_me_writer_byte_budget_oversize_total()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_me_writer_pick_total ME writer-pick outcomes by mode and result"
|
||||
|
||||
+33
-7
@@ -12,7 +12,7 @@ use tracing::{debug, info, warn};
|
||||
use crate::config::{NetworkConfig, UpstreamConfig, UpstreamType};
|
||||
use crate::error::Result;
|
||||
use crate::network::stun::{
|
||||
DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind,
|
||||
DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind_and_tcp_fallback,
|
||||
};
|
||||
use crate::transport::UpstreamManager;
|
||||
|
||||
@@ -58,6 +58,7 @@ impl NetworkDecision {
|
||||
}
|
||||
|
||||
const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const STUN_BATCH_TCP_FALLBACK_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
pub async fn run_probe(
|
||||
config: &NetworkConfig,
|
||||
@@ -81,8 +82,14 @@ pub async fn run_probe(
|
||||
warn!("STUN probe is enabled but network.stun_servers is empty");
|
||||
DualStunResult::default()
|
||||
} else {
|
||||
probe_stun_servers_parallel(&servers, stun_nat_probe_concurrency.max(1), None, None)
|
||||
.await
|
||||
probe_stun_servers_parallel(
|
||||
&servers,
|
||||
stun_nat_probe_concurrency.max(1),
|
||||
None,
|
||||
None,
|
||||
config.stun_tcp_fallback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else if nat_probe {
|
||||
info!("STUN probe is disabled by network.stun_use=false");
|
||||
@@ -163,6 +170,7 @@ pub async fn run_probe(
|
||||
stun_nat_probe_concurrency.max(1),
|
||||
bind_v4,
|
||||
bind_v6,
|
||||
config.stun_tcp_fallback,
|
||||
)
|
||||
.await;
|
||||
if let Some(reflected) = direct_stun_res.v4.map(|r| r.reflected_addr) {
|
||||
@@ -234,7 +242,7 @@ pub async fn run_probe(
|
||||
Ok(probe)
|
||||
}
|
||||
|
||||
async fn detect_public_ipv4_http(urls: &[String]) -> Option<Ipv4Addr> {
|
||||
pub(crate) async fn detect_public_ipv4_http(urls: &[String]) -> Option<Ipv4Addr> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
@@ -277,6 +285,7 @@ async fn probe_stun_servers_parallel(
|
||||
concurrency: usize,
|
||||
bind_v4: Option<IpAddr>,
|
||||
bind_v6: Option<IpAddr>,
|
||||
tcp_fallback: bool,
|
||||
) -> DualStunResult {
|
||||
let mut join_set = JoinSet::new();
|
||||
let mut next_idx = 0usize;
|
||||
@@ -288,9 +297,26 @@ async fn probe_stun_servers_parallel(
|
||||
let stun_addr = servers[next_idx].clone();
|
||||
next_idx += 1;
|
||||
join_set.spawn(async move {
|
||||
let res = timeout(STUN_BATCH_TIMEOUT, async {
|
||||
let v4 = stun_probe_family_with_bind(&stun_addr, IpFamily::V4, bind_v4).await?;
|
||||
let v6 = stun_probe_family_with_bind(&stun_addr, IpFamily::V6, bind_v6).await?;
|
||||
let batch_timeout = if tcp_fallback {
|
||||
STUN_BATCH_TCP_FALLBACK_TIMEOUT
|
||||
} else {
|
||||
STUN_BATCH_TIMEOUT
|
||||
};
|
||||
let res = timeout(batch_timeout, async {
|
||||
let v4 = stun_probe_family_with_bind_and_tcp_fallback(
|
||||
&stun_addr,
|
||||
IpFamily::V4,
|
||||
bind_v4,
|
||||
tcp_fallback,
|
||||
)
|
||||
.await?;
|
||||
let v6 = stun_probe_family_with_bind_and_tcp_fallback(
|
||||
&stun_addr,
|
||||
IpFamily::V6,
|
||||
bind_v6,
|
||||
tcp_fallback,
|
||||
)
|
||||
.await?;
|
||||
Ok::<DualStunResult, crate::error::ProxyError>(DualStunResult { v4, v6 })
|
||||
})
|
||||
.await;
|
||||
|
||||
+241
-41
@@ -4,7 +4,8 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tokio::net::{UdpSocket, lookup_host};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpSocket, UdpSocket, lookup_host};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
|
||||
use crate::crypto::SecureRandom;
|
||||
@@ -36,9 +37,16 @@ pub struct DualStunResult {
|
||||
}
|
||||
|
||||
pub async fn stun_probe_dual(stun_addr: &str) -> Result<DualStunResult> {
|
||||
stun_probe_dual_with_tcp_fallback(stun_addr, false).await
|
||||
}
|
||||
|
||||
pub async fn stun_probe_dual_with_tcp_fallback(
|
||||
stun_addr: &str,
|
||||
tcp_fallback: bool,
|
||||
) -> Result<DualStunResult> {
|
||||
let (v4, v6) = tokio::join!(
|
||||
stun_probe_family(stun_addr, IpFamily::V4),
|
||||
stun_probe_family(stun_addr, IpFamily::V6),
|
||||
stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V4, tcp_fallback),
|
||||
stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V6, tcp_fallback),
|
||||
);
|
||||
|
||||
Ok(DualStunResult { v4: v4?, v6: v6? })
|
||||
@@ -48,13 +56,44 @@ pub async fn stun_probe_family(
|
||||
stun_addr: &str,
|
||||
family: IpFamily,
|
||||
) -> Result<Option<StunProbeResult>> {
|
||||
stun_probe_family_with_bind(stun_addr, family, None).await
|
||||
stun_probe_family_with_tcp_fallback(stun_addr, family, false).await
|
||||
}
|
||||
|
||||
pub async fn stun_probe_family_with_tcp_fallback(
|
||||
stun_addr: &str,
|
||||
family: IpFamily,
|
||||
tcp_fallback: bool,
|
||||
) -> Result<Option<StunProbeResult>> {
|
||||
stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, None, tcp_fallback).await
|
||||
}
|
||||
|
||||
pub async fn stun_probe_family_with_bind(
|
||||
stun_addr: &str,
|
||||
family: IpFamily,
|
||||
bind_ip: Option<IpAddr>,
|
||||
) -> Result<Option<StunProbeResult>> {
|
||||
stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, bind_ip, false).await
|
||||
}
|
||||
|
||||
pub async fn stun_probe_family_with_bind_and_tcp_fallback(
|
||||
stun_addr: &str,
|
||||
family: IpFamily,
|
||||
bind_ip: Option<IpAddr>,
|
||||
tcp_fallback: bool,
|
||||
) -> Result<Option<StunProbeResult>> {
|
||||
let udp_attempts = if tcp_fallback { 1 } else { 3 };
|
||||
let udp_result = stun_probe_family_udp(stun_addr, family, bind_ip, udp_attempts).await?;
|
||||
if udp_result.is_some() || !tcp_fallback {
|
||||
return Ok(udp_result);
|
||||
}
|
||||
stun_probe_family_tcp(stun_addr, family, bind_ip).await
|
||||
}
|
||||
|
||||
async fn stun_probe_family_udp(
|
||||
stun_addr: &str,
|
||||
family: IpFamily,
|
||||
bind_ip: Option<IpAddr>,
|
||||
max_attempts: u8,
|
||||
) -> Result<Option<StunProbeResult>> {
|
||||
let bind_addr = match (family, bind_ip) {
|
||||
(IpFamily::V4, Some(IpAddr::V4(ip))) => SocketAddr::new(IpAddr::V4(ip), 0),
|
||||
@@ -94,12 +133,7 @@ pub async fn stun_probe_family_with_bind(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut req = [0u8; 20];
|
||||
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes()); // Binding Request
|
||||
req[2..4].copy_from_slice(&0u16.to_be_bytes()); // length
|
||||
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes()); // magic cookie
|
||||
stun_rng().fill(&mut req[8..20]); // transaction ID
|
||||
|
||||
let req = build_binding_request();
|
||||
let mut buf = [0u8; 256];
|
||||
let mut attempt = 0;
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
@@ -115,7 +149,7 @@ pub async fn stun_probe_family_with_bind(
|
||||
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN recv failed: {e}"))),
|
||||
Err(_) => {
|
||||
attempt += 1;
|
||||
if attempt >= 3 {
|
||||
if attempt >= max_attempts {
|
||||
return Ok(None);
|
||||
}
|
||||
sleep(backoff).await;
|
||||
@@ -128,19 +162,139 @@ pub async fn stun_probe_family_with_bind(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let magic = 0x2112A442u32.to_be_bytes();
|
||||
let txid = &req[8..20];
|
||||
let mut idx = 20;
|
||||
while idx + 4 <= n {
|
||||
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().unwrap());
|
||||
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().unwrap()) as usize;
|
||||
idx += 4;
|
||||
if idx + alen > n {
|
||||
break;
|
||||
}
|
||||
if let Some(reflected_addr) = parse_reflected_addr(&buf[..n], txid) {
|
||||
let local_addr = socket
|
||||
.local_addr()
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
|
||||
return Ok(Some(StunProbeResult {
|
||||
local_addr,
|
||||
reflected_addr,
|
||||
family,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
match atype {
|
||||
0x0020 /* XOR-MAPPED-ADDRESS */ | 0x0001 /* MAPPED-ADDRESS */ => {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn stun_probe_family_tcp(
|
||||
stun_addr: &str,
|
||||
family: IpFamily,
|
||||
bind_ip: Option<IpAddr>,
|
||||
) -> Result<Option<StunProbeResult>> {
|
||||
let target_addr = match resolve_stun_addr(stun_addr, family).await? {
|
||||
Some(addr) => addr,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let socket = match family {
|
||||
IpFamily::V4 => TcpSocket::new_v4(),
|
||||
IpFamily::V6 => TcpSocket::new_v6(),
|
||||
}
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN TCP socket failed: {e}")))?;
|
||||
match (family, bind_ip) {
|
||||
(IpFamily::V4, Some(IpAddr::V4(ip))) => {
|
||||
if socket.bind(SocketAddr::new(IpAddr::V4(ip), 0)).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
(IpFamily::V6, Some(IpAddr::V6(ip))) => {
|
||||
if socket.bind(SocketAddr::new(IpAddr::V6(ip), 0)).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
(IpFamily::V4, Some(IpAddr::V6(_))) | (IpFamily::V6, Some(IpAddr::V4(_))) => {
|
||||
return Ok(None);
|
||||
}
|
||||
(_, None) => {}
|
||||
}
|
||||
|
||||
let connect_res = timeout(Duration::from_secs(3), socket.connect(target_addr)).await;
|
||||
let mut stream = match connect_res {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(e))
|
||||
if family == IpFamily::V6
|
||||
&& matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::NetworkUnreachable
|
||||
| std::io::ErrorKind::HostUnreachable
|
||||
| std::io::ErrorKind::Unsupported
|
||||
| std::io::ErrorKind::NetworkDown
|
||||
) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN TCP connect failed: {e}"))),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let req = build_binding_request();
|
||||
timeout(Duration::from_secs(3), stream.write_all(&req))
|
||||
.await
|
||||
.map_err(|_| ProxyError::Proxy("STUN TCP send timeout".to_string()))?
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN TCP send failed: {e}")))?;
|
||||
|
||||
let mut header = [0u8; 20];
|
||||
timeout(Duration::from_secs(3), stream.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| ProxyError::Proxy("STUN TCP header timeout".to_string()))?
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN TCP header read failed: {e}")))?;
|
||||
let body_len = u16::from_be_bytes([header[2], header[3]]) as usize;
|
||||
if body_len > 236 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut buf = [0u8; 256];
|
||||
buf[..20].copy_from_slice(&header);
|
||||
if body_len > 0 {
|
||||
timeout(
|
||||
Duration::from_secs(3),
|
||||
stream.read_exact(&mut buf[20..20 + body_len]),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ProxyError::Proxy("STUN TCP body timeout".to_string()))?
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN TCP body read failed: {e}")))?;
|
||||
}
|
||||
|
||||
let txid = &req[8..20];
|
||||
let Some(reflected_addr) = parse_reflected_addr(&buf[..20 + body_len], txid) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let local_addr = stream
|
||||
.local_addr()
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN TCP local_addr failed: {e}")))?;
|
||||
Ok(Some(StunProbeResult {
|
||||
local_addr,
|
||||
reflected_addr,
|
||||
family,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_binding_request() -> [u8; 20] {
|
||||
let mut req = [0u8; 20];
|
||||
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes());
|
||||
req[2..4].copy_from_slice(&0u16.to_be_bytes());
|
||||
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
|
||||
stun_rng().fill(&mut req[8..20]);
|
||||
req
|
||||
}
|
||||
|
||||
fn parse_reflected_addr(buf: &[u8], txid: &[u8]) -> Option<SocketAddr> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let magic = 0x2112A442u32.to_be_bytes();
|
||||
let mut idx = 20;
|
||||
while idx + 4 <= buf.len() {
|
||||
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().ok()?);
|
||||
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().ok()?) as usize;
|
||||
idx += 4;
|
||||
if idx + alen > buf.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
match atype {
|
||||
0x0020 | 0x0001 => {
|
||||
if alen < 8 {
|
||||
break;
|
||||
}
|
||||
@@ -157,7 +311,6 @@ pub async fn stun_probe_family_with_bind(
|
||||
|
||||
let raw_ip = &buf[idx + 4..idx + 4 + len_check];
|
||||
let mut port = u16::from_be_bytes(port_bytes);
|
||||
|
||||
let reflected_ip = if atype == 0x0020 {
|
||||
port ^= ((magic[0] as u16) << 8) | magic[1] as u16;
|
||||
match family_byte {
|
||||
@@ -172,7 +325,9 @@ pub async fn stun_probe_family_with_bind(
|
||||
}
|
||||
0x02 => {
|
||||
let mut ip = [0u8; 16];
|
||||
let xor_key = [magic.as_slice(), txid].concat();
|
||||
let mut xor_key = [0u8; 16];
|
||||
xor_key[..4].copy_from_slice(&magic);
|
||||
xor_key[4..].copy_from_slice(txid.get(..12)?);
|
||||
for (i, b) in raw_ip.iter().enumerate().take(16) {
|
||||
ip[i] = *b ^ xor_key[i];
|
||||
}
|
||||
@@ -185,34 +340,24 @@ pub async fn stun_probe_family_with_bind(
|
||||
}
|
||||
} else {
|
||||
match family_byte {
|
||||
0x01 => IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3])),
|
||||
0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).unwrap())),
|
||||
0x01 => {
|
||||
IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3]))
|
||||
}
|
||||
0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).ok()?)),
|
||||
_ => {
|
||||
idx += (alen + 3) & !3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let reflected_addr = SocketAddr::new(reflected_ip, port);
|
||||
let local_addr = socket
|
||||
.local_addr()
|
||||
.map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
|
||||
|
||||
return Ok(Some(StunProbeResult {
|
||||
local_addr,
|
||||
reflected_addr,
|
||||
family,
|
||||
}));
|
||||
return Some(SocketAddr::new(reflected_ip, port));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
idx += (alen + 3) & !3;
|
||||
}
|
||||
idx += (alen + 3) & !3;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
None
|
||||
}
|
||||
|
||||
async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<SocketAddr>> {
|
||||
@@ -245,3 +390,58 @@ async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<S
|
||||
});
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_reflected_addr_reads_mapped_ipv4() {
|
||||
let txid = [0u8; 12];
|
||||
let mut response = [0u8; 32];
|
||||
response[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
|
||||
response[2..4].copy_from_slice(&12u16.to_be_bytes());
|
||||
response[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
|
||||
response[20..22].copy_from_slice(&0x0001u16.to_be_bytes());
|
||||
response[22..24].copy_from_slice(&8u16.to_be_bytes());
|
||||
response[25] = 0x01;
|
||||
response[26..28].copy_from_slice(&443u16.to_be_bytes());
|
||||
response[28..32].copy_from_slice(&[203, 0, 113, 9]);
|
||||
|
||||
let reflected = parse_reflected_addr(&response, &txid).unwrap();
|
||||
assert_eq!(
|
||||
reflected,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), 443)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reflected_addr_reads_xor_mapped_ipv4() {
|
||||
let txid = [0u8; 12];
|
||||
let magic = 0x2112A442u32.to_be_bytes();
|
||||
let port = 443u16;
|
||||
let ip = [203u8, 0, 113, 9];
|
||||
let xport = port ^ (((magic[0] as u16) << 8) | magic[1] as u16);
|
||||
let xip = [
|
||||
ip[0] ^ magic[0],
|
||||
ip[1] ^ magic[1],
|
||||
ip[2] ^ magic[2],
|
||||
ip[3] ^ magic[3],
|
||||
];
|
||||
let mut response = [0u8; 32];
|
||||
response[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
|
||||
response[2..4].copy_from_slice(&12u16.to_be_bytes());
|
||||
response[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
|
||||
response[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
|
||||
response[22..24].copy_from_slice(&8u16.to_be_bytes());
|
||||
response[25] = 0x01;
|
||||
response[26..28].copy_from_slice(&xport.to_be_bytes());
|
||||
response[28..32].copy_from_slice(&xip);
|
||||
|
||||
let reflected = parse_reflected_addr(&response, &txid).unwrap();
|
||||
assert_eq!(
|
||||
reflected,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), 443)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use crate::crypto::SecureRandom;
|
||||
use crate::protocol::framing::{
|
||||
secure_version_d_body_len_from_wire_len, secure_version_d_padding_len,
|
||||
};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// ============= Telegram Datacenters =============
|
||||
@@ -236,22 +239,20 @@ pub fn is_valid_secure_payload_len(data_len: usize) -> bool {
|
||||
}
|
||||
|
||||
/// Compute Secure Intermediate payload length from wire length.
|
||||
/// Secure mode strips up to 3 random tail bytes by truncating to 4-byte boundary.
|
||||
/// Secure mode cannot distinguish full-word padding from payload, so only the
|
||||
/// non-aligned tail bytes are stripped.
|
||||
pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option<usize> {
|
||||
if wire_len < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(wire_len - (wire_len % 4))
|
||||
secure_version_d_body_len_from_wire_len(wire_len)
|
||||
}
|
||||
|
||||
/// Generate padding length for Secure Intermediate protocol.
|
||||
/// Data must be 4-byte aligned; padding is 1..=3 so total is never divisible by 4.
|
||||
/// Outbound padding is 1..=3 so a receiver can strip it by 4-byte alignment.
|
||||
pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize {
|
||||
debug_assert!(
|
||||
is_valid_secure_payload_len(data_len),
|
||||
"Secure payload must be 4-byte aligned, got {data_len}"
|
||||
);
|
||||
rng.range(3) + 1
|
||||
secure_version_d_padding_len(rng)
|
||||
}
|
||||
|
||||
// ============= Timeouts =============
|
||||
@@ -430,7 +431,7 @@ mod tests {
|
||||
for _ in 0..100 {
|
||||
let padding = secure_padding_len(data_len, &rng);
|
||||
assert!(
|
||||
padding <= 3,
|
||||
(1..=3).contains(&padding),
|
||||
"padding out of range: data_len={data_len}, padding={padding}"
|
||||
);
|
||||
assert_ne!(
|
||||
@@ -454,6 +455,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_wire_len_preserves_full_word_tail() {
|
||||
let payload_len = 64;
|
||||
for padding in [4usize, 8, 12] {
|
||||
let wire_len = payload_len + padding;
|
||||
let recovered = secure_payload_len_from_wire_len(wire_len);
|
||||
assert_eq!(recovered, Some(wire_len));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_wire_len_rejects_too_short_frames() {
|
||||
assert_eq!(secure_payload_len_from_wire_len(0), None);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Shared MTProto transport framing helpers.
|
||||
|
||||
use crate::crypto::SecureRandom;
|
||||
|
||||
/// QuickACK marker bit used by Intermediate and Secure Intermediate headers.
|
||||
pub(crate) const INTERMEDIATE_QUICKACK_FLAG: u32 = 0x8000_0000;
|
||||
|
||||
/// Payload length mask used by Intermediate and Secure Intermediate headers.
|
||||
pub(crate) const INTERMEDIATE_WIRE_LEN_MASK: u32 = 0x7fff_ffff;
|
||||
|
||||
/// Maximum outbound Secure tail length that keeps wire lengths non-aligned.
|
||||
pub(crate) const SECURE_VERSION_D_PADDING_MAX: usize = 3;
|
||||
|
||||
/// Parsed Intermediate/Secure Intermediate length header.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct IntermediateHeader {
|
||||
/// Payload length on the wire, excluding the four-byte header.
|
||||
pub(crate) wire_len: usize,
|
||||
/// Whether the QuickACK marker bit was set in the length header.
|
||||
pub(crate) quickack: bool,
|
||||
}
|
||||
|
||||
/// Parse an Intermediate/Secure Intermediate length header.
|
||||
pub(crate) fn parse_intermediate_header(header: [u8; 4]) -> IntermediateHeader {
|
||||
let raw = u32::from_le_bytes(header);
|
||||
IntermediateHeader {
|
||||
wire_len: (raw & INTERMEDIATE_WIRE_LEN_MASK) as usize,
|
||||
quickack: (raw & INTERMEDIATE_QUICKACK_FLAG) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an Intermediate/Secure Intermediate length header.
|
||||
pub(crate) fn encode_intermediate_header(wire_len: usize, quickack: bool) -> Option<u32> {
|
||||
if wire_len > INTERMEDIATE_WIRE_LEN_MASK as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut raw = u32::try_from(wire_len).ok()?;
|
||||
if quickack {
|
||||
raw |= INTERMEDIATE_QUICKACK_FLAG;
|
||||
}
|
||||
Some(raw)
|
||||
}
|
||||
|
||||
/// Recover the VersionD body length visible to MTProto from the encrypted wire length.
|
||||
pub(crate) fn secure_version_d_body_len_from_wire_len(wire_len: usize) -> Option<usize> {
|
||||
if wire_len < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(wire_len - (wire_len % 4))
|
||||
}
|
||||
|
||||
/// Generate outbound Secure tail length without ambiguous full-word padding.
|
||||
pub(crate) fn secure_version_d_padding_len(rng: &SecureRandom) -> usize {
|
||||
rng.range(SECURE_VERSION_D_PADDING_MAX) + 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intermediate_header_roundtrip_preserves_quickack_zero_length() {
|
||||
let encoded = encode_intermediate_header(0, true).unwrap();
|
||||
assert_eq!(encoded, INTERMEDIATE_QUICKACK_FLAG);
|
||||
|
||||
let parsed = parse_intermediate_header(encoded.to_le_bytes());
|
||||
assert_eq!(parsed.wire_len, 0);
|
||||
assert!(parsed.quickack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intermediate_header_rejects_lengths_above_31_bits() {
|
||||
assert_eq!(
|
||||
encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize, false),
|
||||
Some(INTERMEDIATE_WIRE_LEN_MASK)
|
||||
);
|
||||
assert_eq!(
|
||||
encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize + 1, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_version_d_body_len_strips_only_non_word_tail() {
|
||||
assert_eq!(secure_version_d_body_len_from_wire_len(3), None);
|
||||
assert_eq!(secure_version_d_body_len_from_wire_len(8), Some(8));
|
||||
assert_eq!(secure_version_d_body_len_from_wire_len(11), Some(8));
|
||||
assert_eq!(secure_version_d_body_len_from_wire_len(12), Some(12));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod constants;
|
||||
pub mod frame;
|
||||
pub(crate) mod framing;
|
||||
pub mod obfuscation;
|
||||
pub mod tls;
|
||||
pub mod tls_fingerprint;
|
||||
|
||||
+137
-41
@@ -1,7 +1,4 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
// Adaptive buffer policy is staged and retained for deterministic rollout.
|
||||
// Keep definitions compiled for compatibility and security test scaffolding.
|
||||
// Adaptive buffer policy shared by active Direct relay sessions.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use std::cmp::max;
|
||||
@@ -13,29 +10,42 @@ const PROFILE_TTL: Duration = Duration::from_secs(300);
|
||||
const THROUGHPUT_UP_BPS: f64 = 8_000_000.0;
|
||||
const THROUGHPUT_DOWN_BPS: f64 = 2_000_000.0;
|
||||
const RATIO_CONFIRM_THRESHOLD: f64 = 1.12;
|
||||
const TIER1_HOLD_TICKS: u32 = 8;
|
||||
const TIER2_HOLD_TICKS: u32 = 4;
|
||||
const QUIET_DEMOTE_TICKS: u32 = 480;
|
||||
const HARD_COOLDOWN_TICKS: u32 = 20;
|
||||
const TIER1_HOLD: Duration = Duration::from_secs(2);
|
||||
const TIER2_HOLD: Duration = Duration::from_secs(1);
|
||||
const QUIET_DEMOTE: Duration = Duration::from_secs(120);
|
||||
const HARD_COOLDOWN: Duration = Duration::from_secs(5);
|
||||
const SUSTAINED_PRESSURE_DEMOTE: Duration = Duration::from_secs(30);
|
||||
const PRESSURE_DEMOTE_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const HARD_PENDING_THRESHOLD: u32 = 3;
|
||||
const HARD_PARTIAL_RATIO_THRESHOLD: f64 = 0.25;
|
||||
#[cfg(test)]
|
||||
const DIRECT_C2S_CAP_BYTES: usize = 128 * 1024;
|
||||
#[cfg(test)]
|
||||
const DIRECT_S2C_CAP_BYTES: usize = 512 * 1024;
|
||||
#[cfg(test)]
|
||||
const ME_FRAMES_CAP: usize = 96;
|
||||
#[cfg(test)]
|
||||
const ME_BYTES_CAP: usize = 384 * 1024;
|
||||
#[cfg(test)]
|
||||
const ME_DELAY_MIN_US: u64 = 150;
|
||||
const MAX_USER_PROFILES_ENTRIES: usize = 50_000;
|
||||
const MAX_USER_KEY_BYTES: usize = 512;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
/// Per-session Direct copy-buffer capacity tier.
|
||||
pub enum AdaptiveTier {
|
||||
/// Conservative baseline capacity.
|
||||
Base = 0,
|
||||
/// First throughput promotion.
|
||||
Tier1 = 1,
|
||||
/// Sustained bidirectional pressure promotion.
|
||||
Tier2 = 2,
|
||||
/// Configured per-direction ceilings.
|
||||
Tier3 = 3,
|
||||
}
|
||||
|
||||
impl AdaptiveTier {
|
||||
/// Returns the next larger tier, saturating at `Tier3`.
|
||||
pub fn promote(self) -> Self {
|
||||
match self {
|
||||
Self::Base => Self::Tier1,
|
||||
@@ -45,6 +55,7 @@ impl AdaptiveTier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next smaller tier, saturating at `Base`.
|
||||
pub fn demote(self) -> Self {
|
||||
match self {
|
||||
Self::Base => Self::Base,
|
||||
@@ -54,6 +65,7 @@ impl AdaptiveTier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ratio(self) -> (usize, usize) {
|
||||
match self {
|
||||
Self::Base => (1, 1),
|
||||
@@ -63,49 +75,70 @@ impl AdaptiveTier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable numeric tier used by bounded metrics.
|
||||
pub fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Signal that caused an accepted adaptive tier transition.
|
||||
pub enum TierTransitionReason {
|
||||
/// Sustained throughput and directional ratio confirmation.
|
||||
SoftConfirmed,
|
||||
/// Short pending or partial-write pressure burst.
|
||||
HardPressure,
|
||||
/// Sustained low-throughput period.
|
||||
QuietDemotion,
|
||||
/// Sustained pending or partial-write pressure.
|
||||
SustainedWritePressure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Proposed transition emitted by the per-session controller.
|
||||
pub struct TierTransition {
|
||||
/// Tier active before the observation.
|
||||
pub from: AdaptiveTier,
|
||||
/// Tier requested after the observation.
|
||||
pub to: AdaptiveTier,
|
||||
/// Pressure or throughput condition that requested the transition.
|
||||
pub reason: TierTransitionReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
/// Directional byte and write-pressure deltas for one observation period.
|
||||
pub struct RelaySignalSample {
|
||||
/// Client-to-DC bytes copied during the period.
|
||||
pub c2s_bytes: u64,
|
||||
/// Bytes offered to DC-to-client writes during the period.
|
||||
pub s2c_requested_bytes: u64,
|
||||
/// Bytes accepted by DC-to-client writes during the period.
|
||||
pub s2c_written_bytes: u64,
|
||||
/// Successful DC-to-client write operations during the period.
|
||||
pub s2c_write_ops: u64,
|
||||
/// Partial DC-to-client write operations during the period.
|
||||
pub s2c_partial_writes: u64,
|
||||
/// Consecutive pending DC-to-client writes at sample time.
|
||||
pub s2c_consecutive_pending_writes: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// Stateful hysteresis controller for one active Direct session.
|
||||
pub struct SessionAdaptiveController {
|
||||
tier: AdaptiveTier,
|
||||
max_tier_seen: AdaptiveTier,
|
||||
throughput_ema_bps: f64,
|
||||
incoming_ema_bps: f64,
|
||||
outgoing_ema_bps: f64,
|
||||
tier1_hold_ticks: u32,
|
||||
tier2_hold_ticks: u32,
|
||||
quiet_ticks: u32,
|
||||
hard_cooldown_ticks: u32,
|
||||
tier1_hold: Duration,
|
||||
tier2_hold: Duration,
|
||||
quiet: Duration,
|
||||
hard_cooldown: Duration,
|
||||
sustained_pressure: Duration,
|
||||
}
|
||||
|
||||
impl SessionAdaptiveController {
|
||||
/// Creates a controller at the tier whose memory reservation was accepted.
|
||||
pub fn new(initial_tier: AdaptiveTier) -> Self {
|
||||
Self {
|
||||
tier: initial_tier,
|
||||
@@ -113,25 +146,33 @@ impl SessionAdaptiveController {
|
||||
throughput_ema_bps: 0.0,
|
||||
incoming_ema_bps: 0.0,
|
||||
outgoing_ema_bps: 0.0,
|
||||
tier1_hold_ticks: 0,
|
||||
tier2_hold_ticks: 0,
|
||||
quiet_ticks: 0,
|
||||
hard_cooldown_ticks: 0,
|
||||
tier1_hold: Duration::ZERO,
|
||||
tier2_hold: Duration::ZERO,
|
||||
quiet: Duration::ZERO,
|
||||
hard_cooldown: Duration::ZERO,
|
||||
sustained_pressure: Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the highest tier proposed by controller observations.
|
||||
#[allow(dead_code)]
|
||||
pub fn max_tier_seen(&self) -> AdaptiveTier {
|
||||
self.max_tier_seen
|
||||
}
|
||||
|
||||
/// Returns the controller's current logical tier.
|
||||
pub fn tier(&self) -> AdaptiveTier {
|
||||
self.tier
|
||||
}
|
||||
|
||||
/// Observes one period and returns at most one hysteresis-controlled transition.
|
||||
pub fn observe(&mut self, sample: RelaySignalSample, tick_secs: f64) -> Option<TierTransition> {
|
||||
if tick_secs <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.hard_cooldown_ticks > 0 {
|
||||
self.hard_cooldown_ticks -= 1;
|
||||
}
|
||||
let tick = Duration::from_secs_f64(tick_secs);
|
||||
self.hard_cooldown = self.hard_cooldown.saturating_sub(tick);
|
||||
|
||||
let c2s_bps = (sample.c2s_bytes as f64 * 8.0) / tick_secs;
|
||||
let incoming_bps = (sample.s2c_requested_bytes as f64 * 8.0) / tick_secs;
|
||||
@@ -144,9 +185,9 @@ impl SessionAdaptiveController {
|
||||
|
||||
let tier1_now = self.throughput_ema_bps >= THROUGHPUT_UP_BPS;
|
||||
if tier1_now {
|
||||
self.tier1_hold_ticks = self.tier1_hold_ticks.saturating_add(1);
|
||||
self.tier1_hold = self.tier1_hold.saturating_add(tick);
|
||||
} else {
|
||||
self.tier1_hold_ticks = 0;
|
||||
self.tier1_hold = Duration::ZERO;
|
||||
}
|
||||
|
||||
let ratio = if self.outgoing_ema_bps <= f64::EPSILON {
|
||||
@@ -156,9 +197,9 @@ impl SessionAdaptiveController {
|
||||
};
|
||||
let tier2_now = ratio >= RATIO_CONFIRM_THRESHOLD;
|
||||
if tier2_now {
|
||||
self.tier2_hold_ticks = self.tier2_hold_ticks.saturating_add(1);
|
||||
self.tier2_hold = self.tier2_hold.saturating_add(tick);
|
||||
} else {
|
||||
self.tier2_hold_ticks = 0;
|
||||
self.tier2_hold = Duration::ZERO;
|
||||
}
|
||||
|
||||
let partial_ratio = if sample.s2c_write_ops == 0 {
|
||||
@@ -169,24 +210,37 @@ impl SessionAdaptiveController {
|
||||
let hard_now = sample.s2c_consecutive_pending_writes >= HARD_PENDING_THRESHOLD
|
||||
|| partial_ratio >= HARD_PARTIAL_RATIO_THRESHOLD;
|
||||
|
||||
if hard_now && self.hard_cooldown_ticks == 0 {
|
||||
return self.promote(TierTransitionReason::HardPressure, HARD_COOLDOWN_TICKS);
|
||||
if hard_now {
|
||||
self.sustained_pressure = self.sustained_pressure.saturating_add(tick);
|
||||
if self.sustained_pressure >= SUSTAINED_PRESSURE_DEMOTE {
|
||||
self.sustained_pressure = Duration::ZERO;
|
||||
return self.demote(
|
||||
TierTransitionReason::SustainedWritePressure,
|
||||
PRESSURE_DEMOTE_COOLDOWN,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.sustained_pressure = Duration::ZERO;
|
||||
}
|
||||
|
||||
if self.tier1_hold_ticks >= TIER1_HOLD_TICKS && self.tier2_hold_ticks >= TIER2_HOLD_TICKS {
|
||||
return self.promote(TierTransitionReason::SoftConfirmed, 0);
|
||||
if hard_now && self.hard_cooldown.is_zero() {
|
||||
return self.promote(TierTransitionReason::HardPressure, HARD_COOLDOWN);
|
||||
}
|
||||
|
||||
if self.tier1_hold >= TIER1_HOLD && self.tier2_hold >= TIER2_HOLD {
|
||||
return self.promote(TierTransitionReason::SoftConfirmed, Duration::ZERO);
|
||||
}
|
||||
|
||||
let demote_candidate =
|
||||
self.throughput_ema_bps < THROUGHPUT_DOWN_BPS && !tier2_now && !hard_now;
|
||||
if demote_candidate {
|
||||
self.quiet_ticks = self.quiet_ticks.saturating_add(1);
|
||||
if self.quiet_ticks >= QUIET_DEMOTE_TICKS {
|
||||
self.quiet_ticks = 0;
|
||||
return self.demote(TierTransitionReason::QuietDemotion);
|
||||
self.quiet = self.quiet.saturating_add(tick);
|
||||
if self.quiet >= QUIET_DEMOTE {
|
||||
self.quiet = Duration::ZERO;
|
||||
return self.demote(TierTransitionReason::QuietDemotion, Duration::ZERO);
|
||||
}
|
||||
} else {
|
||||
self.quiet_ticks = 0;
|
||||
self.quiet = Duration::ZERO;
|
||||
}
|
||||
|
||||
None
|
||||
@@ -195,7 +249,7 @@ impl SessionAdaptiveController {
|
||||
fn promote(
|
||||
&mut self,
|
||||
reason: TierTransitionReason,
|
||||
hard_cooldown_ticks: u32,
|
||||
hard_cooldown: Duration,
|
||||
) -> Option<TierTransition> {
|
||||
let from = self.tier;
|
||||
let to = from.promote();
|
||||
@@ -204,22 +258,27 @@ impl SessionAdaptiveController {
|
||||
}
|
||||
self.tier = to;
|
||||
self.max_tier_seen = max(self.max_tier_seen, to);
|
||||
self.hard_cooldown_ticks = hard_cooldown_ticks;
|
||||
self.tier1_hold_ticks = 0;
|
||||
self.tier2_hold_ticks = 0;
|
||||
self.quiet_ticks = 0;
|
||||
self.hard_cooldown = hard_cooldown;
|
||||
self.tier1_hold = Duration::ZERO;
|
||||
self.tier2_hold = Duration::ZERO;
|
||||
self.quiet = Duration::ZERO;
|
||||
Some(TierTransition { from, to, reason })
|
||||
}
|
||||
|
||||
fn demote(&mut self, reason: TierTransitionReason) -> Option<TierTransition> {
|
||||
fn demote(
|
||||
&mut self,
|
||||
reason: TierTransitionReason,
|
||||
hard_cooldown: Duration,
|
||||
) -> Option<TierTransition> {
|
||||
let from = self.tier;
|
||||
let to = from.demote();
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
self.tier = to;
|
||||
self.tier1_hold_ticks = 0;
|
||||
self.tier2_hold_ticks = 0;
|
||||
self.hard_cooldown = hard_cooldown;
|
||||
self.tier1_hold = Duration::ZERO;
|
||||
self.tier2_hold = Duration::ZERO;
|
||||
Some(TierTransition { from, to, reason })
|
||||
}
|
||||
}
|
||||
@@ -235,6 +294,8 @@ fn profiles() -> &'static DashMap<String, UserAdaptiveProfile> {
|
||||
USER_PROFILES.get_or_init(DashMap::new)
|
||||
}
|
||||
|
||||
/// Returns a fresh user's recent successful Direct tier, or `Base` when stale.
|
||||
#[allow(dead_code)]
|
||||
pub fn seed_tier_for_user(user: &str) -> AdaptiveTier {
|
||||
if user.len() > MAX_USER_KEY_BYTES {
|
||||
return AdaptiveTier::Base;
|
||||
@@ -253,6 +314,8 @@ pub fn seed_tier_for_user(user: &str) -> AdaptiveTier {
|
||||
AdaptiveTier::Base
|
||||
}
|
||||
|
||||
/// Records the highest successfully allocated tier for bounded session seeding.
|
||||
#[allow(dead_code)]
|
||||
pub fn record_user_tier(user: &str, tier: AdaptiveTier) {
|
||||
if user.len() > MAX_USER_KEY_BYTES {
|
||||
return;
|
||||
@@ -282,6 +345,8 @@ pub fn record_user_tier(user: &str, tier: AdaptiveTier) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Returns the legacy staged scaling policy retained by security fixtures.
|
||||
pub fn direct_copy_buffers_for_tier(
|
||||
tier: AdaptiveTier,
|
||||
base_c2s: usize,
|
||||
@@ -294,6 +359,32 @@ pub fn direct_copy_buffers_for_tier(
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps an adaptive tier to independent capacities within configured ceilings.
|
||||
pub(crate) fn direct_copy_buffers_for_tier_with_ceilings(
|
||||
tier: AdaptiveTier,
|
||||
base_c2s: usize,
|
||||
base_s2c: usize,
|
||||
ceiling_c2s: usize,
|
||||
ceiling_s2c: usize,
|
||||
) -> (usize, usize) {
|
||||
(
|
||||
direct_direction_size(tier, base_c2s, ceiling_c2s),
|
||||
direct_direction_size(tier, base_s2c, ceiling_s2c),
|
||||
)
|
||||
}
|
||||
|
||||
fn direct_direction_size(tier: AdaptiveTier, base: usize, ceiling: usize) -> usize {
|
||||
let target = match tier {
|
||||
AdaptiveTier::Base => base,
|
||||
AdaptiveTier::Tier1 => ceiling / 4,
|
||||
AdaptiveTier::Tier2 => ceiling / 2,
|
||||
AdaptiveTier::Tier3 => ceiling,
|
||||
};
|
||||
target.max(base).min(ceiling.max(base)).max(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Returns the staged Middle-End flush policy retained by security fixtures.
|
||||
pub fn me_flush_policy_for_tier(
|
||||
tier: AdaptiveTier,
|
||||
base_frames: usize,
|
||||
@@ -323,6 +414,7 @@ fn ema(prev: f64, value: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn scale(base: usize, numerator: usize, denominator: usize, cap: usize) -> usize {
|
||||
let scaled = base
|
||||
.saturating_mul(numerator)
|
||||
@@ -338,6 +430,10 @@ mod adaptive_buffers_security_tests;
|
||||
#[path = "tests/adaptive_buffers_record_race_security_tests.rs"]
|
||||
mod adaptive_buffers_record_race_security_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/adaptive_direct_budget_policy_tests.rs"]
|
||||
mod adaptive_direct_budget_policy_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -396,7 +492,7 @@ mod tests {
|
||||
fn test_quiet_demotion_is_slow_and_stepwise() {
|
||||
let mut ctrl = SessionAdaptiveController::new(AdaptiveTier::Tier2);
|
||||
let mut demotion = None;
|
||||
for _ in 0..QUIET_DEMOTE_TICKS {
|
||||
for _ in 0..480 {
|
||||
demotion = ctrl.observe(sample(1, 1, 1, 1, 0, 0), 0.25);
|
||||
}
|
||||
|
||||
|
||||
+32
-2
@@ -113,7 +113,7 @@ use crate::proxy::handshake::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
|
||||
use crate::proxy::masking::handle_bad_client;
|
||||
use crate::proxy::masking::handle_bad_client_with_shared;
|
||||
use crate::proxy::middle_relay::handle_via_middle_proxy;
|
||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||
use crate::proxy::shared_state::ProxySharedState;
|
||||
@@ -310,6 +310,7 @@ fn masking_outcome<R, W>(
|
||||
local_addr: SocketAddr,
|
||||
config: Arc<ProxyConfig>,
|
||||
beobachten: Arc<BeobachtenStore>,
|
||||
shared: Arc<ProxySharedState>,
|
||||
) -> HandshakeOutcome
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
@@ -325,7 +326,7 @@ where
|
||||
)
|
||||
.await;
|
||||
|
||||
handle_bad_client(
|
||||
handle_bad_client_with_shared(
|
||||
reader,
|
||||
writer,
|
||||
&initial_data,
|
||||
@@ -333,6 +334,7 @@ where
|
||||
local_addr,
|
||||
&config,
|
||||
&beobachten,
|
||||
shared.as_ref(),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
@@ -718,6 +720,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -739,6 +742,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -757,6 +761,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -787,6 +792,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => {
|
||||
@@ -844,6 +850,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => return Err(e),
|
||||
@@ -873,6 +880,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -898,6 +906,7 @@ where
|
||||
local_addr,
|
||||
config.clone(),
|
||||
beobachten.clone(),
|
||||
shared.clone(),
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => return Err(e),
|
||||
@@ -1096,6 +1105,12 @@ impl RunningClientHandler {
|
||||
#[cfg(unix)]
|
||||
let raw_fd = self.raw_fd;
|
||||
let rst_on_close = self.rst_on_close;
|
||||
// MSS for the bulk data phase: once the handshake (incl. ServerHello) is
|
||||
// sent, restore a normal MSS so only the handshake stays fragmented by the
|
||||
// low listener `client_mss`. Cuts pps ~10x (anti-DDoS abuse on pps-policing
|
||||
// hosts like FastVPS). None = keep handshake MSS for the whole connection.
|
||||
#[cfg(unix)]
|
||||
let bulk_mss: Option<u16> = self.config.server.client_mss_bulk_value().ok().flatten();
|
||||
|
||||
let outcome = match self.do_handshake().await? {
|
||||
Some(outcome) => outcome,
|
||||
@@ -1109,6 +1124,14 @@ impl RunningClientHandler {
|
||||
if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) {
|
||||
let _ = crate::transport::socket::clear_linger_fd(raw_fd);
|
||||
}
|
||||
// Handshake (ServerHello) done — raise MSS for bulk transfer.
|
||||
#[cfg(unix)]
|
||||
if let Some(mss) = bulk_mss {
|
||||
if let Err(e) = crate::transport::socket::set_tcp_mss_fd(raw_fd, u32::from(mss))
|
||||
{
|
||||
debug!(error = %e, "Failed to raise bulk MSS; keeping handshake MSS");
|
||||
}
|
||||
}
|
||||
fut.await
|
||||
}
|
||||
HandshakeOutcome::NeedsMasking(fut) => fut.await,
|
||||
@@ -1329,6 +1352,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
self.config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1350,6 +1374,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
self.config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -1369,6 +1394,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
self.config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1416,6 +1442,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => {
|
||||
@@ -1483,6 +1510,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => return Err(e),
|
||||
@@ -1530,6 +1558,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
self.config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1568,6 +1597,7 @@ impl RunningClientHandler {
|
||||
local_addr,
|
||||
config.clone(),
|
||||
self.beobachten.clone(),
|
||||
self.shared.clone(),
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => return Err(e),
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::BufferPool;
|
||||
|
||||
use super::shared_state::ProxySharedState;
|
||||
|
||||
/// Accounting granularity for process-wide Direct copy-buffer reservations.
|
||||
pub(crate) const DIRECT_BUFFER_UNIT_BYTES: usize = 4 * 1024;
|
||||
/// Minimum client-to-DC copy-buffer capacity for one Direct session.
|
||||
pub(crate) const DIRECT_BASE_C2S_BYTES: usize = 4 * 1024;
|
||||
/// Minimum DC-to-client copy-buffer capacity for one Direct session.
|
||||
pub(crate) const DIRECT_BASE_S2C_BYTES: usize = 8 * 1024;
|
||||
|
||||
const AUTO_HARD_MIN_BYTES: usize = 64 * 1024 * 1024;
|
||||
const AUTO_HARD_MAX_BYTES: usize = 2 * 1024 * 1024 * 1024;
|
||||
const AUTO_HARD_FALLBACK_BYTES: usize = 512 * 1024 * 1024;
|
||||
const TARGET_FLOOR_MIN_BYTES: usize = 16 * 1024 * 1024;
|
||||
const CONTROL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const HEALTHY_RECOVERY_SAMPLES: u8 = 30;
|
||||
const BUFFER_POOL_TRIM_LOW_WATERMARK: usize = 64;
|
||||
const BUFFER_POOL_TRIM_HIGH_WATERMARK: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
/// Lock-free observability snapshot of the Direct copy-buffer envelope.
|
||||
pub(crate) struct DirectBufferBudgetSnapshot {
|
||||
/// Absolute process-wide copy-buffer ceiling.
|
||||
pub(crate) hard_limit_bytes: u64,
|
||||
/// Current pressure-adjusted promotion target.
|
||||
pub(crate) target_bytes: u64,
|
||||
/// Bytes currently covered by active session leases.
|
||||
pub(crate) reserved_bytes: u64,
|
||||
/// Effective host or cgroup memory limit.
|
||||
pub(crate) memory_total_bytes: u64,
|
||||
/// Effective host or cgroup memory headroom.
|
||||
pub(crate) memory_available_bytes: u64,
|
||||
/// Current process resident set size.
|
||||
pub(crate) process_rss_bytes: u64,
|
||||
/// Successful tier growth reservations.
|
||||
pub(crate) promotion_total: u64,
|
||||
/// Tier growth attempts rejected by the adaptive target.
|
||||
pub(crate) promotion_denied_total: u64,
|
||||
/// Sessions admitted at minimum size above the adaptive target.
|
||||
pub(crate) minimum_fallback_total: u64,
|
||||
/// Sessions rejected by the absolute ceiling.
|
||||
pub(crate) admission_rejected_total: u64,
|
||||
/// Quiet-period tier reductions.
|
||||
pub(crate) quiet_demotion_total: u64,
|
||||
/// Sustained write-pressure tier reductions.
|
||||
pub(crate) write_pressure_demotion_total: u64,
|
||||
/// Process-wide pressure tier reductions.
|
||||
pub(crate) global_pressure_demotion_total: u64,
|
||||
/// Current sessions for Base through Tier3.
|
||||
pub(crate) tier_sessions: [u64; 4],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct SystemMemorySample {
|
||||
total_bytes: u64,
|
||||
available_bytes: u64,
|
||||
process_rss_bytes: u64,
|
||||
}
|
||||
|
||||
/// Process-wide hard envelope and adaptive target for Direct copy buffers.
|
||||
pub(crate) struct DirectBufferBudget {
|
||||
hard_limit_bytes: u64,
|
||||
target_bytes: AtomicU64,
|
||||
reserved_bytes: AtomicU64,
|
||||
pressure_generation: AtomicU64,
|
||||
pressure_tx: watch::Sender<u64>,
|
||||
memory_total_bytes: AtomicU64,
|
||||
memory_available_bytes: AtomicU64,
|
||||
process_rss_bytes: AtomicU64,
|
||||
promotion_total: AtomicU64,
|
||||
promotion_denied_total: AtomicU64,
|
||||
minimum_fallback_total: AtomicU64,
|
||||
admission_rejected_total: AtomicU64,
|
||||
quiet_demotion_total: AtomicU64,
|
||||
write_pressure_demotion_total: AtomicU64,
|
||||
global_pressure_demotion_total: AtomicU64,
|
||||
tier_sessions: [AtomicU64; 4],
|
||||
}
|
||||
|
||||
impl DirectBufferBudget {
|
||||
/// Creates an envelope with a fixed absolute ceiling.
|
||||
pub(crate) fn new(hard_limit_bytes: usize) -> Arc<Self> {
|
||||
let hard_limit_bytes = align_down(hard_limit_bytes.max(DIRECT_BUFFER_UNIT_BYTES)) as u64;
|
||||
let (pressure_tx, _) = watch::channel(0);
|
||||
Arc::new(Self {
|
||||
hard_limit_bytes,
|
||||
target_bytes: AtomicU64::new(hard_limit_bytes),
|
||||
reserved_bytes: AtomicU64::new(0),
|
||||
pressure_generation: AtomicU64::new(0),
|
||||
pressure_tx,
|
||||
memory_total_bytes: AtomicU64::new(0),
|
||||
memory_available_bytes: AtomicU64::new(0),
|
||||
process_rss_bytes: AtomicU64::new(0),
|
||||
promotion_total: AtomicU64::new(0),
|
||||
promotion_denied_total: AtomicU64::new(0),
|
||||
minimum_fallback_total: AtomicU64::new(0),
|
||||
admission_rejected_total: AtomicU64::new(0),
|
||||
quiet_demotion_total: AtomicU64::new(0),
|
||||
write_pressure_demotion_total: AtomicU64::new(0),
|
||||
global_pressure_demotion_total: AtomicU64::new(0),
|
||||
tier_sessions: std::array::from_fn(|_| AtomicU64::new(0)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the current pressure-adjusted reservation target.
|
||||
pub(crate) fn target_bytes(&self) -> usize {
|
||||
self.target_bytes.load(Ordering::Relaxed) as usize
|
||||
}
|
||||
|
||||
/// Subscribes to target reductions that require prompt session demotion.
|
||||
pub(crate) fn subscribe_pressure(&self) -> watch::Receiver<u64> {
|
||||
self.pressure_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Reserves bytes against either the adaptive target or the absolute ceiling.
|
||||
pub(crate) fn try_reserve(
|
||||
self: &Arc<Self>,
|
||||
bytes: usize,
|
||||
allow_above_target: bool,
|
||||
) -> Option<DirectBufferLease> {
|
||||
let bytes = align_up(bytes) as u64;
|
||||
let limit = if allow_above_target {
|
||||
self.hard_limit_bytes
|
||||
} else {
|
||||
self.target_bytes
|
||||
.load(Ordering::Relaxed)
|
||||
.min(self.hard_limit_bytes)
|
||||
};
|
||||
if !self.try_add_reserved(bytes, limit) {
|
||||
return None;
|
||||
}
|
||||
self.tier_sessions[0].fetch_add(1, Ordering::Relaxed);
|
||||
Some(DirectBufferLease {
|
||||
budget: Arc::clone(self),
|
||||
reserved_bytes: bytes,
|
||||
tier: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_add_reserved(&self, bytes: u64, limit: u64) -> bool {
|
||||
let mut current = self.reserved_bytes.load(Ordering::Acquire);
|
||||
loop {
|
||||
if bytes > limit.saturating_sub(current) {
|
||||
return false;
|
||||
}
|
||||
match self.reserved_bytes.compare_exchange_weak(
|
||||
current,
|
||||
current + bytes,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => return true,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn target_floor_bytes(&self) -> u64 {
|
||||
(self.hard_limit_bytes / 8)
|
||||
.max(TARGET_FLOOR_MIN_BYTES as u64)
|
||||
.min(self.hard_limit_bytes)
|
||||
}
|
||||
|
||||
fn set_target_bytes(&self, target: u64) {
|
||||
let target =
|
||||
align_down(target.clamp(self.target_floor_bytes(), self.hard_limit_bytes) as usize)
|
||||
as u64;
|
||||
let previous = self.target_bytes.swap(target, Ordering::AcqRel);
|
||||
if target < previous {
|
||||
let generation = self
|
||||
.pressure_generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
self.pressure_tx.send_replace(generation);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_system_sample(&self, sample: SystemMemorySample) {
|
||||
self.memory_total_bytes
|
||||
.store(sample.total_bytes, Ordering::Relaxed);
|
||||
self.memory_available_bytes
|
||||
.store(sample.available_bytes, Ordering::Relaxed);
|
||||
self.process_rss_bytes
|
||||
.store(sample.process_rss_bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a session that had to bypass the adaptive target at minimum size.
|
||||
pub(crate) fn increment_minimum_fallback(&self) {
|
||||
self.minimum_fallback_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a session rejected because the absolute ceiling was exhausted.
|
||||
pub(crate) fn increment_admission_rejected(&self) {
|
||||
self.admission_rejected_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a tier reduction after sustained low throughput.
|
||||
pub(crate) fn increment_quiet_demotion(&self) {
|
||||
self.quiet_demotion_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a tier reduction after sustained partial or pending writes.
|
||||
pub(crate) fn increment_write_pressure_demotion(&self) {
|
||||
self.write_pressure_demotion_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a tier reduction requested by the process-wide controller.
|
||||
pub(crate) fn increment_global_pressure_demotion(&self) {
|
||||
self.global_pressure_demotion_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Captures all bounded metrics without allocating or locking.
|
||||
pub(crate) fn snapshot(&self) -> DirectBufferBudgetSnapshot {
|
||||
DirectBufferBudgetSnapshot {
|
||||
hard_limit_bytes: self.hard_limit_bytes,
|
||||
target_bytes: self.target_bytes.load(Ordering::Relaxed),
|
||||
reserved_bytes: self.reserved_bytes.load(Ordering::Relaxed),
|
||||
memory_total_bytes: self.memory_total_bytes.load(Ordering::Relaxed),
|
||||
memory_available_bytes: self.memory_available_bytes.load(Ordering::Relaxed),
|
||||
process_rss_bytes: self.process_rss_bytes.load(Ordering::Relaxed),
|
||||
promotion_total: self.promotion_total.load(Ordering::Relaxed),
|
||||
promotion_denied_total: self.promotion_denied_total.load(Ordering::Relaxed),
|
||||
minimum_fallback_total: self.minimum_fallback_total.load(Ordering::Relaxed),
|
||||
admission_rejected_total: self.admission_rejected_total.load(Ordering::Relaxed),
|
||||
quiet_demotion_total: self.quiet_demotion_total.load(Ordering::Relaxed),
|
||||
write_pressure_demotion_total: self
|
||||
.write_pressure_demotion_total
|
||||
.load(Ordering::Relaxed),
|
||||
global_pressure_demotion_total: self
|
||||
.global_pressure_demotion_total
|
||||
.load(Ordering::Relaxed),
|
||||
tier_sessions: std::array::from_fn(|index| {
|
||||
self.tier_sessions[index].load(Ordering::Relaxed)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conservative ceiling used when memory discovery is unavailable.
|
||||
pub(crate) fn fallback_direct_buffer_hard_limit() -> usize {
|
||||
AUTO_HARD_FALLBACK_BYTES
|
||||
}
|
||||
|
||||
/// RAII ownership of all copy-buffer bytes retained by one Direct session.
|
||||
pub(crate) struct DirectBufferLease {
|
||||
budget: Arc<DirectBufferBudget>,
|
||||
reserved_bytes: u64,
|
||||
tier: usize,
|
||||
}
|
||||
|
||||
impl DirectBufferLease {
|
||||
/// Returns the currently covered allocation rounded to accounting units.
|
||||
pub(crate) fn reserved_bytes(&self) -> usize {
|
||||
self.reserved_bytes as usize
|
||||
}
|
||||
|
||||
/// Attempts to cover a larger tier before its buffers are resized.
|
||||
pub(crate) fn try_grow_to(&mut self, bytes: usize) -> bool {
|
||||
let bytes = align_up(bytes) as u64;
|
||||
if bytes <= self.reserved_bytes {
|
||||
return true;
|
||||
}
|
||||
let delta = bytes - self.reserved_bytes;
|
||||
let limit = self
|
||||
.budget
|
||||
.target_bytes
|
||||
.load(Ordering::Relaxed)
|
||||
.min(self.budget.hard_limit_bytes);
|
||||
if !self.budget.try_add_reserved(delta, limit) {
|
||||
self.budget
|
||||
.promotion_denied_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
self.reserved_bytes = bytes;
|
||||
self.budget.promotion_total.fetch_add(1, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
|
||||
/// Releases bytes only after both directional buffers report smaller coverage.
|
||||
pub(crate) fn shrink_to(&mut self, bytes: usize) {
|
||||
let bytes = align_up(bytes) as u64;
|
||||
if bytes >= self.reserved_bytes {
|
||||
return;
|
||||
}
|
||||
let released = self.reserved_bytes - bytes;
|
||||
self.reserved_bytes = bytes;
|
||||
self.budget
|
||||
.reserved_bytes
|
||||
.fetch_sub(released, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Updates bounded per-tier session gauges for an accepted transition.
|
||||
pub(crate) fn set_tier(&mut self, tier: usize) {
|
||||
let tier = tier.min(self.budget.tier_sessions.len() - 1);
|
||||
if tier == self.tier {
|
||||
return;
|
||||
}
|
||||
decrement_saturating(&self.budget.tier_sessions[self.tier]);
|
||||
self.budget.tier_sessions[tier].fetch_add(1, Ordering::Relaxed);
|
||||
self.tier = tier;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DirectBufferLease {
|
||||
fn drop(&mut self) {
|
||||
self.budget
|
||||
.reserved_bytes
|
||||
.fetch_sub(self.reserved_bytes, Ordering::AcqRel);
|
||||
decrement_saturating(&self.budget.tier_sessions[self.tier]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the startup hard ceiling from config, cgroup, and host memory.
|
||||
pub(crate) async fn resolve_direct_buffer_hard_limit(configured: usize) -> usize {
|
||||
if configured != 0 {
|
||||
return align_down(configured);
|
||||
}
|
||||
let sample = read_system_memory_sample().await;
|
||||
if sample.total_bytes == 0 {
|
||||
return AUTO_HARD_FALLBACK_BYTES;
|
||||
}
|
||||
let derived = (sample.total_bytes / 4)
|
||||
.clamp(AUTO_HARD_MIN_BYTES as u64, AUTO_HARD_MAX_BYTES as u64)
|
||||
.min(sample.total_bytes);
|
||||
align_down(derived as usize).max(DIRECT_BUFFER_UNIT_BYTES)
|
||||
}
|
||||
|
||||
/// Starts the single control-plane task for Direct budget and shared pool pressure.
|
||||
pub(crate) fn spawn_direct_buffer_budget_controller(
|
||||
budget: Arc<DirectBufferBudget>,
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
stats: Arc<Stats>,
|
||||
shared: Arc<ProxySharedState>,
|
||||
max_connections: u32,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(CONTROL_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut healthy_streak = 0u8;
|
||||
let mut previous_denied = 0u64;
|
||||
let mut previous_fallback = 0u64;
|
||||
let mut previous_rejected = 0u64;
|
||||
let pool_trim_low = buffer_pool
|
||||
.max_buffers()
|
||||
.min(BUFFER_POOL_TRIM_LOW_WATERMARK);
|
||||
let pool_trim_high = buffer_pool
|
||||
.max_buffers()
|
||||
.min(BUFFER_POOL_TRIM_HIGH_WATERMARK);
|
||||
let mut pool_trim_armed = true;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let sample = read_system_memory_sample().await;
|
||||
budget.update_system_sample(sample);
|
||||
|
||||
let snapshot = budget.snapshot();
|
||||
let denied_delta = snapshot
|
||||
.promotion_denied_total
|
||||
.saturating_sub(previous_denied);
|
||||
previous_denied = snapshot.promotion_denied_total;
|
||||
let fallback_delta = snapshot
|
||||
.minimum_fallback_total
|
||||
.saturating_sub(previous_fallback);
|
||||
previous_fallback = snapshot.minimum_fallback_total;
|
||||
let rejected_delta = snapshot
|
||||
.admission_rejected_total
|
||||
.saturating_sub(previous_rejected);
|
||||
previous_rejected = snapshot.admission_rejected_total;
|
||||
|
||||
let connection_pct = connection_fill_pct(stats.as_ref(), max_connections);
|
||||
let memory_available_pct = percentage(sample.available_bytes, sample.total_bytes);
|
||||
let target_utilization_pct = percentage(snapshot.reserved_bytes, snapshot.target_bytes);
|
||||
let pressure = shared.conntrack_pressure_active()
|
||||
|| connection_pct.is_some_and(|value| value >= 85)
|
||||
|| memory_available_pct.is_some_and(|value| value <= 15)
|
||||
|| target_utilization_pct.is_some_and(|value| value >= 90)
|
||||
|| denied_delta > 0
|
||||
|| fallback_delta > 0
|
||||
|| rejected_delta > 0;
|
||||
|
||||
if !pressure {
|
||||
pool_trim_armed = true;
|
||||
} else if pool_trim_armed && buffer_pool.pooled() > pool_trim_high {
|
||||
buffer_pool.trim_to(pool_trim_low);
|
||||
pool_trim_armed = false;
|
||||
}
|
||||
|
||||
let pool_snapshot = buffer_pool.stats();
|
||||
stats.set_buffer_pool_gauges(
|
||||
pool_snapshot.pooled,
|
||||
pool_snapshot.allocated,
|
||||
pool_snapshot.allocated.saturating_sub(pool_snapshot.pooled),
|
||||
);
|
||||
stats.set_buffer_pool_replaced_nonstandard_total(pool_snapshot.replaced_nonstandard);
|
||||
|
||||
let headroom_target = if sample.total_bytes == 0 {
|
||||
snapshot.hard_limit_bytes
|
||||
} else {
|
||||
snapshot
|
||||
.reserved_bytes
|
||||
.saturating_add(sample.available_bytes / 4)
|
||||
.min(snapshot.hard_limit_bytes)
|
||||
};
|
||||
|
||||
if pressure {
|
||||
healthy_streak = 0;
|
||||
let reduced = snapshot.target_bytes.saturating_mul(3) / 4;
|
||||
budget.set_target_bytes(reduced.min(headroom_target));
|
||||
continue;
|
||||
}
|
||||
|
||||
let healthy = memory_available_pct.is_none_or(|value| value >= 30)
|
||||
&& connection_pct.is_none_or(|value| value <= 70);
|
||||
if !healthy {
|
||||
healthy_streak = 0;
|
||||
if headroom_target < snapshot.target_bytes {
|
||||
budget.set_target_bytes(headroom_target);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
healthy_streak = healthy_streak.saturating_add(1);
|
||||
if healthy_streak >= HEALTHY_RECOVERY_SAMPLES {
|
||||
healthy_streak = 0;
|
||||
let increment = (snapshot.target_bytes / 16).max(4 * 1024 * 1024);
|
||||
budget.set_target_bytes(
|
||||
snapshot
|
||||
.target_bytes
|
||||
.saturating_add(increment)
|
||||
.min(headroom_target),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn connection_fill_pct(stats: &Stats, max_connections: u32) -> Option<u8> {
|
||||
if max_connections == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
((stats.get_current_connections_total().saturating_mul(100)) / u64::from(max_connections))
|
||||
.min(100) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
fn percentage(value: u64, total: u64) -> Option<u8> {
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(((value.saturating_mul(100)) / total).min(100) as u8)
|
||||
}
|
||||
|
||||
async fn read_system_memory_sample() -> SystemMemorySample {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let meminfo = tokio::fs::read_to_string("/proc/meminfo")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let status = tokio::fs::read_to_string("/proc/self/status")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let host_total = parse_kib_field(&meminfo, "MemTotal:");
|
||||
let host_available = parse_kib_field(&meminfo, "MemAvailable:");
|
||||
let process_rss = parse_kib_field(&status, "VmRSS:");
|
||||
|
||||
let cgroup_v2_max = read_cgroup_limit("/sys/fs/cgroup/memory.max").await;
|
||||
let cgroup_v2_current = read_u64_file("/sys/fs/cgroup/memory.current").await;
|
||||
let cgroup_v1_max = read_cgroup_limit("/sys/fs/cgroup/memory/memory.limit_in_bytes").await;
|
||||
let cgroup_v1_current = read_u64_file("/sys/fs/cgroup/memory/memory.usage_in_bytes").await;
|
||||
let cgroup_max = cgroup_v2_max.or(cgroup_v1_max);
|
||||
let cgroup_current = cgroup_v2_current.or(cgroup_v1_current);
|
||||
|
||||
let total = match (host_total, cgroup_max) {
|
||||
(0, Some(limit)) => limit,
|
||||
(host, Some(limit)) => host.min(limit),
|
||||
(host, None) => host,
|
||||
};
|
||||
let cgroup_available = cgroup_max
|
||||
.zip(cgroup_current)
|
||||
.map(|(limit, current)| limit.saturating_sub(current));
|
||||
let available = match (host_available, cgroup_available) {
|
||||
(0, Some(value)) => value,
|
||||
(host, Some(value)) => host.min(value),
|
||||
(host, None) => host,
|
||||
};
|
||||
return SystemMemorySample {
|
||||
total_bytes: total,
|
||||
available_bytes: available,
|
||||
process_rss_bytes: process_rss,
|
||||
};
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
SystemMemorySample::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn read_cgroup_limit(path: &str) -> Option<u64> {
|
||||
let raw = tokio::fs::read_to_string(path).await.ok()?;
|
||||
let raw = raw.trim();
|
||||
if raw == "max" {
|
||||
return None;
|
||||
}
|
||||
let value = raw.parse::<u64>().ok()?;
|
||||
(value < (1u64 << 60)).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn read_u64_file(path: &str) -> Option<u64> {
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_kib_field(raw: &str, key: &str) -> u64 {
|
||||
raw.lines()
|
||||
.find_map(|line| {
|
||||
let value = line.strip_prefix(key)?.split_whitespace().next()?;
|
||||
value.parse::<u64>().ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
.saturating_mul(1024)
|
||||
}
|
||||
|
||||
fn align_up(bytes: usize) -> usize {
|
||||
bytes
|
||||
.div_ceil(DIRECT_BUFFER_UNIT_BYTES)
|
||||
.saturating_mul(DIRECT_BUFFER_UNIT_BYTES)
|
||||
}
|
||||
|
||||
fn align_down(bytes: usize) -> usize {
|
||||
bytes / DIRECT_BUFFER_UNIT_BYTES * DIRECT_BUFFER_UNIT_BYTES
|
||||
}
|
||||
|
||||
fn decrement_saturating(value: &AtomicU64) {
|
||||
let mut current = value.load(Ordering::Relaxed);
|
||||
while current != 0 {
|
||||
match value.compare_exchange_weak(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/direct_buffer_budget_tests.rs"]
|
||||
mod tests;
|
||||
+16
-17
@@ -345,22 +345,22 @@ where
|
||||
} else {
|
||||
Duration::from_secs(1800)
|
||||
};
|
||||
let relay_result =
|
||||
crate::proxy::relay::relay_bidirectional_with_activity_timeout_lease_and_cancel(
|
||||
client_reader,
|
||||
client_writer,
|
||||
tg_reader,
|
||||
tg_writer,
|
||||
config.general.direct_relay_copy_buf_c2s_bytes,
|
||||
config.general.direct_relay_copy_buf_s2c_bytes,
|
||||
user,
|
||||
Arc::clone(&stats),
|
||||
config.access.user_data_quota.get(user).copied(),
|
||||
buffer_pool,
|
||||
traffic_lease,
|
||||
relay_activity_timeout,
|
||||
session_cancel.clone(),
|
||||
);
|
||||
let relay_result = crate::proxy::relay::relay_direct_adaptive(
|
||||
client_reader,
|
||||
client_writer,
|
||||
tg_reader,
|
||||
tg_writer,
|
||||
config.general.direct_relay_copy_buf_c2s_bytes,
|
||||
config.general.direct_relay_copy_buf_s2c_bytes,
|
||||
config.server.max_connections,
|
||||
user,
|
||||
Arc::clone(&stats),
|
||||
config.access.user_data_quota.get(user).copied(),
|
||||
traffic_lease,
|
||||
relay_activity_timeout,
|
||||
session_cancel.clone(),
|
||||
Arc::clone(&shared.direct_buffer_budget),
|
||||
);
|
||||
tokio::pin!(relay_result);
|
||||
let relay_result = loop {
|
||||
if let Some(cutover) =
|
||||
@@ -400,7 +400,6 @@ where
|
||||
Err(e) => debug!(user = %user, error = %e, "Direct relay ended with error"),
|
||||
}
|
||||
|
||||
buffer_pool_trim.trim_to(buffer_pool_trim.max_buffers().min(64));
|
||||
let pool_snapshot = buffer_pool_trim.stats();
|
||||
stats.set_buffer_pool_gauges(
|
||||
pool_snapshot.pooled,
|
||||
|
||||
+14
-121
@@ -4,7 +4,6 @@
|
||||
|
||||
use dashmap::DashMap;
|
||||
use dashmap::mapref::entry::Entry;
|
||||
use hmac::{Hmac, Mac};
|
||||
#[cfg(test)]
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
@@ -33,8 +32,10 @@ use crate::stream::{CryptoReader, CryptoWriter, FakeTlsReader, FakeTlsWriter};
|
||||
use crate::tls_front::{TlsFrontCache, emulator};
|
||||
#[cfg(test)]
|
||||
use rand::RngExt;
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
mod tls_auth;
|
||||
|
||||
use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate};
|
||||
|
||||
const ACCESS_SECRET_BYTES: usize = 16;
|
||||
const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5;
|
||||
@@ -58,8 +59,6 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8;
|
||||
const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64;
|
||||
const RECENT_USER_RING_SCAN_LIMIT: usize = 32;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[cfg(test)]
|
||||
const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1;
|
||||
#[cfg(not(test))]
|
||||
@@ -104,23 +103,6 @@ fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> b
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ParsedTlsAuthMaterial {
|
||||
digest: [u8; tls::TLS_DIGEST_LEN],
|
||||
session_id: [u8; 32],
|
||||
session_id_len: usize,
|
||||
now: i64,
|
||||
ignore_time_skew: bool,
|
||||
boot_time_cap_secs: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TlsCandidateValidation {
|
||||
digest: [u8; tls::TLS_DIGEST_LEN],
|
||||
session_id: [u8; 32],
|
||||
session_id_len: usize,
|
||||
}
|
||||
|
||||
struct MtprotoCandidateValidation {
|
||||
proto_tag: ProtoTag,
|
||||
dc_idx: i16,
|
||||
@@ -251,104 +233,6 @@ fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) ->
|
||||
total_users.min(cap.max(1))
|
||||
}
|
||||
|
||||
fn parse_tls_auth_material(
|
||||
handshake: &[u8],
|
||||
ignore_time_skew: bool,
|
||||
replay_window_secs: u64,
|
||||
) -> Option<ParsedTlsAuthMaterial> {
|
||||
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let digest: [u8; tls::TLS_DIGEST_LEN] = handshake
|
||||
[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||
.try_into()
|
||||
.ok()?;
|
||||
|
||||
let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN;
|
||||
let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?);
|
||||
if session_id_len > 32 {
|
||||
return None;
|
||||
}
|
||||
let session_id_start = session_id_len_pos + 1;
|
||||
if handshake.len() < session_id_start + session_id_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut session_id = [0u8; 32];
|
||||
session_id[..session_id_len]
|
||||
.copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]);
|
||||
|
||||
let now = if !ignore_time_skew {
|
||||
let d = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?;
|
||||
i64::try_from(d.as_secs()).ok()?
|
||||
} else {
|
||||
0_i64
|
||||
};
|
||||
|
||||
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
|
||||
let boot_time_cap_secs = if ignore_time_skew {
|
||||
0
|
||||
} else {
|
||||
tls::BOOT_TIME_MAX_SECS
|
||||
.min(replay_window_u32)
|
||||
.min(tls::BOOT_TIME_COMPAT_MAX_SECS)
|
||||
};
|
||||
|
||||
Some(ParsedTlsAuthMaterial {
|
||||
digest,
|
||||
session_id,
|
||||
session_id_len,
|
||||
now,
|
||||
ignore_time_skew,
|
||||
boot_time_cap_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> [u8; 32] {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
|
||||
mac.update(&handshake[..tls::TLS_DIGEST_POS]);
|
||||
mac.update(&[0u8; tls::TLS_DIGEST_LEN]);
|
||||
mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]);
|
||||
mac.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
fn validate_tls_secret_candidate(
|
||||
parsed: &ParsedTlsAuthMaterial,
|
||||
handshake: &[u8],
|
||||
secret: &[u8],
|
||||
) -> Option<TlsCandidateValidation> {
|
||||
let computed = compute_tls_hmac_zeroed_digest(secret, handshake);
|
||||
if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let timestamp = u32::from_le_bytes([
|
||||
parsed.digest[28] ^ computed[28],
|
||||
parsed.digest[29] ^ computed[29],
|
||||
parsed.digest[30] ^ computed[30],
|
||||
parsed.digest[31] ^ computed[31],
|
||||
]);
|
||||
|
||||
if !parsed.ignore_time_skew {
|
||||
let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs;
|
||||
if !is_boot_time {
|
||||
let time_diff = parsed.now - i64::from(timestamp);
|
||||
if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(TlsCandidateValidation {
|
||||
digest: parsed.digest,
|
||||
session_id: parsed.session_id,
|
||||
session_id_len: parsed.session_id_len,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_mtproto_secret_candidate(
|
||||
handshake: &[u8; HANDSHAKE_LEN],
|
||||
dec_prekey: &[u8; PREKEY_LEN],
|
||||
@@ -1857,7 +1741,16 @@ where
|
||||
return HandshakeResult::BadClient { reader, writer };
|
||||
}
|
||||
|
||||
let validation = matched_validation.expect("validation must exist when matched");
|
||||
let Some(validation) = matched_validation else {
|
||||
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||
maybe_apply_server_hello_delay(config).await;
|
||||
warn!(
|
||||
peer = %peer,
|
||||
user = %matched_user,
|
||||
"MTProto handshake matched user without validation material"
|
||||
);
|
||||
return HandshakeResult::BadClient { reader, writer };
|
||||
};
|
||||
|
||||
if config
|
||||
.access
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::protocol::tls;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Parsed TLS authentication material extracted from a ClientHello candidate.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ParsedTlsAuthMaterial {
|
||||
digest: [u8; tls::TLS_DIGEST_LEN],
|
||||
session_id: [u8; 32],
|
||||
session_id_len: usize,
|
||||
now: i64,
|
||||
ignore_time_skew: bool,
|
||||
boot_time_cap_secs: u32,
|
||||
}
|
||||
|
||||
/// Successful TLS secret validation output used by the handshake state machine.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct TlsCandidateValidation {
|
||||
pub(super) digest: [u8; tls::TLS_DIGEST_LEN],
|
||||
pub(super) session_id: [u8; 32],
|
||||
pub(super) session_id_len: usize,
|
||||
}
|
||||
|
||||
/// Parse TLS auth digest and session-id material from a candidate handshake.
|
||||
pub(super) fn parse_tls_auth_material(
|
||||
handshake: &[u8],
|
||||
ignore_time_skew: bool,
|
||||
replay_window_secs: u64,
|
||||
) -> Option<ParsedTlsAuthMaterial> {
|
||||
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let digest: [u8; tls::TLS_DIGEST_LEN] = handshake
|
||||
[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||
.try_into()
|
||||
.ok()?;
|
||||
|
||||
let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN;
|
||||
let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?);
|
||||
if session_id_len > 32 {
|
||||
return None;
|
||||
}
|
||||
let session_id_start = session_id_len_pos + 1;
|
||||
if handshake.len() < session_id_start + session_id_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut session_id = [0u8; 32];
|
||||
session_id[..session_id_len]
|
||||
.copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]);
|
||||
|
||||
let now = if !ignore_time_skew {
|
||||
let d = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?;
|
||||
i64::try_from(d.as_secs()).ok()?
|
||||
} else {
|
||||
0_i64
|
||||
};
|
||||
|
||||
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
|
||||
let boot_time_cap_secs = if ignore_time_skew {
|
||||
0
|
||||
} else {
|
||||
tls::BOOT_TIME_MAX_SECS
|
||||
.min(replay_window_u32)
|
||||
.min(tls::BOOT_TIME_COMPAT_MAX_SECS)
|
||||
};
|
||||
|
||||
Some(ParsedTlsAuthMaterial {
|
||||
digest,
|
||||
session_id,
|
||||
session_id_len,
|
||||
now,
|
||||
ignore_time_skew,
|
||||
boot_time_cap_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> Option<[u8; 32]> {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).ok()?;
|
||||
mac.update(&handshake[..tls::TLS_DIGEST_POS]);
|
||||
mac.update(&[0u8; tls::TLS_DIGEST_LEN]);
|
||||
mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]);
|
||||
Some(mac.finalize().into_bytes().into())
|
||||
}
|
||||
|
||||
/// Validate a candidate secret against parsed TLS authentication material.
|
||||
pub(super) fn validate_tls_secret_candidate(
|
||||
parsed: &ParsedTlsAuthMaterial,
|
||||
handshake: &[u8],
|
||||
secret: &[u8],
|
||||
) -> Option<TlsCandidateValidation> {
|
||||
let computed = compute_tls_hmac_zeroed_digest(secret, handshake)?;
|
||||
if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let timestamp = u32::from_le_bytes([
|
||||
parsed.digest[28] ^ computed[28],
|
||||
parsed.digest[29] ^ computed[29],
|
||||
parsed.digest[30] ^ computed[30],
|
||||
parsed.digest[31] ^ computed[31],
|
||||
]);
|
||||
|
||||
if !parsed.ignore_time_skew {
|
||||
let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs;
|
||||
if !is_boot_time {
|
||||
let time_diff = parsed.now - i64::from(timestamp);
|
||||
if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(TlsCandidateValidation {
|
||||
digest: parsed.digest,
|
||||
session_id: parsed.session_id,
|
||||
session_id_len: parsed.session_id_len,
|
||||
})
|
||||
}
|
||||
+181
-73
@@ -3,12 +3,15 @@
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::network::dns_overrides::resolve_socket_addr;
|
||||
use crate::protocol::tls;
|
||||
use crate::proxy::shared_state::ProxySharedState;
|
||||
use crate::stats::beobachten::BeobachtenStore;
|
||||
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
|
||||
use crate::transport::socket::configure_tcp_socket;
|
||||
#[cfg(unix)]
|
||||
use nix::ifaddrs::getifaddrs;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, RngExt, SeedableRng};
|
||||
use std::io::{Error as IoError, ErrorKind};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::str;
|
||||
#[cfg(test)]
|
||||
@@ -17,9 +20,9 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant as StdInstant};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::net::{TcpStream, lookup_host};
|
||||
#[cfg(unix)]
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::time::{Instant, timeout};
|
||||
@@ -36,6 +39,8 @@ const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200);
|
||||
#[cfg(test)]
|
||||
const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const MASK_BUFFER_SIZE: usize = 8192;
|
||||
const MASK_BUFFER_GROW_AFTER_BYTES: usize = 256 * 1024;
|
||||
const MASK_BUFFER_MAX_SIZE: usize = 64 * 1024;
|
||||
#[cfg(unix)]
|
||||
#[cfg(not(test))]
|
||||
const LOCAL_INTERFACE_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
@@ -53,6 +58,27 @@ struct MaskTcpTarget<'a> {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn mask_copy_read_len(total: usize, byte_cap: usize) -> usize {
|
||||
// Keep short scanner probes on the small baseline buffer and grow only
|
||||
// after the session has proven to be sustained masking relay traffic.
|
||||
let active_buffer_size = if total >= MASK_BUFFER_GROW_AFTER_BYTES {
|
||||
MASK_BUFFER_MAX_SIZE
|
||||
} else {
|
||||
MASK_BUFFER_SIZE
|
||||
};
|
||||
|
||||
if byte_cap == 0 {
|
||||
return active_buffer_size;
|
||||
}
|
||||
|
||||
let remaining_budget = byte_cap.saturating_sub(total);
|
||||
if remaining_budget == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
remaining_budget.min(active_buffer_size)
|
||||
}
|
||||
|
||||
async fn copy_with_idle_timeout<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
@@ -64,21 +90,18 @@ where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]);
|
||||
let mut buf = vec![0u8; MASK_BUFFER_SIZE];
|
||||
let mut total = 0usize;
|
||||
let mut ended_by_eof = false;
|
||||
let unlimited = byte_cap == 0;
|
||||
|
||||
loop {
|
||||
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_len = mask_copy_read_len(total, byte_cap);
|
||||
if read_len == 0 {
|
||||
break;
|
||||
}
|
||||
if buf.len() < read_len {
|
||||
buf.resize(read_len, 0);
|
||||
}
|
||||
let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await;
|
||||
let n = match read_res {
|
||||
Ok(Ok(n)) => n,
|
||||
@@ -250,6 +273,32 @@ async fn consume_client_data_with_timeout_and_cap<R>(
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_failure_drain_cap(config: &ProxyConfig) -> usize {
|
||||
let configured_cap = config.censorship.mask_relay_max_bytes;
|
||||
if configured_cap == 0 {
|
||||
return MASK_BUFFER_SIZE;
|
||||
}
|
||||
|
||||
configured_cap.min(MASK_BUFFER_SIZE)
|
||||
}
|
||||
|
||||
async fn consume_mask_failure_path<R>(
|
||||
reader: R,
|
||||
config: &ProxyConfig,
|
||||
relay_timeout: Duration,
|
||||
idle_timeout: Duration,
|
||||
) where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
mask_failure_drain_cap(config),
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_mask_connect_budget(started: Instant) {
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed < MASK_TIMEOUT {
|
||||
@@ -480,6 +529,32 @@ fn parse_mask_host_ip_literal(host: &str) -> Option<IpAddr> {
|
||||
host.parse::<IpAddr>().ok()
|
||||
}
|
||||
|
||||
async fn resolve_mask_target_addrs(
|
||||
mask_host: &str,
|
||||
mask_port: u16,
|
||||
) -> std::io::Result<Vec<SocketAddr>> {
|
||||
if let Some(addr) = resolve_socket_addr(mask_host, mask_port) {
|
||||
return Ok(vec![addr]);
|
||||
}
|
||||
|
||||
if let Some(ip) = parse_mask_host_ip_literal(mask_host) {
|
||||
return Ok(vec![SocketAddr::new(ip, mask_port)]);
|
||||
}
|
||||
|
||||
let addrs = timeout(MASK_TIMEOUT, lookup_host((mask_host, mask_port)))
|
||||
.await
|
||||
.map_err(|_| IoError::new(ErrorKind::TimedOut, "mask target DNS lookup timed out"))??;
|
||||
let addrs = addrs.collect::<Vec<_>>();
|
||||
if addrs.is_empty() {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::NotFound,
|
||||
"mask target DNS lookup returned no addresses",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> {
|
||||
if config.censorship.tls_domain.eq_ignore_ascii_case(sni) {
|
||||
return Some(config.censorship.tls_domain.as_str());
|
||||
@@ -761,7 +836,7 @@ fn is_mask_target_local_listener_with_interfaces(
|
||||
mask_host: &str,
|
||||
mask_port: u16,
|
||||
local_addr: SocketAddr,
|
||||
resolved_override: Option<SocketAddr>,
|
||||
resolved_addrs: &[SocketAddr],
|
||||
interface_ips: &[IpAddr],
|
||||
) -> bool {
|
||||
if mask_port != local_addr.port() {
|
||||
@@ -771,7 +846,7 @@ fn is_mask_target_local_listener_with_interfaces(
|
||||
let local_ip = canonical_ip(local_addr.ip());
|
||||
let literal_mask_ip = parse_mask_host_ip_literal(mask_host).map(canonical_ip);
|
||||
|
||||
if let Some(addr) = resolved_override {
|
||||
for addr in resolved_addrs {
|
||||
let resolved_ip = canonical_ip(addr.ip());
|
||||
if resolved_ip == local_ip {
|
||||
return true;
|
||||
@@ -808,7 +883,7 @@ fn is_mask_target_local_listener(
|
||||
mask_host: &str,
|
||||
mask_port: u16,
|
||||
local_addr: SocketAddr,
|
||||
resolved_override: Option<SocketAddr>,
|
||||
resolved_addrs: &[SocketAddr],
|
||||
) -> bool {
|
||||
if mask_port != local_addr.port() {
|
||||
return false;
|
||||
@@ -819,7 +894,7 @@ fn is_mask_target_local_listener(
|
||||
mask_host,
|
||||
mask_port,
|
||||
local_addr,
|
||||
resolved_override,
|
||||
resolved_addrs,
|
||||
&interfaces,
|
||||
)
|
||||
}
|
||||
@@ -828,7 +903,7 @@ async fn is_mask_target_local_listener_async(
|
||||
mask_host: &str,
|
||||
mask_port: u16,
|
||||
local_addr: SocketAddr,
|
||||
resolved_override: Option<SocketAddr>,
|
||||
resolved_addrs: &[SocketAddr],
|
||||
) -> bool {
|
||||
if mask_port != local_addr.port() {
|
||||
return false;
|
||||
@@ -839,7 +914,7 @@ async fn is_mask_target_local_listener_async(
|
||||
mask_host,
|
||||
mask_port,
|
||||
local_addr,
|
||||
resolved_override,
|
||||
resolved_addrs,
|
||||
&interfaces,
|
||||
)
|
||||
}
|
||||
@@ -877,7 +952,13 @@ fn build_mask_proxy_header(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a bad client by forwarding to mask host
|
||||
fn configure_mask_backend_socket(stream: &TcpStream) {
|
||||
if let Err(e) = configure_tcp_socket(stream, false, Duration::from_secs(0)) {
|
||||
debug!(error = %e, "Failed to configure mask backend socket");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a bad client by forwarding it to the configured mask target.
|
||||
pub async fn handle_bad_client<R, W>(
|
||||
reader: R,
|
||||
writer: W,
|
||||
@@ -889,6 +970,34 @@ pub async fn handle_bad_client<R, W>(
|
||||
) where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let shared = ProxySharedState::new();
|
||||
handle_bad_client_with_shared(
|
||||
reader,
|
||||
writer,
|
||||
initial_data,
|
||||
peer,
|
||||
local_addr,
|
||||
config,
|
||||
beobachten,
|
||||
shared.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Handles a bad client with shared pre-auth fallback admission state.
|
||||
pub(crate) async fn handle_bad_client_with_shared<R, W>(
|
||||
reader: R,
|
||||
writer: W,
|
||||
initial_data: &[u8],
|
||||
peer: SocketAddr,
|
||||
local_addr: SocketAddr,
|
||||
config: &ProxyConfig,
|
||||
beobachten: &BeobachtenStore,
|
||||
shared: &ProxySharedState,
|
||||
) where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let client_type = detect_client_type(initial_data);
|
||||
if config.general.beobachten {
|
||||
@@ -911,6 +1020,17 @@ pub async fn handle_bad_client<R, W>(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(_masking_permit) = shared.try_acquire_masking_fallback_permit() else {
|
||||
let outcome_started = Instant::now();
|
||||
debug!(
|
||||
client_type = client_type,
|
||||
"Masking fallback concurrency limit reached"
|
||||
);
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let client_sni = tls::extract_sni_from_client_hello(initial_data);
|
||||
let exclusive_tcp_target = client_sni
|
||||
.as_deref()
|
||||
@@ -973,24 +1093,12 @@ pub async fn handle_bad_client<R, W>(
|
||||
Ok(Err(e)) => {
|
||||
wait_mask_connect_budget_if_needed(connect_started, config).await;
|
||||
debug!(error = %e, "Failed to connect to mask unix socket");
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("Timeout connecting to mask unix socket");
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
}
|
||||
}
|
||||
@@ -1003,11 +1111,27 @@ pub async fn handle_bad_client<R, W>(
|
||||
let mask_host = mask_target.host;
|
||||
let mask_port = mask_target.port;
|
||||
|
||||
let resolved_mask_addrs = match resolve_mask_target_addrs(mask_host, mask_port).await {
|
||||
Ok(addrs) => addrs,
|
||||
Err(e) => {
|
||||
let outcome_started = Instant::now();
|
||||
debug!(
|
||||
client_type = client_type,
|
||||
host = %mask_host,
|
||||
port = mask_port,
|
||||
error = %e,
|
||||
"Failed to resolve mask target"
|
||||
);
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Fail closed when fallback points at our own listener endpoint.
|
||||
// Self-referential masking can create recursive proxy loops under
|
||||
// misconfiguration and leak distinguishable load spikes to adversaries.
|
||||
let resolved_mask_addr = resolve_socket_addr(mask_host, mask_port);
|
||||
if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, resolved_mask_addr)
|
||||
if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, &resolved_mask_addrs)
|
||||
.await
|
||||
{
|
||||
let outcome_started = Instant::now();
|
||||
@@ -1018,13 +1142,7 @@ 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,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
return;
|
||||
}
|
||||
@@ -1039,14 +1157,15 @@ pub async fn handle_bad_client<R, W>(
|
||||
"Forwarding bad client to mask host"
|
||||
);
|
||||
|
||||
// Apply runtime DNS override for mask target when configured.
|
||||
let mask_addr = resolved_mask_addr
|
||||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|| format!("{}:{}", mask_host, mask_port));
|
||||
let connect_started = Instant::now();
|
||||
let connect_result = timeout(MASK_TIMEOUT, TcpStream::connect(&mask_addr)).await;
|
||||
let connect_result = timeout(
|
||||
MASK_TIMEOUT,
|
||||
TcpStream::connect(resolved_mask_addrs.as_slice()),
|
||||
)
|
||||
.await;
|
||||
match connect_result {
|
||||
Ok(Ok(stream)) => {
|
||||
configure_mask_backend_socket(&stream);
|
||||
let proxy_header =
|
||||
build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr);
|
||||
|
||||
@@ -1085,24 +1204,12 @@ pub async fn handle_bad_client<R, W>(
|
||||
Ok(Err(e)) => {
|
||||
wait_mask_connect_budget_if_needed(connect_started, config).await;
|
||||
debug!(error = %e, "Failed to connect to mask host");
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("Timeout connecting to mask host");
|
||||
consume_client_data_with_timeout_and_cap(
|
||||
reader,
|
||||
config.censorship.mask_relay_max_bytes,
|
||||
relay_timeout,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
|
||||
wait_mask_outcome_budget(outcome_started, config).await;
|
||||
}
|
||||
}
|
||||
@@ -1190,20 +1297,17 @@ async fn consume_client_data<R: AsyncRead + Unpin>(
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
// Keep drain path fail-closed under slow-loris stalls.
|
||||
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]);
|
||||
let mut buf = vec![0u8; MASK_BUFFER_SIZE];
|
||||
let mut total = 0usize;
|
||||
let unlimited = byte_cap == 0;
|
||||
|
||||
loop {
|
||||
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_len = mask_copy_read_len(total, byte_cap);
|
||||
if read_len == 0 {
|
||||
break;
|
||||
}
|
||||
if buf.len() < read_len {
|
||||
buf.resize(read_len, 0);
|
||||
}
|
||||
let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await {
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(_)) | Err(_) => break,
|
||||
@@ -1214,7 +1318,7 @@ async fn consume_client_data<R: AsyncRead + Unpin>(
|
||||
}
|
||||
|
||||
total = total.saturating_add(n);
|
||||
if !unlimited && total >= byte_cap {
|
||||
if byte_cap != 0 && total >= byte_cap {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1332,6 +1436,10 @@ mod masking_interface_cache_concurrency_security_tests;
|
||||
#[path = "tests/masking_production_cap_regression_security_tests.rs"]
|
||||
mod masking_production_cap_regression_security_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/masking_relay_manual_perf_tests.rs"]
|
||||
mod masking_relay_manual_perf_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/masking_extended_attack_surface_security_tests.rs"]
|
||||
mod masking_extended_attack_surface_security_tests;
|
||||
|
||||
@@ -52,7 +52,7 @@ use self::c2me::{
|
||||
};
|
||||
use self::d2c::{
|
||||
MeD2cFlushPolicy, MeWriterResponseOutcome, classify_me_d2c_flush_reason,
|
||||
flush_client_or_cancel, observe_me_d2c_flush_event,
|
||||
flush_client_or_cancel, me_d2c_flush_reason_requires_client_flush, observe_me_d2c_flush_event,
|
||||
process_me_writer_response_with_traffic_lease,
|
||||
};
|
||||
use self::desync::{RelayForensicsState, hash_ip_in, report_desync_frame_too_large_in};
|
||||
@@ -69,7 +69,9 @@ use self::quota::{
|
||||
#[cfg(test)]
|
||||
use self::c2me::enqueue_c2me_command;
|
||||
#[cfg(test)]
|
||||
use self::d2c::{compute_intermediate_secure_wire_len, process_me_writer_response};
|
||||
use self::d2c::{
|
||||
compute_intermediate_secure_wire_len, process_me_writer_response, write_client_payload,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::desync::{
|
||||
clear_desync_dedup_for_testing_in_shared, desync_dedup_get_for_testing,
|
||||
@@ -166,3 +168,7 @@ mod middle_relay_atomic_quota_invariant_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "tests/middle_relay_baseline_invariant_tests.rs"]
|
||||
mod middle_relay_baseline_invariant_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/middle_relay_d2c_flush_padding_security_tests.rs"]
|
||||
mod middle_relay_d2c_flush_padding_security_tests;
|
||||
|
||||
@@ -55,6 +55,37 @@ pub(super) fn classify_me_d2c_flush_reason(
|
||||
MeD2cFlushReason::QueueDrain
|
||||
}
|
||||
|
||||
pub(super) fn me_d2c_flush_reason_requires_client_flush(_reason: MeD2cFlushReason) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_flush_reasons_trigger_physical_flush() {
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::QueueDrain
|
||||
));
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::AckImmediate
|
||||
));
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::BatchFrames
|
||||
));
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::BatchBytes
|
||||
));
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::MaxDelay
|
||||
));
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::Close
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_me_d2c_flush_event(
|
||||
stats: &Stats,
|
||||
reason: MeD2cFlushReason,
|
||||
@@ -276,20 +307,13 @@ pub(in crate::proxy::middle_relay) fn compute_intermediate_secure_wire_len(
|
||||
let wire_len = data_len
|
||||
.checked_add(padding_len)
|
||||
.ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?;
|
||||
if wire_len > 0x7fff_ffffusize {
|
||||
return Err(ProxyError::Proxy(format!(
|
||||
"Intermediate/Secure frame too large: {wire_len}"
|
||||
)));
|
||||
}
|
||||
|
||||
let len_val = crate::protocol::framing::encode_intermediate_header(wire_len, quickack)
|
||||
.ok_or_else(|| {
|
||||
ProxyError::Proxy(format!("Intermediate/Secure frame too large: {wire_len}"))
|
||||
})?;
|
||||
let total = 4usize
|
||||
.checked_add(wire_len)
|
||||
.ok_or_else(|| ProxyError::Proxy("Frame buffer size overflow".into()))?;
|
||||
let mut len_val = u32::try_from(wire_len)
|
||||
.map_err(|_| ProxyError::Proxy("Frame length conversion overflow".into()))?;
|
||||
if quickack {
|
||||
len_val |= 0x8000_0000;
|
||||
}
|
||||
Ok((len_val, total))
|
||||
}
|
||||
|
||||
|
||||
@@ -236,12 +236,8 @@ where
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let quickack = (len_buf[3] & 0x80) != 0;
|
||||
(
|
||||
(u32::from_le_bytes(len_buf) & 0x7fff_ffff) as usize,
|
||||
quickack,
|
||||
Some(len_buf),
|
||||
)
|
||||
let header = crate::protocol::framing::parse_intermediate_header(len_buf);
|
||||
(header.wire_len, header.quickack, Some(len_buf))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -331,7 +327,8 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Secure Intermediate: strip validated trailing padding bytes.
|
||||
// Secure Intermediate strips only non-aligned tail padding; full-word
|
||||
// padding is indistinguishable from payload in VersionD framing.
|
||||
if proto_tag == ProtoTag::Secure {
|
||||
payload.truncate(secure_payload_len);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ where
|
||||
peer,
|
||||
translated_local_addr,
|
||||
payload,
|
||||
_permit,
|
||||
flags,
|
||||
effective_tag_array,
|
||||
)
|
||||
@@ -491,12 +492,18 @@ where
|
||||
d2c_flush_policy.max_bytes,
|
||||
max_delay_fired,
|
||||
);
|
||||
let flush_started_at = if stats_clone.telemetry_policy().me_level.allows_debug() {
|
||||
let physical_flush =
|
||||
me_d2c_flush_reason_requires_client_flush(flush_reason);
|
||||
let flush_started_at = if physical_flush
|
||||
&& stats_clone.telemetry_policy().me_level.allows_debug()
|
||||
{
|
||||
Some(Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?;
|
||||
if physical_flush {
|
||||
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?;
|
||||
}
|
||||
let flush_duration_us = flush_started_at.map(|started| {
|
||||
started
|
||||
.elapsed()
|
||||
@@ -816,7 +823,6 @@ where
|
||||
|
||||
clear_relay_idle_candidate_in(shared.as_ref(), conn_id);
|
||||
me_pool.registry().unregister(conn_id).await;
|
||||
buffer_pool.trim_to(buffer_pool.max_buffers().min(64));
|
||||
let pool_snapshot = buffer_pool.stats();
|
||||
stats.set_buffer_pool_gauges(
|
||||
pool_snapshot.pooled,
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
|
||||
pub mod adaptive_buffers;
|
||||
pub mod client;
|
||||
// Process-wide Direct relay copy-buffer ownership and pressure policy.
|
||||
pub(crate) mod direct_buffer_budget;
|
||||
pub mod direct_relay;
|
||||
pub mod handshake;
|
||||
pub mod masking;
|
||||
|
||||
@@ -84,8 +84,11 @@ fn watchdog_delta(current: u64, previous: u64) -> u64 {
|
||||
current.saturating_sub(previous)
|
||||
}
|
||||
|
||||
mod adaptive_copy;
|
||||
mod io;
|
||||
|
||||
pub(crate) use self::adaptive_copy::relay_direct_adaptive;
|
||||
|
||||
use self::io::{CombinedStream, SharedCounters, StatsIo, is_quota_io_error};
|
||||
#[cfg(test)]
|
||||
use self::io::{quota_adaptive_interval_bytes, should_immediate_quota_check};
|
||||
@@ -217,6 +220,7 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn relay_bidirectional_with_activity_timeout_lease_and_cancel<CR, CW, SR, SW>(
|
||||
client_reader: CR,
|
||||
client_writer: CW,
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, copy_buf};
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::proxy::adaptive_buffers::{
|
||||
AdaptiveTier, RelaySignalSample, SessionAdaptiveController, TierTransitionReason,
|
||||
direct_copy_buffers_for_tier_with_ceilings,
|
||||
};
|
||||
use crate::proxy::direct_buffer_budget::{
|
||||
DIRECT_BASE_C2S_BYTES, DIRECT_BASE_S2C_BYTES, DirectBufferBudget, DirectBufferLease,
|
||||
};
|
||||
use crate::proxy::traffic_limiter::TrafficLease;
|
||||
use crate::stats::Stats;
|
||||
|
||||
use super::WATCHDOG_INTERVAL;
|
||||
use super::io::{SharedCounters, StatsIo, is_quota_io_error};
|
||||
use super::watchdog_delta;
|
||||
|
||||
mod write_pressure;
|
||||
|
||||
use self::write_pressure::WritePressureIo;
|
||||
|
||||
struct AdaptiveBufferState {
|
||||
desired_bytes: AtomicUsize,
|
||||
actual_bytes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl AdaptiveBufferState {
|
||||
fn new(bytes: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
desired_bytes: AtomicUsize::new(bytes.max(1)),
|
||||
actual_bytes: AtomicUsize::new(bytes.max(1)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct AdaptiveBufReader<R> {
|
||||
inner: R,
|
||||
buffer: Box<[u8]>,
|
||||
pos: usize,
|
||||
cap: usize,
|
||||
state: Arc<AdaptiveBufferState>,
|
||||
}
|
||||
|
||||
impl<R> AdaptiveBufReader<R> {
|
||||
fn new(inner: R, state: Arc<AdaptiveBufferState>) -> Self {
|
||||
let bytes = state.actual_bytes.load(Ordering::Relaxed).max(1);
|
||||
Self {
|
||||
inner,
|
||||
buffer: vec![0; bytes].into_boxed_slice(),
|
||||
pos: 0,
|
||||
cap: 0,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fn resize_if_drained(&mut self) {
|
||||
if self.pos != self.cap {
|
||||
return;
|
||||
}
|
||||
let desired = self.state.desired_bytes.load(Ordering::Acquire).max(1);
|
||||
if desired == self.buffer.len() {
|
||||
return;
|
||||
}
|
||||
self.buffer = vec![0; desired].into_boxed_slice();
|
||||
self.pos = 0;
|
||||
self.cap = 0;
|
||||
self.state.actual_bytes.store(desired, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for AdaptiveBufReader<R> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
output: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
if this.pos < this.cap {
|
||||
let available = &this.buffer[this.pos..this.cap];
|
||||
let copied = available.len().min(output.remaining());
|
||||
output.put_slice(&available[..copied]);
|
||||
this.pos += copied;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
this.resize_if_drained();
|
||||
Pin::new(&mut this.inner).poll_read(cx, output)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncBufRead for AdaptiveBufReader<R> {
|
||||
fn poll_fill_buf(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<&[u8]>> {
|
||||
let this = self.get_mut();
|
||||
if this.pos < this.cap {
|
||||
return Poll::Ready(Ok(&this.buffer[this.pos..this.cap]));
|
||||
}
|
||||
|
||||
this.resize_if_drained();
|
||||
let mut read_buf = ReadBuf::new(&mut this.buffer);
|
||||
match Pin::new(&mut this.inner).poll_read(cx, &mut read_buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
this.pos = 0;
|
||||
this.cap = read_buf.filled().len();
|
||||
Poll::Ready(Ok(&this.buffer[..this.cap]))
|
||||
}
|
||||
Poll::Ready(Err(error)) => Poll::Ready(Err(error)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn consume(self: Pin<&mut Self>, amount: usize) {
|
||||
let this = self.get_mut();
|
||||
this.pos = this.pos.saturating_add(amount).min(this.cap);
|
||||
}
|
||||
}
|
||||
|
||||
enum AdaptiveRelayOutcome {
|
||||
Copy(io::Result<(u64, u64)>),
|
||||
ActivityTimeout,
|
||||
UserDisabled,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Relays one Direct session with independently resizable directional buffers.
|
||||
pub(crate) async fn relay_direct_adaptive<CR, CW, SR, SW>(
|
||||
client_reader: CR,
|
||||
client_writer: CW,
|
||||
server_reader: SR,
|
||||
server_writer: SW,
|
||||
ceiling_c2s_bytes: usize,
|
||||
ceiling_s2c_bytes: usize,
|
||||
max_connections: u32,
|
||||
user: &str,
|
||||
stats: Arc<Stats>,
|
||||
quota_limit: Option<u64>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
activity_timeout: Duration,
|
||||
session_cancel: CancellationToken,
|
||||
budget: Arc<DirectBufferBudget>,
|
||||
) -> Result<()>
|
||||
where
|
||||
CR: AsyncRead + Unpin + Send + 'static,
|
||||
CW: AsyncWrite + Unpin + Send + 'static,
|
||||
SR: AsyncRead + Unpin + Send + 'static,
|
||||
SW: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let activity_timeout = activity_timeout.max(Duration::from_secs(1));
|
||||
let epoch = Instant::now();
|
||||
let counters = Arc::new(SharedCounters::new());
|
||||
let quota_exceeded = Arc::new(AtomicBool::new(false));
|
||||
let user_owned = user.to_string();
|
||||
|
||||
let (base_c2s, base_s2c) = initial_base_sizes(
|
||||
ceiling_c2s_bytes,
|
||||
ceiling_s2c_bytes,
|
||||
max_connections,
|
||||
budget.target_bytes(),
|
||||
);
|
||||
let base_total = base_c2s.saturating_add(base_s2c);
|
||||
let mut lease = match budget.try_reserve(base_total, false) {
|
||||
Some(lease) => lease,
|
||||
None => {
|
||||
let minimum_total = DIRECT_BASE_C2S_BYTES + DIRECT_BASE_S2C_BYTES;
|
||||
match budget.try_reserve(minimum_total, true) {
|
||||
Some(lease) => {
|
||||
budget.increment_minimum_fallback();
|
||||
lease
|
||||
}
|
||||
None => {
|
||||
budget.increment_admission_rejected();
|
||||
return Err(ProxyError::Proxy(
|
||||
"Direct relay buffer pressure: budget exhausted".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let effective_base = if lease.reserved_bytes() < base_total {
|
||||
(DIRECT_BASE_C2S_BYTES, DIRECT_BASE_S2C_BYTES)
|
||||
} else {
|
||||
(base_c2s, base_s2c)
|
||||
};
|
||||
let c2s_state = AdaptiveBufferState::new(effective_base.0);
|
||||
let s2c_state = AdaptiveBufferState::new(effective_base.1);
|
||||
|
||||
let mut controller = SessionAdaptiveController::new(AdaptiveTier::Base);
|
||||
|
||||
let c2s_client = StatsIo::new_with_traffic_lease(
|
||||
client_reader,
|
||||
Arc::clone(&counters),
|
||||
Arc::clone(&stats),
|
||||
user_owned.clone(),
|
||||
traffic_lease.clone(),
|
||||
quota_limit,
|
||||
Arc::clone("a_exceeded),
|
||||
epoch,
|
||||
);
|
||||
let client_writer = StatsIo::new_with_traffic_lease(
|
||||
client_writer,
|
||||
Arc::clone(&counters),
|
||||
Arc::clone(&stats),
|
||||
user_owned.clone(),
|
||||
traffic_lease,
|
||||
quota_limit,
|
||||
Arc::clone("a_exceeded),
|
||||
epoch,
|
||||
);
|
||||
let mut client_writer = WritePressureIo::new(client_writer, Arc::clone(&counters));
|
||||
let mut c2s_reader = AdaptiveBufReader::new(c2s_client, Arc::clone(&c2s_state));
|
||||
let mut s2c_reader = AdaptiveBufReader::new(server_reader, Arc::clone(&s2c_state));
|
||||
let mut server_writer = server_writer;
|
||||
let mut pressure_rx = budget.subscribe_pressure();
|
||||
|
||||
let relay_outcome = {
|
||||
let copy = async {
|
||||
let c2s = async {
|
||||
let copied = copy_buf(&mut c2s_reader, &mut server_writer).await?;
|
||||
server_writer.shutdown().await?;
|
||||
Ok::<u64, io::Error>(copied)
|
||||
};
|
||||
let s2c = async {
|
||||
let copied = copy_buf(&mut s2c_reader, &mut client_writer).await?;
|
||||
client_writer.shutdown().await?;
|
||||
Ok::<u64, io::Error>(copied)
|
||||
};
|
||||
tokio::try_join!(c2s, s2c)
|
||||
};
|
||||
tokio::pin!(copy);
|
||||
|
||||
let mut interval = tokio::time::interval(WATCHDOG_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
interval.tick().await;
|
||||
let mut previous = RelaySignalSample::default();
|
||||
let mut previous_log_c2s = 0u64;
|
||||
let mut previous_log_s2c = 0u64;
|
||||
let mut previous_sample_at = epoch;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut copy => break AdaptiveRelayOutcome::Copy(result),
|
||||
_ = session_cancel.cancelled() => break AdaptiveRelayOutcome::UserDisabled,
|
||||
changed = pressure_rx.changed() => {
|
||||
if changed.is_ok() {
|
||||
apply_global_pressure_demotion(
|
||||
&mut controller,
|
||||
&mut lease,
|
||||
&c2s_state,
|
||||
&s2c_state,
|
||||
effective_base,
|
||||
(ceiling_c2s_bytes, ceiling_s2c_bytes),
|
||||
budget.as_ref(),
|
||||
);
|
||||
reconcile_reservation(&mut lease, &c2s_state, &s2c_state);
|
||||
}
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
let now = Instant::now();
|
||||
let idle = counters.idle_duration(now, epoch);
|
||||
if quota_exceeded.load(Ordering::Acquire) {
|
||||
warn!(user = %user_owned, "User data quota reached, closing relay");
|
||||
break AdaptiveRelayOutcome::ActivityTimeout;
|
||||
}
|
||||
if idle >= activity_timeout {
|
||||
warn!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = counters.c2s_bytes.load(Ordering::Relaxed),
|
||||
s2c_bytes = counters.s2c_bytes.load(Ordering::Relaxed),
|
||||
idle_secs = idle.as_secs(),
|
||||
"Activity timeout"
|
||||
);
|
||||
break AdaptiveRelayOutcome::ActivityTimeout;
|
||||
}
|
||||
|
||||
let sample = current_sample(counters.as_ref());
|
||||
let c2s_delta = watchdog_delta(sample.c2s_bytes, previous_log_c2s);
|
||||
let s2c_delta = watchdog_delta(sample.s2c_written_bytes, previous_log_s2c);
|
||||
if c2s_delta > 0 || s2c_delta > 0 {
|
||||
let secs = now.saturating_duration_since(previous_sample_at).as_secs_f64();
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
c2s_kbps = (c2s_delta as f64 / secs / 1024.0) as u64,
|
||||
s2c_kbps = (s2c_delta as f64 / secs / 1024.0) as u64,
|
||||
c2s_total = sample.c2s_bytes,
|
||||
s2c_total = sample.s2c_written_bytes,
|
||||
"Relay active"
|
||||
);
|
||||
}
|
||||
|
||||
let delta = sample_delta(sample, previous);
|
||||
let tick_secs = now.saturating_duration_since(previous_sample_at).as_secs_f64();
|
||||
if let Some(transition) = controller.observe(delta, tick_secs) {
|
||||
apply_controller_transition(
|
||||
transition,
|
||||
&mut controller,
|
||||
&mut lease,
|
||||
&c2s_state,
|
||||
&s2c_state,
|
||||
effective_base,
|
||||
(ceiling_c2s_bytes, ceiling_s2c_bytes),
|
||||
budget.as_ref(),
|
||||
);
|
||||
}
|
||||
reconcile_reservation(&mut lease, &c2s_state, &s2c_state);
|
||||
previous = sample;
|
||||
previous_log_c2s = sample.c2s_bytes;
|
||||
previous_log_s2c = sample.s2c_written_bytes;
|
||||
previous_sample_at = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = client_writer.shutdown().await;
|
||||
let _ = server_writer.shutdown().await;
|
||||
let c2s_ops = counters.c2s_ops.load(Ordering::Relaxed);
|
||||
let s2c_ops = counters.s2c_ops.load(Ordering::Relaxed);
|
||||
let duration = epoch.elapsed();
|
||||
match relay_outcome {
|
||||
AdaptiveRelayOutcome::Copy(Ok((c2s, s2c))) => {
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = c2s,
|
||||
s2c_bytes = s2c,
|
||||
c2s_msgs = c2s_ops,
|
||||
s2c_msgs = s2c_ops,
|
||||
duration_secs = duration.as_secs(),
|
||||
"Relay finished"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
AdaptiveRelayOutcome::Copy(Err(error)) if is_quota_io_error(&error) => {
|
||||
warn!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = counters.c2s_bytes.load(Ordering::Relaxed),
|
||||
s2c_bytes = counters.s2c_bytes.load(Ordering::Relaxed),
|
||||
c2s_msgs = c2s_ops,
|
||||
s2c_msgs = s2c_ops,
|
||||
duration_secs = duration.as_secs(),
|
||||
"Data quota reached, closing relay"
|
||||
);
|
||||
Err(ProxyError::DataQuotaExceeded { user: user_owned })
|
||||
}
|
||||
AdaptiveRelayOutcome::Copy(Err(error)) => {
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = counters.c2s_bytes.load(Ordering::Relaxed),
|
||||
s2c_bytes = counters.s2c_bytes.load(Ordering::Relaxed),
|
||||
c2s_msgs = c2s_ops,
|
||||
s2c_msgs = s2c_ops,
|
||||
duration_secs = duration.as_secs(),
|
||||
error = %error,
|
||||
"Relay error"
|
||||
);
|
||||
Err(error.into())
|
||||
}
|
||||
AdaptiveRelayOutcome::ActivityTimeout => {
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = counters.c2s_bytes.load(Ordering::Relaxed),
|
||||
s2c_bytes = counters.s2c_bytes.load(Ordering::Relaxed),
|
||||
c2s_msgs = c2s_ops,
|
||||
s2c_msgs = s2c_ops,
|
||||
duration_secs = duration.as_secs(),
|
||||
"Relay finished (activity timeout)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
AdaptiveRelayOutcome::UserDisabled => {
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = counters.c2s_bytes.load(Ordering::Relaxed),
|
||||
s2c_bytes = counters.s2c_bytes.load(Ordering::Relaxed),
|
||||
c2s_msgs = c2s_ops,
|
||||
s2c_msgs = s2c_ops,
|
||||
duration_secs = duration.as_secs(),
|
||||
"Relay finished (user disabled)"
|
||||
);
|
||||
Err(ProxyError::UserDisabled { user: user_owned })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_base_sizes(
|
||||
ceiling_c2s: usize,
|
||||
ceiling_s2c: usize,
|
||||
max_connections: u32,
|
||||
target_bytes: usize,
|
||||
) -> (usize, usize) {
|
||||
let configured_total = ceiling_c2s.saturating_add(ceiling_s2c);
|
||||
let configured_worst_case = configured_total.saturating_mul(max_connections as usize);
|
||||
if max_connections != 0 && configured_worst_case <= target_bytes {
|
||||
return (ceiling_c2s, ceiling_s2c);
|
||||
}
|
||||
(
|
||||
DIRECT_BASE_C2S_BYTES.min(ceiling_c2s),
|
||||
DIRECT_BASE_S2C_BYTES.min(ceiling_s2c),
|
||||
)
|
||||
}
|
||||
|
||||
fn current_sample(counters: &SharedCounters) -> RelaySignalSample {
|
||||
RelaySignalSample {
|
||||
c2s_bytes: counters.c2s_bytes.load(Ordering::Relaxed),
|
||||
s2c_requested_bytes: counters.s2c_requested_bytes.load(Ordering::Relaxed),
|
||||
s2c_written_bytes: counters.s2c_bytes.load(Ordering::Relaxed),
|
||||
s2c_write_ops: counters.s2c_ops.load(Ordering::Relaxed),
|
||||
s2c_partial_writes: counters.s2c_partial_writes.load(Ordering::Relaxed),
|
||||
s2c_consecutive_pending_writes: counters
|
||||
.s2c_consecutive_pending_writes
|
||||
.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_delta(current: RelaySignalSample, previous: RelaySignalSample) -> RelaySignalSample {
|
||||
RelaySignalSample {
|
||||
c2s_bytes: current.c2s_bytes.saturating_sub(previous.c2s_bytes),
|
||||
s2c_requested_bytes: current
|
||||
.s2c_requested_bytes
|
||||
.saturating_sub(previous.s2c_requested_bytes),
|
||||
s2c_written_bytes: current
|
||||
.s2c_written_bytes
|
||||
.saturating_sub(previous.s2c_written_bytes),
|
||||
s2c_write_ops: current.s2c_write_ops.saturating_sub(previous.s2c_write_ops),
|
||||
s2c_partial_writes: current
|
||||
.s2c_partial_writes
|
||||
.saturating_sub(previous.s2c_partial_writes),
|
||||
s2c_consecutive_pending_writes: current.s2c_consecutive_pending_writes,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_controller_transition(
|
||||
transition: crate::proxy::adaptive_buffers::TierTransition,
|
||||
controller: &mut SessionAdaptiveController,
|
||||
lease: &mut DirectBufferLease,
|
||||
c2s_state: &AdaptiveBufferState,
|
||||
s2c_state: &AdaptiveBufferState,
|
||||
base: (usize, usize),
|
||||
ceilings: (usize, usize),
|
||||
budget: &DirectBufferBudget,
|
||||
) {
|
||||
let sizes = direct_copy_buffers_for_tier_with_ceilings(
|
||||
transition.to,
|
||||
base.0,
|
||||
base.1,
|
||||
ceilings.0,
|
||||
ceilings.1,
|
||||
);
|
||||
if transition.to > transition.from {
|
||||
if !lease.try_grow_to(sizes.0.saturating_add(sizes.1)) {
|
||||
*controller = SessionAdaptiveController::new(transition.from);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
match transition.reason {
|
||||
TierTransitionReason::QuietDemotion => budget.increment_quiet_demotion(),
|
||||
TierTransitionReason::SustainedWritePressure => {
|
||||
budget.increment_write_pressure_demotion();
|
||||
}
|
||||
TierTransitionReason::SoftConfirmed | TierTransitionReason::HardPressure => {}
|
||||
}
|
||||
}
|
||||
set_desired_sizes(c2s_state, s2c_state, sizes);
|
||||
lease.set_tier(transition.to.as_u8() as usize);
|
||||
}
|
||||
|
||||
fn apply_global_pressure_demotion(
|
||||
controller: &mut SessionAdaptiveController,
|
||||
lease: &mut DirectBufferLease,
|
||||
c2s_state: &AdaptiveBufferState,
|
||||
s2c_state: &AdaptiveBufferState,
|
||||
base: (usize, usize),
|
||||
ceilings: (usize, usize),
|
||||
budget: &DirectBufferBudget,
|
||||
) {
|
||||
let current = controller.tier();
|
||||
let target = current.demote();
|
||||
if target == current {
|
||||
return;
|
||||
}
|
||||
*controller = SessionAdaptiveController::new(target);
|
||||
let sizes =
|
||||
direct_copy_buffers_for_tier_with_ceilings(target, base.0, base.1, ceilings.0, ceilings.1);
|
||||
set_desired_sizes(c2s_state, s2c_state, sizes);
|
||||
lease.set_tier(target.as_u8() as usize);
|
||||
budget.increment_global_pressure_demotion();
|
||||
}
|
||||
|
||||
fn set_desired_sizes(
|
||||
c2s_state: &AdaptiveBufferState,
|
||||
s2c_state: &AdaptiveBufferState,
|
||||
sizes: (usize, usize),
|
||||
) {
|
||||
c2s_state
|
||||
.desired_bytes
|
||||
.store(sizes.0.max(1), Ordering::Release);
|
||||
s2c_state
|
||||
.desired_bytes
|
||||
.store(sizes.1.max(1), Ordering::Release);
|
||||
}
|
||||
|
||||
fn reconcile_reservation(
|
||||
lease: &mut DirectBufferLease,
|
||||
c2s_state: &AdaptiveBufferState,
|
||||
s2c_state: &AdaptiveBufferState,
|
||||
) {
|
||||
// Promotion reserves the desired allocation before either reader grows.
|
||||
// Demotion keeps the actual allocation covered until its buffered bytes drain.
|
||||
let covered_c2s = c2s_state
|
||||
.actual_bytes
|
||||
.load(Ordering::Acquire)
|
||||
.max(c2s_state.desired_bytes.load(Ordering::Acquire));
|
||||
let covered_s2c = s2c_state
|
||||
.actual_bytes
|
||||
.load(Ordering::Acquire)
|
||||
.max(s2c_state.desired_bytes.load(Ordering::Acquire));
|
||||
lease.shrink_to(covered_c2s.saturating_add(covered_s2c));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
use super::super::io::SharedCounters;
|
||||
|
||||
/// Direct-only writer wrapper that exposes bounded backpressure signals.
|
||||
pub(super) struct WritePressureIo<W> {
|
||||
inner: W,
|
||||
counters: Arc<SharedCounters>,
|
||||
}
|
||||
|
||||
impl<W> WritePressureIo<W> {
|
||||
/// Wraps the client writer without changing its I/O or error contract.
|
||||
pub(super) fn new(inner: W, counters: Arc<SharedCounters>) -> Self {
|
||||
Self { inner, counters }
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for WritePressureIo<W> {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buffer: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
if !buffer.is_empty() {
|
||||
this.counters
|
||||
.s2c_requested_bytes
|
||||
.fetch_add(buffer.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
match Pin::new(&mut this.inner).poll_write(cx, buffer) {
|
||||
Poll::Ready(Ok(written)) => {
|
||||
this.counters
|
||||
.s2c_consecutive_pending_writes
|
||||
.store(0, Ordering::Relaxed);
|
||||
if written < buffer.len() {
|
||||
this.counters
|
||||
.s2c_partial_writes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Poll::Ready(Ok(written))
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
this.counters
|
||||
.s2c_consecutive_pending_writes
|
||||
.store(0, Ordering::Relaxed);
|
||||
Poll::Ready(Err(error))
|
||||
}
|
||||
Poll::Pending => {
|
||||
let _ = this.counters.s2c_consecutive_pending_writes.fetch_update(
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
|current| Some(current.saturating_add(1)),
|
||||
);
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time::Instant;
|
||||
@@ -20,6 +20,12 @@ pub(in crate::proxy::relay) struct SharedCounters {
|
||||
pub(in crate::proxy::relay) c2s_ops: AtomicU64,
|
||||
/// Number of poll_write completions (≈ S→C chunks)
|
||||
pub(in crate::proxy::relay) s2c_ops: AtomicU64,
|
||||
/// Bytes presented to client writes, including retried pending writes.
|
||||
pub(in crate::proxy::relay) s2c_requested_bytes: AtomicU64,
|
||||
/// Successful client writes that consumed only part of the offered slice.
|
||||
pub(in crate::proxy::relay) s2c_partial_writes: AtomicU64,
|
||||
/// Consecutive pending client writes observed by the active copy loop.
|
||||
pub(in crate::proxy::relay) s2c_consecutive_pending_writes: AtomicU32,
|
||||
/// Milliseconds since relay epoch of last I/O activity
|
||||
last_activity_ms: AtomicU64,
|
||||
}
|
||||
@@ -31,6 +37,9 @@ impl SharedCounters {
|
||||
s2c_bytes: AtomicU64::new(0),
|
||||
c2s_ops: AtomicU64::new(0),
|
||||
s2c_ops: AtomicU64::new(0),
|
||||
s2c_requested_bytes: AtomicU64::new(0),
|
||||
s2c_partial_writes: AtomicU64::new(0),
|
||||
s2c_consecutive_pending_writes: AtomicU32::new(0),
|
||||
last_activity_ms: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::proxy::direct_buffer_budget::{DirectBufferBudget, fallback_direct_buffer_hard_limit};
|
||||
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;
|
||||
const MASKING_FALLBACK_MAX_CONCURRENT: usize = 512;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ConntrackCloseReason {
|
||||
@@ -68,10 +70,12 @@ pub(crate) struct ProxySharedState {
|
||||
pub(crate) handshake: HandshakeSharedState,
|
||||
pub(crate) middle_relay: MiddleRelaySharedState,
|
||||
pub(crate) traffic_limiter: Arc<TrafficLimiter>,
|
||||
pub(crate) direct_buffer_budget: Arc<DirectBufferBudget>,
|
||||
disabled_users: DashMap<String, ()>,
|
||||
active_user_sessions: DashMap<(String, u64), CancellationToken>,
|
||||
pub(crate) conntrack_pressure_active: AtomicBool,
|
||||
pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>,
|
||||
masking_fallback_permits: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[must_use = "registered user sessions must be kept alive until relay completion"]
|
||||
@@ -99,6 +103,15 @@ impl Drop for UserSessionGuard {
|
||||
|
||||
impl ProxySharedState {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
Self::new_with_direct_buffer_budget(DirectBufferBudget::new(
|
||||
fallback_direct_buffer_hard_limit(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Creates process state with the startup-resolved Direct buffer envelope.
|
||||
pub(crate) fn new_with_direct_buffer_budget(
|
||||
direct_buffer_budget: Arc<DirectBufferBudget>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
handshake: HandshakeSharedState {
|
||||
auth_probe: DashMap::new(),
|
||||
@@ -127,13 +140,23 @@ impl ProxySharedState {
|
||||
relay_idle_mark_seq: AtomicU64::new(0),
|
||||
},
|
||||
traffic_limiter: TrafficLimiter::new(),
|
||||
direct_buffer_budget,
|
||||
disabled_users: DashMap::new(),
|
||||
active_user_sessions: DashMap::new(),
|
||||
conntrack_pressure_active: AtomicBool::new(false),
|
||||
conntrack_close_tx: Mutex::new(None),
|
||||
masking_fallback_permits: Arc::new(Semaphore::new(MASKING_FALLBACK_MAX_CONCURRENT)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Attempts to reserve one masking fallback slot for a pre-auth connection.
|
||||
pub(crate) fn try_acquire_masking_fallback_permit(&self) -> Option<OwnedSemaphorePermit> {
|
||||
self.masking_fallback_permits
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub(crate) fn is_user_enabled(&self, user: &str) -> bool {
|
||||
!self.disabled_users.contains_key(user)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_direct_sizes_are_strict_tier_ceilings() {
|
||||
let base = (4 * 1024, 8 * 1024);
|
||||
let ceilings = (64 * 1024, 256 * 1024);
|
||||
assert_eq!(
|
||||
direct_copy_buffers_for_tier_with_ceilings(
|
||||
AdaptiveTier::Base,
|
||||
base.0,
|
||||
base.1,
|
||||
ceilings.0,
|
||||
ceilings.1,
|
||||
),
|
||||
base
|
||||
);
|
||||
assert_eq!(
|
||||
direct_copy_buffers_for_tier_with_ceilings(
|
||||
AdaptiveTier::Tier3,
|
||||
base.0,
|
||||
base.1,
|
||||
ceilings.0,
|
||||
ceilings.1,
|
||||
),
|
||||
ceilings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sustained_pending_pressure_demotes_after_transient_promotion() {
|
||||
let mut controller = SessionAdaptiveController::new(AdaptiveTier::Tier1);
|
||||
let pressure = RelaySignalSample {
|
||||
c2s_bytes: 0,
|
||||
s2c_requested_bytes: 1024,
|
||||
s2c_written_bytes: 0,
|
||||
s2c_write_ops: 0,
|
||||
s2c_partial_writes: 0,
|
||||
s2c_consecutive_pending_writes: 3,
|
||||
};
|
||||
|
||||
let first = controller
|
||||
.observe(pressure, 10.0)
|
||||
.expect("transient pressure must retain the staged promotion");
|
||||
assert_eq!(first.reason, TierTransitionReason::HardPressure);
|
||||
|
||||
let second = controller
|
||||
.observe(pressure, 10.0)
|
||||
.expect("bounded transient pressure may promote one additional tier");
|
||||
assert_eq!(second.reason, TierTransitionReason::HardPressure);
|
||||
let sustained = controller
|
||||
.observe(pressure, 10.0)
|
||||
.expect("sustained pressure must release one tier");
|
||||
assert_eq!(
|
||||
sustained.reason,
|
||||
TierTransitionReason::SustainedWritePressure
|
||||
);
|
||||
assert_eq!(sustained.to, AdaptiveTier::Tier2);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lease_drop_releases_the_complete_reservation() {
|
||||
let budget = DirectBufferBudget::new(16 * 1024);
|
||||
{
|
||||
let lease = budget
|
||||
.try_reserve(12 * 1024, false)
|
||||
.expect("minimum reservation must fit");
|
||||
assert_eq!(lease.reserved_bytes(), 12 * 1024);
|
||||
assert_eq!(budget.snapshot().reserved_bytes, 12 * 1024);
|
||||
}
|
||||
assert_eq!(budget.snapshot().reserved_bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_ceiling_rejects_excess_minimum_reservations() {
|
||||
let budget = DirectBufferBudget::new(16 * 1024);
|
||||
let _lease = budget
|
||||
.try_reserve(12 * 1024, true)
|
||||
.expect("first minimum reservation must fit");
|
||||
assert!(budget.try_reserve(8 * 1024, true).is_none());
|
||||
assert_eq!(budget.snapshot().reserved_bytes, 12 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_and_shrink_keep_accounting_balanced() {
|
||||
let budget = DirectBufferBudget::new(32 * 1024);
|
||||
let mut lease = budget
|
||||
.try_reserve(12 * 1024, false)
|
||||
.expect("base reservation must fit");
|
||||
assert!(lease.try_grow_to(24 * 1024));
|
||||
assert_eq!(budget.snapshot().reserved_bytes, 24 * 1024);
|
||||
lease.shrink_to(16 * 1024);
|
||||
assert_eq!(budget.snapshot().reserved_bytes, 16 * 1024);
|
||||
drop(lease);
|
||||
assert_eq!(budget.snapshot().reserved_bytes, 0);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fn loop_guard_unspecified_bind_uses_interface_inventory() {
|
||||
"mask.example",
|
||||
443,
|
||||
local,
|
||||
Some(resolved),
|
||||
&[resolved],
|
||||
&interfaces,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ async fn adversarial_parallel_cold_miss_performs_single_interface_refresh() {
|
||||
let barrier = std::sync::Arc::clone(&barrier);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await
|
||||
is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ async fn tdd_repeated_local_listener_checks_do_not_repeat_interface_enumeration_
|
||||
|
||||
let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr");
|
||||
|
||||
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await;
|
||||
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await;
|
||||
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await;
|
||||
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await;
|
||||
|
||||
assert_eq!(
|
||||
local_interface_enumerations_for_tests(),
|
||||
@@ -35,7 +35,7 @@ async fn tdd_non_local_port_short_circuit_does_not_enumerate_interfaces() {
|
||||
reset_local_interface_enumerations_for_tests();
|
||||
|
||||
let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr");
|
||||
let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, None).await;
|
||||
let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, &[]).await;
|
||||
|
||||
assert!(
|
||||
!is_local,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use super::*;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
const PERF_TOTAL_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
struct PatternReader {
|
||||
remaining: usize,
|
||||
chunk: usize,
|
||||
read_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl PatternReader {
|
||||
fn new(total: usize, chunk: usize) -> Self {
|
||||
Self {
|
||||
remaining: total,
|
||||
chunk,
|
||||
read_calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_calls(&self) -> usize {
|
||||
self.read_calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for PatternReader {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
self.read_calls.fetch_add(1, Ordering::Relaxed);
|
||||
if self.remaining == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let take = self.remaining.min(self.chunk).min(buf.remaining());
|
||||
if take == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
static PATTERN: [u8; MASK_BUFFER_MAX_SIZE] = [0xA5; MASK_BUFFER_MAX_SIZE];
|
||||
buf.put_slice(&PATTERN[..take]);
|
||||
self.remaining -= take;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CountingWriter {
|
||||
written: usize,
|
||||
}
|
||||
|
||||
impl AsyncWrite for CountingWriter {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
self.written = self.written.saturating_add(buf.len());
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "manual benchmark: throughput-sensitive and host-dependent"]
|
||||
async fn masking_copy_with_idle_timeout_manual_throughput() {
|
||||
let mut reader = PatternReader::new(PERF_TOTAL_BYTES, MASK_BUFFER_MAX_SIZE);
|
||||
let mut writer = CountingWriter::default();
|
||||
let started = Instant::now();
|
||||
|
||||
let outcome = copy_with_idle_timeout(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
PERF_TOTAL_BYTES,
|
||||
true,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await;
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
let mb = PERF_TOTAL_BYTES as f64 / (1024.0 * 1024.0);
|
||||
let mbps = mb / elapsed.as_secs_f64();
|
||||
|
||||
assert_eq!(outcome.total, PERF_TOTAL_BYTES);
|
||||
assert_eq!(writer.written, PERF_TOTAL_BYTES);
|
||||
assert!(
|
||||
!outcome.ended_by_eof,
|
||||
"manual throughput run should terminate at byte cap"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"masking manual throughput: bytes={} elapsed_ms={} mib_per_sec={:.2} read_calls={}",
|
||||
PERF_TOTAL_BYTES,
|
||||
elapsed.as_millis(),
|
||||
mbps,
|
||||
reader.read_calls()
|
||||
);
|
||||
}
|
||||
@@ -15,38 +15,49 @@ fn closed_local_port() -> u16 {
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_matches_literal_ipv4_listener() {
|
||||
let local: SocketAddr = "198.51.100.40:443".parse().unwrap();
|
||||
assert!(is_mask_target_local_listener_async("198.51.100.40", 443, local, None,).await);
|
||||
assert!(is_mask_target_local_listener_async("198.51.100.40", 443, local, &[],).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_matches_bracketed_ipv6_listener() {
|
||||
let local: SocketAddr = "[2001:db8::44]:8443".parse().unwrap();
|
||||
assert!(is_mask_target_local_listener_async("[2001:db8::44]", 8443, local, None,).await);
|
||||
assert!(is_mask_target_local_listener_async("[2001:db8::44]", 8443, local, &[],).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_keeps_same_ip_different_port_forwardable() {
|
||||
let local: SocketAddr = "203.0.113.44:443".parse().unwrap();
|
||||
assert!(!is_mask_target_local_listener_async("203.0.113.44", 8443, local, None,).await);
|
||||
assert!(!is_mask_target_local_listener_async("203.0.113.44", 8443, local, &[],).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_normalizes_ipv4_mapped_ipv6_literal() {
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
assert!(is_mask_target_local_listener_async("::ffff:127.0.0.1", 443, local, None,).await);
|
||||
assert!(is_mask_target_local_listener_async("::ffff:127.0.0.1", 443, local, &[],).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_unspecified_bind_blocks_loopback_target() {
|
||||
let local: SocketAddr = "0.0.0.0:443".parse().unwrap();
|
||||
assert!(is_mask_target_local_listener_async("127.0.0.1", 443, local, None,).await);
|
||||
assert!(is_mask_target_local_listener_async("127.0.0.1", 443, local, &[],).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_unspecified_bind_keeps_remote_target_forwardable() {
|
||||
let local: SocketAddr = "0.0.0.0:443".parse().unwrap();
|
||||
let remote: SocketAddr = "198.51.100.44:443".parse().unwrap();
|
||||
assert!(!is_mask_target_local_listener_async("mask.example", 443, local, Some(remote),).await);
|
||||
assert!(!is_mask_target_local_listener_async("mask.example", 443, local, &[remote],).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_target_detection_checks_all_resolved_addresses() {
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let remote: SocketAddr = "198.51.100.44:443".parse().unwrap();
|
||||
let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
|
||||
assert!(
|
||||
is_mask_target_local_listener_async("mask.example", 443, local, &[remote, loopback],).await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
use super::*;
|
||||
use crate::crypto::AesCtr;
|
||||
use crate::protocol::framing::INTERMEDIATE_WIRE_LEN_MASK;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingWriter {
|
||||
writes: Arc<Mutex<Vec<u8>>>,
|
||||
flushes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl RecordingWriter {
|
||||
fn captured(&self) -> Vec<u8> {
|
||||
self.writes
|
||||
.lock()
|
||||
.expect("test writer capture lock must not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for RecordingWriter {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
self.writes
|
||||
.lock()
|
||||
.expect("test writer capture lock must not be poisoned")
|
||||
.extend_from_slice(buf);
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.flushes.fetch_add(1, Ordering::Relaxed);
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn crypto_writer(inner: RecordingWriter) -> CryptoWriter<RecordingWriter> {
|
||||
let key = [0u8; 32];
|
||||
CryptoWriter::new(inner, AesCtr::new(&key, 0), 8 * 1024 * 1024)
|
||||
}
|
||||
|
||||
fn decrypt_capture(mut encrypted: Vec<u8>) -> Vec<u8> {
|
||||
let key = [0u8; 32];
|
||||
let mut cipher = AesCtr::new(&key, 0);
|
||||
cipher.apply(&mut encrypted);
|
||||
encrypted
|
||||
}
|
||||
|
||||
fn secure_wire_len(cleartext: &[u8]) -> usize {
|
||||
let header = cleartext
|
||||
.get(..4)
|
||||
.expect("secure frame must include an intermediate header");
|
||||
(u32::from_le_bytes(
|
||||
header
|
||||
.try_into()
|
||||
.expect("secure frame header must be four bytes"),
|
||||
) & INTERMEDIATE_WIRE_LEN_MASK) as usize
|
||||
}
|
||||
|
||||
async fn write_secure_payload(payload_len: usize) -> (MeD2cWriteMode, Vec<u8>) {
|
||||
let inner = RecordingWriter::default();
|
||||
let capture = inner.clone();
|
||||
let mut writer = crypto_writer(inner);
|
||||
let payload = vec![0xa5; payload_len];
|
||||
let mut frame_buf = Vec::new();
|
||||
let cancel = CancellationToken::new();
|
||||
let rng = SecureRandom::new();
|
||||
|
||||
let mode = write_client_payload(
|
||||
&mut writer,
|
||||
ProtoTag::Secure,
|
||||
0,
|
||||
&payload,
|
||||
&rng,
|
||||
&mut frame_buf,
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.expect("secure payload write must succeed");
|
||||
flush_client_or_cancel(&mut writer, &cancel)
|
||||
.await
|
||||
.expect("secure payload flush must succeed");
|
||||
|
||||
(mode, decrypt_capture(capture.captured()))
|
||||
}
|
||||
|
||||
fn assert_secure_payload_with_tail_padding(cleartext: &[u8], payload_len: usize) {
|
||||
let wire_len = secure_wire_len(cleartext);
|
||||
assert_eq!(cleartext.len(), 4 + wire_len);
|
||||
assert!(
|
||||
cleartext[4..4 + payload_len]
|
||||
.iter()
|
||||
.all(|byte| *byte == 0xa5)
|
||||
);
|
||||
|
||||
let padding_len = wire_len
|
||||
.checked_sub(payload_len)
|
||||
.expect("secure wire length must include payload bytes");
|
||||
assert!((1..=3).contains(&padding_len));
|
||||
assert_ne!(wire_len % 4, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_drain_flush_reason_performs_physical_client_flush() {
|
||||
let inner = RecordingWriter::default();
|
||||
let flushes = inner.flushes.clone();
|
||||
let mut writer = crypto_writer(inner);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
assert!(me_d2c_flush_reason_requires_client_flush(
|
||||
MeD2cFlushReason::QueueDrain
|
||||
));
|
||||
flush_client_or_cancel(&mut writer, &cancel)
|
||||
.await
|
||||
.expect("client flush must succeed");
|
||||
|
||||
assert_eq!(flushes.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_payload_coalesced_path_keeps_tail_padding() {
|
||||
let payload_len = 8;
|
||||
let (mode, cleartext) = write_secure_payload(payload_len).await;
|
||||
|
||||
assert!(matches!(mode, MeD2cWriteMode::Coalesced));
|
||||
assert_secure_payload_with_tail_padding(&cleartext, payload_len);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_payload_split_path_keeps_tail_padding() {
|
||||
let payload_len = ME_D2C_SINGLE_WRITE_COALESCE_MAX_BYTES;
|
||||
let (mode, cleartext) = write_secure_payload(payload_len).await;
|
||||
|
||||
assert!(matches!(mode, MeD2cWriteMode::Split));
|
||||
assert_secure_payload_with_tail_padding(&cleartext, payload_len);
|
||||
}
|
||||
+164
-19
@@ -10,7 +10,7 @@ use arc_swap::ArcSwap;
|
||||
use dashmap::DashMap;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::RateLimitBps;
|
||||
use crate::config::{CidrRateLimitKey, RateLimitBps};
|
||||
|
||||
const REGISTRY_SHARDS: usize = 64;
|
||||
const FAIR_EPOCH_MS: u64 = 20;
|
||||
@@ -413,16 +413,29 @@ struct CidrRule {
|
||||
prefix_len: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CidrAutoRule {
|
||||
prefix_len: u8,
|
||||
limits: RateLimitBps,
|
||||
}
|
||||
|
||||
enum CidrPolicyMatch<'a> {
|
||||
Explicit(&'a CidrRule),
|
||||
Auto { key: String, limits: RateLimitBps },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicySnapshot {
|
||||
user_limits: HashMap<String, RateLimitBps>,
|
||||
cidr_rules_v4: Vec<CidrRule>,
|
||||
cidr_rules_v6: Vec<CidrRule>,
|
||||
cidr_auto_rules_v4: Vec<CidrAutoRule>,
|
||||
cidr_auto_rules_v6: Vec<CidrAutoRule>,
|
||||
cidr_rule_keys: HashSet<String>,
|
||||
}
|
||||
|
||||
impl PolicySnapshot {
|
||||
fn match_cidr(&self, ip: IpAddr) -> Option<&CidrRule> {
|
||||
fn match_cidr(&self, ip: IpAddr) -> Option<CidrPolicyMatch<'_>> {
|
||||
match ip {
|
||||
IpAddr::V4(_) => self
|
||||
.cidr_rules_v4
|
||||
@@ -433,6 +446,20 @@ impl PolicySnapshot {
|
||||
.iter()
|
||||
.find(|rule| rule.cidr.contains(ip)),
|
||||
}
|
||||
.map(CidrPolicyMatch::Explicit)
|
||||
.or_else(|| self.match_auto_cidr(ip))
|
||||
}
|
||||
|
||||
fn match_auto_cidr(&self, ip: IpAddr) -> Option<CidrPolicyMatch<'_>> {
|
||||
let rule = match ip {
|
||||
IpAddr::V4(_) => self.cidr_auto_rules_v4.first()?,
|
||||
IpAddr::V6(_) => self.cidr_auto_rules_v6.first()?,
|
||||
};
|
||||
let key = auto_cidr_bucket_key(ip, rule.prefix_len)?;
|
||||
Some(CidrPolicyMatch::Auto {
|
||||
key,
|
||||
limits: rule.limits,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +660,7 @@ impl TrafficLimiter {
|
||||
pub fn apply_policy(
|
||||
&self,
|
||||
user_limits: HashMap<String, RateLimitBps>,
|
||||
cidr_limits: HashMap<IpNetwork, RateLimitBps>,
|
||||
cidr_limits: HashMap<CidrRateLimitKey, RateLimitBps>,
|
||||
) {
|
||||
let filtered_users = user_limits
|
||||
.into_iter()
|
||||
@@ -642,39 +669,64 @@ impl TrafficLimiter {
|
||||
|
||||
let mut cidr_rules_v4 = Vec::new();
|
||||
let mut cidr_rules_v6 = Vec::new();
|
||||
let mut cidr_auto_rules_v4 = Vec::new();
|
||||
let mut cidr_auto_rules_v6 = Vec::new();
|
||||
let mut cidr_rule_keys = HashSet::new();
|
||||
for (cidr, limits) in cidr_limits {
|
||||
for (key, 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),
|
||||
match key {
|
||||
CidrRateLimitKey::Network(cidr) => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
CidrRateLimitKey::AutoV4(prefix_len) => {
|
||||
cidr_auto_rules_v4.push(CidrAutoRule { prefix_len, limits });
|
||||
}
|
||||
CidrRateLimitKey::AutoV6(prefix_len) => {
|
||||
cidr_auto_rules_v6.push(CidrAutoRule { prefix_len, limits });
|
||||
}
|
||||
CidrRateLimitKey::AutoDual(prefix_len) => {
|
||||
cidr_auto_rules_v4.push(CidrAutoRule { prefix_len, limits });
|
||||
cidr_auto_rules_v6.push(CidrAutoRule {
|
||||
prefix_len: prefix_len.saturating_mul(4),
|
||||
limits,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
cidr_auto_rules_v4.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
cidr_auto_rules_v6.sort_by(|a, b| b.prefix_len.cmp(&a.prefix_len));
|
||||
let cidr_policy_entries =
|
||||
cidr_rule_keys.len() + cidr_auto_rules_v4.len() + cidr_auto_rules_v6.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);
|
||||
.store(cidr_policy_entries as u64, Ordering::Relaxed);
|
||||
|
||||
self.policy.store(Arc::new(PolicySnapshot {
|
||||
user_limits: filtered_users,
|
||||
cidr_rules_v4,
|
||||
cidr_rules_v6,
|
||||
cidr_auto_rules_v4,
|
||||
cidr_auto_rules_v6,
|
||||
cidr_rule_keys,
|
||||
}));
|
||||
|
||||
@@ -703,11 +755,15 @@ impl TrafficLimiter {
|
||||
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) {
|
||||
if let Some(rule_match) = policy.match_cidr(client_ip) {
|
||||
let (key, limits) = match &rule_match {
|
||||
CidrPolicyMatch::Explicit(rule) => (rule.key.as_str(), rule.limits),
|
||||
CidrPolicyMatch::Auto { key, limits } => (key.as_str(), *limits),
|
||||
};
|
||||
let bucket = self
|
||||
.cidr_buckets
|
||||
.get_or_insert_with(rule.key.as_str(), || CidrBucket::new(rule.limits));
|
||||
bucket.set_rates(rule.limits);
|
||||
.get_or_insert_with(key, || CidrBucket::new(limits));
|
||||
bucket.set_rates(limits);
|
||||
bucket.active_leases.fetch_add(1, Ordering::Relaxed);
|
||||
self.cidr_scope
|
||||
.active_leases
|
||||
@@ -841,6 +897,16 @@ fn bytes_per_epoch(bps: u64) -> u64 {
|
||||
bytes.max(1)
|
||||
}
|
||||
|
||||
fn auto_cidr_bucket_key(ip: IpAddr, prefix_len: u8) -> Option<String> {
|
||||
let cidr = IpNetwork::new(ip, prefix_len).ok()?;
|
||||
let network = IpNetwork::new(cidr.network(), prefix_len).ok()?;
|
||||
let family = match network {
|
||||
IpNetwork::V4(_) => "4",
|
||||
IpNetwork::V6(_) => "6",
|
||||
};
|
||||
Some(format!("auto:{family}:{network}"))
|
||||
}
|
||||
|
||||
fn current_epoch() -> u64 {
|
||||
let start = limiter_epoch_start();
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
@@ -851,3 +917,82 @@ fn limiter_epoch_start() -> &'static Instant {
|
||||
static START: OnceLock<Instant> = OnceLock::new();
|
||||
START.get_or_init(Instant::now)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rate(up_bps: u64, down_bps: u64) -> RateLimitBps {
|
||||
RateLimitBps { up_bps, down_bps }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_cidr_rule_wins_over_auto_template() {
|
||||
let limiter = TrafficLimiter::new();
|
||||
let mut cidr_limits = HashMap::new();
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoV4(24), rate(1_000, 0));
|
||||
cidr_limits.insert(
|
||||
CidrRateLimitKey::Network("203.0.113.7/32".parse().unwrap()),
|
||||
rate(2_000, 0),
|
||||
);
|
||||
|
||||
limiter.apply_policy(HashMap::new(), cidr_limits);
|
||||
let policy = limiter.policy.load_full();
|
||||
let matched = policy.match_cidr("203.0.113.7".parse().unwrap()).unwrap();
|
||||
|
||||
match matched {
|
||||
CidrPolicyMatch::Explicit(rule) => assert_eq!(rule.key.as_str(), "203.0.113.7/32"),
|
||||
CidrPolicyMatch::Auto { .. } => panic!("explicit CIDR must have priority"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_template_uses_longest_prefix() {
|
||||
let limiter = TrafficLimiter::new();
|
||||
let mut cidr_limits = HashMap::new();
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoV4(24), rate(1_000, 0));
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoV4(32), rate(2_000, 0));
|
||||
|
||||
limiter.apply_policy(HashMap::new(), cidr_limits);
|
||||
let policy = limiter.policy.load_full();
|
||||
let matched = policy.match_cidr("203.0.113.129".parse().unwrap()).unwrap();
|
||||
|
||||
match matched {
|
||||
CidrPolicyMatch::Auto { key, limits } => {
|
||||
assert_eq!(key, "auto:4:203.0.113.129/32");
|
||||
assert_eq!(limits.up_bps, 2_000);
|
||||
}
|
||||
CidrPolicyMatch::Explicit(_) => panic!("auto-template match expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dual_auto_template_maps_v6_prefix_by_four() {
|
||||
let limiter = TrafficLimiter::new();
|
||||
let mut cidr_limits = HashMap::new();
|
||||
cidr_limits.insert(CidrRateLimitKey::AutoDual(32), rate(1_000, 0));
|
||||
|
||||
limiter.apply_policy(HashMap::new(), cidr_limits);
|
||||
let policy = limiter.policy.load_full();
|
||||
let matched = policy.match_cidr("2001:db8::1".parse().unwrap()).unwrap();
|
||||
|
||||
match matched {
|
||||
CidrPolicyMatch::Auto { key, .. } => {
|
||||
assert_eq!(key, "auto:6:2001:db8::1/128");
|
||||
}
|
||||
CidrPolicyMatch::Explicit(_) => panic!("auto-template match expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_cidr_bucket_key_canonicalizes_network_address() {
|
||||
assert_eq!(
|
||||
auto_cidr_bucket_key("203.0.113.129".parse().unwrap(), 24).unwrap(),
|
||||
"auto:4:203.0.113.0/24"
|
||||
);
|
||||
assert_eq!(
|
||||
auto_cidr_bucket_key("2001:db8::abcd".parse().unwrap(), 64).unwrap(),
|
||||
"auto:6:2001:db8::/64"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,12 @@ impl Stats {
|
||||
self.buffer_pool_in_use_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns the count of non-standard buffers replaced before pooling.
|
||||
pub fn get_buffer_pool_replaced_nonstandard_total(&self) -> u64 {
|
||||
self.buffer_pool_replaced_nonstandard_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_me_c2me_send_full_total(&self) -> u64 {
|
||||
self.me_c2me_send_full_total.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -269,6 +275,36 @@ impl Stats {
|
||||
self.me_writer_pick_mode_switch_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Returns the configured resident-memory limit per ME writer.
|
||||
pub fn get_me_writer_byte_budget_limit_bytes_gauge(&self) -> u64 {
|
||||
self.me_writer_byte_budget_limit_bytes_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Returns aggregate queued or enqueueing writer memory reservations.
|
||||
pub fn get_me_writer_byte_budget_queued_bytes_gauge(&self) -> u64 {
|
||||
self.me_writer_byte_budget_queued_bytes_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Returns aggregate writer reservations currently owned by socket writes.
|
||||
pub fn get_me_writer_byte_budget_inflight_bytes_gauge(&self) -> u64 {
|
||||
self.me_writer_byte_budget_inflight_bytes_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Returns the count of blocking writer byte-budget waits.
|
||||
pub fn get_me_writer_byte_budget_wait_total(&self) -> u64 {
|
||||
self.me_writer_byte_budget_wait_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Returns the count of writer byte-budget wait timeouts.
|
||||
pub fn get_me_writer_byte_budget_timeout_total(&self) -> u64 {
|
||||
self.me_writer_byte_budget_timeout_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Returns the count of payloads that cannot fit the configured writer budget.
|
||||
pub fn get_me_writer_byte_budget_oversize_total(&self) -> u64 {
|
||||
self.me_writer_byte_budget_oversize_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_socks_kdf_strict_reject(&self) -> u64 {
|
||||
self.me_socks_kdf_strict_reject.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -274,6 +274,7 @@ pub struct Stats {
|
||||
buffer_pool_pooled_gauge: AtomicU64,
|
||||
buffer_pool_allocated_gauge: AtomicU64,
|
||||
buffer_pool_in_use_gauge: AtomicU64,
|
||||
buffer_pool_replaced_nonstandard_total: AtomicU64,
|
||||
// C2ME enqueue observability
|
||||
me_c2me_send_full_total: AtomicU64,
|
||||
me_c2me_send_high_water_total: AtomicU64,
|
||||
@@ -292,6 +293,12 @@ pub struct Stats {
|
||||
me_writer_pick_p2c_no_candidate_total: AtomicU64,
|
||||
me_writer_pick_blocking_fallback_total: AtomicU64,
|
||||
me_writer_pick_mode_switch_total: AtomicU64,
|
||||
me_writer_byte_budget_limit_bytes_gauge: AtomicU64,
|
||||
me_writer_byte_budget_queued_bytes_gauge: AtomicU64,
|
||||
me_writer_byte_budget_inflight_bytes_gauge: AtomicU64,
|
||||
me_writer_byte_budget_wait_total: AtomicU64,
|
||||
me_writer_byte_budget_timeout_total: AtomicU64,
|
||||
me_writer_byte_budget_oversize_total: AtomicU64,
|
||||
me_socks_kdf_strict_reject: AtomicU64,
|
||||
me_socks_kdf_compat_fallback: AtomicU64,
|
||||
secure_padding_invalid: AtomicU64,
|
||||
|
||||
@@ -88,6 +88,56 @@ impl Stats {
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
/// Publishes the configured resident-memory limit applied to each ME writer.
|
||||
pub fn set_me_writer_byte_budget_limit_bytes(&self, bytes: usize) {
|
||||
self.me_writer_byte_budget_limit_bytes_gauge
|
||||
.store(bytes as u64, Ordering::Relaxed);
|
||||
}
|
||||
pub(crate) fn add_me_writer_byte_budget_queued_bytes(&self, bytes: u64) {
|
||||
self.me_writer_byte_budget_queued_bytes_gauge
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
pub(crate) fn move_me_writer_byte_budget_to_inflight(&self, bytes: u64) {
|
||||
let _ = self.me_writer_byte_budget_queued_bytes_gauge.fetch_update(
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
|current| Some(current.saturating_sub(bytes)),
|
||||
);
|
||||
self.me_writer_byte_budget_inflight_bytes_gauge
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
pub(crate) fn release_me_writer_byte_budget_queued_bytes(&self, bytes: u64) {
|
||||
let _ = self.me_writer_byte_budget_queued_bytes_gauge.fetch_update(
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
|current| Some(current.saturating_sub(bytes)),
|
||||
);
|
||||
}
|
||||
pub(crate) fn release_me_writer_byte_budget_inflight_bytes(&self, bytes: u64) {
|
||||
let _ = self
|
||||
.me_writer_byte_budget_inflight_bytes_gauge
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
||||
Some(current.saturating_sub(bytes))
|
||||
});
|
||||
}
|
||||
pub(crate) fn increment_me_writer_byte_budget_wait_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_writer_byte_budget_wait_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub(crate) fn increment_me_writer_byte_budget_timeout_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_writer_byte_budget_timeout_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub(crate) fn increment_me_writer_byte_budget_oversize_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_writer_byte_budget_oversize_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_socks_kdf_strict_reject(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_socks_kdf_strict_reject
|
||||
@@ -502,6 +552,14 @@ impl Stats {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes the cumulative count of non-standard pool buffer replacements.
|
||||
pub fn set_buffer_pool_replaced_nonstandard_total(&self, value: usize) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.buffer_pool_replaced_nonstandard_total
|
||||
.store(value as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_me_c2me_send_full_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_c2me_send_full_total.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
+20
-18
@@ -68,8 +68,8 @@ use crate::crypto::AesCtr;
|
||||
/// Actual limit is supplied at runtime from configuration.
|
||||
const DEFAULT_MAX_PENDING_WRITE: usize = 64 * 1024;
|
||||
|
||||
/// Default read buffer capacity (reader mostly decrypts in-place into caller buffer).
|
||||
const DEFAULT_READ_CAPACITY: usize = 16 * 1024;
|
||||
/// Maximum scratch capacity retained after a completed write.
|
||||
const MAX_RETAINED_SCRATCH_CAPACITY: usize = 32 * 1024;
|
||||
|
||||
// ============= CryptoReader State =============
|
||||
|
||||
@@ -110,10 +110,6 @@ pub struct CryptoReader<R> {
|
||||
upstream: R,
|
||||
decryptor: AesCtr,
|
||||
state: CryptoReaderState,
|
||||
|
||||
/// Reserved for future coalescing optimizations.
|
||||
#[allow(dead_code)]
|
||||
read_buf: BytesMut,
|
||||
}
|
||||
|
||||
impl<R> CryptoReader<R> {
|
||||
@@ -122,7 +118,6 @@ impl<R> CryptoReader<R> {
|
||||
upstream,
|
||||
decryptor,
|
||||
state: CryptoReaderState::Idle,
|
||||
read_buf: BytesMut::with_capacity(DEFAULT_READ_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +316,7 @@ struct PendingCiphertext {
|
||||
impl PendingCiphertext {
|
||||
fn new(max_len: usize) -> Self {
|
||||
Self {
|
||||
buf: BytesMut::with_capacity(16 * 1024),
|
||||
buf: BytesMut::new(),
|
||||
pos: 0,
|
||||
max_len,
|
||||
}
|
||||
@@ -372,15 +367,12 @@ impl PendingCiphertext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the entire pending ciphertext by moving `src` in (swap, no copy).
|
||||
fn replace_with(&mut self, mut src: BytesMut) {
|
||||
/// Replace the entire pending ciphertext by moving `src` in without copying.
|
||||
fn replace_with(&mut self, src: BytesMut) {
|
||||
debug_assert!(src.len() <= self.max_len);
|
||||
|
||||
self.buf.clear();
|
||||
self.buf = src;
|
||||
self.pos = 0;
|
||||
|
||||
// Swap: keep allocations hot and avoid copying bytes.
|
||||
std::mem::swap(&mut self.buf, &mut src);
|
||||
}
|
||||
|
||||
/// Append plaintext and encrypt appended range in-place.
|
||||
@@ -465,7 +457,7 @@ impl<W> CryptoWriter<W> {
|
||||
upstream,
|
||||
encryptor,
|
||||
state: CryptoWriterState::Idle,
|
||||
scratch: BytesMut::with_capacity(16 * 1024),
|
||||
scratch: BytesMut::new(),
|
||||
max_pending_write: max_pending.max(4 * 1024),
|
||||
}
|
||||
}
|
||||
@@ -502,6 +494,7 @@ impl<W> CryptoWriter<W> {
|
||||
}
|
||||
|
||||
fn poison(&mut self, error: io::Error) {
|
||||
self.scratch = BytesMut::new();
|
||||
self.state = CryptoWriterState::Poisoned { error: Some(error) };
|
||||
}
|
||||
|
||||
@@ -552,6 +545,15 @@ impl<W> CryptoWriter<W> {
|
||||
scratch.extend_from_slice(plaintext);
|
||||
encryptor.apply(&mut scratch[..]);
|
||||
}
|
||||
|
||||
/// Clear reusable scratch while releasing allocations inflated by large writes.
|
||||
fn recycle_scratch(scratch: &mut BytesMut) {
|
||||
if scratch.capacity() > MAX_RETAINED_SCRATCH_CAPACITY {
|
||||
*scratch = BytesMut::new();
|
||||
} else {
|
||||
scratch.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: AsyncWrite + Unpin> CryptoWriter<W> {
|
||||
@@ -698,13 +700,13 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for CryptoWriter<W> {
|
||||
|
||||
Poll::Ready(Ok(n)) => {
|
||||
if n == this.scratch.len() {
|
||||
this.scratch.clear();
|
||||
Self::recycle_scratch(&mut this.scratch);
|
||||
return Poll::Ready(Ok(to_accept));
|
||||
}
|
||||
|
||||
// Partial upstream write of ciphertext
|
||||
let remainder = this.scratch.split_off(n);
|
||||
this.scratch.clear();
|
||||
let mut remainder = std::mem::take(&mut this.scratch);
|
||||
let _ = remainder.split_to(n);
|
||||
|
||||
let pending = Self::ensure_pending(&mut this.state, this.max_pending_write);
|
||||
pending.replace_with(remainder);
|
||||
|
||||
+33
-28
@@ -15,6 +15,7 @@ use crate::crypto::SecureRandom;
|
||||
use crate::protocol::constants::{
|
||||
ProtoTag, is_valid_secure_payload_len, secure_padding_len, secure_payload_len_from_wire_len,
|
||||
};
|
||||
use crate::protocol::framing::{encode_intermediate_header, parse_intermediate_header};
|
||||
|
||||
// ============= Unified Codec =============
|
||||
|
||||
@@ -197,13 +198,9 @@ fn decode_intermediate(src: &mut BytesMut, max_size: usize) -> io::Result<Option
|
||||
}
|
||||
|
||||
let mut meta = FrameMeta::new();
|
||||
let mut len = u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize;
|
||||
|
||||
// Check QuickACK flag
|
||||
if len >= 0x80000000 {
|
||||
meta.quickack = true;
|
||||
len -= 0x80000000;
|
||||
}
|
||||
let header = parse_intermediate_header([src[0], src[1], src[2], src[3]]);
|
||||
let len = header.wire_len;
|
||||
meta.quickack = header.quickack;
|
||||
|
||||
// Validate size
|
||||
if len > max_size {
|
||||
@@ -239,10 +236,12 @@ fn encode_intermediate(frame: &Frame, dst: &mut BytesMut) -> io::Result<()> {
|
||||
|
||||
dst.reserve(4 + data.len());
|
||||
|
||||
let mut len = data.len() as u32;
|
||||
if frame.meta.quickack {
|
||||
len |= 0x80000000;
|
||||
}
|
||||
let len = encode_intermediate_header(data.len(), frame.meta.quickack).ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("frame too large: {} bytes", data.len()),
|
||||
)
|
||||
})?;
|
||||
|
||||
dst.extend_from_slice(&len.to_le_bytes());
|
||||
dst.extend_from_slice(data);
|
||||
@@ -258,13 +257,9 @@ fn decode_secure(src: &mut BytesMut, max_size: usize) -> io::Result<Option<Frame
|
||||
}
|
||||
|
||||
let mut meta = FrameMeta::new();
|
||||
let mut len = u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize;
|
||||
|
||||
// Check QuickACK flag
|
||||
if len >= 0x80000000 {
|
||||
meta.quickack = true;
|
||||
len -= 0x80000000;
|
||||
}
|
||||
let header = parse_intermediate_header([src[0], src[1], src[2], src[3]]);
|
||||
let len = header.wire_len;
|
||||
meta.quickack = header.quickack;
|
||||
|
||||
// Validate size
|
||||
if len > max_size {
|
||||
@@ -317,16 +312,18 @@ fn encode_secure(frame: &Frame, dst: &mut BytesMut, rng: &SecureRandom) -> io::R
|
||||
));
|
||||
}
|
||||
|
||||
// Generate padding that keeps total length non-divisible by 4.
|
||||
// Outbound Secure padding avoids full-word tails that readers cannot strip.
|
||||
let padding_len = secure_padding_len(data.len(), rng);
|
||||
|
||||
let total_len = data.len() + padding_len;
|
||||
dst.reserve(4 + total_len);
|
||||
|
||||
let mut len = total_len as u32;
|
||||
if frame.meta.quickack {
|
||||
len |= 0x80000000;
|
||||
}
|
||||
let len = encode_intermediate_header(total_len, frame.meta.quickack).ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("frame too large: {} bytes", total_len),
|
||||
)
|
||||
})?;
|
||||
|
||||
dst.extend_from_slice(&len.to_le_bytes());
|
||||
dst.extend_from_slice(data);
|
||||
@@ -523,6 +520,10 @@ mod tests {
|
||||
use tokio::io::duplex;
|
||||
use tokio_util::codec::{FramedRead, FramedWrite};
|
||||
|
||||
fn assert_secure_decoded_payload(decoded: &[u8], original: &[u8]) {
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_framed_abridged() {
|
||||
let (client, server) = duplex(4096);
|
||||
@@ -565,7 +566,7 @@ mod tests {
|
||||
writer.send(frame).await.unwrap();
|
||||
|
||||
let received = reader.next().await.unwrap().unwrap();
|
||||
assert_eq!(&received.data[..], &original[..]);
|
||||
assert_secure_decoded_payload(&received.data, &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -588,7 +589,11 @@ mod tests {
|
||||
writer.send(frame).await.unwrap();
|
||||
|
||||
let received = reader.next().await.unwrap().unwrap();
|
||||
assert_eq!(received.data.len(), 8);
|
||||
if proto_tag == ProtoTag::Secure {
|
||||
assert_secure_decoded_payload(&received.data, &original);
|
||||
} else {
|
||||
assert_eq!(received.data.len(), original.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +647,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_codec_always_adds_padding_and_jitters_wire_length() {
|
||||
fn secure_codec_uses_non_aligned_padding_and_jitters_wire_length() {
|
||||
let codec = SecureCodec::new(Arc::new(SecureRandom::new()));
|
||||
let payload = Bytes::from_static(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
let mut wire_lens = HashSet::new();
|
||||
@@ -652,13 +657,13 @@ mod tests {
|
||||
let mut out = BytesMut::new();
|
||||
codec.encode(&frame, &mut out).unwrap();
|
||||
|
||||
assert!(out.len() > 4 + payload.len());
|
||||
let wire_len = u32::from_le_bytes([out[0], out[1], out[2], out[3]]) as usize;
|
||||
assert_eq!(out.len(), 4 + wire_len);
|
||||
assert!(
|
||||
(payload.len() + 1..=payload.len() + 3).contains(&wire_len),
|
||||
"Secure wire length must be payload+1..3, got {wire_len}"
|
||||
);
|
||||
assert_ne!(wire_len % 4, 0, "Secure wire length must be non-4-aligned");
|
||||
assert_ne!(wire_len % 4, 0);
|
||||
wire_lens.insert(wire_len);
|
||||
}
|
||||
|
||||
|
||||
+203
-40
@@ -5,21 +5,47 @@
|
||||
use super::traits::{FrameMeta, LayeredStream};
|
||||
use crate::crypto::{SecureRandom, crc32};
|
||||
use crate::protocol::constants::*;
|
||||
use crate::protocol::framing::{encode_intermediate_header, parse_intermediate_header};
|
||||
use bytes::Bytes;
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
const DEFAULT_MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn reject_oversize_frame(len: usize, max_frame_size: usize, protocol: &str) -> Result<()> {
|
||||
if len > max_frame_size {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("{protocol} frame too large: {len} bytes (max {max_frame_size})"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============= Abridged (Compact) Frame =============
|
||||
|
||||
/// Reader for abridged MTProto framing
|
||||
pub struct AbridgedFrameReader<R> {
|
||||
upstream: R,
|
||||
max_frame_size: usize,
|
||||
}
|
||||
|
||||
impl<R> AbridgedFrameReader<R> {
|
||||
/// Creates a reader with the default maximum frame size.
|
||||
pub fn new(upstream: R) -> Self {
|
||||
Self { upstream }
|
||||
Self {
|
||||
upstream,
|
||||
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_max_frame_size(upstream: R, max_frame_size: usize) -> Self {
|
||||
Self {
|
||||
upstream,
|
||||
max_frame_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +73,12 @@ impl<R: AsyncRead + Unpin> AbridgedFrameReader<R> {
|
||||
len = u32::from_le_bytes([len_bytes[0], len_bytes[1], len_bytes[2], 0]) as usize;
|
||||
}
|
||||
|
||||
// Length is in 4-byte words
|
||||
let byte_len = len * 4;
|
||||
// Length is in 4-byte words.
|
||||
let byte_len = len
|
||||
.checked_mul(4)
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "abridged frame length overflow"))?;
|
||||
reject_oversize_frame(byte_len, self.max_frame_size, "abridged")?;
|
||||
|
||||
// Read data
|
||||
let mut data = vec![0u8; byte_len];
|
||||
self.upstream.read_exact(&mut data).await?;
|
||||
|
||||
@@ -105,10 +133,17 @@ impl<W: AsyncWrite + Unpin> AbridgedFrameWriter<W> {
|
||||
|
||||
if len_div_4 < 0x7f {
|
||||
// Short length (1 byte)
|
||||
self.upstream.write_all(&[len_div_4 as u8]).await?;
|
||||
let mut first = len_div_4 as u8;
|
||||
if meta.quickack {
|
||||
first |= 0x80;
|
||||
}
|
||||
self.upstream.write_all(&[first]).await?;
|
||||
} else if len_div_4 < (1 << 24) {
|
||||
// Long length (4 bytes: 0x7f + 3 bytes)
|
||||
let mut header = [0x7f, 0, 0, 0];
|
||||
if meta.quickack {
|
||||
header[0] |= 0x80;
|
||||
}
|
||||
header[1..4].copy_from_slice(&(len_div_4 as u32).to_le_bytes()[..3]);
|
||||
self.upstream.write_all(&header).await?;
|
||||
} else {
|
||||
@@ -144,11 +179,23 @@ impl<W> LayeredStream<W> for AbridgedFrameWriter<W> {
|
||||
/// Reader for intermediate MTProto framing
|
||||
pub struct IntermediateFrameReader<R> {
|
||||
upstream: R,
|
||||
max_frame_size: usize,
|
||||
}
|
||||
|
||||
impl<R> IntermediateFrameReader<R> {
|
||||
/// Creates a reader with the default maximum frame size.
|
||||
pub fn new(upstream: R) -> Self {
|
||||
Self { upstream }
|
||||
Self {
|
||||
upstream,
|
||||
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_max_frame_size(upstream: R, max_frame_size: usize) -> Self {
|
||||
Self {
|
||||
upstream,
|
||||
max_frame_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,15 +207,11 @@ impl<R: AsyncRead + Unpin> IntermediateFrameReader<R> {
|
||||
let mut len_bytes = [0u8; 4];
|
||||
self.upstream.read_exact(&mut len_bytes).await?;
|
||||
|
||||
let mut len = u32::from_le_bytes(len_bytes) as usize;
|
||||
let header = parse_intermediate_header(len_bytes);
|
||||
let len = header.wire_len;
|
||||
meta.quickack = header.quickack;
|
||||
reject_oversize_frame(len, self.max_frame_size, "intermediate")?;
|
||||
|
||||
// Check QuickACK flag (high bit)
|
||||
if len > 0x80000000 {
|
||||
meta.quickack = true;
|
||||
len -= 0x80000000;
|
||||
}
|
||||
|
||||
// Read data
|
||||
let mut data = vec![0u8; len];
|
||||
self.upstream.read_exact(&mut data).await?;
|
||||
|
||||
@@ -204,7 +247,13 @@ impl<W: AsyncWrite + Unpin> IntermediateFrameWriter<W> {
|
||||
if meta.simple_ack {
|
||||
self.upstream.write_all(data).await?;
|
||||
} else {
|
||||
let len_bytes = (data.len() as u32).to_le_bytes();
|
||||
let len = encode_intermediate_header(data.len(), meta.quickack).ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("Frame too large: {} bytes", data.len()),
|
||||
)
|
||||
})?;
|
||||
let len_bytes = len.to_le_bytes();
|
||||
self.upstream.write_all(&len_bytes).await?;
|
||||
self.upstream.write_all(data).await?;
|
||||
}
|
||||
@@ -233,11 +282,23 @@ impl<W> LayeredStream<W> for IntermediateFrameWriter<W> {
|
||||
/// Reader for secure intermediate MTProto framing (with padding)
|
||||
pub struct SecureIntermediateFrameReader<R> {
|
||||
upstream: R,
|
||||
max_frame_size: usize,
|
||||
}
|
||||
|
||||
impl<R> SecureIntermediateFrameReader<R> {
|
||||
/// Creates a reader with the default maximum frame size.
|
||||
pub fn new(upstream: R) -> Self {
|
||||
Self { upstream }
|
||||
Self {
|
||||
upstream,
|
||||
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_max_frame_size(upstream: R, max_frame_size: usize) -> Self {
|
||||
Self {
|
||||
upstream,
|
||||
max_frame_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,24 +310,19 @@ impl<R: AsyncRead + Unpin> SecureIntermediateFrameReader<R> {
|
||||
let mut len_bytes = [0u8; 4];
|
||||
self.upstream.read_exact(&mut len_bytes).await?;
|
||||
|
||||
let mut len = u32::from_le_bytes(len_bytes) as usize;
|
||||
|
||||
// Check QuickACK flag
|
||||
if len > 0x80000000 {
|
||||
meta.quickack = true;
|
||||
len -= 0x80000000;
|
||||
}
|
||||
|
||||
// Read data (including padding)
|
||||
let mut data = vec![0u8; len];
|
||||
self.upstream.read_exact(&mut data).await?;
|
||||
|
||||
let header = parse_intermediate_header(len_bytes);
|
||||
let len = header.wire_len;
|
||||
meta.quickack = header.quickack;
|
||||
reject_oversize_frame(len, self.max_frame_size, "secure intermediate")?;
|
||||
let payload_len = secure_payload_len_from_wire_len(len).ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("Invalid secure frame length: {len}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut data = vec![0u8; len];
|
||||
self.upstream.read_exact(&mut data).await?;
|
||||
data.truncate(payload_len);
|
||||
|
||||
Ok((Bytes::from(data), meta))
|
||||
@@ -311,12 +367,21 @@ impl<W: AsyncWrite + Unpin> SecureIntermediateFrameWriter<W> {
|
||||
));
|
||||
}
|
||||
|
||||
// Add padding so total length is never divisible by 4 (MTProto Secure)
|
||||
// Outbound Secure padding avoids full-word tails that readers cannot strip.
|
||||
let padding_len = secure_padding_len(data.len(), &self.rng);
|
||||
let padding = self.rng.bytes(padding_len);
|
||||
|
||||
let total_len = data.len() + padding_len;
|
||||
let len_bytes = (total_len as u32).to_le_bytes();
|
||||
let total_len = data
|
||||
.len()
|
||||
.checked_add(padding_len)
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, "secure frame length overflow"))?;
|
||||
let len = encode_intermediate_header(total_len, meta.quickack).ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("Frame too large: {total_len} bytes"),
|
||||
)
|
||||
})?;
|
||||
let len_bytes = len.to_le_bytes();
|
||||
|
||||
self.upstream.write_all(&len_bytes).await?;
|
||||
self.upstream.write_all(data).await?;
|
||||
@@ -495,15 +560,22 @@ pub enum FrameReaderKind<R> {
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> FrameReaderKind<R> {
|
||||
/// Creates a frame reader with the default maximum frame size.
|
||||
pub fn new(upstream: R, proto_tag: ProtoTag) -> Self {
|
||||
Self::with_max_frame_size(upstream, proto_tag, DEFAULT_MAX_FRAME_SIZE)
|
||||
}
|
||||
|
||||
fn with_max_frame_size(upstream: R, proto_tag: ProtoTag, max_frame_size: usize) -> Self {
|
||||
match proto_tag {
|
||||
ProtoTag::Abridged => FrameReaderKind::Abridged(AbridgedFrameReader::new(upstream)),
|
||||
ProtoTag::Intermediate => {
|
||||
FrameReaderKind::Intermediate(IntermediateFrameReader::new(upstream))
|
||||
}
|
||||
ProtoTag::Secure => {
|
||||
FrameReaderKind::SecureIntermediate(SecureIntermediateFrameReader::new(upstream))
|
||||
}
|
||||
ProtoTag::Abridged => FrameReaderKind::Abridged(
|
||||
AbridgedFrameReader::with_max_frame_size(upstream, max_frame_size),
|
||||
),
|
||||
ProtoTag::Intermediate => FrameReaderKind::Intermediate(
|
||||
IntermediateFrameReader::with_max_frame_size(upstream, max_frame_size),
|
||||
),
|
||||
ProtoTag::Secure => FrameReaderKind::SecureIntermediate(
|
||||
SecureIntermediateFrameReader::with_max_frame_size(upstream, max_frame_size),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +629,12 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::SecureRandom;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::duplex;
|
||||
use tokio::io::{AsyncWriteExt, duplex};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
fn assert_secure_decoded_payload(decoded: &[u8], original: &[u8]) {
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_abridged_roundtrip() {
|
||||
@@ -613,6 +690,92 @@ mod tests {
|
||||
assert_eq!(&received[..], &data[..]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_intermediate_quickack_zero_length_roundtrip() {
|
||||
let (client, server) = duplex(1024);
|
||||
|
||||
let mut writer = IntermediateFrameWriter::new(client);
|
||||
let mut reader = IntermediateFrameReader::new(server);
|
||||
|
||||
writer
|
||||
.write_frame(&[], &FrameMeta::new().with_quickack())
|
||||
.await
|
||||
.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
let (received, meta) = reader.read_frame().await.unwrap();
|
||||
assert!(received.is_empty());
|
||||
assert!(meta.quickack);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_abridged_quickack_roundtrip() {
|
||||
let (client, server) = duplex(1024);
|
||||
|
||||
let mut writer = AbridgedFrameWriter::new(client);
|
||||
let mut reader = AbridgedFrameReader::new(server);
|
||||
|
||||
let data = vec![1u8, 2, 3, 4];
|
||||
writer
|
||||
.write_frame(&data, &FrameMeta::new().with_quickack())
|
||||
.await
|
||||
.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
let (received, meta) = reader.read_frame().await.unwrap();
|
||||
assert_eq!(&received[..], &data[..]);
|
||||
assert!(meta.quickack);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abridged_reader_rejects_oversize_frame_before_body_read() {
|
||||
let (mut client, server) = duplex(1024);
|
||||
let mut reader = AbridgedFrameReader::new(server);
|
||||
let len_words = (DEFAULT_MAX_FRAME_SIZE / 4) + 1;
|
||||
let encoded = (len_words as u32).to_le_bytes();
|
||||
|
||||
client
|
||||
.write_all(&[0x7f, encoded[0], encoded[1], encoded[2]])
|
||||
.await
|
||||
.unwrap();
|
||||
let err = timeout(Duration::from_millis(50), reader.read_frame())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intermediate_reader_rejects_oversize_frame_before_body_read() {
|
||||
let (mut client, server) = duplex(1024);
|
||||
let mut reader = IntermediateFrameReader::new(server);
|
||||
let len = encode_intermediate_header(DEFAULT_MAX_FRAME_SIZE + 1, false).unwrap();
|
||||
|
||||
client.write_all(&len.to_le_bytes()).await.unwrap();
|
||||
let err = timeout(Duration::from_millis(50), reader.read_frame())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_reader_rejects_oversize_frame_before_body_read() {
|
||||
let (mut client, server) = duplex(1024);
|
||||
let mut reader = SecureIntermediateFrameReader::new(server);
|
||||
let len = encode_intermediate_header(DEFAULT_MAX_FRAME_SIZE + 4, false).unwrap();
|
||||
|
||||
client.write_all(&len.to_le_bytes()).await.unwrap();
|
||||
let err = timeout(Duration::from_millis(50), reader.read_frame())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_secure_intermediate_padding() {
|
||||
let (client, server) = duplex(1024);
|
||||
@@ -625,7 +788,7 @@ mod tests {
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
let (received, _meta) = reader.read_frame().await.unwrap();
|
||||
assert_eq!(received.len(), data.len());
|
||||
assert_secure_decoded_payload(&received, &data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+66
-34
@@ -40,7 +40,7 @@ use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
|
||||
use super::state::{HeaderBuffer, StreamState, WriteBuffer, YieldBuffer};
|
||||
use super::state::{HeaderBuffer, StreamState, YieldBuffer};
|
||||
use crate::protocol::constants::{
|
||||
MAX_TLS_CIPHERTEXT_SIZE, MAX_TLS_PLAINTEXT_SIZE, TLS_RECORD_ALERT, TLS_RECORD_APPLICATION,
|
||||
TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_VERSION,
|
||||
@@ -59,6 +59,9 @@ const MAX_TLS_PAYLOAD: usize = MAX_TLS_CIPHERTEXT_SIZE;
|
||||
/// Note: we never queue unlimited amount of data here; state holds at most one record.
|
||||
const MAX_PENDING_WRITE: usize = 64 * 1024;
|
||||
|
||||
/// Maximum record buffer capacity retained between writes.
|
||||
const MAX_RETAINED_RECORD_CAPACITY: usize = 2 * (TLS_HEADER_SIZE + MAX_TLS_PAYLOAD);
|
||||
|
||||
// ============= TLS Record Types =============
|
||||
|
||||
/// Parsed TLS record header (5 bytes)
|
||||
@@ -639,7 +642,7 @@ enum TlsWriterState {
|
||||
|
||||
/// Writing a complete TLS record (header + body), possibly partially
|
||||
WritingRecord {
|
||||
record: WriteBuffer,
|
||||
position: usize,
|
||||
payload_size: usize,
|
||||
},
|
||||
|
||||
@@ -676,6 +679,7 @@ impl StreamState for TlsWriterState {
|
||||
pub struct FakeTlsWriter<W> {
|
||||
upstream: W,
|
||||
state: TlsWriterState,
|
||||
record_buffer: BytesMut,
|
||||
}
|
||||
|
||||
impl<W> FakeTlsWriter<W> {
|
||||
@@ -683,6 +687,7 @@ impl<W> FakeTlsWriter<W> {
|
||||
Self {
|
||||
upstream,
|
||||
state: TlsWriterState::Idle,
|
||||
record_buffer: BytesMut::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,10 +709,15 @@ impl<W> FakeTlsWriter<W> {
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
matches!(&self.state, TlsWriterState::WritingRecord { record, .. } if !record.is_empty())
|
||||
matches!(
|
||||
&self.state,
|
||||
TlsWriterState::WritingRecord { position, .. }
|
||||
if *position < self.record_buffer.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn poison(&mut self, error: io::Error) {
|
||||
self.record_buffer = BytesMut::new();
|
||||
self.state = TlsWriterState::Poisoned { error: Some(error) };
|
||||
}
|
||||
|
||||
@@ -720,17 +730,26 @@ impl<W> FakeTlsWriter<W> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_record(data: &[u8]) -> BytesMut {
|
||||
fn build_record(&mut self, data: &[u8]) {
|
||||
let header = TlsRecordHeader {
|
||||
record_type: TLS_RECORD_APPLICATION,
|
||||
version: TLS_VERSION,
|
||||
length: data.len() as u16,
|
||||
};
|
||||
|
||||
let mut record = BytesMut::with_capacity(TLS_HEADER_SIZE + data.len());
|
||||
record.extend_from_slice(&header.to_bytes());
|
||||
record.extend_from_slice(data);
|
||||
record
|
||||
self.record_buffer.clear();
|
||||
self.record_buffer.reserve(TLS_HEADER_SIZE + data.len());
|
||||
self.record_buffer.extend_from_slice(&header.to_bytes());
|
||||
self.record_buffer.extend_from_slice(data);
|
||||
debug_assert!(self.record_buffer.len() <= MAX_PENDING_WRITE);
|
||||
}
|
||||
|
||||
fn recycle_record_buffer(&mut self) {
|
||||
if self.record_buffer.capacity() > MAX_RETAINED_RECORD_CAPACITY {
|
||||
self.record_buffer = BytesMut::new();
|
||||
} else {
|
||||
self.record_buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,10 +763,11 @@ impl<W: AsyncWrite + Unpin> FakeTlsWriter<W> {
|
||||
fn poll_flush_record_inner(
|
||||
upstream: &mut W,
|
||||
cx: &mut Context<'_>,
|
||||
record: &mut WriteBuffer,
|
||||
record: &[u8],
|
||||
position: &mut usize,
|
||||
) -> FlushResult {
|
||||
while !record.is_empty() {
|
||||
let data = record.pending();
|
||||
while *position < record.len() {
|
||||
let data = &record[*position..];
|
||||
match Pin::new(&mut *upstream).poll_write(cx, data) {
|
||||
Poll::Pending => return FlushResult::Pending,
|
||||
Poll::Ready(Err(e)) => return FlushResult::Error(e),
|
||||
@@ -757,7 +777,7 @@ impl<W: AsyncWrite + Unpin> FakeTlsWriter<W> {
|
||||
"upstream returned 0 bytes written",
|
||||
));
|
||||
}
|
||||
Poll::Ready(Ok(n)) => record.advance(n),
|
||||
Poll::Ready(Ok(n)) => *position += n,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,14 +800,19 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
}
|
||||
|
||||
TlsWriterState::WritingRecord {
|
||||
mut record,
|
||||
mut position,
|
||||
payload_size,
|
||||
} => {
|
||||
// Finish writing previous record before accepting new bytes.
|
||||
match Self::poll_flush_record_inner(&mut this.upstream, cx, &mut record) {
|
||||
match Self::poll_flush_record_inner(
|
||||
&mut this.upstream,
|
||||
cx,
|
||||
&this.record_buffer,
|
||||
&mut position,
|
||||
) {
|
||||
FlushResult::Pending => {
|
||||
this.state = TlsWriterState::WritingRecord {
|
||||
record,
|
||||
position,
|
||||
payload_size,
|
||||
};
|
||||
return Poll::Pending;
|
||||
@@ -797,6 +822,7 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
FlushResult::Complete(_) => {
|
||||
this.recycle_record_buffer();
|
||||
this.state = TlsWriterState::Idle;
|
||||
// continue to accept new buf below
|
||||
}
|
||||
@@ -818,19 +844,17 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
let chunk = &buf[..chunk_size];
|
||||
|
||||
// Build the complete record (header + payload)
|
||||
let record_data = Self::build_record(chunk);
|
||||
this.build_record(chunk);
|
||||
|
||||
match Pin::new(&mut this.upstream).poll_write(cx, &record_data) {
|
||||
Poll::Ready(Ok(n)) if n == record_data.len() => Poll::Ready(Ok(chunk_size)),
|
||||
match Pin::new(&mut this.upstream).poll_write(cx, &this.record_buffer) {
|
||||
Poll::Ready(Ok(n)) if n == this.record_buffer.len() => {
|
||||
this.recycle_record_buffer();
|
||||
Poll::Ready(Ok(chunk_size))
|
||||
}
|
||||
|
||||
Poll::Ready(Ok(n)) => {
|
||||
// Partial write of the record: store remainder.
|
||||
let mut write_buffer = WriteBuffer::with_max_size(MAX_PENDING_WRITE);
|
||||
// record_data length is <= 16389, fits MAX_PENDING_WRITE
|
||||
let _ = write_buffer.extend(&record_data[n..]);
|
||||
|
||||
this.state = TlsWriterState::WritingRecord {
|
||||
record: write_buffer,
|
||||
position: n,
|
||||
payload_size: chunk_size,
|
||||
};
|
||||
|
||||
@@ -844,12 +868,8 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
}
|
||||
|
||||
Poll::Pending => {
|
||||
// Buffer entire record and report success for this chunk.
|
||||
let mut write_buffer = WriteBuffer::with_max_size(MAX_PENDING_WRITE);
|
||||
let _ = write_buffer.extend(&record_data);
|
||||
|
||||
this.state = TlsWriterState::WritingRecord {
|
||||
record: write_buffer,
|
||||
position: 0,
|
||||
payload_size: chunk_size,
|
||||
};
|
||||
|
||||
@@ -871,12 +891,17 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
}
|
||||
|
||||
TlsWriterState::WritingRecord {
|
||||
mut record,
|
||||
mut position,
|
||||
payload_size,
|
||||
} => match Self::poll_flush_record_inner(&mut this.upstream, cx, &mut record) {
|
||||
} => match Self::poll_flush_record_inner(
|
||||
&mut this.upstream,
|
||||
cx,
|
||||
&this.record_buffer,
|
||||
&mut position,
|
||||
) {
|
||||
FlushResult::Pending => {
|
||||
this.state = TlsWriterState::WritingRecord {
|
||||
record,
|
||||
position,
|
||||
payload_size,
|
||||
};
|
||||
return Poll::Pending;
|
||||
@@ -886,6 +911,7 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
FlushResult::Complete(_) => {
|
||||
this.recycle_record_buffer();
|
||||
this.state = TlsWriterState::Idle;
|
||||
}
|
||||
},
|
||||
@@ -905,11 +931,17 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for FakeTlsWriter<W> {
|
||||
|
||||
match state {
|
||||
TlsWriterState::WritingRecord {
|
||||
mut record,
|
||||
mut position,
|
||||
payload_size: _,
|
||||
} => {
|
||||
// Best-effort flush (do not block shutdown forever).
|
||||
let _ = Self::poll_flush_record_inner(&mut this.upstream, cx, &mut record);
|
||||
let _ = Self::poll_flush_record_inner(
|
||||
&mut this.upstream,
|
||||
cx,
|
||||
&this.record_buffer,
|
||||
&mut position,
|
||||
);
|
||||
this.recycle_record_buffer();
|
||||
this.state = TlsWriterState::Idle;
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -1,601 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::watch;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::{ProxyConfig, SynLimitMode};
|
||||
|
||||
const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT";
|
||||
const IPTABLES_HASHLIMIT_NAME: &str = "TELEMT-BUMPER";
|
||||
const NFT_TABLE: &str = "telemt_synlimit";
|
||||
const NFT_CHAIN: &str = "input";
|
||||
type SynLimitTarget = (Option<IpAddr>, u16, u32, u32, u32);
|
||||
|
||||
#[derive(Default)]
|
||||
struct SynLimitTargets {
|
||||
iptables_v4: Vec<SynLimitTarget>,
|
||||
iptables_v6: Vec<SynLimitTarget>,
|
||||
nft_v4: Vec<SynLimitTarget>,
|
||||
nft_v6: Vec<SynLimitTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NftTableFamilies {
|
||||
inet: bool,
|
||||
ip: bool,
|
||||
ip6: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum NftFamily {
|
||||
Inet,
|
||||
Ip,
|
||||
Ip6,
|
||||
}
|
||||
|
||||
struct NftApplyPlan<'a> {
|
||||
family: NftFamily,
|
||||
v4_targets: &'a [SynLimitTarget],
|
||||
v6_targets: &'a [SynLimitTarget],
|
||||
}
|
||||
|
||||
impl SynLimitTargets {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.iptables_v4.is_empty()
|
||||
&& self.iptables_v6.is_empty()
|
||||
&& self.nft_v4.is_empty()
|
||||
&& self.nft_v6.is_empty()
|
||||
}
|
||||
|
||||
fn has_iptables_targets(&self) -> bool {
|
||||
!self.iptables_v4.is_empty() || !self.iptables_v6.is_empty()
|
||||
}
|
||||
|
||||
fn has_nft_targets(&self) -> bool {
|
||||
!self.nft_v4.is_empty() || !self.nft_v6.is_empty()
|
||||
}
|
||||
}
|
||||
impl NftFamily {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inet => "inet",
|
||||
Self::Ip => "ip",
|
||||
Self::Ip6 => "ip6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<Arc<ProxyConfig>>) {
|
||||
if !cfg!(target_os = "linux") {
|
||||
if has_synlimit_config(&config_rx.borrow()) {
|
||||
warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
wait_for_config_channel_close_and_reconcile(config_rx).await;
|
||||
if let Err(error) = clear_synlimit_rules_all_backends().await {
|
||||
warn!(error = %error, "Failed to clear SYN limiter rules after config channel close");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn wait_for_config_channel_close_and_reconcile(
|
||||
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||
) {
|
||||
while config_rx.changed().await.is_ok() {
|
||||
let cfg = config_rx.borrow_and_update().clone();
|
||||
reconcile_synlimit_rules(&cfg).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
|
||||
if let Err(error) = clear_synlimit_rules_all_backends().await {
|
||||
warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile");
|
||||
}
|
||||
|
||||
let targets = synlimit_targets(cfg);
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !has_cap_net_admin() {
|
||||
warn!(
|
||||
"SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if targets.has_iptables_targets()
|
||||
&& let Err(error) = apply_iptables_synlimit_rules(&targets).await
|
||||
{
|
||||
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
|
||||
}
|
||||
if targets.has_nft_targets()
|
||||
&& let Err(error) = apply_nft_synlimit_rules(&targets).await
|
||||
{
|
||||
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
if let Err(error) = clear_nft_synlimit_rules_all_families().await {
|
||||
errors.push(error);
|
||||
}
|
||||
if let Err(error) = clear_iptables_synlimit_rules_for_binary("iptables").await {
|
||||
errors.push(error);
|
||||
}
|
||||
if let Err(error) = clear_iptables_synlimit_rules_for_binary("ip6tables").await {
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
|
||||
cfg.server
|
||||
.listeners
|
||||
.iter()
|
||||
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
|
||||
}
|
||||
|
||||
fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets {
|
||||
let mut iptables_v4 = BTreeSet::new();
|
||||
let mut iptables_v6 = BTreeSet::new();
|
||||
let mut nft_v4 = BTreeSet::new();
|
||||
let mut nft_v6 = BTreeSet::new();
|
||||
|
||||
for listener in &cfg.server.listeners {
|
||||
let backend = listener.synlimit;
|
||||
if matches!(backend, SynLimitMode::Off) {
|
||||
continue;
|
||||
}
|
||||
let port = listener.port.unwrap_or(cfg.server.port);
|
||||
let ip = (!listener.ip.is_unspecified()).then_some(listener.ip);
|
||||
let seconds = listener.synlimit_seconds;
|
||||
let hitcount = listener.synlimit_hitcount;
|
||||
let burst = listener.synlimit_burst;
|
||||
|
||||
match (backend, listener.ip.is_ipv4()) {
|
||||
(SynLimitMode::Iptables, true) => {
|
||||
iptables_v4.insert((ip, port, seconds, hitcount, burst));
|
||||
}
|
||||
(SynLimitMode::Iptables, false) => {
|
||||
iptables_v6.insert((ip, port, seconds, hitcount, burst));
|
||||
}
|
||||
(SynLimitMode::Nftables, true) => {
|
||||
nft_v4.insert((ip, port, seconds, hitcount, burst));
|
||||
}
|
||||
(SynLimitMode::Nftables, false) => {
|
||||
nft_v6.insert((ip, port, seconds, hitcount, burst));
|
||||
}
|
||||
(SynLimitMode::Off, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
SynLimitTargets {
|
||||
iptables_v4: iptables_v4.into_iter().collect(),
|
||||
iptables_v6: iptables_v6.into_iter().collect(),
|
||||
nft_v4: nft_v4.into_iter().collect(),
|
||||
nft_v6: nft_v6.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
|
||||
apply_iptables_synlimit_rules_for_binary("iptables", &targets.iptables_v4).await?;
|
||||
apply_iptables_synlimit_rules_for_binary("ip6tables", &targets.iptables_v6).await
|
||||
}
|
||||
|
||||
async fn apply_iptables_synlimit_rules_for_binary(
|
||||
binary: &str,
|
||||
targets: &[SynLimitTarget],
|
||||
) -> Result<(), String> {
|
||||
if targets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await;
|
||||
run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?;
|
||||
if run_command(
|
||||
binary,
|
||||
&["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
run_command(
|
||||
binary,
|
||||
&["-t", "filter", "-A", "INPUT", "-j", IPTABLES_CHAIN],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (idx, (ip, port, seconds, hitcount, burst)) in targets.iter().enumerate() {
|
||||
let hashlimit_name = format!("{IPTABLES_HASHLIMIT_NAME}-{idx}");
|
||||
let accept_args = iptables_hashlimit_accept_rule_args(
|
||||
ip,
|
||||
*port,
|
||||
*seconds,
|
||||
*hitcount,
|
||||
*burst,
|
||||
&hashlimit_name,
|
||||
);
|
||||
let drop_args = iptables_synlimit_drop_rule_args(ip, *port);
|
||||
let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect();
|
||||
let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect();
|
||||
run_command(binary, &accept_refs, None).await?;
|
||||
run_command(binary, &drop_refs, None).await?;
|
||||
}
|
||||
run_command(
|
||||
binary,
|
||||
&["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn iptables_hashlimit_accept_rule_args(
|
||||
ip: &Option<IpAddr>,
|
||||
port: u16,
|
||||
seconds: u32,
|
||||
hitcount: u32,
|
||||
burst: u32,
|
||||
hashlimit_name: &str,
|
||||
) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-t".to_string(),
|
||||
"filter".to_string(),
|
||||
"-A".to_string(),
|
||||
IPTABLES_CHAIN.to_string(),
|
||||
"-p".to_string(),
|
||||
"tcp".to_string(),
|
||||
"--syn".to_string(),
|
||||
];
|
||||
if let Some(ip) = ip {
|
||||
args.push("-d".to_string());
|
||||
args.push(ip.to_string());
|
||||
}
|
||||
let rate = synlimit_rate_arg(seconds, hitcount);
|
||||
args.extend([
|
||||
"--dport".to_string(),
|
||||
port.to_string(),
|
||||
"-m".to_string(),
|
||||
"hashlimit".to_string(),
|
||||
"--hashlimit-name".to_string(),
|
||||
hashlimit_name.to_string(),
|
||||
"--hashlimit-mode".to_string(),
|
||||
"srcip".to_string(),
|
||||
"--hashlimit-upto".to_string(),
|
||||
rate,
|
||||
"--hashlimit-burst".to_string(),
|
||||
burst.to_string(),
|
||||
"--hashlimit-htable-expire".to_string(),
|
||||
"15000".to_string(),
|
||||
"-j".to_string(),
|
||||
"ACCEPT".to_string(),
|
||||
]);
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_synlimit_drop_rule_args(ip: &Option<IpAddr>, port: u16) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-t".to_string(),
|
||||
"filter".to_string(),
|
||||
"-A".to_string(),
|
||||
IPTABLES_CHAIN.to_string(),
|
||||
"-p".to_string(),
|
||||
"tcp".to_string(),
|
||||
"--syn".to_string(),
|
||||
];
|
||||
if let Some(ip) = ip {
|
||||
args.push("-d".to_string());
|
||||
args.push(ip.to_string());
|
||||
}
|
||||
args.extend([
|
||||
"--dport".to_string(),
|
||||
port.to_string(),
|
||||
"-j".to_string(),
|
||||
"DROP".to_string(),
|
||||
]);
|
||||
args
|
||||
}
|
||||
|
||||
fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
|
||||
let seconds = u64::from(seconds.max(1));
|
||||
let hitcount = u64::from(hitcount.max(1));
|
||||
for (unit_seconds, unit_name) in [
|
||||
(1_u64, "second"),
|
||||
(60_u64, "minute"),
|
||||
(3_600_u64, "hour"),
|
||||
(86_400_u64, "day"),
|
||||
] {
|
||||
let amount = hitcount.saturating_mul(unit_seconds);
|
||||
if amount >= seconds && amount % seconds == 0 {
|
||||
return format!("{}/{}", amount / seconds, unit_name);
|
||||
}
|
||||
}
|
||||
let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds;
|
||||
format!("{}/day", amount.max(1))
|
||||
}
|
||||
|
||||
async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
for _ in 0..8 {
|
||||
match run_command(
|
||||
binary,
|
||||
&["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await
|
||||
&& !is_missing_command_or_iptables_rule(&error)
|
||||
{
|
||||
errors.push(format!("{binary} flush chain failed: {error}"));
|
||||
}
|
||||
if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await
|
||||
&& !is_missing_command_or_iptables_rule(&error)
|
||||
{
|
||||
errors.push(format!("{binary} delete chain failed: {error}"));
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_nft_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
|
||||
let families = detect_nft_table_families().await;
|
||||
for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) {
|
||||
let script = nft_synlimit_script(plan);
|
||||
run_command("nft", &["-f", "-"], Some(script)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_nft_table_families() -> NftTableFamilies {
|
||||
let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else {
|
||||
return NftTableFamilies {
|
||||
inet: false,
|
||||
ip: false,
|
||||
ip6: false,
|
||||
};
|
||||
};
|
||||
|
||||
let mut families = NftTableFamilies {
|
||||
inet: false,
|
||||
ip: false,
|
||||
ip6: false,
|
||||
};
|
||||
for line in output.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
if fields.next() != Some("table") {
|
||||
continue;
|
||||
}
|
||||
match fields.next() {
|
||||
Some("inet") => families.inet = true,
|
||||
Some("ip") => families.ip = true,
|
||||
Some("ip6") => families.ip6 = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
families
|
||||
}
|
||||
fn nft_apply_plan<'a>(
|
||||
families: NftTableFamilies,
|
||||
v4_targets: &'a [SynLimitTarget],
|
||||
v6_targets: &'a [SynLimitTarget],
|
||||
) -> Vec<NftApplyPlan<'a>> {
|
||||
if !v4_targets.is_empty() && !v6_targets.is_empty() {
|
||||
return vec![NftApplyPlan {
|
||||
family: NftFamily::Inet,
|
||||
v4_targets,
|
||||
v6_targets,
|
||||
}];
|
||||
}
|
||||
if !v4_targets.is_empty() {
|
||||
return vec![NftApplyPlan {
|
||||
family: if families.inet || !families.ip {
|
||||
NftFamily::Inet
|
||||
} else {
|
||||
NftFamily::Ip
|
||||
},
|
||||
v4_targets,
|
||||
v6_targets: &[],
|
||||
}];
|
||||
}
|
||||
if !v6_targets.is_empty() {
|
||||
return vec![NftApplyPlan {
|
||||
family: if families.inet || !families.ip6 {
|
||||
NftFamily::Inet
|
||||
} else {
|
||||
NftFamily::Ip6
|
||||
},
|
||||
v4_targets: &[],
|
||||
v6_targets,
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str()));
|
||||
script.push_str(&format!(" chain {NFT_CHAIN} {{\n"));
|
||||
script.push_str(" type filter hook input priority filter; policy accept;\n");
|
||||
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v4_targets.iter().enumerate() {
|
||||
let daddr = ip
|
||||
.map(|ip| format!(" ip daddr {ip}"))
|
||||
.unwrap_or_else(String::new);
|
||||
let rate = synlimit_rate_arg(*seconds, *hitcount);
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v4_{idx} {{ ip saddr limit rate over {rate} burst {burst} packets }} drop\n"
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
|
||||
));
|
||||
}
|
||||
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v6_targets.iter().enumerate() {
|
||||
let daddr = ip
|
||||
.map(|ip| format!(" ip6 daddr {ip}"))
|
||||
.unwrap_or_else(String::new);
|
||||
let rate = synlimit_rate_arg(*seconds, *hitcount);
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v6_{idx} {{ ip6 saddr limit rate over {rate} burst {burst} packets }} drop\n"
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
|
||||
));
|
||||
}
|
||||
script.push_str(" }\n");
|
||||
script.push_str("}\n");
|
||||
script
|
||||
}
|
||||
|
||||
async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
|
||||
if let Err(error) = run_command(
|
||||
"nft",
|
||||
&["delete", "table", family.as_str(), NFT_TABLE],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
&& !is_missing_command_or_nft_table(&error)
|
||||
{
|
||||
errors.push(format!(
|
||||
"nft delete table {} {NFT_TABLE} failed: {error}",
|
||||
family.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_missing_command_or_iptables_rule(error: &str) -> bool {
|
||||
error.contains("is not available")
|
||||
|| error.contains("No chain/target/match by that name")
|
||||
|| error.contains("does not exist")
|
||||
}
|
||||
|
||||
fn is_missing_command_or_nft_table(error: &str) -> bool {
|
||||
error.contains("is not available") || error.contains("No such file or directory")
|
||||
}
|
||||
|
||||
async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Result<(), String> {
|
||||
let Some(command_path) = resolve_command(binary) else {
|
||||
return Err(format!("{binary} is not available"));
|
||||
};
|
||||
let mut command = Command::new(command_path);
|
||||
command.args(args);
|
||||
if stdin.is_some() {
|
||||
command.stdin(std::process::Stdio::piped());
|
||||
}
|
||||
command.stdout(std::process::Stdio::null());
|
||||
command.stderr(std::process::Stdio::piped());
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn {binary} failed: {e}"))?;
|
||||
if let Some(blob) = stdin
|
||||
&& let Some(mut writer) = child.stdin.take()
|
||||
{
|
||||
writer
|
||||
.write_all(blob.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdin write {binary} failed: {e}"))?;
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("wait {binary} failed: {e}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{binary} exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> {
|
||||
let Some(command_path) = resolve_command(binary) else {
|
||||
return Err(format!("{binary} is not available"));
|
||||
};
|
||||
let output = Command::new(command_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("wait {binary} failed: {e}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&output.stdout).to_string());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{binary} exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_command(binary: &str) -> Option<PathBuf> {
|
||||
let mut dirs = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from));
|
||||
dirs.into_iter()
|
||||
.map(|dir| dir.join(binary))
|
||||
.find(|candidate| candidate.exists() && candidate.is_file())
|
||||
}
|
||||
|
||||
fn has_cap_net_admin() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||
return false;
|
||||
};
|
||||
for line in status.lines() {
|
||||
if let Some(raw) = line.strip_prefix("CapEff:") {
|
||||
let caps = raw.trim();
|
||||
if let Ok(bits) = u64::from_str_radix(caps, 16) {
|
||||
const CAP_NET_ADMIN_BIT: u64 = 12;
|
||||
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub(super) async fn run_command(
|
||||
binary: &str,
|
||||
args: &[&str],
|
||||
stdin: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let Some(command_path) = resolve_command(binary) else {
|
||||
return Err(format!("{binary} is not available"));
|
||||
};
|
||||
let mut command = Command::new(command_path);
|
||||
command.args(args);
|
||||
if stdin.is_some() {
|
||||
command.stdin(std::process::Stdio::piped());
|
||||
}
|
||||
command.stdout(std::process::Stdio::null());
|
||||
command.stderr(std::process::Stdio::piped());
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn {binary} failed: {e}"))?;
|
||||
if let Some(blob) = stdin
|
||||
&& let Some(mut writer) = child.stdin.take()
|
||||
{
|
||||
writer
|
||||
.write_all(blob.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdin write {binary} failed: {e}"))?;
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("wait {binary} failed: {e}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{binary} exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> {
|
||||
let Some(command_path) = resolve_command(binary) else {
|
||||
return Err(format!("{binary} is not available"));
|
||||
};
|
||||
let output = Command::new(command_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("wait {binary} failed: {e}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&output.stdout).to_string());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{binary} exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_command(binary: &str) -> Option<PathBuf> {
|
||||
let mut dirs = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from));
|
||||
dirs.into_iter()
|
||||
.map(|dir| dir.join(binary))
|
||||
.find(|candidate| candidate.exists() && candidate.is_file())
|
||||
}
|
||||
|
||||
pub(super) fn has_cap_net_admin() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||
return false;
|
||||
};
|
||||
for line in status.lines() {
|
||||
if let Some(raw) = line.strip_prefix("CapEff:") {
|
||||
let caps = raw.trim();
|
||||
if let Ok(bits) = u64::from_str_radix(caps, 16) {
|
||||
const CAP_NET_ADMIN_BIT: u64 = 12;
|
||||
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use super::command::run_command;
|
||||
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_rate_arg};
|
||||
|
||||
const IPV4_IOS_PACKET_LENGTH: u16 = 64;
|
||||
const IPV6_IOS_PACKET_LENGTH: u16 = 84;
|
||||
const IOS_TTL_LIMIT: u8 = 65;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum IpTablesFamily {
|
||||
V4,
|
||||
V6,
|
||||
}
|
||||
|
||||
impl IpTablesFamily {
|
||||
fn ios_packet_length(self) -> u16 {
|
||||
match self {
|
||||
Self::V4 => IPV4_IOS_PACKET_LENGTH,
|
||||
Self::V6 => IPV6_IOS_PACKET_LENGTH,
|
||||
}
|
||||
}
|
||||
|
||||
fn ttl_match(self) -> [&'static str; 3] {
|
||||
match self {
|
||||
Self::V4 => ["-m", "ttl", "--ttl-lt"],
|
||||
Self::V6 => ["-m", "hl", "--hl-lt"],
|
||||
}
|
||||
}
|
||||
|
||||
fn hashlimit_tag(self) -> &'static str {
|
||||
match self {
|
||||
Self::V4 => "4",
|
||||
Self::V6 => "6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn apply_synlimit_rules(
|
||||
targets: &SynLimitTargets,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<(), String> {
|
||||
apply_rules_for_binary(
|
||||
"iptables",
|
||||
&targets.iptables_v4,
|
||||
IpTablesFamily::V4,
|
||||
namespace,
|
||||
)
|
||||
.await?;
|
||||
apply_rules_for_binary(
|
||||
"ip6tables",
|
||||
&targets.iptables_v6,
|
||||
IpTablesFamily::V6,
|
||||
namespace,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn apply_rules_for_binary(
|
||||
binary: &str,
|
||||
targets: &[SynLimitRule],
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<(), String> {
|
||||
if targets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let chain = namespace.iptables_chain.as_str();
|
||||
let _ = run_command(binary, &["-t", "filter", "-N", chain], None).await;
|
||||
run_command(binary, &["-t", "filter", "-F", chain], None).await?;
|
||||
if run_command(binary, &["-t", "filter", "-C", "INPUT", "-j", chain], None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
run_command(
|
||||
binary,
|
||||
&["-t", "filter", "-I", "INPUT", "1", "-j", chain],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (idx, target) in targets.iter().enumerate() {
|
||||
for rule in iptables_synfix_rule_args(target, idx, family, namespace) {
|
||||
let refs: Vec<&str> = rule.iter().map(String::as_str).collect();
|
||||
run_command(binary, &refs, None).await?;
|
||||
}
|
||||
}
|
||||
run_command(binary, &["-t", "filter", "-A", chain, "-j", "RETURN"], None).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn iptables_synfix_rule_args(
|
||||
target: &SynLimitRule,
|
||||
idx: usize,
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<Vec<String>> {
|
||||
vec![
|
||||
iptables_ios_accept_rule_args(target, idx, family, namespace),
|
||||
iptables_ios_reject_rule_args(target, family, namespace),
|
||||
iptables_generic_accept_rule_args(target, idx, family, namespace),
|
||||
iptables_generic_reject_rule_args(target, namespace),
|
||||
]
|
||||
}
|
||||
|
||||
fn iptables_ios_accept_rule_args(
|
||||
target: &SynLimitRule,
|
||||
idx: usize,
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let hashlimit_name = format!(
|
||||
"{}-I{}-{idx}",
|
||||
namespace.iptables_hashlimit_prefix,
|
||||
family.hashlimit_tag()
|
||||
);
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), target.ip, target.port);
|
||||
args.extend(iptables_ios_match_args(family));
|
||||
args.extend(iptables_hashlimit_args(
|
||||
&hashlimit_name,
|
||||
target.ios_seconds,
|
||||
target.ios_hitcount,
|
||||
target.ios_burst,
|
||||
target.hashlimit_expire_ms,
|
||||
target.hashlimit_size,
|
||||
));
|
||||
args.extend(["-j".to_string(), "ACCEPT".to_string()]);
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_ios_reject_rule_args(
|
||||
target: &SynLimitRule,
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), target.ip, target.port);
|
||||
args.extend(iptables_ios_match_args(family));
|
||||
args.extend(iptables_reject_args());
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_generic_accept_rule_args(
|
||||
target: &SynLimitRule,
|
||||
idx: usize,
|
||||
family: IpTablesFamily,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let hashlimit_name = format!(
|
||||
"{}-G{}-{idx}",
|
||||
namespace.iptables_hashlimit_prefix,
|
||||
family.hashlimit_tag()
|
||||
);
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), target.ip, target.port);
|
||||
args.extend(iptables_hashlimit_args(
|
||||
&hashlimit_name,
|
||||
target.generic_seconds,
|
||||
target.generic_hitcount,
|
||||
target.generic_burst,
|
||||
target.hashlimit_expire_ms,
|
||||
target.hashlimit_size,
|
||||
));
|
||||
args.extend(["-j".to_string(), "ACCEPT".to_string()]);
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_generic_reject_rule_args(
|
||||
target: &SynLimitRule,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Vec<String> {
|
||||
let mut args =
|
||||
iptables_base_rule_args(namespace.iptables_chain.as_str(), target.ip, target.port);
|
||||
args.extend(iptables_reject_args());
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_base_rule_args(chain: &str, ip: Option<IpAddr>, port: u16) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-t".to_string(),
|
||||
"filter".to_string(),
|
||||
"-A".to_string(),
|
||||
chain.to_string(),
|
||||
"-p".to_string(),
|
||||
"tcp".to_string(),
|
||||
"--syn".to_string(),
|
||||
"-m".to_string(),
|
||||
"tcp".to_string(),
|
||||
"--tcp-flags".to_string(),
|
||||
"SYN".to_string(),
|
||||
"SYN".to_string(),
|
||||
];
|
||||
if let Some(ip) = ip {
|
||||
args.push("-d".to_string());
|
||||
args.push(ip.to_string());
|
||||
}
|
||||
args.extend(["--dport".to_string(), port.to_string()]);
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_ios_match_args(family: IpTablesFamily) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-m".to_string(),
|
||||
"length".to_string(),
|
||||
"--length".to_string(),
|
||||
family.ios_packet_length().to_string(),
|
||||
];
|
||||
args.extend(family.ttl_match().map(str::to_string));
|
||||
args.push(IOS_TTL_LIMIT.to_string());
|
||||
args
|
||||
}
|
||||
|
||||
fn iptables_hashlimit_args(
|
||||
name: &str,
|
||||
seconds: u32,
|
||||
hitcount: u32,
|
||||
burst: u32,
|
||||
expire_ms: u32,
|
||||
size: u32,
|
||||
) -> Vec<String> {
|
||||
vec![
|
||||
"-m".to_string(),
|
||||
"hashlimit".to_string(),
|
||||
"--hashlimit-name".to_string(),
|
||||
name.to_string(),
|
||||
"--hashlimit-mode".to_string(),
|
||||
"srcip".to_string(),
|
||||
"--hashlimit-upto".to_string(),
|
||||
synlimit_rate_arg(seconds, hitcount),
|
||||
"--hashlimit-burst".to_string(),
|
||||
burst.to_string(),
|
||||
"--hashlimit-htable-expire".to_string(),
|
||||
expire_ms.to_string(),
|
||||
"--hashlimit-htable-size".to_string(),
|
||||
size.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn iptables_reject_args() -> Vec<String> {
|
||||
vec![
|
||||
"-j".to_string(),
|
||||
"REJECT".to_string(),
|
||||
"--reject-with".to_string(),
|
||||
"tcp-reset".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) async fn clear_rules_for_binary(
|
||||
binary: &str,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<bool, String> {
|
||||
let mut errors = Vec::new();
|
||||
let mut removed = false;
|
||||
let chain = namespace.iptables_chain.as_str();
|
||||
for _ in 0..8 {
|
||||
match run_command(binary, &["-t", "filter", "-D", "INPUT", "-j", chain], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match run_command(binary, &["-t", "filter", "-F", chain], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => {}
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} flush chain failed: {error}"));
|
||||
}
|
||||
}
|
||||
match run_command(binary, &["-t", "filter", "-X", chain], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_iptables_rule(&error) => {}
|
||||
Err(error) => {
|
||||
errors.push(format!("{binary} delete chain failed: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(removed)
|
||||
} else {
|
||||
Err(errors.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_missing_command_or_iptables_rule(error: &str) -> bool {
|
||||
error.contains("is not available")
|
||||
|| error.contains("No chain/target/match by that name")
|
||||
|| error.contains("does not exist")
|
||||
|| error.contains("Couldn't load target")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::synlimit_control::model::test_rule;
|
||||
|
||||
fn has_pair(args: &[String], key: &str, value: &str) -> bool {
|
||||
args.windows(2)
|
||||
.any(|pair| pair[0].as_str() == key && pair[1].as_str() == value)
|
||||
}
|
||||
|
||||
fn has_key(args: &[String], key: &str) -> bool {
|
||||
args.iter().any(|arg| arg == key)
|
||||
}
|
||||
|
||||
fn test_namespace() -> SynLimitNamespace {
|
||||
SynLimitNamespace {
|
||||
nft_table: "telemt_synlimit_test".to_string(),
|
||||
iptables_chain: "TMT_SYN_TEST".to_string(),
|
||||
iptables_hashlimit_prefix: "TMTTEST".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_rules_use_synfix_order_and_rejects() {
|
||||
let target = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
||||
let namespace = test_namespace();
|
||||
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4, &namespace);
|
||||
|
||||
assert_eq!(rules.len(), 4);
|
||||
assert!(has_pair(&rules[0], "-A", "TMT_SYN_TEST"));
|
||||
assert!(has_pair(&rules[0], "--length", "64"));
|
||||
assert!(has_pair(&rules[0], "--ttl-lt", "65"));
|
||||
assert!(has_pair(&rules[0], "--hashlimit-upto", "12/second"));
|
||||
assert!(has_pair(&rules[0], "--hashlimit-burst", "24"));
|
||||
assert!(has_pair(&rules[0], "--hashlimit-htable-expire", "60000"));
|
||||
assert!(has_pair(&rules[0], "--hashlimit-htable-size", "32768"));
|
||||
assert!(has_pair(&rules[0], "-j", "ACCEPT"));
|
||||
assert!(has_pair(&rules[1], "-j", "REJECT"));
|
||||
assert!(has_pair(&rules[1], "--reject-with", "tcp-reset"));
|
||||
assert!(has_pair(&rules[2], "--hashlimit-upto", "48/minute"));
|
||||
assert!(has_pair(&rules[3], "--reject-with", "tcp-reset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip6tables_rules_use_ipv6_hoplimit_classifier() {
|
||||
let target = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
|
||||
let namespace = test_namespace();
|
||||
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V6, &namespace);
|
||||
|
||||
assert!(has_pair(&rules[0], "--length", "84"));
|
||||
assert!(has_pair(&rules[0], "--hl-lt", "65"));
|
||||
assert!(has_pair(&rules[0], "-d", "::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_missing_rule_errors_are_cleanup_benign() {
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"iptables is not available"
|
||||
));
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"iptables: No chain/target/match by that name."
|
||||
));
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"iptables: Chain TELEMT_SYNLIMIT does not exist."
|
||||
));
|
||||
assert!(is_missing_command_or_iptables_rule(
|
||||
"Couldn't load target `TELEMT_SYNLIMIT': No such file or directory"
|
||||
));
|
||||
assert!(!is_missing_command_or_iptables_rule(
|
||||
"iptables: Permission denied"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iptables_wildcard_rule_omits_destination_match() {
|
||||
let target = test_rule(None, 443);
|
||||
let namespace = test_namespace();
|
||||
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4, &namespace);
|
||||
|
||||
for rule in rules {
|
||||
assert!(!has_key(&rule, "-d"));
|
||||
assert!(has_pair(&rule, "--dport", "443"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::{ProxyConfig, SynLimitMode};
|
||||
|
||||
mod command;
|
||||
mod iptables;
|
||||
mod model;
|
||||
mod nftables;
|
||||
|
||||
use self::command::has_cap_net_admin;
|
||||
use self::model::{SynLimitNamespace, synlimit_namespace, synlimit_targets};
|
||||
|
||||
static ACTIVE_SYNLIMIT_NAMESPACE: Mutex<Option<SynLimitNamespace>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<Arc<ProxyConfig>>) {
|
||||
if !cfg!(target_os = "linux") {
|
||||
if has_synlimit_config(&config_rx.borrow()) {
|
||||
warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
wait_for_config_channel_close_and_reconcile(config_rx).await;
|
||||
if let Err(error) = clear_synlimit_rules_all_backends().await {
|
||||
warn!(error = %error, "Failed to clear SYN limiter rules after config channel close");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn wait_for_config_channel_close_and_reconcile(
|
||||
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||
) {
|
||||
while config_rx.changed().await.is_ok() {
|
||||
let cfg = config_rx.borrow_and_update().clone();
|
||||
reconcile_synlimit_rules(&cfg).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
|
||||
let targets = synlimit_targets(cfg);
|
||||
let namespace = synlimit_namespace(&targets);
|
||||
if let Some(previous_namespace) = set_active_synlimit_namespace(namespace.clone()) {
|
||||
match clear_synlimit_rules_for_namespace(&previous_namespace).await {
|
||||
Ok(true) => {
|
||||
warn!("Removed previous SYN limiter namespace before reconcile");
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to clear previous SYN limiter namespace before reconcile");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(namespace) = namespace else {
|
||||
return;
|
||||
};
|
||||
if !has_cap_net_admin() {
|
||||
warn!(
|
||||
"SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
match clear_synlimit_rules_for_namespace(&namespace).await {
|
||||
Ok(true) => {
|
||||
warn!("Removed stale SYN limiter rules left by a previous run before reconcile");
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to clear stale SYN limiter rules before reconcile");
|
||||
}
|
||||
}
|
||||
|
||||
if targets.has_iptables_targets()
|
||||
&& let Err(error) = iptables::apply_synlimit_rules(&targets, &namespace).await
|
||||
{
|
||||
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
|
||||
}
|
||||
if targets.has_nft_targets()
|
||||
&& let Err(error) = nftables::apply_synlimit_rules(&targets, &namespace).await
|
||||
{
|
||||
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<bool, String> {
|
||||
let Some(namespace) = take_active_synlimit_namespace() else {
|
||||
return Ok(false);
|
||||
};
|
||||
clear_synlimit_rules_for_namespace(&namespace).await
|
||||
}
|
||||
|
||||
async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Result<bool, String> {
|
||||
if !has_cap_net_admin() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut removed = false;
|
||||
match nftables::clear_rules_all_families(namespace).await {
|
||||
Ok(value) => {
|
||||
removed |= value;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
match iptables::clear_rules_for_binary("iptables", namespace).await {
|
||||
Ok(value) => {
|
||||
removed |= value;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
match iptables::clear_rules_for_binary("ip6tables", namespace).await {
|
||||
Ok(value) => {
|
||||
removed |= value;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(removed)
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_active_synlimit_namespace(next: Option<SynLimitNamespace>) -> Option<SynLimitNamespace> {
|
||||
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||
Ok(mut active) => {
|
||||
if *active == next {
|
||||
None
|
||||
} else {
|
||||
std::mem::replace(&mut *active, next)
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to update active SYN limiter namespace");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_active_synlimit_namespace() -> Option<SynLimitNamespace> {
|
||||
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||
Ok(mut active) => active.take(),
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to read active SYN limiter namespace");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
|
||||
cfg.server
|
||||
.listeners
|
||||
.iter()
|
||||
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::config::{ProxyConfig, SynLimitMode};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) struct SynLimitRule {
|
||||
pub(super) ip: Option<IpAddr>,
|
||||
pub(super) port: u16,
|
||||
pub(super) generic_seconds: u32,
|
||||
pub(super) generic_hitcount: u32,
|
||||
pub(super) generic_burst: u32,
|
||||
pub(super) ios_seconds: u32,
|
||||
pub(super) ios_hitcount: u32,
|
||||
pub(super) ios_burst: u32,
|
||||
pub(super) hashlimit_expire_ms: u32,
|
||||
pub(super) hashlimit_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(super) struct SynLimitNamespace {
|
||||
pub(super) nft_table: String,
|
||||
pub(super) iptables_chain: String,
|
||||
pub(super) iptables_hashlimit_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SynLimitTargets {
|
||||
pub(super) iptables_v4: Vec<SynLimitRule>,
|
||||
pub(super) iptables_v6: Vec<SynLimitRule>,
|
||||
pub(super) nft_v4: Vec<SynLimitRule>,
|
||||
pub(super) nft_v6: Vec<SynLimitRule>,
|
||||
}
|
||||
|
||||
impl SynLimitTargets {
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.iptables_v4.is_empty()
|
||||
&& self.iptables_v6.is_empty()
|
||||
&& self.nft_v4.is_empty()
|
||||
&& self.nft_v6.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn has_iptables_targets(&self) -> bool {
|
||||
!self.iptables_v4.is_empty() || !self.iptables_v6.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn has_nft_targets(&self) -> bool {
|
||||
!self.nft_v4.is_empty() || !self.nft_v6.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
struct SynLimitNamespaceHasher {
|
||||
value: u64,
|
||||
}
|
||||
|
||||
impl SynLimitNamespaceHasher {
|
||||
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
value: Self::OFFSET,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
self.value ^= u64::from(*byte);
|
||||
self.value = self.value.wrapping_mul(Self::PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_u8(&mut self, value: u8) {
|
||||
self.write(&[value]);
|
||||
}
|
||||
|
||||
fn write_u16(&mut self, value: u16) {
|
||||
self.write(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32(&mut self, value: u32) {
|
||||
self.write(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn finish(self) -> u64 {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets {
|
||||
let mut iptables_v4 = BTreeSet::new();
|
||||
let mut iptables_v6 = BTreeSet::new();
|
||||
let mut nft_v4 = BTreeSet::new();
|
||||
let mut nft_v6 = BTreeSet::new();
|
||||
|
||||
for listener in &cfg.server.listeners {
|
||||
let backend = listener.synlimit;
|
||||
if matches!(backend, SynLimitMode::Off) {
|
||||
continue;
|
||||
}
|
||||
let target = SynLimitRule {
|
||||
ip: (!listener.ip.is_unspecified()).then_some(listener.ip),
|
||||
port: listener.port.unwrap_or(cfg.server.port),
|
||||
generic_seconds: listener.synlimit_seconds,
|
||||
generic_hitcount: listener.synlimit_hitcount,
|
||||
generic_burst: listener.synlimit_burst,
|
||||
ios_seconds: listener.synlimit_ios_seconds,
|
||||
ios_hitcount: listener.synlimit_ios_hitcount,
|
||||
ios_burst: listener.synlimit_ios_burst,
|
||||
hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms,
|
||||
hashlimit_size: listener.synlimit_hashlimit_size,
|
||||
};
|
||||
|
||||
match (backend, listener.ip.is_ipv4()) {
|
||||
(SynLimitMode::Iptables, true) => {
|
||||
iptables_v4.insert(target);
|
||||
}
|
||||
(SynLimitMode::Iptables, false) => {
|
||||
iptables_v6.insert(target);
|
||||
}
|
||||
(SynLimitMode::Nftables, true) => {
|
||||
nft_v4.insert(target);
|
||||
}
|
||||
(SynLimitMode::Nftables, false) => {
|
||||
nft_v6.insert(target);
|
||||
}
|
||||
(SynLimitMode::Off, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
SynLimitTargets {
|
||||
iptables_v4: iptables_v4.into_iter().collect(),
|
||||
iptables_v6: iptables_v6.into_iter().collect(),
|
||||
nft_v4: nft_v4.into_iter().collect(),
|
||||
nft_v6: nft_v6.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn synlimit_namespace(targets: &SynLimitTargets) -> Option<SynLimitNamespace> {
|
||||
if targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut hasher = SynLimitNamespaceHasher::new();
|
||||
write_namespace_rule_group(&mut hasher, b"iptables-v4", &targets.iptables_v4);
|
||||
write_namespace_rule_group(&mut hasher, b"iptables-v6", &targets.iptables_v6);
|
||||
write_namespace_rule_group(&mut hasher, b"nft-v4", &targets.nft_v4);
|
||||
write_namespace_rule_group(&mut hasher, b"nft-v6", &targets.nft_v6);
|
||||
|
||||
let suffix = format!("{:016x}", hasher.finish());
|
||||
let iptables_suffix = &suffix[..12];
|
||||
let hashlimit_suffix = &suffix[..10];
|
||||
Some(SynLimitNamespace {
|
||||
nft_table: format!("telemt_synlimit_{suffix}"),
|
||||
iptables_chain: format!("TMT_SYN_{iptables_suffix}"),
|
||||
iptables_hashlimit_prefix: format!("TMT{hashlimit_suffix}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_namespace_rule_group(
|
||||
hasher: &mut SynLimitNamespaceHasher,
|
||||
group: &[u8],
|
||||
rules: &[SynLimitRule],
|
||||
) {
|
||||
hasher.write(group);
|
||||
hasher.write_u32(rules.len() as u32);
|
||||
for rule in rules {
|
||||
write_namespace_rule(hasher, rule);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_namespace_rule(hasher: &mut SynLimitNamespaceHasher, rule: &SynLimitRule) {
|
||||
match rule.ip {
|
||||
Some(IpAddr::V4(ip)) => {
|
||||
hasher.write_u8(4);
|
||||
hasher.write(&ip.octets());
|
||||
}
|
||||
Some(IpAddr::V6(ip)) => {
|
||||
hasher.write_u8(6);
|
||||
hasher.write(&ip.octets());
|
||||
}
|
||||
None => {
|
||||
hasher.write_u8(0);
|
||||
}
|
||||
}
|
||||
hasher.write_u16(rule.port);
|
||||
hasher.write_u32(rule.generic_seconds);
|
||||
hasher.write_u32(rule.generic_hitcount);
|
||||
hasher.write_u32(rule.generic_burst);
|
||||
hasher.write_u32(rule.ios_seconds);
|
||||
hasher.write_u32(rule.ios_hitcount);
|
||||
hasher.write_u32(rule.ios_burst);
|
||||
hasher.write_u32(rule.hashlimit_expire_ms);
|
||||
hasher.write_u32(rule.hashlimit_size);
|
||||
}
|
||||
|
||||
pub(super) fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
|
||||
let seconds = u64::from(seconds.max(1));
|
||||
let hitcount = u64::from(hitcount.max(1));
|
||||
for (unit_seconds, unit_name) in [
|
||||
(1_u64, "second"),
|
||||
(60_u64, "minute"),
|
||||
(3_600_u64, "hour"),
|
||||
(86_400_u64, "day"),
|
||||
] {
|
||||
let amount = hitcount.saturating_mul(unit_seconds);
|
||||
if amount >= seconds && amount % seconds == 0 {
|
||||
return format!("{}/{}", amount / seconds, unit_name);
|
||||
}
|
||||
}
|
||||
let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds;
|
||||
format!("{}/day", amount.max(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_rule(ip: Option<IpAddr>, port: u16) -> SynLimitRule {
|
||||
SynLimitRule {
|
||||
ip,
|
||||
port,
|
||||
generic_seconds: 60,
|
||||
generic_hitcount: 48,
|
||||
generic_burst: 1,
|
||||
ios_seconds: 1,
|
||||
ios_hitcount: 12,
|
||||
ios_burst: 24,
|
||||
hashlimit_expire_ms: 60_000,
|
||||
hashlimit_size: 32_768,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::config::ListenerConfig;
|
||||
|
||||
fn listener(ip: IpAddr, port: Option<u16>, synlimit: SynLimitMode) -> ListenerConfig {
|
||||
ListenerConfig {
|
||||
ip,
|
||||
port,
|
||||
client_mss: None,
|
||||
synlimit,
|
||||
synlimit_seconds: 60,
|
||||
synlimit_hitcount: 48,
|
||||
synlimit_burst: 1,
|
||||
synlimit_ios_seconds: 1,
|
||||
synlimit_ios_hitcount: 12,
|
||||
synlimit_ios_burst: 24,
|
||||
synlimit_hashlimit_expire_ms: 60_000,
|
||||
synlimit_hashlimit_size: 32_768,
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
reuse_allow: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_targets_deduplicate_and_use_legacy_port_fallback() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.server.port = 9443;
|
||||
cfg.server.listeners = vec![
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
None,
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
None,
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
];
|
||||
|
||||
let targets = synlimit_targets(&cfg);
|
||||
|
||||
assert_eq!(targets.iptables_v4.len(), 1);
|
||||
assert_eq!(targets.iptables_v4[0].ip, None);
|
||||
assert_eq!(targets.iptables_v4[0].port, 9443);
|
||||
assert!(targets.iptables_v6.is_empty());
|
||||
assert!(targets.nft_v4.is_empty());
|
||||
assert!(targets.nft_v6.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_targets_separate_backends_and_ip_families() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.server.listeners = vec![
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)),
|
||||
Some(443),
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
Some(443),
|
||||
SynLimitMode::Iptables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 2)),
|
||||
Some(444),
|
||||
SynLimitMode::Nftables,
|
||||
),
|
||||
listener(
|
||||
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
Some(444),
|
||||
SynLimitMode::Nftables,
|
||||
),
|
||||
];
|
||||
|
||||
let targets = synlimit_targets(&cfg);
|
||||
|
||||
assert_eq!(targets.iptables_v4.len(), 1);
|
||||
assert_eq!(targets.iptables_v6.len(), 1);
|
||||
assert_eq!(targets.nft_v4.len(), 1);
|
||||
assert_eq!(targets.nft_v6.len(), 1);
|
||||
assert_eq!(
|
||||
targets.iptables_v4[0].ip,
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
targets.iptables_v6[0].ip,
|
||||
Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
|
||||
);
|
||||
assert_eq!(
|
||||
targets.nft_v4[0].ip,
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 2)))
|
||||
);
|
||||
assert_eq!(targets.nft_v6[0].ip, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_namespace_is_stable_and_changes_by_targets() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.server.listeners = vec![listener(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)),
|
||||
Some(443),
|
||||
SynLimitMode::Nftables,
|
||||
)];
|
||||
let first = synlimit_namespace(&synlimit_targets(&cfg))
|
||||
.expect("configured targets must have a namespace");
|
||||
let second = synlimit_namespace(&synlimit_targets(&cfg))
|
||||
.expect("configured targets must have a namespace");
|
||||
|
||||
cfg.server.listeners[0].port = Some(444);
|
||||
let changed = synlimit_namespace(&synlimit_targets(&cfg))
|
||||
.expect("configured targets must have a namespace");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(first, changed);
|
||||
assert!(first.nft_table.starts_with("telemt_synlimit_"));
|
||||
assert!(first.iptables_chain.starts_with("TMT_SYN_"));
|
||||
assert!(first.iptables_chain.len() <= 28);
|
||||
assert!(first.iptables_hashlimit_prefix.starts_with("TMT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synlimit_rate_arg_uses_native_units_without_fractional_rates() {
|
||||
assert_eq!(synlimit_rate_arg(1, 12), "12/second");
|
||||
assert_eq!(synlimit_rate_arg(60, 48), "48/minute");
|
||||
assert_eq!(synlimit_rate_arg(3600, 121), "121/hour");
|
||||
assert_eq!(synlimit_rate_arg(86400, 241), "241/day");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use super::command::{run_command, run_command_stdout};
|
||||
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_rate_arg};
|
||||
|
||||
const NFT_CHAIN: &str = "input";
|
||||
const NFT_INPUT_PRIORITY: i16 = -5;
|
||||
const IPV4_IOS_PACKET_LENGTH: u16 = 64;
|
||||
const IPV6_IOS_PACKET_LENGTH: u16 = 84;
|
||||
const IOS_TTL_LIMIT: u8 = 65;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NftTableFamilies {
|
||||
inet: bool,
|
||||
ip: bool,
|
||||
ip6: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum NftFamily {
|
||||
Inet,
|
||||
Ip,
|
||||
Ip6,
|
||||
}
|
||||
|
||||
struct NftApplyPlan<'a> {
|
||||
family: NftFamily,
|
||||
v4_targets: &'a [SynLimitRule],
|
||||
v6_targets: &'a [SynLimitRule],
|
||||
}
|
||||
|
||||
impl NftFamily {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inet => "inet",
|
||||
Self::Ip => "ip",
|
||||
Self::Ip6 => "ip6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn apply_synlimit_rules(
|
||||
targets: &SynLimitTargets,
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<(), String> {
|
||||
let families = detect_nft_table_families().await;
|
||||
for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) {
|
||||
let script = nft_synlimit_script(plan, namespace);
|
||||
run_command("nft", &["-f", "-"], Some(script)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_nft_table_families() -> NftTableFamilies {
|
||||
let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else {
|
||||
return NftTableFamilies {
|
||||
inet: false,
|
||||
ip: false,
|
||||
ip6: false,
|
||||
};
|
||||
};
|
||||
|
||||
let mut families = NftTableFamilies {
|
||||
inet: false,
|
||||
ip: false,
|
||||
ip6: false,
|
||||
};
|
||||
for line in output.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
if fields.next() != Some("table") {
|
||||
continue;
|
||||
}
|
||||
match fields.next() {
|
||||
Some("inet") => families.inet = true,
|
||||
Some("ip") => families.ip = true,
|
||||
Some("ip6") => families.ip6 = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
families
|
||||
}
|
||||
|
||||
fn nft_apply_plan<'a>(
|
||||
families: NftTableFamilies,
|
||||
v4_targets: &'a [SynLimitRule],
|
||||
v6_targets: &'a [SynLimitRule],
|
||||
) -> Vec<NftApplyPlan<'a>> {
|
||||
if !v4_targets.is_empty() && !v6_targets.is_empty() {
|
||||
return vec![NftApplyPlan {
|
||||
family: NftFamily::Inet,
|
||||
v4_targets,
|
||||
v6_targets,
|
||||
}];
|
||||
}
|
||||
if !v4_targets.is_empty() {
|
||||
return vec![NftApplyPlan {
|
||||
family: if families.inet || !families.ip {
|
||||
NftFamily::Inet
|
||||
} else {
|
||||
NftFamily::Ip
|
||||
},
|
||||
v4_targets,
|
||||
v6_targets: &[],
|
||||
}];
|
||||
}
|
||||
if !v6_targets.is_empty() {
|
||||
return vec![NftApplyPlan {
|
||||
family: if families.inet || !families.ip6 {
|
||||
NftFamily::Inet
|
||||
} else {
|
||||
NftFamily::Ip6
|
||||
},
|
||||
v4_targets: &[],
|
||||
v6_targets,
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn nft_synlimit_script(plan: NftApplyPlan<'_>, namespace: &SynLimitNamespace) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
"table {} {} {{\n",
|
||||
plan.family.as_str(),
|
||||
namespace.nft_table
|
||||
));
|
||||
script.push_str(&format!(" chain {NFT_CHAIN} {{\n"));
|
||||
script.push_str(&format!(
|
||||
" type filter hook input priority {NFT_INPUT_PRIORITY}; policy accept;\n"
|
||||
));
|
||||
for (idx, target) in plan.v4_targets.iter().enumerate() {
|
||||
push_nft_v4_rules(&mut script, target, idx);
|
||||
}
|
||||
for (idx, target) in plan.v6_targets.iter().enumerate() {
|
||||
push_nft_v6_rules(&mut script, target, idx);
|
||||
}
|
||||
script.push_str(" }\n");
|
||||
script.push_str("}\n");
|
||||
script
|
||||
}
|
||||
|
||||
fn push_nft_v4_rules(script: &mut String, target: &SynLimitRule, idx: usize) {
|
||||
let daddr = target
|
||||
.ip
|
||||
.map(|ip| format!(" ip daddr {ip}"))
|
||||
.unwrap_or_default();
|
||||
let ios_rate = synlimit_rate_arg(target.ios_seconds, target.ios_hitcount);
|
||||
let generic_rate = synlimit_rate_arg(target.generic_seconds, target.generic_hitcount);
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV4_IOS_PACKET_LENGTH} ip ttl < {IOS_TTL_LIMIT} tcp dport {port} meter telemt_synfix_ios_v4_{idx} {{ ip saddr limit rate over {ios_rate} burst {ios_burst} packets }} reject with tcp reset\n",
|
||||
port = target.port,
|
||||
ios_burst = target.ios_burst,
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV4_IOS_PACKET_LENGTH} ip ttl < {IOS_TTL_LIMIT} tcp dport {port} accept\n",
|
||||
port = target.port,
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synfix_v4_{idx} {{ ip saddr limit rate over {generic_rate} burst {generic_burst} packets }} reject with tcp reset\n",
|
||||
port = target.port,
|
||||
generic_burst = target.generic_burst,
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n",
|
||||
port = target.port,
|
||||
));
|
||||
}
|
||||
|
||||
fn push_nft_v6_rules(script: &mut String, target: &SynLimitRule, idx: usize) {
|
||||
let daddr = target
|
||||
.ip
|
||||
.map(|ip| format!(" ip6 daddr {ip}"))
|
||||
.unwrap_or_default();
|
||||
let ios_rate = synlimit_rate_arg(target.ios_seconds, target.ios_hitcount);
|
||||
let generic_rate = synlimit_rate_arg(target.generic_seconds, target.generic_hitcount);
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV6_IOS_PACKET_LENGTH} ip6 hoplimit < {IOS_TTL_LIMIT} tcp dport {port} meter telemt_synfix_ios_v6_{idx} {{ ip6 saddr limit rate over {ios_rate} burst {ios_burst} packets }} reject with tcp reset\n",
|
||||
port = target.port,
|
||||
ios_burst = target.ios_burst,
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV6_IOS_PACKET_LENGTH} ip6 hoplimit < {IOS_TTL_LIMIT} tcp dport {port} accept\n",
|
||||
port = target.port,
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synfix_v6_{idx} {{ ip6 saddr limit rate over {generic_rate} burst {generic_burst} packets }} reject with tcp reset\n",
|
||||
port = target.port,
|
||||
generic_burst = target.generic_burst,
|
||||
));
|
||||
script.push_str(&format!(
|
||||
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n",
|
||||
port = target.port,
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) async fn clear_rules_all_families(
|
||||
namespace: &SynLimitNamespace,
|
||||
) -> Result<bool, String> {
|
||||
let mut errors = Vec::new();
|
||||
let mut removed = false;
|
||||
let table = namespace.nft_table.as_str();
|
||||
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
|
||||
match run_command("nft", &["delete", "table", family.as_str(), table], None).await {
|
||||
Ok(()) => {
|
||||
removed = true;
|
||||
}
|
||||
Err(error) if is_missing_command_or_nft_table(&error) => {}
|
||||
Err(error) => {
|
||||
errors.push(format!(
|
||||
"nft delete table {} {table} failed: {error}",
|
||||
family.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(removed)
|
||||
} else {
|
||||
Err(errors.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_missing_command_or_nft_table(error: &str) -> bool {
|
||||
error.contains("is not available") || error.contains("No such file or directory")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::synlimit_control::model::test_rule;
|
||||
|
||||
fn test_namespace(table: &str) -> SynLimitNamespace {
|
||||
SynLimitNamespace {
|
||||
nft_table: table.to_string(),
|
||||
iptables_chain: "TMT_SYN_TEST".to_string(),
|
||||
iptables_hashlimit_prefix: "TMTTEST".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nft_script_uses_synfix_v4_rules_and_early_priority() {
|
||||
let rule = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
||||
let namespace = test_namespace("telemt_synlimit_test_a");
|
||||
let script = nft_synlimit_script(
|
||||
NftApplyPlan {
|
||||
family: NftFamily::Inet,
|
||||
v4_targets: &[rule],
|
||||
v6_targets: &[],
|
||||
},
|
||||
&namespace,
|
||||
);
|
||||
|
||||
assert!(script.contains("table inet telemt_synlimit_test_a"));
|
||||
assert!(script.contains("type filter hook input priority -5; policy accept;"));
|
||||
assert!(script.contains("ip daddr 203.0.113.7"));
|
||||
assert!(script.contains("meta length 64 ip ttl < 65"));
|
||||
assert!(script.contains("limit rate over 12/second burst 24 packets"));
|
||||
assert!(script.contains("limit rate over 48/minute burst 1 packets"));
|
||||
assert!(script.contains("reject with tcp reset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nft_script_uses_ipv6_hoplimit_classifier() {
|
||||
let rule = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
|
||||
let namespace = test_namespace("telemt_synlimit_test_b");
|
||||
let script = nft_synlimit_script(
|
||||
NftApplyPlan {
|
||||
family: NftFamily::Inet,
|
||||
v4_targets: &[],
|
||||
v6_targets: &[rule],
|
||||
},
|
||||
&namespace,
|
||||
);
|
||||
|
||||
assert!(script.contains("table inet telemt_synlimit_test_b"));
|
||||
assert!(script.contains("ip6 daddr ::1"));
|
||||
assert!(script.contains("meta length 84 ip6 hoplimit < 65"));
|
||||
assert!(script.contains("ip6 saddr limit rate over 12/second burst 24 packets"));
|
||||
assert!(script.contains("ip6 saddr limit rate over 48/minute burst 1 packets"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nft_missing_table_errors_are_cleanup_benign() {
|
||||
assert!(is_missing_command_or_nft_table("nft is not available"));
|
||||
assert!(is_missing_command_or_nft_table(
|
||||
"Error: No such file or directory"
|
||||
));
|
||||
assert!(!is_missing_command_or_nft_table(
|
||||
"Error: Operation not permitted"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nft_apply_plan_keeps_dual_stack_rules_in_inet_table() {
|
||||
let v4_rule = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
||||
let v6_rule = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
|
||||
let v4_rules = [v4_rule];
|
||||
let v6_rules = [v6_rule];
|
||||
let plans = nft_apply_plan(
|
||||
NftTableFamilies {
|
||||
inet: false,
|
||||
ip: false,
|
||||
ip6: false,
|
||||
},
|
||||
&v4_rules,
|
||||
&v6_rules,
|
||||
);
|
||||
|
||||
assert_eq!(plans.len(), 1);
|
||||
assert_eq!(plans[0].family.as_str(), "inet");
|
||||
assert_eq!(plans[0].v4_targets, v4_rules.as_slice());
|
||||
assert_eq!(plans[0].v6_targets, v6_rules.as_slice());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
use bytes::Bytes;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::OwnedSemaphorePermit;
|
||||
|
||||
use crate::crypto::{AesCbc, crc32, crc32c};
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::protocol::constants::*;
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::PooledBuffer;
|
||||
|
||||
use super::wire::{append_proxy_req_payload_into, proxy_req_payload_len};
|
||||
@@ -11,9 +14,66 @@ use super::wire::{append_proxy_req_payload_into, proxy_req_payload_len};
|
||||
const RPC_WRITER_FRAME_BUF_SHRINK_THRESHOLD: usize = 256 * 1024;
|
||||
const RPC_WRITER_FRAME_BUF_RETAIN: usize = 64 * 1024;
|
||||
|
||||
enum WriterBytePermitState {
|
||||
Queued,
|
||||
Inflight,
|
||||
}
|
||||
|
||||
/// Holds one writer memory reservation through queueing and socket write completion.
|
||||
pub(crate) struct WriterBytePermit {
|
||||
_permit: OwnedSemaphorePermit,
|
||||
reserved_bytes: u64,
|
||||
stats: Arc<Stats>,
|
||||
state: WriterBytePermitState,
|
||||
}
|
||||
|
||||
impl WriterBytePermit {
|
||||
/// Creates a queued reservation and publishes its rounded resident-byte cost.
|
||||
pub(crate) fn new(
|
||||
permit: OwnedSemaphorePermit,
|
||||
reserved_bytes: usize,
|
||||
stats: Arc<Stats>,
|
||||
) -> Self {
|
||||
let reserved_bytes = reserved_bytes as u64;
|
||||
stats.add_me_writer_byte_budget_queued_bytes(reserved_bytes);
|
||||
Self {
|
||||
_permit: permit,
|
||||
reserved_bytes,
|
||||
stats,
|
||||
state: WriterBytePermitState::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the reservation from queued to in-flight accounting exactly once.
|
||||
pub(crate) fn mark_inflight(&mut self) {
|
||||
if matches!(self.state, WriterBytePermitState::Queued) {
|
||||
self.stats
|
||||
.move_me_writer_byte_budget_to_inflight(self.reserved_bytes);
|
||||
self.state = WriterBytePermitState::Inflight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WriterBytePermit {
|
||||
fn drop(&mut self) {
|
||||
match self.state {
|
||||
WriterBytePermitState::Queued => self
|
||||
.stats
|
||||
.release_me_writer_byte_budget_queued_bytes(self.reserved_bytes),
|
||||
WriterBytePermitState::Inflight => self
|
||||
.stats
|
||||
.release_me_writer_byte_budget_inflight_bytes(self.reserved_bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands sent to dedicated writer tasks to avoid mutex contention on TCP writes.
|
||||
pub(crate) enum WriterCommand {
|
||||
Data(Bytes),
|
||||
Data {
|
||||
payload: Bytes,
|
||||
_permit: Option<OwnedSemaphorePermit>,
|
||||
writer_permit: WriterBytePermit,
|
||||
},
|
||||
DataAndFlush(Bytes),
|
||||
ProxyReq(ProxyReqCommand),
|
||||
ControlAndFlush([u8; 12]),
|
||||
@@ -28,6 +88,8 @@ pub(crate) struct ProxyReqCommand {
|
||||
pub(crate) proto_flags: u32,
|
||||
pub(crate) proxy_tag: Option<[u8; 16]>,
|
||||
pub(crate) payload: PooledBuffer,
|
||||
pub(crate) _permit: OwnedSemaphorePermit,
|
||||
pub(crate) writer_permit: WriterBytePermit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -82,7 +144,7 @@ fn build_rpc_frame_into(
|
||||
) {
|
||||
let total_len = 4 + 4 + payload.len() + 4;
|
||||
frame.clear();
|
||||
frame.reserve(total_len + 15);
|
||||
frame.reserve_exact(total_len + 15);
|
||||
let total_len = total_len as u32;
|
||||
frame.extend_from_slice(&total_len.to_le_bytes());
|
||||
frame.extend_from_slice(&seq_no.to_le_bytes());
|
||||
@@ -274,7 +336,7 @@ impl RpcWriter {
|
||||
);
|
||||
let total_len = 4 + 4 + payload_len + 4;
|
||||
self.frame_buf.clear();
|
||||
self.frame_buf.reserve(total_len + 15);
|
||||
self.frame_buf.reserve_exact(total_len + 15);
|
||||
self.frame_buf
|
||||
.extend_from_slice(&(total_len as u32).to_le_bytes());
|
||||
self.frame_buf.extend_from_slice(&self.seq_no.to_le_bytes());
|
||||
@@ -322,3 +384,38 @@ impl RpcWriter {
|
||||
self.writer.flush().await.map_err(ProxyError::Io)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[test]
|
||||
fn writer_byte_permit_tracks_queued_and_inflight_lifecycle() {
|
||||
let stats = Arc::new(Stats::default());
|
||||
let semaphore = Arc::new(Semaphore::new(2));
|
||||
let permit = semaphore
|
||||
.clone()
|
||||
.try_acquire_many_owned(2)
|
||||
.expect("writer byte permits must be available");
|
||||
let mut writer_permit = WriterBytePermit::new(permit, 32 * 1024, stats.clone());
|
||||
|
||||
assert_eq!(
|
||||
stats.get_me_writer_byte_budget_queued_bytes_gauge(),
|
||||
32 * 1024
|
||||
);
|
||||
assert_eq!(stats.get_me_writer_byte_budget_inflight_bytes_gauge(), 0);
|
||||
|
||||
writer_permit.mark_inflight();
|
||||
assert_eq!(stats.get_me_writer_byte_budget_queued_bytes_gauge(), 0);
|
||||
assert_eq!(
|
||||
stats.get_me_writer_byte_budget_inflight_bytes_gauge(),
|
||||
32 * 1024
|
||||
);
|
||||
|
||||
drop(writer_permit);
|
||||
assert_eq!(stats.get_me_writer_byte_budget_queued_bytes_gauge(), 0);
|
||||
assert_eq!(stats.get_me_writer_byte_budget_inflight_bytes_gauge(), 0);
|
||||
assert_eq!(semaphore.available_permits(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use tokio::time::timeout;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::MeSocksKdfPolicy;
|
||||
use crate::crypto::{SecureRandom, build_middleproxy_prekey, derive_middleproxy_keys, sha256};
|
||||
use crate::crypto::{SecureRandom, derive_middleproxy_keys};
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::network::IpFamily;
|
||||
use crate::network::probe::is_bogon;
|
||||
@@ -292,14 +292,17 @@ impl MePool {
|
||||
BndPortStatus::Error
|
||||
};
|
||||
record_bnd_status(bnd_addr_status, bnd_port_status, raw_socks_bound_addr);
|
||||
let reflected = if let Some(bound) = socks_bound_addr {
|
||||
let socks_bound_kdf_addr = socks_bound_addr.filter(|bound| bound.port() != 0);
|
||||
// SOCKS BND is the only reflected source that can supply both KDF IP and
|
||||
// port. Direct STUN reflection is IP-only and keeps the TCP local port.
|
||||
let reflected = if let Some(bound) = socks_bound_kdf_addr {
|
||||
Some(bound)
|
||||
} else if is_socks_route {
|
||||
match self.socks_kdf_policy() {
|
||||
MeSocksKdfPolicy::Strict => {
|
||||
self.stats.increment_me_socks_kdf_strict_reject();
|
||||
return Err(ProxyError::InvalidHandshake(
|
||||
"SOCKS route returned no valid BND.ADDR for ME KDF (strict policy)"
|
||||
"SOCKS route returned no valid BND tuple for ME KDF (strict policy)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
@@ -323,16 +326,14 @@ impl MePool {
|
||||
let local_addr_nat = self.translate_our_addr_with_reflection(local_addr, reflected);
|
||||
let peer_addr_nat =
|
||||
SocketAddr::new(self.translate_ip_for_nat(peer_addr.ip()), peer_addr.port());
|
||||
let client_addr_for_kdf = socks_bound_kdf_addr.unwrap_or(local_addr_nat);
|
||||
if let Some(upstream_info) = upstream_egress {
|
||||
let client_ip_for_kdf = socks_bound_addr
|
||||
.map(|value| value.ip())
|
||||
.unwrap_or(local_addr_nat.ip());
|
||||
record_upstream_bnd_status(
|
||||
upstream_info.upstream_id,
|
||||
bnd_addr_status,
|
||||
bnd_port_status,
|
||||
raw_socks_bound_addr,
|
||||
Some(client_ip_for_kdf),
|
||||
Some(client_addr_for_kdf.ip()),
|
||||
);
|
||||
}
|
||||
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||
@@ -409,6 +410,7 @@ impl MePool {
|
||||
info!(
|
||||
%local_addr,
|
||||
%local_addr_nat,
|
||||
%client_addr_for_kdf,
|
||||
reflected_ip = reflected.map(|r| r.ip()).as_ref().map(ToString::to_string),
|
||||
%peer_addr,
|
||||
%transport_peer_addr,
|
||||
@@ -417,21 +419,20 @@ impl MePool {
|
||||
key_selector = format_args!("0x{ks:08x}"),
|
||||
crypto_schema = format_args!("0x{schema:08x}"),
|
||||
skew_secs = skew,
|
||||
socks_kdf_policy = ?self.socks_kdf_policy(),
|
||||
"ME key derivation parameters"
|
||||
);
|
||||
|
||||
let ts_bytes = crypto_ts.to_le_bytes();
|
||||
let server_port_bytes = peer_addr_nat.port().to_le_bytes();
|
||||
let socks_bound_port = socks_bound_addr
|
||||
.map(|bound| bound.port())
|
||||
.filter(|port| *port != 0);
|
||||
let client_port_for_kdf = socks_bound_port.unwrap_or(local_addr_nat.port());
|
||||
let socks_bound_port = socks_bound_kdf_addr.map(|bound| bound.port());
|
||||
let client_port_for_kdf = client_addr_for_kdf.port();
|
||||
let client_port_source = KdfClientPortSource::from_socks_bound_port(socks_bound_port);
|
||||
let kdf_fingerprint = Self::kdf_material_fingerprint(
|
||||
local_addr_nat.ip(),
|
||||
client_addr_for_kdf.ip(),
|
||||
peer_addr_nat,
|
||||
reflected.map(|value| value.ip()),
|
||||
socks_bound_addr.map(|value| value.ip()),
|
||||
socks_bound_kdf_addr.map(|value| value.ip()),
|
||||
client_port_source,
|
||||
);
|
||||
let previous_kdf_fingerprint = {
|
||||
@@ -473,7 +474,7 @@ impl MePool {
|
||||
let client_port_bytes = client_port_for_kdf.to_le_bytes();
|
||||
|
||||
let server_ip = extract_ip_material(peer_addr_nat);
|
||||
let client_ip = extract_ip_material(local_addr_nat);
|
||||
let client_ip = extract_ip_material(client_addr_for_kdf);
|
||||
|
||||
let (srv_ip_opt, clt_ip_opt, clt_v6_opt, srv_v6_opt, hs_our_ip, hs_peer_ip) =
|
||||
match (server_ip, client_ip) {
|
||||
@@ -494,38 +495,6 @@ impl MePool {
|
||||
}
|
||||
};
|
||||
|
||||
let diag_level: u8 = std::env::var("ME_DIAG")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let prekey_client = build_middleproxy_prekey(
|
||||
&srv_nonce,
|
||||
&my_nonce,
|
||||
&ts_bytes,
|
||||
srv_ip_opt.as_ref().map(|x| &x[..]),
|
||||
&client_port_bytes,
|
||||
b"CLIENT",
|
||||
clt_ip_opt.as_ref().map(|x| &x[..]),
|
||||
&server_port_bytes,
|
||||
&secret,
|
||||
clt_v6_opt.as_ref(),
|
||||
srv_v6_opt.as_ref(),
|
||||
);
|
||||
let prekey_server = build_middleproxy_prekey(
|
||||
&srv_nonce,
|
||||
&my_nonce,
|
||||
&ts_bytes,
|
||||
srv_ip_opt.as_ref().map(|x| &x[..]),
|
||||
&client_port_bytes,
|
||||
b"SERVER",
|
||||
clt_ip_opt.as_ref().map(|x| &x[..]),
|
||||
&server_port_bytes,
|
||||
&secret,
|
||||
clt_v6_opt.as_ref(),
|
||||
srv_v6_opt.as_ref(),
|
||||
);
|
||||
|
||||
let (wk, wi) = derive_middleproxy_keys(
|
||||
&srv_nonce,
|
||||
&my_nonce,
|
||||
@@ -556,47 +525,14 @@ impl MePool {
|
||||
let requested_crc_mode = RpcChecksumMode::Crc32c;
|
||||
let hs_payload = build_handshake_payload(
|
||||
hs_our_ip,
|
||||
local_addr.port(),
|
||||
client_port_for_kdf,
|
||||
hs_peer_ip,
|
||||
peer_addr.port(),
|
||||
peer_addr_nat.port(),
|
||||
requested_crc_mode.advertised_flags(),
|
||||
);
|
||||
let hs_frame = build_rpc_frame(-1, &hs_payload, RpcChecksumMode::Crc32);
|
||||
if diag_level >= 1 {
|
||||
info!(
|
||||
write_key = %hex_dump(&wk),
|
||||
write_iv = %hex_dump(&wi),
|
||||
read_key = %hex_dump(&rk),
|
||||
read_iv = %hex_dump(&ri),
|
||||
srv_ip = %srv_ip_opt.map(|ip| hex_dump(&ip)).unwrap_or_default(),
|
||||
clt_ip = %clt_ip_opt.map(|ip| hex_dump(&ip)).unwrap_or_default(),
|
||||
srv_port = %hex_dump(&server_port_bytes),
|
||||
clt_port = %hex_dump(&client_port_bytes),
|
||||
crypto_ts = %hex_dump(&ts_bytes),
|
||||
nonce_srv = %hex_dump(&srv_nonce),
|
||||
nonce_clt = %hex_dump(&my_nonce),
|
||||
prekey_sha256_client = %hex_dump(&sha256(&prekey_client)),
|
||||
prekey_sha256_server = %hex_dump(&sha256(&prekey_server)),
|
||||
hs_plain = %hex_dump(&hs_frame),
|
||||
proxy_secret_sha256 = %hex_dump(&sha256(&secret)),
|
||||
"ME diag: derived keys and handshake plaintext"
|
||||
);
|
||||
}
|
||||
if diag_level >= 2 {
|
||||
info!(
|
||||
prekey_client = %hex_dump(&prekey_client),
|
||||
prekey_server = %hex_dump(&prekey_server),
|
||||
"ME diag: full prekey buffers"
|
||||
);
|
||||
}
|
||||
|
||||
let (encrypted_hs, write_iv) = cbc_encrypt_padded(&wk, &wi, &hs_frame)?;
|
||||
if diag_level >= 1 {
|
||||
info!(
|
||||
hs_cipher = %hex_dump(&encrypted_hs),
|
||||
"ME diag: handshake ciphertext"
|
||||
);
|
||||
}
|
||||
wr.write_all(&encrypted_hs).await.map_err(ProxyError::Io)?;
|
||||
wr.flush().await.map_err(ProxyError::Io)?;
|
||||
|
||||
@@ -739,8 +675,122 @@ fn hex_dump(data: &[u8]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
fn upstream_egress(
|
||||
route_kind: UpstreamRouteKind,
|
||||
socks_bound_addr: Option<SocketAddr>,
|
||||
) -> UpstreamEgressInfo {
|
||||
UpstreamEgressInfo {
|
||||
upstream_id: 7,
|
||||
route_kind,
|
||||
local_addr: None,
|
||||
direct_bind_ip: None,
|
||||
socks_bound_addr,
|
||||
socks_proxy_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socks_bound_addr_is_used_only_for_public_same_family_tuple() {
|
||||
let v4_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
|
||||
let v6_bound = SocketAddr::new(
|
||||
IpAddr::V6(
|
||||
"2606:4700:4700::1111"
|
||||
.parse::<Ipv6Addr>()
|
||||
.expect("test IPv6 address must parse"),
|
||||
),
|
||||
443,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(UpstreamRouteKind::Socks5, Some(v4_bound)))
|
||||
),
|
||||
Some(v4_bound)
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V6,
|
||||
Some(upstream_egress(UpstreamRouteKind::Socks5, Some(v6_bound)))
|
||||
),
|
||||
Some(v6_bound)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socks_bound_addr_rejects_bogon_unspecified_wrong_family_and_non_socks_routes() {
|
||||
let bogon_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 443);
|
||||
let unspecified_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443);
|
||||
let public_v4_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
|
||||
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Socks5,
|
||||
Some(bogon_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Socks5,
|
||||
Some(unspecified_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V6,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Socks5,
|
||||
Some(public_v4_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
MePool::select_socks_bound_addr(
|
||||
IpFamily::V4,
|
||||
Some(upstream_egress(
|
||||
UpstreamRouteKind::Direct,
|
||||
Some(public_v4_bound)
|
||||
))
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdf_client_port_source_tracks_only_valid_socks_bound_port() {
|
||||
let bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
|
||||
let zero_port_bound = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 0);
|
||||
|
||||
assert_eq!(
|
||||
KdfClientPortSource::from_socks_bound_port(Some(bound.port())),
|
||||
KdfClientPortSource::SocksBound
|
||||
);
|
||||
assert_eq!(
|
||||
KdfClientPortSource::from_socks_bound_port(
|
||||
Some(zero_port_bound)
|
||||
.filter(|addr| addr.port() != 0)
|
||||
.map(|addr| addr.port())
|
||||
),
|
||||
KdfClientPortSource::LocalSocket
|
||||
);
|
||||
assert_eq!(
|
||||
KdfClientPortSource::from_socks_bound_port(None),
|
||||
KdfClientPortSource::LocalSocket
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_configure_keepalive_loopback() {
|
||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||
|
||||
@@ -1728,6 +1728,8 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -1793,6 +1795,7 @@ mod tests {
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
@@ -1819,6 +1822,7 @@ mod tests {
|
||||
) -> u64 {
|
||||
let (conn_id, _rx) = pool.registry.register().await;
|
||||
let (tx, _writer_rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let writer = MeWriter {
|
||||
id: writer_id,
|
||||
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4000 + writer_id as u16),
|
||||
@@ -1828,6 +1832,7 @@ mod tests {
|
||||
contour: Arc::new(AtomicU8::new(WriterContour::Draining.as_u8())),
|
||||
created_at: Instant::now() - Duration::from_secs(writer_id),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -1837,7 +1842,9 @@ mod tests {
|
||||
allow_drain_fallback: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
pool.writers.write().await.push(writer);
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
pool.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
assert!(
|
||||
pool.registry
|
||||
@@ -1858,6 +1865,7 @@ mod tests {
|
||||
|
||||
async fn insert_live_writer(pool: &Arc<MePool>, writer_id: u64, writer_dc: i32) {
|
||||
let (tx, _writer_rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let writer = MeWriter {
|
||||
id: writer_id,
|
||||
addr: SocketAddr::new(
|
||||
@@ -1875,6 +1883,7 @@ mod tests {
|
||||
contour: Arc::new(AtomicU8::new(WriterContour::Active.as_u8())),
|
||||
created_at: Instant::now(),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -1884,7 +1893,9 @@ mod tests {
|
||||
allow_drain_fallback: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
pool.writers.write().await.push(writer);
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
pool.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::atomic::{
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use tokio::sync::{Mutex, RwLock, mpsc, watch};
|
||||
use tokio::sync::{Mutex, RwLock, Semaphore, mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::{
|
||||
@@ -48,6 +48,8 @@ pub struct MeWriter {
|
||||
pub contour: Arc<AtomicU8>,
|
||||
pub created_at: Instant,
|
||||
pub tx: mpsc::Sender<WriterCommand>,
|
||||
/// Aggregate resident-memory budget shared by all data commands for this writer.
|
||||
pub byte_budget: Arc<Semaphore>,
|
||||
pub cancel: CancellationToken,
|
||||
pub degraded: Arc<AtomicBool>,
|
||||
pub rtt_ema_ms_x10: Arc<AtomicU32>,
|
||||
@@ -277,6 +279,7 @@ pub(super) struct WriterLifecycleCore {
|
||||
pub(super) me_keepalive_payload_random: bool,
|
||||
pub(super) rpc_proxy_req_every_secs: AtomicU64,
|
||||
pub(super) writer_cmd_channel_capacity: usize,
|
||||
pub(super) writer_byte_budget_permits: usize,
|
||||
}
|
||||
|
||||
pub(super) struct RouteRuntimeCore {
|
||||
@@ -336,6 +339,8 @@ pub(super) struct NatRuntimeCore {
|
||||
pub(super) nat_probe: bool,
|
||||
pub(super) nat_stun: Option<String>,
|
||||
pub(super) nat_stun_servers: Vec<String>,
|
||||
pub(super) stun_tcp_fallback: bool,
|
||||
pub(super) http_ip_detect_urls: Vec<String>,
|
||||
pub(super) nat_stun_live_servers: Arc<RwLock<Vec<String>>>,
|
||||
pub(super) nat_probe_concurrency: usize,
|
||||
pub(super) detected_ipv6: Option<Ipv6Addr>,
|
||||
@@ -484,6 +489,8 @@ impl MePool {
|
||||
nat_probe: bool,
|
||||
nat_stun: Option<String>,
|
||||
nat_stun_servers: Vec<String>,
|
||||
stun_tcp_fallback: bool,
|
||||
http_ip_detect_urls: Vec<String>,
|
||||
nat_probe_concurrency: usize,
|
||||
detected_ipv6: Option<Ipv6Addr>,
|
||||
me_one_retry: u8,
|
||||
@@ -549,6 +556,7 @@ impl MePool {
|
||||
me_writer_pick_sample_size: u8,
|
||||
me_socks_kdf_policy: MeSocksKdfPolicy,
|
||||
me_writer_cmd_channel_capacity: usize,
|
||||
me_writer_byte_budget_bytes: usize,
|
||||
me_route_channel_capacity: usize,
|
||||
me_route_backpressure_enabled: bool,
|
||||
me_route_fairshare_enabled: bool,
|
||||
@@ -579,6 +587,7 @@ impl MePool {
|
||||
);
|
||||
let (writer_epoch, _) = watch::channel(0u64);
|
||||
let now_epoch_secs = Self::now_epoch_secs();
|
||||
stats.set_me_writer_byte_budget_limit_bytes(me_writer_byte_budget_bytes);
|
||||
Arc::new(Self {
|
||||
routing: Arc::new(RoutingCore {
|
||||
registry,
|
||||
@@ -611,6 +620,9 @@ impl MePool {
|
||||
me_keepalive_payload_random,
|
||||
rpc_proxy_req_every_secs: AtomicU64::new(rpc_proxy_req_every_secs),
|
||||
writer_cmd_channel_capacity: me_writer_cmd_channel_capacity.max(1),
|
||||
writer_byte_budget_permits: me_writer_byte_budget_bytes
|
||||
.div_ceil(crate::config::defaults::ME_WRITER_BYTE_PERMIT_UNIT_BYTES)
|
||||
.max(1),
|
||||
}),
|
||||
route_runtime: Arc::new(RouteRuntimeCore {
|
||||
me_route_no_writer_mode: AtomicU8::new(me_route_no_writer_mode.as_u8()),
|
||||
@@ -706,6 +718,8 @@ impl MePool {
|
||||
nat_probe,
|
||||
nat_stun,
|
||||
nat_stun_servers,
|
||||
stun_tcp_fallback,
|
||||
http_ip_detect_urls,
|
||||
nat_stun_live_servers: Arc::new(RwLock::new(Vec::new())),
|
||||
nat_probe_concurrency: nat_probe_concurrency.max(1),
|
||||
detected_ipv6,
|
||||
@@ -827,6 +841,13 @@ impl MePool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the immutable byte semaphore assigned to one ME writer generation.
|
||||
pub(crate) fn new_writer_byte_budget(&self) -> Arc<Semaphore> {
|
||||
Arc::new(Semaphore::new(
|
||||
self.writer_lifecycle.writer_byte_budget_permits,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn current_generation(&self) -> u64 {
|
||||
self.reinit.active_generation.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::network::probe::is_bogon;
|
||||
use crate::network::stun::{IpFamily, stun_probe_dual, stun_probe_family_with_bind};
|
||||
use crate::network::probe::{detect_public_ipv4_http, is_bogon};
|
||||
use crate::network::stun::{
|
||||
IpFamily, stun_probe_dual_with_tcp_fallback, stun_probe_family_with_bind_and_tcp_fallback,
|
||||
};
|
||||
|
||||
use super::MePool;
|
||||
use std::time::Instant;
|
||||
|
||||
const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const STUN_BATCH_TCP_FALLBACK_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn stun_probe(stun_addr: Option<String>) -> Result<crate::network::stun::DualStunResult> {
|
||||
@@ -28,16 +31,13 @@ pub async fn stun_probe(stun_addr: Option<String>) -> Result<crate::network::stu
|
||||
"STUN server is not configured".to_string(),
|
||||
));
|
||||
}
|
||||
stun_probe_dual(&stun_addr).await
|
||||
stun_probe_dual_with_tcp_fallback(&stun_addr, false).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn detect_public_ip() -> Option<IpAddr> {
|
||||
fetch_public_ipv4_with_retry()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(IpAddr::V4)
|
||||
let urls = crate::config::defaults::default_http_ip_detect_urls();
|
||||
detect_public_ipv4_http(&urls).await.map(IpAddr::V4)
|
||||
}
|
||||
|
||||
impl MePool {
|
||||
@@ -65,15 +65,26 @@ impl MePool {
|
||||
let mut live_servers = Vec::new();
|
||||
let mut best_by_ip: HashMap<IpAddr, (usize, std::net::SocketAddr)> = HashMap::new();
|
||||
let concurrency = self.nat_runtime.nat_probe_concurrency.max(1);
|
||||
let tcp_fallback = self.nat_runtime.stun_tcp_fallback;
|
||||
|
||||
while next_idx < servers.len() || !join_set.is_empty() {
|
||||
while next_idx < servers.len() && join_set.len() < concurrency {
|
||||
let stun_addr = servers[next_idx].clone();
|
||||
next_idx += 1;
|
||||
join_set.spawn(async move {
|
||||
let batch_timeout = if tcp_fallback {
|
||||
STUN_BATCH_TCP_FALLBACK_TIMEOUT
|
||||
} else {
|
||||
STUN_BATCH_TIMEOUT
|
||||
};
|
||||
let res = timeout(
|
||||
STUN_BATCH_TIMEOUT,
|
||||
stun_probe_family_with_bind(&stun_addr, family, bind_ip),
|
||||
batch_timeout,
|
||||
stun_probe_family_with_bind_and_tcp_fallback(
|
||||
&stun_addr,
|
||||
family,
|
||||
bind_ip,
|
||||
tcp_fallback,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
(stun_addr, res)
|
||||
@@ -193,6 +204,10 @@ impl MePool {
|
||||
return self.nat_runtime.nat_ip_cfg;
|
||||
}
|
||||
|
||||
if !self.nat_runtime.nat_probe {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !(is_bogon(local_ip) || local_ip.is_loopback() || local_ip.is_unspecified()) {
|
||||
return None;
|
||||
}
|
||||
@@ -201,21 +216,15 @@ impl MePool {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
match fetch_public_ipv4_with_retry().await {
|
||||
Ok(Some(ip)) => {
|
||||
{
|
||||
let mut guard = self.nat_runtime.nat_ip_detected.write().await;
|
||||
*guard = Some(IpAddr::V4(ip));
|
||||
}
|
||||
info!(public_ip = %ip, "Auto-detected public IP for NAT translation");
|
||||
Some(IpAddr::V4(ip))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to auto-detect public IP");
|
||||
None
|
||||
}
|
||||
let Some(ip) = detect_public_ipv4_http(&self.nat_runtime.http_ip_detect_urls).await else {
|
||||
return None;
|
||||
};
|
||||
{
|
||||
let mut guard = self.nat_runtime.nat_ip_detected.write().await;
|
||||
*guard = Some(IpAddr::V4(ip));
|
||||
}
|
||||
info!(public_ip = %ip, "Auto-detected public IP for NAT translation");
|
||||
Some(IpAddr::V4(ip))
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_reflect_public_addr(
|
||||
@@ -365,31 +374,3 @@ impl MePool {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_public_ipv4_with_retry() -> Result<Option<Ipv4Addr>> {
|
||||
let providers = [
|
||||
"https://checkip.amazonaws.com",
|
||||
"http://v4.ident.me",
|
||||
"http://ipv4.icanhazip.com",
|
||||
];
|
||||
for url in providers {
|
||||
if let Ok(Some(ip)) = fetch_public_ipv4_once(url).await {
|
||||
return Ok(Some(ip));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn fetch_public_ipv4_once(url: &str) -> Result<Option<Ipv4Addr>> {
|
||||
let res = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| ProxyError::Proxy(format!("public IP detection request failed: {e}")))?;
|
||||
|
||||
let text = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Proxy(format!("public IP detection read failed: {e}")))?;
|
||||
|
||||
let ip = text.trim().parse().ok();
|
||||
Ok(ip)
|
||||
}
|
||||
|
||||
@@ -63,13 +63,19 @@ async fn writer_command_loop(
|
||||
_ = cancel.cancelled() => return Ok(()),
|
||||
cmd = rx.recv() => {
|
||||
match cmd {
|
||||
Some(WriterCommand::Data(payload)) => {
|
||||
Some(WriterCommand::Data {
|
||||
payload,
|
||||
_permit,
|
||||
mut writer_permit,
|
||||
}) => {
|
||||
writer_permit.mark_inflight();
|
||||
rpc_writer.send(&payload).await?;
|
||||
}
|
||||
Some(WriterCommand::DataAndFlush(payload)) => {
|
||||
rpc_writer.send_and_flush(&payload).await?;
|
||||
}
|
||||
Some(WriterCommand::ProxyReq(command)) => {
|
||||
Some(WriterCommand::ProxyReq(mut command)) => {
|
||||
command.writer_permit.mark_inflight();
|
||||
rpc_writer.send_proxy_req(&command).await?;
|
||||
}
|
||||
Some(WriterCommand::ControlAndFlush(payload)) => {
|
||||
@@ -419,6 +425,7 @@ impl MePool {
|
||||
let draining_started_at_epoch_secs = Arc::new(AtomicU64::new(0));
|
||||
let drain_deadline_epoch_secs = Arc::new(AtomicU64::new(0));
|
||||
let allow_drain_fallback = Arc::new(AtomicBool::new(false));
|
||||
let byte_budget = self.new_writer_byte_budget();
|
||||
let (tx, rx) =
|
||||
mpsc::channel::<WriterCommand>(self.writer_lifecycle.writer_cmd_channel_capacity);
|
||||
let rpc_writer = RpcWriter {
|
||||
@@ -438,6 +445,7 @@ impl MePool {
|
||||
contour: contour.clone(),
|
||||
created_at: Instant::now(),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: cancel.clone(),
|
||||
degraded: degraded.clone(),
|
||||
rtt_ema_ms_x10: rtt_ema_ms_x10.clone(),
|
||||
@@ -449,7 +457,9 @@ impl MePool {
|
||||
self.writers
|
||||
.update(|writers| writers.push(writer.clone()))
|
||||
.await;
|
||||
self.registry.register_writer(writer_id, tx.clone()).await;
|
||||
self.registry
|
||||
.register_writer(writer_id, tx.clone(), byte_budget)
|
||||
.await;
|
||||
self.registry.mark_writer_idle(writer_id).await;
|
||||
self.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.notify_writer_epoch();
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct BoundConn {
|
||||
pub struct ConnWriter {
|
||||
pub writer_id: u64,
|
||||
pub tx: mpsc::Sender<WriterCommand>,
|
||||
/// Writer-local memory budget used by the hot bound-client route.
|
||||
pub byte_budget: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -62,7 +64,13 @@ struct RoutingTable {
|
||||
}
|
||||
|
||||
struct WriterTable {
|
||||
map: DashMap<u64, mpsc::Sender<WriterCommand>>,
|
||||
map: DashMap<u64, WriterRoute>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WriterRoute {
|
||||
tx: mpsc::Sender<WriterCommand>,
|
||||
byte_budget: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{ConnMeta, ConnRegistry, RouteResult};
|
||||
use crate::transport::middle_proxy::MeResponse;
|
||||
|
||||
fn writer_byte_budget() -> Arc<Semaphore> {
|
||||
Arc::new(Semaphore::new(2049))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writer_activity_snapshot_tracks_writer_and_dc_load() {
|
||||
let registry = ConnRegistry::new();
|
||||
@@ -14,8 +20,12 @@ async fn writer_activity_snapshot_tracks_writer_and_dc_load() {
|
||||
let (conn_c, _rx_c) = registry.register().await;
|
||||
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
|
||||
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
|
||||
registry.register_writer(10, writer_tx_a.clone()).await;
|
||||
registry.register_writer(20, writer_tx_b.clone()).await;
|
||||
registry
|
||||
.register_writer(10, writer_tx_a.clone(), writer_byte_budget())
|
||||
.await;
|
||||
registry
|
||||
.register_writer(20, writer_tx_b.clone(), writer_byte_budget())
|
||||
.await;
|
||||
|
||||
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
||||
assert!(
|
||||
@@ -124,8 +134,12 @@ async fn bind_writer_rebinds_conn_atomically() {
|
||||
let (conn_id, _rx) = registry.register().await;
|
||||
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
|
||||
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
|
||||
registry.register_writer(10, writer_tx_a).await;
|
||||
registry.register_writer(20, writer_tx_b).await;
|
||||
registry
|
||||
.register_writer(10, writer_tx_a, writer_byte_budget())
|
||||
.await;
|
||||
registry
|
||||
.register_writer(20, writer_tx_b, writer_byte_budget())
|
||||
.await;
|
||||
|
||||
let client_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
||||
let first_our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443);
|
||||
@@ -184,8 +198,12 @@ async fn writer_lost_does_not_drop_rebound_conn() {
|
||||
let (conn_id, _rx) = registry.register().await;
|
||||
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
|
||||
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
|
||||
registry.register_writer(10, writer_tx_a).await;
|
||||
registry.register_writer(20, writer_tx_b).await;
|
||||
registry
|
||||
.register_writer(10, writer_tx_a, writer_byte_budget())
|
||||
.await;
|
||||
registry
|
||||
.register_writer(20, writer_tx_b, writer_byte_budget())
|
||||
.await;
|
||||
|
||||
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
||||
assert!(
|
||||
@@ -262,8 +280,12 @@ async fn non_empty_writer_ids_returns_only_writers_with_bound_clients() {
|
||||
let (conn_id, _rx) = registry.register().await;
|
||||
let (writer_tx_a, _writer_rx_a) = tokio::sync::mpsc::channel(8);
|
||||
let (writer_tx_b, _writer_rx_b) = tokio::sync::mpsc::channel(8);
|
||||
registry.register_writer(10, writer_tx_a).await;
|
||||
registry.register_writer(20, writer_tx_b).await;
|
||||
registry
|
||||
.register_writer(10, writer_tx_a, writer_byte_budget())
|
||||
.await;
|
||||
registry
|
||||
.register_writer(20, writer_tx_b, writer_byte_budget())
|
||||
.await;
|
||||
|
||||
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
||||
assert!(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -56,7 +57,13 @@ impl ConnRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_writer(&self, writer_id: u64, tx: mpsc::Sender<WriterCommand>) {
|
||||
/// Registers one writer command route and its matching memory budget atomically.
|
||||
pub async fn register_writer(
|
||||
&self,
|
||||
writer_id: u64,
|
||||
tx: mpsc::Sender<WriterCommand>,
|
||||
byte_budget: Arc<tokio::sync::Semaphore>,
|
||||
) {
|
||||
let mut binding = self.binding.inner.lock().await;
|
||||
binding
|
||||
.conns_for_writer
|
||||
@@ -70,7 +77,9 @@ impl ConnRegistry {
|
||||
.writer_idle_since_epoch_secs
|
||||
.entry(writer_id)
|
||||
.or_insert_with(Self::now_epoch_secs);
|
||||
self.writers.map.insert(writer_id, tx);
|
||||
self.writers
|
||||
.map
|
||||
.insert(writer_id, super::WriterRoute { tx, byte_budget });
|
||||
}
|
||||
|
||||
/// Unregister connection, returning associated writer_id if any.
|
||||
@@ -417,7 +426,8 @@ impl ConnRegistry {
|
||||
.map(|entry| entry.value().clone())?;
|
||||
Some(ConnWriter {
|
||||
writer_id,
|
||||
tx: writer,
|
||||
tx: writer.tx,
|
||||
byte_budget: writer.byte_budget,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -438,7 +448,8 @@ impl ConnRegistry {
|
||||
Some((
|
||||
ConnWriter {
|
||||
writer_id,
|
||||
tx: writer,
|
||||
tx: writer.tx,
|
||||
byte_budget: writer.byte_budget,
|
||||
},
|
||||
meta,
|
||||
))
|
||||
|
||||
@@ -6,16 +6,18 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError, mpsc};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::MePool;
|
||||
use super::codec::{ProxyReqCommand, WriterCommand};
|
||||
use super::codec::{ProxyReqCommand, WriterBytePermit, WriterCommand};
|
||||
use super::registry::ConnMeta;
|
||||
use super::wire::build_proxy_req_payload;
|
||||
use super::wire::{build_proxy_req_payload, proxy_req_payload_len};
|
||||
use crate::config::defaults::ME_WRITER_BYTE_PERMIT_UNIT_BYTES;
|
||||
use crate::config::{MeRouteNoWriterMode, MeWriterPickMode};
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::PooledBuffer;
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
@@ -29,6 +31,8 @@ const PICK_PENALTY_WARM: u64 = 200;
|
||||
const PICK_PENALTY_DRAINING: u64 = 600;
|
||||
const PICK_PENALTY_STALE: u64 = 300;
|
||||
const PICK_PENALTY_DEGRADED: u64 = 250;
|
||||
const RPC_WRITER_FRAME_CAPACITY_OVERHEAD_BYTES: usize = 27;
|
||||
const LEGACY_PROXY_REQ_SOURCE_CAPACITY_OVERHEAD_BYTES: usize = 128;
|
||||
|
||||
mod close;
|
||||
mod recovery;
|
||||
@@ -39,34 +43,133 @@ enum WriterCommandReserveError {
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
enum WriterByteReserveError {
|
||||
Closed,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
fn proxy_tag_array(tag: Option<&[u8]>) -> Option<[u8; 16]> {
|
||||
tag.and_then(|tag| <[u8; 16]>::try_from(tag).ok())
|
||||
}
|
||||
|
||||
fn proxy_req_payload_from_command(cmd: WriterCommand) -> Option<PooledBuffer> {
|
||||
fn proxy_req_payload_from_command(
|
||||
cmd: WriterCommand,
|
||||
) -> Option<(PooledBuffer, OwnedSemaphorePermit)> {
|
||||
match cmd {
|
||||
WriterCommand::ProxyReq(command) => Some(command.payload),
|
||||
WriterCommand::ProxyReq(command) => Some((command.payload, command._permit)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_permit_from_data_command(cmd: WriterCommand) -> Option<OwnedSemaphorePermit> {
|
||||
match cmd {
|
||||
WriterCommand::Data { _permit, .. } => _permit,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reserve_writer_command_slot(
|
||||
tx: &mpsc::Sender<WriterCommand>,
|
||||
wait: Option<Duration>,
|
||||
deadline: Option<Instant>,
|
||||
) -> std::result::Result<mpsc::OwnedPermit<WriterCommand>, WriterCommandReserveError> {
|
||||
let reserve = tx.clone().reserve_owned();
|
||||
match wait {
|
||||
Some(wait) => match tokio::time::timeout(wait, reserve).await {
|
||||
Ok(Ok(permit)) => Ok(permit),
|
||||
Ok(Err(_)) => Err(WriterCommandReserveError::Closed),
|
||||
Err(_) => Err(WriterCommandReserveError::TimedOut),
|
||||
},
|
||||
match deadline {
|
||||
Some(deadline) => {
|
||||
match tokio::time::timeout(deadline.saturating_duration_since(Instant::now()), reserve)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(permit)) => Ok(permit),
|
||||
Ok(Err(_)) => Err(WriterCommandReserveError::Closed),
|
||||
Err(_) => Err(WriterCommandReserveError::TimedOut),
|
||||
}
|
||||
}
|
||||
None => reserve.await.map_err(|_| WriterCommandReserveError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
fn writer_send_deadline(wait: Option<Duration>) -> Option<Instant> {
|
||||
wait.map(|wait| Instant::now() + wait)
|
||||
}
|
||||
|
||||
fn writer_resident_permits(
|
||||
source_capacity: usize,
|
||||
encoded_payload_len: usize,
|
||||
) -> Option<(u32, usize)> {
|
||||
let resident_bytes = source_capacity
|
||||
.checked_add(encoded_payload_len)?
|
||||
.checked_add(RPC_WRITER_FRAME_CAPACITY_OVERHEAD_BYTES)?;
|
||||
let permits = resident_bytes.div_ceil(ME_WRITER_BYTE_PERMIT_UNIT_BYTES);
|
||||
let permits = u32::try_from(permits).ok()?;
|
||||
let reserved_bytes = (permits as usize).checked_mul(ME_WRITER_BYTE_PERMIT_UNIT_BYTES)?;
|
||||
Some((
|
||||
permits.max(1),
|
||||
reserved_bytes.max(ME_WRITER_BYTE_PERMIT_UNIT_BYTES),
|
||||
))
|
||||
}
|
||||
|
||||
fn proxy_req_resident_permits(
|
||||
source_capacity: usize,
|
||||
data_len: usize,
|
||||
proxy_tag: Option<&[u8]>,
|
||||
proto_flags: u32,
|
||||
) -> Option<(u32, usize)> {
|
||||
writer_resident_permits(
|
||||
source_capacity,
|
||||
proxy_req_payload_len(data_len, proxy_tag, proto_flags),
|
||||
)
|
||||
}
|
||||
|
||||
fn try_reserve_writer_bytes(
|
||||
byte_budget: &Arc<Semaphore>,
|
||||
permits: u32,
|
||||
reserved_bytes: usize,
|
||||
stats: &Arc<Stats>,
|
||||
) -> std::result::Result<WriterBytePermit, TryAcquireError> {
|
||||
byte_budget
|
||||
.clone()
|
||||
.try_acquire_many_owned(permits)
|
||||
.map(|permit| WriterBytePermit::new(permit, reserved_bytes, stats.clone()))
|
||||
}
|
||||
|
||||
async fn reserve_writer_bytes(
|
||||
byte_budget: &Arc<Semaphore>,
|
||||
permits: u32,
|
||||
reserved_bytes: usize,
|
||||
deadline: Option<Instant>,
|
||||
stats: &Arc<Stats>,
|
||||
) -> std::result::Result<WriterBytePermit, WriterByteReserveError> {
|
||||
match try_reserve_writer_bytes(byte_budget, permits, reserved_bytes, stats) {
|
||||
Ok(permit) => return Ok(permit),
|
||||
Err(TryAcquireError::Closed) => return Err(WriterByteReserveError::Closed),
|
||||
Err(TryAcquireError::NoPermits) => {
|
||||
stats.increment_me_writer_byte_budget_wait_total();
|
||||
}
|
||||
}
|
||||
|
||||
let acquire = byte_budget.clone().acquire_many_owned(permits);
|
||||
match deadline {
|
||||
Some(deadline) => {
|
||||
match tokio::time::timeout(deadline.saturating_duration_since(Instant::now()), acquire)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(permit)) => Ok(WriterBytePermit::new(permit, reserved_bytes, stats.clone())),
|
||||
Ok(Err(_)) => Err(WriterByteReserveError::Closed),
|
||||
Err(_) => {
|
||||
stats.increment_me_writer_byte_budget_timeout_total();
|
||||
Err(WriterByteReserveError::TimedOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => acquire
|
||||
.await
|
||||
.map(|permit| WriterBytePermit::new(permit, reserved_bytes, stats.clone()))
|
||||
.map_err(|_| WriterByteReserveError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
impl MePool {
|
||||
/// Send RPC_PROXY_REQ. `tag_override`: per-user ad_tag (from access.user_ad_tags); if None, uses pool default.
|
||||
/// `payload_permit` keeps optional client byte accounting alive until the writer consumes the command.
|
||||
pub async fn send_proxy_req(
|
||||
self: &Arc<Self>,
|
||||
conn_id: u64,
|
||||
@@ -76,8 +179,32 @@ impl MePool {
|
||||
data: &[u8],
|
||||
proto_flags: u32,
|
||||
tag_override: Option<&[u8]>,
|
||||
mut payload_permit: Option<OwnedSemaphorePermit>,
|
||||
) -> Result<()> {
|
||||
let tag = tag_override.or(self.proxy_tag.as_deref());
|
||||
let Some(source_capacity) = data
|
||||
.len()
|
||||
.checked_add(LEGACY_PROXY_REQ_SOURCE_CAPACITY_OVERHEAD_BYTES)
|
||||
else {
|
||||
self.stats.increment_me_writer_byte_budget_oversize_total();
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer payload residency calculation overflow".into(),
|
||||
));
|
||||
};
|
||||
let Some((writer_byte_permits, writer_reserved_bytes)) =
|
||||
proxy_req_resident_permits(source_capacity, data.len(), tag, proto_flags)
|
||||
else {
|
||||
self.stats.increment_me_writer_byte_budget_oversize_total();
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer payload residency calculation overflow".into(),
|
||||
));
|
||||
};
|
||||
if writer_byte_permits as usize > self.writer_lifecycle.writer_byte_budget_permits {
|
||||
self.stats.increment_me_writer_byte_budget_oversize_total();
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer payload exceeds configured byte budget".into(),
|
||||
));
|
||||
}
|
||||
let build_routed_payload = |effective_our_addr: SocketAddr| {
|
||||
(
|
||||
build_proxy_req_payload(
|
||||
@@ -118,19 +245,48 @@ impl MePool {
|
||||
loop {
|
||||
if let Some((current, current_meta)) = self.registry.get_writer_with_meta(conn_id).await
|
||||
{
|
||||
let deadline =
|
||||
writer_send_deadline(self.route_runtime.me_route_blocking_send_timeout);
|
||||
let writer_permit = match reserve_writer_bytes(
|
||||
¤t.byte_budget,
|
||||
writer_byte_permits,
|
||||
writer_reserved_bytes,
|
||||
deadline,
|
||||
&self.stats,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(permit) => permit,
|
||||
Err(WriterByteReserveError::TimedOut) => {
|
||||
self.stats
|
||||
.increment_me_writer_pick_full_total(self.writer_pick_mode());
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer byte budget full within blocking send timeout".into(),
|
||||
));
|
||||
}
|
||||
Err(WriterByteReserveError::Closed) => {
|
||||
warn!(
|
||||
writer_id = current.writer_id,
|
||||
"ME writer byte budget closed"
|
||||
);
|
||||
self.remove_writer_and_close_clients(current.writer_id)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (current_payload, _) = build_routed_payload(current_meta.our_addr);
|
||||
match current.tx.try_send(WriterCommand::Data(current_payload)) {
|
||||
let command = WriterCommand::Data {
|
||||
payload: current_payload,
|
||||
_permit: payload_permit.take(),
|
||||
writer_permit,
|
||||
};
|
||||
match current.tx.try_send(command) {
|
||||
Ok(()) => {
|
||||
self.note_hybrid_route_success();
|
||||
return Ok(());
|
||||
}
|
||||
Err(TrySendError::Full(cmd)) => {
|
||||
match reserve_writer_command_slot(
|
||||
¤t.tx,
|
||||
self.route_runtime.me_route_blocking_send_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match reserve_writer_command_slot(¤t.tx, deadline).await {
|
||||
Ok(permit) => {
|
||||
permit.send(cmd);
|
||||
self.note_hybrid_route_success();
|
||||
@@ -143,14 +299,17 @@ impl MePool {
|
||||
"ME writer channel full within blocking send timeout".into(),
|
||||
));
|
||||
}
|
||||
Err(WriterCommandReserveError::Closed) => {}
|
||||
Err(WriterCommandReserveError::Closed) => {
|
||||
payload_permit = payload_permit_from_data_command(cmd);
|
||||
}
|
||||
}
|
||||
warn!(writer_id = current.writer_id, "ME writer channel closed");
|
||||
self.remove_writer_and_close_clients(current.writer_id)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
Err(TrySendError::Closed(_)) => {
|
||||
Err(TrySendError::Closed(cmd)) => {
|
||||
payload_permit = payload_permit_from_data_command(cmd);
|
||||
warn!(writer_id = current.writer_id, "ME writer channel closed");
|
||||
self.remove_writer_and_close_clients(current.writer_id)
|
||||
.await;
|
||||
@@ -464,10 +623,31 @@ impl MePool {
|
||||
if !self.writer_accepts_new_binding(w) {
|
||||
continue;
|
||||
}
|
||||
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port());
|
||||
let (payload, meta) = build_routed_payload(effective_our_addr);
|
||||
let writer_permit = match try_reserve_writer_bytes(
|
||||
&w.byte_budget,
|
||||
writer_byte_permits,
|
||||
writer_reserved_bytes,
|
||||
&self.stats,
|
||||
) {
|
||||
Ok(permit) => permit,
|
||||
Err(TryAcquireError::NoPermits) => {
|
||||
if fallback_blocking_idx.is_none() {
|
||||
fallback_blocking_idx = Some(idx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(TryAcquireError::Closed) => {
|
||||
self.stats.increment_me_writer_pick_closed_total(pick_mode);
|
||||
warn!(writer_id = w.id, "ME writer byte budget closed");
|
||||
self.remove_writer_and_close_clients(w.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match w.tx.clone().try_reserve_owned() {
|
||||
Ok(permit) => {
|
||||
// Keep the advertised proxy IP aligned with the selected ME writer source.
|
||||
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port());
|
||||
let (payload, meta) = build_routed_payload(effective_our_addr);
|
||||
if !self.registry.bind_writer(conn_id, w.id, meta).await {
|
||||
debug!(
|
||||
conn_id,
|
||||
@@ -478,7 +658,11 @@ impl MePool {
|
||||
self.remove_writer_and_close_clients(w.id).await;
|
||||
continue;
|
||||
}
|
||||
permit.send(WriterCommand::Data(payload));
|
||||
permit.send(WriterCommand::Data {
|
||||
payload,
|
||||
_permit: payload_permit.take(),
|
||||
writer_permit,
|
||||
});
|
||||
self.stats
|
||||
.increment_me_writer_pick_success_try_total(pick_mode);
|
||||
if w.generation < self.current_generation() {
|
||||
@@ -520,51 +704,71 @@ impl MePool {
|
||||
}
|
||||
self.stats
|
||||
.increment_me_writer_pick_blocking_fallback_total();
|
||||
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port());
|
||||
let (payload, meta) = build_routed_payload(effective_our_addr);
|
||||
let reserve_result =
|
||||
if let Some(timeout) = self.route_runtime.me_route_blocking_send_timeout {
|
||||
match tokio::time::timeout(timeout, w.tx.clone().reserve_owned()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
self.stats.increment_me_writer_pick_full_total(pick_mode);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
w.tx.clone().reserve_owned().await
|
||||
};
|
||||
match reserve_result {
|
||||
Ok(permit) => {
|
||||
if !self.registry.bind_writer(conn_id, w.id, meta).await {
|
||||
debug!(
|
||||
conn_id,
|
||||
writer_id = w.id,
|
||||
"ME writer disappeared before fallback bind commit, pruning stale writer"
|
||||
);
|
||||
drop(permit);
|
||||
self.remove_writer_and_close_clients(w.id).await;
|
||||
continue;
|
||||
}
|
||||
permit.send(WriterCommand::Data(payload));
|
||||
self.stats
|
||||
.increment_me_writer_pick_success_fallback_total(pick_mode);
|
||||
if w.generation < self.current_generation() {
|
||||
self.stats.increment_pool_stale_pick_total();
|
||||
}
|
||||
self.note_hybrid_route_success();
|
||||
return Ok(());
|
||||
let deadline = writer_send_deadline(self.route_runtime.me_route_blocking_send_timeout);
|
||||
let writer_permit = match reserve_writer_bytes(
|
||||
&w.byte_budget,
|
||||
writer_byte_permits,
|
||||
writer_reserved_bytes,
|
||||
deadline,
|
||||
&self.stats,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(permit) => permit,
|
||||
Err(WriterByteReserveError::TimedOut) => {
|
||||
self.stats.increment_me_writer_pick_full_total(pick_mode);
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(WriterByteReserveError::Closed) => {
|
||||
self.stats.increment_me_writer_pick_closed_total(pick_mode);
|
||||
warn!(writer_id = w.id, "ME writer byte budget closed (blocking)");
|
||||
self.remove_writer_and_close_clients(w.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let permit = match reserve_writer_command_slot(&w.tx, deadline).await {
|
||||
Ok(permit) => permit,
|
||||
Err(WriterCommandReserveError::TimedOut) => {
|
||||
self.stats.increment_me_writer_pick_full_total(pick_mode);
|
||||
continue;
|
||||
}
|
||||
Err(WriterCommandReserveError::Closed) => {
|
||||
self.stats.increment_me_writer_pick_closed_total(pick_mode);
|
||||
warn!(writer_id = w.id, "ME writer channel closed (blocking)");
|
||||
self.remove_writer_and_close_clients(w.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Keep the advertised proxy IP aligned with the selected ME writer source.
|
||||
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port());
|
||||
let (payload, meta) = build_routed_payload(effective_our_addr);
|
||||
if !self.registry.bind_writer(conn_id, w.id, meta).await {
|
||||
debug!(
|
||||
conn_id,
|
||||
writer_id = w.id,
|
||||
"ME writer disappeared before fallback bind commit, pruning stale writer"
|
||||
);
|
||||
drop(permit);
|
||||
self.remove_writer_and_close_clients(w.id).await;
|
||||
continue;
|
||||
}
|
||||
permit.send(WriterCommand::Data {
|
||||
payload,
|
||||
_permit: payload_permit.take(),
|
||||
writer_permit,
|
||||
});
|
||||
self.stats
|
||||
.increment_me_writer_pick_success_fallback_total(pick_mode);
|
||||
if w.generation < self.current_generation() {
|
||||
self.stats.increment_pool_stale_pick_total();
|
||||
}
|
||||
self.note_hybrid_route_success();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Send RPC_PROXY_REQ while keeping the first bound-writer path allocation-light.
|
||||
/// The client byte permit follows the payload until writer completion or command drop.
|
||||
pub async fn send_proxy_req_pooled(
|
||||
self: &Arc<Self>,
|
||||
conn_id: u64,
|
||||
@@ -572,12 +776,69 @@ impl MePool {
|
||||
client_addr: SocketAddr,
|
||||
our_addr: SocketAddr,
|
||||
payload: PooledBuffer,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
proto_flags: u32,
|
||||
tag_override: Option<[u8; 16]>,
|
||||
) -> Result<()> {
|
||||
let tag = tag_override.or_else(|| proxy_tag_array(self.proxy_tag.as_deref()));
|
||||
let Some((writer_byte_permits, writer_reserved_bytes)) = proxy_req_resident_permits(
|
||||
payload.capacity(),
|
||||
payload.len(),
|
||||
tag.as_ref().map(|tag| tag.as_slice()),
|
||||
proto_flags,
|
||||
) else {
|
||||
self.stats.increment_me_writer_byte_budget_oversize_total();
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer payload residency calculation overflow".into(),
|
||||
));
|
||||
};
|
||||
if writer_byte_permits as usize > self.writer_lifecycle.writer_byte_budget_permits {
|
||||
self.stats.increment_me_writer_byte_budget_oversize_total();
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer payload exceeds configured byte budget".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some((current, current_meta)) = self.registry.get_writer_with_meta(conn_id).await {
|
||||
let deadline = writer_send_deadline(self.route_runtime.me_route_blocking_send_timeout);
|
||||
let writer_permit = match reserve_writer_bytes(
|
||||
¤t.byte_budget,
|
||||
writer_byte_permits,
|
||||
writer_reserved_bytes,
|
||||
deadline,
|
||||
&self.stats,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(permit) => permit,
|
||||
Err(WriterByteReserveError::TimedOut) => {
|
||||
self.stats
|
||||
.increment_me_writer_pick_full_total(self.writer_pick_mode());
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer byte budget full within blocking send timeout".into(),
|
||||
));
|
||||
}
|
||||
Err(WriterByteReserveError::Closed) => {
|
||||
warn!(
|
||||
writer_id = current.writer_id,
|
||||
"ME writer byte budget closed"
|
||||
);
|
||||
self.remove_writer_and_close_clients(current.writer_id)
|
||||
.await;
|
||||
return self
|
||||
.send_proxy_req(
|
||||
conn_id,
|
||||
target_dc,
|
||||
client_addr,
|
||||
our_addr,
|
||||
payload.as_ref(),
|
||||
proto_flags,
|
||||
tag.as_ref().map(|tag| tag.as_slice()),
|
||||
Some(_permit),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let command = WriterCommand::ProxyReq(ProxyReqCommand {
|
||||
conn_id,
|
||||
client_addr,
|
||||
@@ -585,6 +846,8 @@ impl MePool {
|
||||
proto_flags,
|
||||
proxy_tag: tag,
|
||||
payload,
|
||||
_permit,
|
||||
writer_permit,
|
||||
});
|
||||
match current.tx.try_send(command) {
|
||||
Ok(()) => {
|
||||
@@ -592,12 +855,7 @@ impl MePool {
|
||||
return Ok(());
|
||||
}
|
||||
Err(TrySendError::Full(cmd)) => {
|
||||
match reserve_writer_command_slot(
|
||||
¤t.tx,
|
||||
self.route_runtime.me_route_blocking_send_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match reserve_writer_command_slot(¤t.tx, deadline).await {
|
||||
Ok(permit) => {
|
||||
permit.send(cmd);
|
||||
self.note_hybrid_route_success();
|
||||
@@ -611,7 +869,8 @@ impl MePool {
|
||||
));
|
||||
}
|
||||
Err(WriterCommandReserveError::Closed) => {
|
||||
let Some(payload) = proxy_req_payload_from_command(cmd) else {
|
||||
let Some((payload, _permit)) = proxy_req_payload_from_command(cmd)
|
||||
else {
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer rejected unexpected command type".into(),
|
||||
));
|
||||
@@ -628,13 +887,14 @@ impl MePool {
|
||||
payload.as_ref(),
|
||||
proto_flags,
|
||||
tag.as_ref().map(|tag| tag.as_slice()),
|
||||
Some(_permit),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Closed(cmd)) => {
|
||||
let Some(payload) = proxy_req_payload_from_command(cmd) else {
|
||||
let Some((payload, _permit)) = proxy_req_payload_from_command(cmd) else {
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME writer rejected unexpected command type".into(),
|
||||
));
|
||||
@@ -651,6 +911,7 @@ impl MePool {
|
||||
payload.as_ref(),
|
||||
proto_flags,
|
||||
tag.as_ref().map(|tag| tag.as_slice()),
|
||||
Some(_permit),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -665,6 +926,7 @@ impl MePool {
|
||||
payload.as_ref(),
|
||||
proto_flags,
|
||||
tag.as_ref().map(|tag| tag.as_slice()),
|
||||
Some(_permit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::protocol::constants::{RPC_CLOSE_CONN_U32, RPC_CLOSE_EXT_U32};
|
||||
|
||||
use super::super::MePool;
|
||||
use super::super::codec::{WriterCommand, build_control_payload};
|
||||
use super::{WriterCommandReserveError, reserve_writer_command_slot};
|
||||
use super::{WriterCommandReserveError, reserve_writer_command_slot, writer_send_deadline};
|
||||
|
||||
const ME_CLOSE_SIGNAL_SEND_TIMEOUT: Duration = Duration::from_millis(50);
|
||||
|
||||
@@ -22,8 +22,11 @@ impl MePool {
|
||||
match w.tx.try_send(WriterCommand::ControlAndFlush(payload)) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(cmd)) => {
|
||||
match reserve_writer_command_slot(&w.tx, Some(ME_CLOSE_SIGNAL_SEND_TIMEOUT))
|
||||
.await
|
||||
match reserve_writer_command_slot(
|
||||
&w.tx,
|
||||
writer_send_deadline(Some(ME_CLOSE_SIGNAL_SEND_TIMEOUT)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(permit) => {
|
||||
permit.send(cmd);
|
||||
@@ -63,9 +66,12 @@ impl MePool {
|
||||
match w.tx.try_send(WriterCommand::ControlAndFlush(payload)) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(cmd)) => {
|
||||
let _ = reserve_writer_command_slot(&w.tx, Some(ME_CLOSE_SIGNAL_SEND_TIMEOUT))
|
||||
.await
|
||||
.map(|permit| permit.send(cmd));
|
||||
let _ = reserve_writer_command_slot(
|
||||
&w.tx,
|
||||
writer_send_deadline(Some(ME_CLOSE_SIGNAL_SEND_TIMEOUT)),
|
||||
)
|
||||
.await
|
||||
.map(|permit| permit.send(cmd));
|
||||
}
|
||||
Err(TrySendError::Closed(_)) => {
|
||||
debug!(conn_id, "ME close_conn skipped: writer channel closed");
|
||||
|
||||
@@ -38,6 +38,8 @@ async fn make_pool(
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -103,6 +105,7 @@ async fn make_pool(
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
@@ -132,6 +135,7 @@ async fn insert_draining_writer(
|
||||
drain_deadline_epoch_secs: u64,
|
||||
) {
|
||||
let (tx, _writer_rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let writer = MeWriter {
|
||||
id: writer_id,
|
||||
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6000 + writer_id as u16),
|
||||
@@ -141,6 +145,7 @@ async fn insert_draining_writer(
|
||||
contour: Arc::new(AtomicU8::new(WriterContour::Draining.as_u8())),
|
||||
created_at: Instant::now() - Duration::from_secs(writer_id),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -151,7 +156,9 @@ async fn insert_draining_writer(
|
||||
};
|
||||
|
||||
pool.writers.write().await.push(writer);
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
pool.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
for idx in 0..bound_clients {
|
||||
|
||||
@@ -36,6 +36,8 @@ async fn make_pool(
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -101,6 +103,7 @@ async fn make_pool(
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
@@ -129,6 +132,7 @@ async fn insert_draining_writer(
|
||||
drain_deadline_epoch_secs: u64,
|
||||
) {
|
||||
let (tx, _writer_rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let writer = MeWriter {
|
||||
id: writer_id,
|
||||
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5500 + writer_id as u16),
|
||||
@@ -138,6 +142,7 @@ async fn insert_draining_writer(
|
||||
contour: Arc::new(AtomicU8::new(WriterContour::Draining.as_u8())),
|
||||
created_at: Instant::now() - Duration::from_secs(writer_id),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -147,7 +152,9 @@ async fn insert_draining_writer(
|
||||
allow_drain_fallback: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
pool.writers.write().await.push(writer);
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
pool.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
for idx in 0..bound_clients {
|
||||
let (conn_id, _rx) = pool.registry.register().await;
|
||||
|
||||
@@ -31,6 +31,8 @@ async fn make_pool(me_pool_drain_threshold: u64) -> Arc<MePool> {
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -96,6 +98,7 @@ async fn make_pool(me_pool_drain_threshold: u64) -> Arc<MePool> {
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
@@ -124,6 +127,7 @@ async fn insert_draining_writer(
|
||||
) -> Vec<u64> {
|
||||
let mut conn_ids = Vec::with_capacity(bound_clients);
|
||||
let (tx, _writer_rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let writer = MeWriter {
|
||||
id: writer_id,
|
||||
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4500 + writer_id as u16),
|
||||
@@ -133,6 +137,7 @@ async fn insert_draining_writer(
|
||||
contour: Arc::new(AtomicU8::new(WriterContour::Draining.as_u8())),
|
||||
created_at: Instant::now() - Duration::from_secs(writer_id),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -142,7 +147,9 @@ async fn insert_draining_writer(
|
||||
allow_drain_fallback: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
pool.writers.write().await.push(writer);
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
pool.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
for idx in 0..bound_clients {
|
||||
let (conn_id, _rx) = pool.registry.register().await;
|
||||
|
||||
@@ -20,6 +20,8 @@ async fn make_pool() -> Arc<MePool> {
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -85,6 +87,7 @@ async fn make_pool() -> Arc<MePool> {
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
|
||||
@@ -25,6 +25,8 @@ async fn make_pool() -> Arc<MePool> {
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -90,6 +92,7 @@ async fn make_pool() -> Arc<MePool> {
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
@@ -118,6 +121,7 @@ async fn insert_writer(
|
||||
created_at: Instant,
|
||||
) {
|
||||
let (tx, _rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let contour = if draining {
|
||||
WriterContour::Draining
|
||||
} else {
|
||||
@@ -132,6 +136,7 @@ async fn insert_writer(
|
||||
contour: Arc::new(AtomicU8::new(contour.as_u8())),
|
||||
created_at,
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -142,7 +147,9 @@ async fn insert_writer(
|
||||
};
|
||||
|
||||
pool.writers.write().await.push(writer);
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
pool.conn_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ async fn make_pool() -> (Arc<MePool>, Arc<SecureRandom>) {
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
Vec::new(),
|
||||
1,
|
||||
None,
|
||||
12,
|
||||
@@ -96,6 +98,7 @@ async fn make_pool() -> (Arc<MePool>, Arc<SecureRandom>) {
|
||||
general.me_writer_pick_sample_size,
|
||||
MeSocksKdfPolicy::default(),
|
||||
general.me_writer_cmd_channel_capacity,
|
||||
general.me_writer_byte_budget_bytes,
|
||||
general.me_route_channel_capacity,
|
||||
general.me_route_backpressure_enabled,
|
||||
general.me_route_fairshare_enabled,
|
||||
@@ -125,6 +128,7 @@ async fn insert_writer(
|
||||
register_in_registry: bool,
|
||||
) -> mpsc::Receiver<WriterCommand> {
|
||||
let (tx, rx) = mpsc::channel::<WriterCommand>(8);
|
||||
let byte_budget = pool.new_writer_byte_budget();
|
||||
let writer = MeWriter {
|
||||
id: writer_id,
|
||||
addr,
|
||||
@@ -134,6 +138,7 @@ async fn insert_writer(
|
||||
contour: Arc::new(AtomicU8::new(WriterContour::Active.as_u8())),
|
||||
created_at: Instant::now(),
|
||||
tx: tx.clone(),
|
||||
byte_budget: byte_budget.clone(),
|
||||
cancel: CancellationToken::new(),
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
|
||||
@@ -152,7 +157,9 @@ async fn insert_writer(
|
||||
}
|
||||
pool.rebuild_endpoint_dc_map().await;
|
||||
if register_in_registry {
|
||||
pool.registry.register_writer(writer_id, tx).await;
|
||||
pool.registry
|
||||
.register_writer(writer_id, tx, byte_budget)
|
||||
.await;
|
||||
}
|
||||
rx
|
||||
}
|
||||
@@ -163,7 +170,7 @@ async fn recv_data_count(rx: &mut mpsc::Receiver<WriterCommand>, budget: Duratio
|
||||
while Instant::now().duration_since(start) < budget {
|
||||
let remaining = budget.saturating_sub(Instant::now().duration_since(start));
|
||||
match tokio::time::timeout(remaining.min(Duration::from_millis(10)), rx.recv()).await {
|
||||
Ok(Some(WriterCommand::Data(_))) => data_count += 1,
|
||||
Ok(Some(WriterCommand::Data { .. })) => data_count += 1,
|
||||
Ok(Some(WriterCommand::DataAndFlush(_))) => data_count += 1,
|
||||
Ok(Some(WriterCommand::ProxyReq(_))) => data_count += 1,
|
||||
Ok(Some(WriterCommand::ControlAndFlush(_))) => data_count += 1,
|
||||
@@ -175,6 +182,37 @@ async fn recv_data_count(rx: &mut mpsc::Receiver<WriterCommand>, budget: Duratio
|
||||
data_count
|
||||
}
|
||||
|
||||
async fn recv_first_data_payload(
|
||||
rx: &mut mpsc::Receiver<WriterCommand>,
|
||||
budget: Duration,
|
||||
) -> Option<Vec<u8>> {
|
||||
let start = Instant::now();
|
||||
while Instant::now().duration_since(start) < budget {
|
||||
let remaining = budget.saturating_sub(Instant::now().duration_since(start));
|
||||
match tokio::time::timeout(remaining.min(Duration::from_millis(10)), rx.recv()).await {
|
||||
Ok(Some(WriterCommand::Data { payload, .. })) => return Some(payload.to_vec()),
|
||||
Ok(Some(WriterCommand::DataAndFlush(payload))) => return Some(payload.to_vec()),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn proxy_req_our_addr_from_payload(payload: &[u8]) -> SocketAddr {
|
||||
const CLIENT_ADDR_WIRE_LEN: usize = 20;
|
||||
const OUR_ADDR_OFFSET: usize = 4 + 4 + 8 + CLIENT_ADDR_WIRE_LEN;
|
||||
|
||||
let our_addr = &payload[OUR_ADDR_OFFSET..OUR_ADDR_OFFSET + CLIENT_ADDR_WIRE_LEN];
|
||||
let ip = Ipv4Addr::new(our_addr[12], our_addr[13], our_addr[14], our_addr[15]);
|
||||
let port = u32::from_le_bytes([our_addr[16], our_addr[17], our_addr[18], our_addr[19]]);
|
||||
SocketAddr::new(
|
||||
IpAddr::V4(ip),
|
||||
u16::try_from(port).expect("test port must fit u16"),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_proxy_req_does_not_replay_when_first_bind_commit_fails() {
|
||||
let (pool, _rng) = make_pool().await;
|
||||
@@ -207,6 +245,7 @@ async fn send_proxy_req_does_not_replay_when_first_bind_commit_fails() {
|
||||
b"hello",
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -266,6 +305,7 @@ async fn send_proxy_req_prunes_iterative_stale_bind_failures_without_data_replay
|
||||
b"storm",
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -288,3 +328,112 @@ async fn send_proxy_req_prunes_iterative_stale_bind_failures_without_data_replay
|
||||
drop(writers);
|
||||
assert_eq!(writer_ids, vec![23]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_proxy_req_uses_writer_source_ip_when_advertised_our_addr_differs() {
|
||||
let (pool, _rng) = make_pool().await;
|
||||
pool.rr.store(0, Ordering::Relaxed);
|
||||
|
||||
let (conn_id, _rx) = pool.registry.register().await;
|
||||
let mut live_rx = insert_writer(
|
||||
&pool,
|
||||
31,
|
||||
2,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 2, 31)), 443),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut writers = pool.writers.write().await;
|
||||
let writer = writers
|
||||
.iter_mut()
|
||||
.find(|writer| writer.id == 31)
|
||||
.expect("test writer must exist");
|
||||
writer.source_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 31));
|
||||
}
|
||||
|
||||
let our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)), 8443);
|
||||
let result = pool
|
||||
.send_proxy_req(
|
||||
conn_id,
|
||||
2,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)), 30002),
|
||||
our_addr,
|
||||
b"route",
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let payload = recv_first_data_payload(&mut live_rx, Duration::from_millis(50))
|
||||
.await
|
||||
.expect("writer must receive routed payload");
|
||||
assert_eq!(
|
||||
proxy_req_our_addr_from_payload(&payload),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 31)), our_addr.port())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_proxy_req_blocking_fallback_uses_writer_source_ip() {
|
||||
let (pool, _rng) = make_pool().await;
|
||||
pool.rr.store(0, Ordering::Relaxed);
|
||||
|
||||
let (conn_id, _rx) = pool.registry.register().await;
|
||||
let mut live_rx = insert_writer(
|
||||
&pool,
|
||||
32,
|
||||
2,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 2, 32)), 443),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let source_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 32));
|
||||
|
||||
let tx = {
|
||||
let mut writers = pool.writers.write().await;
|
||||
let writer = writers
|
||||
.iter_mut()
|
||||
.find(|writer| writer.id == 32)
|
||||
.expect("test writer must exist");
|
||||
writer.source_ip = source_ip;
|
||||
writer.tx.clone()
|
||||
};
|
||||
for _ in 0..8 {
|
||||
tx.try_send(WriterCommand::Close)
|
||||
.expect("test writer channel must accept preload");
|
||||
}
|
||||
|
||||
let our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 8)), 9443);
|
||||
let pool_for_send = pool.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
pool_for_send
|
||||
.send_proxy_req(
|
||||
conn_id,
|
||||
2,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 8)), 30003),
|
||||
our_addr,
|
||||
b"blocking",
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(matches!(live_rx.recv().await, Some(WriterCommand::Close)));
|
||||
|
||||
let result = send_task.await.expect("send task must not panic");
|
||||
assert!(result.is_ok());
|
||||
let payload = recv_first_data_payload(&mut live_rx, Duration::from_millis(50))
|
||||
.await
|
||||
.expect("writer must receive blocking fallback payload");
|
||||
assert_eq!(
|
||||
proxy_req_our_addr_from_payload(&payload),
|
||||
SocketAddr::new(source_ip, our_addr.port())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,39 @@ pub fn clear_linger_fd(fd: std::os::unix::io::RawFd) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Raise the TCP MSS on an already-accepted connection's fd. Used to fragment
|
||||
/// ONLY the TLS handshake (via a low listener MSS) and then restore a normal MSS
|
||||
/// for the bulk (post-handshake) data phase — cuts packets-per-second ~10x without losing the
|
||||
/// DPI evasion that the fragmented ServerHello provides. No-op safe: errors are
|
||||
/// returned to the caller, which logs and continues with the handshake MSS.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_tcp_mss_fd(fd: std::os::unix::io::RawFd, mss: u32) -> Result<()> {
|
||||
use std::io::Error;
|
||||
let mss = i32::try_from(mss)
|
||||
.map_err(|_| Error::new(std::io::ErrorKind::InvalidInput, "bulk MSS out of range"))?;
|
||||
// Direct setsockopt(TCP_MAXSEG) — same pattern as the TCP_USER_TIMEOUT call
|
||||
// above; avoids socket2 method-name drift across versions.
|
||||
let rc = unsafe {
|
||||
libc::setsockopt(
|
||||
fd,
|
||||
libc::IPPROTO_TCP,
|
||||
libc::TCP_MAXSEG,
|
||||
&mss as *const libc::c_int as *const libc::c_void,
|
||||
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Non-Linux stub: MSS shaping only on Linux (TCP_MAXSEG).
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
pub fn set_tcp_mss_fd(_fd: std::os::unix::io::RawFd, _mss: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new TCP socket for outgoing connections
|
||||
#[allow(dead_code)]
|
||||
pub fn create_outgoing_socket(addr: SocketAddr) -> Result<Socket> {
|
||||
|
||||
Reference in New Issue
Block a user