mirror of
https://github.com/telemt/telemt.git
synced 2026-06-06 18:42:14 +03:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504cafb129 | ||
|
|
1096e38854 | ||
|
|
9bbdf796d8 | ||
|
|
27a5f5a4ec | ||
|
|
a8adc9fe54 | ||
|
|
44be585ee3 | ||
|
|
cb89d3f4fe | ||
|
|
c4e522a16d | ||
|
|
8e5f73a86b | ||
|
|
7d543aeb67 | ||
|
|
89a885c25f | ||
|
|
54e40fd073 | ||
|
|
1934c1279c | ||
|
|
0bc99b9f74 | ||
|
|
1d8e8890a4 | ||
|
|
d1680a7a80 | ||
|
|
b027608282 | ||
|
|
2f2c9b336c | ||
|
|
b9ebfdcd7b | ||
|
|
34b48325fd | ||
|
|
5c573a926b | ||
|
|
462215b53c | ||
|
|
2264980926 | ||
|
|
3d0d575b94 | ||
|
|
b720906fbc | ||
|
|
ac244962ed | ||
|
|
752a2f5012 | ||
|
|
a77aedfd7a | ||
|
|
8575d0ee5d | ||
|
|
213aba5dc9 | ||
|
|
a79aaee166 | ||
|
|
2a0fcd6e35 | ||
|
|
54a53e9ff0 | ||
|
|
63bcd7b3d0 | ||
|
|
b68b10790c | ||
|
|
383d4318fe | ||
|
|
d293861351 | ||
|
|
31da0a1356 | ||
|
|
34bc1d943a | ||
|
|
50dee40dd2 | ||
|
|
d4adf0ef9a | ||
|
|
dc8951eae8 | ||
|
|
77a7f89075 | ||
|
|
31b9504464 | ||
|
|
54cb4d0f29 | ||
|
|
d449fc080c | ||
|
|
3b8d16bee5 | ||
|
|
9abaf9006c | ||
|
|
231f04a810 | ||
|
|
b32daf79bc | ||
|
|
f668759c05 | ||
|
|
4d9e835fa2 | ||
|
|
885258b85e | ||
|
|
98c985091c | ||
|
|
c02c7fbe43 | ||
|
|
8379b48f69 | ||
|
|
70d02910b7 | ||
|
|
422d97a385 | ||
|
|
6b0cc48c2b | ||
|
|
914f141715 | ||
|
|
b4c33eff39 | ||
|
|
855c5eef8b | ||
|
|
9e877e45c9 | ||
|
|
01b0c5c6ce | ||
|
|
0af64a4d0a | ||
|
|
ad1bb5cc1a | ||
|
|
08cde1a255 | ||
|
|
faf1f28f9d | ||
|
|
32613c8e68 | ||
|
|
f77e9b8881 | ||
|
|
25ca64de1b | ||
|
|
8895947414 | ||
|
|
b175927324 | ||
|
|
1fe621f743 | ||
|
|
3b0ebf3c9e | ||
|
|
b41f6bc21e | ||
|
|
0a9f599611 | ||
|
|
cdb021fc71 | ||
|
|
6b61183b9d | ||
|
|
7a284623d6 |
@@ -52,6 +52,10 @@ By submitting a PR, you confirm that:
|
||||
|
||||
AI-generated code is treated as **draft** and must be validated like any other external contribution.
|
||||
|
||||
The problem isn’t AI as a tool, but the dilution of responsibility. If the commit history says "Claude/GPT authored this", then who is accountable for the bug? Claude? GPT? Anthropic? OpenAI? Samuel Altman?
|
||||
|
||||
The user who didn’t read the diff? No one? But, in a sensitive system, *"no one"* is an unacceptable maintainer model.
|
||||
|
||||
PRs that look like unverified AI dumps WILL be closed
|
||||
|
||||
---
|
||||
@@ -79,4 +83,4 @@ This includes (but is not limited to):
|
||||
- unverified or low-effort changes
|
||||
- inability to explain the change
|
||||
|
||||
These actions follow the Code of Conduct and are intended to preserve signal, quality, and Telemt's integrity
|
||||
These actions follow the Code of Conduct and are intended to preserve signal, quality, and Telemt's integrity
|
||||
|
||||
379
Cargo.lock
generated
379
Cargo.lock
generated
@@ -111,9 +111,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
@@ -121,7 +121,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -167,15 +167,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.3"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -183,9 +183,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.40.0"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -220,12 +220,6 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
@@ -234,9 +228,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.4"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
@@ -266,9 +260,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "byte_string"
|
||||
@@ -299,9 +293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -309,12 +303,6 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -660,9 +648,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
@@ -674,9 +662,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
@@ -724,9 +712,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -771,9 +759,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
@@ -1014,9 +1002,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1070,9 +1058,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1104,7 +1092,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -1127,7 +1115,7 @@ dependencies = [
|
||||
"rand 0.9.4",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1152,9 +1140,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1197,9 +1185,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1380,9 +1368,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
@@ -1395,7 +1383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1406,7 +1394,7 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
@@ -1458,16 +1446,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -1485,27 +1463,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"jni-macros",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"simd_cesu8",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1539,9 +1522,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1561,11 +1544,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1583,9 +1566,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
@@ -1610,9 +1593,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
@@ -1656,9 +1639,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
@@ -1677,9 +1660,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1706,11 +1689,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1733,7 +1716,7 @@ version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
@@ -1751,7 +1734,7 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1775,9 +1758,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
@@ -1875,18 +1858,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.11"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.11"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2017,7 +2000,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha",
|
||||
@@ -2048,7 +2031,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -2070,7 +2053,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -2201,7 +2184,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2235,9 +2218,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -2331,7 +2314,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -2340,9 +2323,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.38"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -2367,9 +2350,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -2377,9 +2360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
@@ -2479,7 +2462,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -2544,9 +2527,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -2629,7 +2612,7 @@ dependencies = [
|
||||
"shadowsocks-crypto",
|
||||
"socket2",
|
||||
"spin",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tfo",
|
||||
"trait-variant",
|
||||
@@ -2667,9 +2650,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
@@ -2687,6 +2670,22 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2701,9 +2700,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -2791,7 +2790,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.4.11"
|
||||
version = "3.4.15"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
@@ -2835,7 +2834,7 @@ dependencies = [
|
||||
"socket2",
|
||||
"static_assertions",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-test",
|
||||
@@ -2864,33 +2863,13 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2981,9 +2960,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3130,20 +3109,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3177,7 +3156,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3384,9 +3363,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -3397,9 +3376,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.68"
|
||||
version = "0.4.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3407,9 +3386,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -3417,9 +3396,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -3430,9 +3409,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -3465,7 +3444,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
@@ -3473,9 +3452,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3616,15 +3595,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -3652,21 +3622,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -3700,12 +3655,6 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -3718,12 +3667,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -3736,12 +3679,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -3766,12 +3703,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -3784,12 +3715,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -3802,12 +3727,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -3820,12 +3739,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -3840,9 +3753,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
@@ -3908,7 +3821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3969,7 +3882,7 @@ dependencies = [
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -3998,18 +3911,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4018,9 +3931,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.4.11"
|
||||
version = "3.4.15"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
|
||||
|
||||
***Löst Probleme, bevor andere überhaupt wissen, dass sie existieren*** / ***It solves problems before others even realize they exist***
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Fixed TLS ClientHello is now available in official clients for Desktop / Android / iOS
|
||||
> From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions"
|
||||
>
|
||||
> Telegram Clients TLS ClientHello has been banned by JA3 Fingerprint: we are already looking for ways to solve this problem
|
||||
>
|
||||
> To work with EE-MTProxy, please update your client!
|
||||
> You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/telemtrs">
|
||||
|
||||
66
README.ru.md
66
README.ru.md
@@ -1,57 +1,52 @@
|
||||
# Telemt — MTProxy на Rust + Tokio
|
||||
|
||||
[](https://github.com/telemt/telemt/releases/latest) [](https://github.com/telemt/telemt/stargazers) [](https://github.com/telemt/telemt/network/members) [](https://t.me/telemtrs)
|
||||
|
||||
***Решает проблемы раньше, чем другие узнают об их существовании***
|
||||
[](https://github.com/telemt/telemt/releases/latest) [](https://github.com/telemt/telemt/stargazers) [](https://github.com/telemt/telemt/network/members)
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Исправленный TLS ClientHello доступен в Telegram для настольных ПК, Android и iOS.
|
||||
> Клиенты Telegram подвергаются блокировке по JA3-отпечатку; мы ищем варианты решения этой проблемы
|
||||
>
|
||||
> Пожалуйста, обновите клиентское приложение для работы с EE-MTProxy.
|
||||
> Вы можете попробовать собрать свой клиент с нашей Telegram Devlibrary — [tdlib-obf](https://github.com/telemt/tdlib-obf)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/telemtrs">
|
||||
<img src="/docs/assets/telegram_button.svg" width="150"/>
|
||||
<img src="https://github.com/user-attachments/assets/30b7e7b9-974a-4e3d-aab6-b58a85de4507" width="240"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust. Он полностью реализует официальный алгоритм прокси Telegram и добавляет множество улучшений для продакшена:
|
||||
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust: он полностью реализует официальный алгоритм Telegram прокси и добавляет множество различных улучшений
|
||||
|
||||
## Установка и обновление одной командой
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
|
||||
```
|
||||
|
||||
- [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md)
|
||||
- [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md)
|
||||
|
||||
Реализация **TLS-fronting** максимально приближена к поведению реального HTTPS-трафика (подробнее - [FAQ](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров)).
|
||||
## Функционал
|
||||
Наша реализация **TLS-fronting** одна из наиболее глубоко отлаженных, продвинутых и почти поведенчески неотличима от настоящего: мы уверены, что сделали это правильно - [см. доказательства в нашей проверке](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров).
|
||||
|
||||
***Middle-End Pool*** оптимизирован для высокой производительности.
|
||||
Наша архитектура ***Middle-End Pool*** в стандартных сценариях самая производительная, по сравнению с другими реализациями подключения к Middle-End прокси: не кардинально, но достаточно
|
||||
|
||||
- Поддержка всех режимов MTProto proxy:
|
||||
- Полная поддержа всех официальных режимов MTProto proxy:
|
||||
- Classic;
|
||||
- Secure (префикс `dd`);
|
||||
- Fake TLS (префикс `ee` + SNI fronting);
|
||||
- Secure — с префиксом `dd`;
|
||||
- Fake TLS — с префиксом `ee` + SNI fronting;
|
||||
- Защита от replay-атак;
|
||||
- Маскировка трафика (перенаправление неизвестных подключений на реальные сайты);
|
||||
- Настраиваемые keepalive, таймауты, IPv6 и «быстрый режим»;
|
||||
- Опциональная маскировка трафика: перенаправление неизвестных подключений на реальные сайты;
|
||||
- Настраиваемые keepalive, таймауты, IPv6 и "быстрый режим";
|
||||
- Корректное завершение работы (Ctrl+C);
|
||||
- Подробное логирование через `trace` и `debug`.
|
||||
- Подробное логирование через `trace` и `debug` с помощью `RUST_LOG`.
|
||||
|
||||
# Подробнее о Telemt
|
||||
- [FAQ](#faq)
|
||||
- [Архитектура](docs/Architecture)
|
||||
- [Параметры конфигурационного файла](docs/Config_params)
|
||||
- [Сборка](#build)
|
||||
- [Установка на BSD](#%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D0%BD%D0%B0-bsd)
|
||||
- [Почему Rust?](#why-rust)
|
||||
## ЧаВо
|
||||
- [Часто задаваемые вопросы](docs/FAQ.ru.md)
|
||||
|
||||
## FAQ
|
||||
- [FAQ RU](docs/FAQ.ru.md)
|
||||
- [FAQ EN](docs/FAQ.en.md)
|
||||
# Узнайте больше о Telemt
|
||||
- [Наша архитектура](docs/Architecture)
|
||||
- [Все конфигурационные параметры](docs/Config_params)
|
||||
- [Как собрать Telemt самостоятельно?](#сборка)
|
||||
- [Установка на BSD](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md)
|
||||
- [Почему Rust?](#почему-rust)
|
||||
|
||||
## Сборка
|
||||
```bash
|
||||
@@ -63,7 +58,7 @@ cd telemt
|
||||
cargo build --release
|
||||
|
||||
# В текущем release-профиле используется lto = "fat" для максимальной оптимизации (см. Cargo.toml).
|
||||
# На системах с малым объёмом RAM (~1 ГБ) можно переопределить это значение на "thin".
|
||||
# На системах с малым объёмом ОЗУ (~1 ГБ) можно переопределить это значение на "thin".
|
||||
|
||||
# Перейдите в каталог /bin
|
||||
mv ./target/release/telemt /bin
|
||||
@@ -73,24 +68,19 @@ chmod +x /bin/telemt
|
||||
telemt config.toml
|
||||
```
|
||||
|
||||
## Установка на BSD
|
||||
- Руководство по сборке и настройке на английском языке [OpenBSD Guide (EN)](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md);
|
||||
- Пример rc.d скрипта: [contrib/openbsd/telemt.rcd](contrib/openbsd/telemt.rcd);
|
||||
- Поддержка sandbox с `pledge(2)` и `unveil(2)` пока не реализована.
|
||||
|
||||
## Почему Rust?
|
||||
- Надёжность для долгоживущих процессов;
|
||||
- Детерминированное управление ресурсами (RAII);
|
||||
- Надёжность при длительной работе и идемпотентное поведение;
|
||||
- Детерминированное управление ресурсами — RAII;
|
||||
- Отсутствие сборщика мусора;
|
||||
- Безопасность памяти;
|
||||
- Безопасность памяти и меньше поверхность атаки;
|
||||
- Асинхронная архитектура Tokio.
|
||||
|
||||
## Поддержать Telemt
|
||||
|
||||
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разработанное в свободное время.
|
||||
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разрабатываемое в свободное время.
|
||||
Если оно оказалось вам полезным, вы можете поддержать дальнейшую разработку.
|
||||
|
||||
Принимаемые криптовалюты (BTC, ETH, USDT, 350+ и другие):
|
||||
Любая криптовалюта (BTC, ETH, USDT и 350+ других):
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener">
|
||||
|
||||
@@ -10,12 +10,15 @@ services:
|
||||
- "443:443"
|
||||
- "127.0.0.1:9090:9090"
|
||||
- "127.0.0.1:9091:9091"
|
||||
# Allow caching 'proxy-secret' in read-only container
|
||||
working_dir: /etc/telemt
|
||||
# Working dir uses tmpfs for caching 'proxy-secret' at runtime.
|
||||
# Config is mounted as a directory (not a single file) so the API can
|
||||
# atomically update config.toml via write-temp → rename within the same FS.
|
||||
working_dir: /run/telemt
|
||||
command: ["/etc/telemt/config.toml"]
|
||||
volumes:
|
||||
- ./config.toml:/etc/telemt/config.toml:ro
|
||||
- ./config:/etc/telemt:rw
|
||||
tmpfs:
|
||||
- /etc/telemt:rw,mode=1777,size=4m
|
||||
- /run/telemt:rw,mode=1777,size=4m
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
healthcheck:
|
||||
@@ -24,8 +27,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
# Uncomment this line if you want to use host network for IPv6, but bridge is default and usually better
|
||||
# network_mode: host
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
@@ -37,3 +38,8 @@ services:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 262144
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
@@ -86,6 +86,9 @@ Die unten angegebenen `Default`-Werte sind Code-Defaults (bei fehlendem Schlüss
|
||||
| `[[upstreams]].weight` | alle Upstreams | `u16` | nein | `1` | Basisgewicht für weighted-random Auswahl. |
|
||||
| `[[upstreams]].enabled` | alle Upstreams | `bool` | nein | `true` | Deaktivierte Einträge werden beim Start ignoriert. |
|
||||
| `[[upstreams]].scopes` | alle Upstreams | `String` | nein | `""` | Komma-separierte Scope-Tags für Request-Routing. |
|
||||
| `[[upstreams]].ipv4` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv4-DC-Ziele für diesen Upstream. |
|
||||
| `[[upstreams]].ipv6` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv6-DC-Ziele für diesen Upstream, inklusive Proxy-Egress unabhängig vom Host-IPv6. |
|
||||
| `[[upstreams]].prefer` | alle Upstreams | `Option<4 \| 6>` | nein | effective `[network].prefer` | Pro-Upstream-Präferenz für die DC-Ziel-Adressfamilie. |
|
||||
| `interface` | `direct` | `Option<String>` | nein | `null` | Interface-Name (z. B. `eth0`) oder lokale Literal-IP. |
|
||||
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | nein | `null` | Explizite Source-IP-Kandidaten (strikter Vorrang vor `interface`). |
|
||||
| `address` | `socks4` | `String` | ja | n/a | SOCKS4-Server (`ip:port` oder `host:port`). |
|
||||
|
||||
@@ -86,6 +86,9 @@ Defaults below are code defaults (used when a key is omitted), not necessarily v
|
||||
| `[[upstreams]].weight` | all upstreams | `u16` | no | `1` | Base weight for weighted-random selection. |
|
||||
| `[[upstreams]].enabled` | all upstreams | `bool` | no | `true` | Disabled entries are ignored at startup. |
|
||||
| `[[upstreams]].scopes` | all upstreams | `String` | no | `""` | Comma-separated scope tags for request-level routing. |
|
||||
| `[[upstreams]].ipv4` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv4 DC targets for this upstream. |
|
||||
| `[[upstreams]].ipv6` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv6 DC targets for this upstream, including proxy egress independent of host IPv6. |
|
||||
| `[[upstreams]].prefer` | all upstreams | `Option<4 \| 6>` | no | effective `[network].prefer` | Per-upstream DC target family preference. |
|
||||
| `interface` | `direct` | `Option<String>` | no | `null` | Interface name (e.g. `eth0`) or literal local IP for bind selection. |
|
||||
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | no | `null` | Explicit local source IP candidates (strict priority over `interface`). |
|
||||
| `address` | `socks4` | `String` | yes | n/a | SOCKS4 server endpoint (`ip:port` or `host:port`). |
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
| `[[upstreams]].weight` | все upstream | `u16` | нет | `1` | Базовый вес в weighted-random выборе. |
|
||||
| `[[upstreams]].enabled` | все upstream | `bool` | нет | `true` | Выключенные записи игнорируются на старте. |
|
||||
| `[[upstreams]].scopes` | все upstream | `String` | нет | `""` | Список scope-токенов через запятую для маршрутизации. |
|
||||
| `[[upstreams]].ipv4` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv4 DC-targets для этого upstream. |
|
||||
| `[[upstreams]].ipv6` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv6 DC-targets для этого upstream, включая proxy egress независимо от IPv6 на хосте. |
|
||||
| `[[upstreams]].prefer` | все upstream | `Option<4 \| 6>` | нет | эффективный `[network].prefer` | Предпочтительное семейство DC-target для конкретного upstream. |
|
||||
| `interface` | `direct` | `Option<String>` | нет | `null` | Имя интерфейса (например `eth0`) или literal локальный IP. |
|
||||
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | нет | `null` | Явные кандидаты source IP (имеют приоритет над `interface`). |
|
||||
| `address` | `socks4` | `String` | да | n/a | Адрес SOCKS4 сервера (`ip:port` или `host:port`). |
|
||||
|
||||
@@ -13,7 +13,7 @@ API runtime is configured in `[server.api]`.
|
||||
| `listen` | `string` (`IP:PORT`) | `0.0.0.0:9091` | API bind address. |
|
||||
| `whitelist` | `CIDR[]` | `127.0.0.0/8` | Source IP allowlist. Empty list means allow all. |
|
||||
| `auth_header` | `string` | `""` | Exact value for `Authorization` header. Empty disables header auth. |
|
||||
| `request_body_limit_bytes` | `usize` | `65536` | Maximum request body size. Must be `> 0`. |
|
||||
| `request_body_limit_bytes` | `usize` | `65536` | Maximum request body size. Must be within `[1, 1048576]`. |
|
||||
| `minimal_runtime_enabled` | `bool` | `true` | Enables runtime snapshot endpoints requiring ME pool read-lock aggregation. |
|
||||
| `minimal_runtime_cache_ttl_ms` | `u64` | `1000` | Cache TTL for minimal snapshots. `0` disables cache; valid range is `[0, 60000]`. |
|
||||
| `runtime_edge_enabled` | `bool` | `false` | Enables runtime edge endpoints with cached aggregation payloads. |
|
||||
@@ -26,7 +26,7 @@ API runtime is configured in `[server.api]`.
|
||||
|
||||
Runtime validation for API config:
|
||||
- `server.api.listen` must be a valid `IP:PORT`.
|
||||
- `server.api.request_body_limit_bytes` must be `> 0`.
|
||||
- `server.api.request_body_limit_bytes` must be within `[1, 1048576]`.
|
||||
- `server.api.minimal_runtime_cache_ttl_ms` must be within `[0, 60000]`.
|
||||
- `server.api.runtime_edge_cache_ttl_ms` must be within `[0, 60000]`.
|
||||
- `server.api.runtime_edge_top_n` must be within `[1, 1000]`.
|
||||
@@ -76,13 +76,14 @@ Requests are processed in this order:
|
||||
|
||||
Notes:
|
||||
- Whitelist is evaluated against the direct TCP peer IP (`SocketAddr::ip`), without `X-Forwarded-For` support.
|
||||
- `Authorization` check is exact string equality against configured `auth_header`.
|
||||
- `Authorization` check is exact constant-time byte equality against configured `auth_header`.
|
||||
|
||||
## Endpoint Matrix
|
||||
|
||||
| Method | Path | Body | Success | `data` contract |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `GET` | `/v1/health` | none | `200` | `HealthData` |
|
||||
| `GET` | `/v1/health/ready` | none | `200` or `503` | `HealthReadyData` |
|
||||
| `GET` | `/v1/system/info` | none | `200` | `SystemInfoData` |
|
||||
| `GET` | `/v1/runtime/gates` | none | `200` | `RuntimeGatesData` |
|
||||
| `GET` | `/v1/runtime/initialization` | none | `200` | `RuntimeInitializationData` |
|
||||
@@ -102,13 +103,55 @@ Notes:
|
||||
| `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` |
|
||||
| `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` |
|
||||
| `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` |
|
||||
| `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` |
|
||||
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
|
||||
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
|
||||
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
|
||||
| `POST` | `/v1/users` | `CreateUserRequest` | `201` | `CreateUserResponse` |
|
||||
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
|
||||
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
|
||||
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` | `UserInfo` |
|
||||
| `DELETE` | `/v1/users/{username}` | none | `200` | `string` (deleted username) |
|
||||
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `404` | `ErrorResponse` (`not_found`, current runtime behavior) |
|
||||
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` or `202` | `UserInfo` |
|
||||
| `DELETE` | `/v1/users/{username}` | none | `200` or `202` | `DeleteUserResponse` |
|
||||
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `200` or `202` | `CreateUserResponse` |
|
||||
| `POST` | `/v1/users/{username}/enable` | empty body | `200` or `202` | `UserInfo` |
|
||||
| `POST` | `/v1/users/{username}/disable` | empty body | `200` or `202` | `UserInfo` |
|
||||
| `POST` | `/v1/users/{username}/reset-quota` | empty body | `200` | `ResetUserQuotaResponse` |
|
||||
|
||||
## Endpoint Behavior
|
||||
|
||||
| Endpoint | Function |
|
||||
| --- | --- |
|
||||
| `GET /v1/health` | Returns basic API liveness and current `read_only` flag. |
|
||||
| `GET /v1/health/ready` | Returns readiness based on admission state and upstream health; returns `503` when not ready. |
|
||||
| `GET /v1/system/info` | Returns binary/build metadata, process uptime, config path/hash, and reload counters. |
|
||||
| `GET /v1/runtime/gates` | Returns admission, ME readiness, fallback/reroute, and startup gate state. |
|
||||
| `GET /v1/runtime/initialization` | Returns startup progress, ME initialization status, and per-component timeline. |
|
||||
| `GET /v1/limits/effective` | Returns effective timeout, upstream, ME, unique-IP, and TCP policy values after config defaults/resolution. |
|
||||
| `GET /v1/security/posture` | Returns current API/security/telemetry posture flags. |
|
||||
| `GET /v1/security/whitelist` | Returns configured API whitelist CIDRs. |
|
||||
| `GET /v1/stats/summary` | Returns compact core counters and classed failure counters. |
|
||||
| `GET /v1/stats/zero/all` | Returns zero-cost core, upstream, ME, pool, and desync counters. |
|
||||
| `GET /v1/stats/upstreams` | Returns upstream zero counters and, when enabled/available, runtime upstream health rows. |
|
||||
| `GET /v1/stats/minimal/all` | Returns cached minimal ME writer/DC/runtime/network-path snapshot. |
|
||||
| `GET /v1/stats/me-writers` | Returns cached ME writer coverage and per-writer status rows. |
|
||||
| `GET /v1/stats/dcs` | Returns cached per-DC endpoint/writer/load status rows. |
|
||||
| `GET /v1/runtime/me_pool_state` | Returns active/warm/pending/draining generation state, writer contour/health, and refill state. |
|
||||
| `GET /v1/runtime/me_quality` | Returns ME lifecycle counters, route-drop counters, family states, drain gate, and per-DC RTT/coverage. |
|
||||
| `GET /v1/runtime/upstream_quality` | Returns upstream policy/counters plus runtime upstream health rows when available. |
|
||||
| `GET /v1/runtime/nat_stun` | Returns NAT/STUN runtime flags, configured/live STUN servers, reflection cache, and backoff. |
|
||||
| `GET /v1/runtime/me-selftest` | Returns ME self-test state for KDF, time skew, IP family, PID, and SOCKS BND observations. |
|
||||
| `GET /v1/runtime/connections/summary` | Returns runtime-edge connection totals and top-N users by connections/throughput. |
|
||||
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
|
||||
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
|
||||
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
|
||||
| `GET /v1/users` | Returns disk-first user views sorted by username. |
|
||||
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
|
||||
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
|
||||
| `PATCH /v1/users/{username}` | Updates selected per-user fields with JSON Merge Patch semantics. |
|
||||
| `DELETE /v1/users/{username}` | Deletes one user and related per-user access-map entries. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | Rotates one user's secret and returns the effective secret. |
|
||||
| `POST /v1/users/{username}/enable` | Enables one user, removing any disabled override from config. |
|
||||
| `POST /v1/users/{username}/disable` | Disables one user and closes active runtime sessions for that user. |
|
||||
| `POST /v1/users/{username}/reset-quota` | Resets one user's runtime quota counter and persists quota state. |
|
||||
|
||||
## Common Error Codes
|
||||
|
||||
@@ -118,7 +161,7 @@ Notes:
|
||||
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
|
||||
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
|
||||
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
|
||||
| `404` | `not_found` | Unknown route, unknown user, or unsupported sub-route (including current `rotate-secret` route). |
|
||||
| `404` | `not_found` | Unknown route, unknown user, or unsupported sub-route. |
|
||||
| `405` | `method_not_allowed` | Unsupported method for `/v1/users/{username}` route shape. |
|
||||
| `409` | `revision_conflict` | `If-Match` revision mismatch. |
|
||||
| `409` | `user_exists` | User already exists on create. |
|
||||
@@ -132,11 +175,14 @@ Notes:
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. |
|
||||
| Trailing slash | Not normalized. Example: `/v1/users/` is `404`. |
|
||||
| Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. |
|
||||
| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. |
|
||||
| `PUT /v1/users/{username}` | `405 method_not_allowed`. |
|
||||
| `POST /v1/users/{username}` | `404 not_found`. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | `404 not_found` in current release due route matcher limitation. |
|
||||
| `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. |
|
||||
| `POST /v1/users/{username}/enable/` | Trailing slash is trimmed and the route matches `enable`. |
|
||||
| `POST /v1/users/{username}/disable/` | Trailing slash is trimmed and the route matches `disable`. |
|
||||
| `POST /v1/users/{username}/reset-quota/` | Trailing slash is trimmed and the route matches `reset-quota`. |
|
||||
|
||||
## Body and JSON Semantics
|
||||
|
||||
@@ -146,7 +192,7 @@ Notes:
|
||||
- Invalid JSON returns `400 bad_request` (`Invalid JSON body`).
|
||||
- `Content-Type` is not required for JSON parsing.
|
||||
- Unknown JSON fields are ignored by deserialization.
|
||||
- `PATCH` updates only provided fields and does not support explicit clearing of optional fields.
|
||||
- `PATCH` uses JSON Merge Patch semantics for optional per-user fields: omitted means unchanged, explicit `null` removes the config entry, and a non-null value sets it.
|
||||
- `If-Match` supports both quoted and unquoted values; surrounding whitespace is trimmed.
|
||||
|
||||
## Query Parameters
|
||||
@@ -166,17 +212,23 @@ Notes:
|
||||
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
|
||||
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
|
||||
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
|
||||
| `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bytes per second. |
|
||||
| `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bytes per second. |
|
||||
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
|
||||
| `enabled` | `bool` | no | User enable flag. Missing means enabled. `false` persists a disabled override. |
|
||||
|
||||
### `PatchUserRequest`
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `secret` | `string` | no | Exactly 32 hex chars. |
|
||||
| `user_ad_tag` | `string` | no | Exactly 32 hex chars. |
|
||||
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
|
||||
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
|
||||
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
|
||||
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
|
||||
| `user_ad_tag` | `string|null` | no | Exactly 32 hex chars; `null` removes the per-user ad tag. |
|
||||
| `max_tcp_conns` | `usize|null` | no | Per-user concurrent TCP limit; `null` removes the per-user override. |
|
||||
| `expiration_rfc3339` | `string|null` | no | RFC3339 expiration timestamp; `null` removes the expiration. |
|
||||
| `data_quota_bytes` | `u64|null` | no | Per-user traffic quota; `null` removes the per-user quota. |
|
||||
| `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bytes per second; `null` removes the upload direction limit. |
|
||||
| `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bytes per second; `null` removes the download direction limit. |
|
||||
| `max_unique_ips` | `usize|null` | no | Per-user unique source IP limit; `null` removes the per-user override. |
|
||||
| `enabled` | `bool|null` | no | `false` disables the user. `true` or `null` removes the disabled override, so the user is enabled. |
|
||||
|
||||
### `access.user_source_deny` via API
|
||||
- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`.
|
||||
@@ -198,7 +250,7 @@ bob = ["198.51.100.42/32"]
|
||||
| --- | --- | --- | --- |
|
||||
| `secret` | `string` | no | Exactly 32 hex chars. If missing, generated automatically. |
|
||||
|
||||
Note: the request contract is defined, but the corresponding route currently returns `404` (see routing edge cases).
|
||||
An empty request body is accepted and generates a new secret automatically.
|
||||
|
||||
## Response Data Contracts
|
||||
|
||||
@@ -208,15 +260,33 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `status` | `string` | Always `"ok"`. |
|
||||
| `read_only` | `bool` | Mirrors current API `read_only` mode. |
|
||||
|
||||
### `HealthReadyData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `ready` | `bool` | `true` when admission is open and at least one upstream is healthy. |
|
||||
| `status` | `string` | `"ready"` or `"not_ready"`. |
|
||||
| `reason` | `string?` | `admission_closed` or `no_healthy_upstreams` when not ready. |
|
||||
| `admission_open` | `bool` | Current admission-gate state. |
|
||||
| `healthy_upstreams` | `usize` | Number of healthy upstream entries. |
|
||||
| `total_upstreams` | `usize` | Number of configured upstream entries. |
|
||||
|
||||
### `SummaryData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `uptime_seconds` | `f64` | Process uptime in seconds. |
|
||||
| `connections_total` | `u64` | Total accepted client connections. |
|
||||
| `connections_bad_total` | `u64` | Failed/invalid client connections. |
|
||||
| `connections_bad_by_class` | `ClassCount[]` | Failed/invalid connections grouped by class. |
|
||||
| `handshake_failures_by_class` | `ClassCount[]` | Handshake failures grouped by class. |
|
||||
| `handshake_timeouts_total` | `u64` | Handshake timeout count. |
|
||||
| `configured_users` | `usize` | Number of configured users in config. |
|
||||
|
||||
#### `ClassCount`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `class` | `string` | Failure class label. |
|
||||
| `total` | `u64` | Counter value for this class. |
|
||||
|
||||
### `SystemInfoData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -241,7 +311,12 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `conditional_cast_enabled` | `bool` | Whether conditional ME admission logic is enabled (`general.use_middle_proxy`). |
|
||||
| `me_runtime_ready` | `bool` | Current ME runtime readiness status used for conditional gate decisions. |
|
||||
| `me2dc_fallback_enabled` | `bool` | Whether ME -> direct fallback is enabled. |
|
||||
| `me2dc_fast_enabled` | `bool` | Whether fast ME -> direct fallback is enabled. |
|
||||
| `use_middle_proxy` | `bool` | Current transport mode preference. |
|
||||
| `route_mode` | `string` | Current route mode label from route runtime controller. |
|
||||
| `reroute_active` | `bool` | `true` when ME fallback currently routes new sessions to Direct-DC. |
|
||||
| `reroute_to_direct_at_epoch_secs` | `u64?` | Unix timestamp when current direct reroute began. |
|
||||
| `reroute_reason` | `string?` | `startup_direct_fallback`, `fast_not_ready_fallback`, or `strict_grace_fallback` while reroute is active. |
|
||||
| `startup_status` | `string` | Startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). |
|
||||
| `startup_stage` | `string` | Current startup stage identifier. |
|
||||
| `startup_progress_pct` | `f64` | Startup progress percentage (`0..100`). |
|
||||
@@ -292,11 +367,13 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `upstream` | `EffectiveUpstreamLimits` | Effective upstream connect/retry limits. |
|
||||
| `middle_proxy` | `EffectiveMiddleProxyLimits` | Effective ME pool/floor/reconnect limits. |
|
||||
| `user_ip_policy` | `EffectiveUserIpPolicyLimits` | Effective unique-IP policy mode/window. |
|
||||
| `user_tcp_policy` | `EffectiveUserTcpPolicyLimits` | Effective per-user TCP connection policy. |
|
||||
|
||||
#### `EffectiveTimeoutLimits`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `client_handshake_secs` | `u64` | Client handshake timeout. |
|
||||
| `client_first_byte_idle_secs` | `u64` | First-byte idle timeout before protocol classification. |
|
||||
| `tg_connect_secs` | `u64` | Upstream Telegram connect timeout. |
|
||||
| `client_keepalive_secs` | `u64` | Client keepalive interval. |
|
||||
| `client_ack_secs` | `u64` | ACK timeout. |
|
||||
@@ -335,13 +412,20 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `writer_pick_mode` | `string` | Writer picker mode (`sorted_rr`, `p2c`). |
|
||||
| `writer_pick_sample_size` | `u8` | Candidate sample size for `p2c` picker mode. |
|
||||
| `me2dc_fallback` | `bool` | Effective ME -> direct fallback flag. |
|
||||
| `me2dc_fast` | `bool` | Effective fast fallback flag. |
|
||||
|
||||
#### `EffectiveUserIpPolicyLimits`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `global_each` | `usize` | Global per-user unique-IP limit applied when no per-user override exists. |
|
||||
| `mode` | `string` | Unique-IP policy mode (`active_window`, `time_window`, `combined`). |
|
||||
| `window_secs` | `u64` | Time window length used by unique-IP policy. |
|
||||
|
||||
#### `EffectiveUserTcpPolicyLimits`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `global_each` | `usize` | Global per-user concurrent TCP limit applied when no per-user override exists. |
|
||||
|
||||
### `SecurityPostureData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -445,6 +529,8 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| --- | --- | --- |
|
||||
| `counters` | `RuntimeMeQualityCountersData` | Key ME lifecycle/error counters. |
|
||||
| `route_drops` | `RuntimeMeQualityRouteDropData` | Route drop counters by reason. |
|
||||
| `family_states` | `RuntimeMeQualityFamilyStateData[]` | Per-family ME route/recovery state rows. |
|
||||
| `drain_gate` | `RuntimeMeQualityDrainGateData` | Current ME drain-gate decision state. |
|
||||
| `dc_rtt` | `RuntimeMeQualityDcRttData[]` | Per-DC RTT and writer coverage rows. |
|
||||
|
||||
#### `RuntimeMeQualityCountersData`
|
||||
@@ -466,6 +552,24 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `queue_full_base_total` | `u64` | Route drops in base-queue path. |
|
||||
| `queue_full_high_total` | `u64` | Route drops in high-priority queue path. |
|
||||
|
||||
#### `RuntimeMeQualityFamilyStateData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `family` | `string` | Address family label. |
|
||||
| `state` | `string` | Current family state label. |
|
||||
| `state_since_epoch_secs` | `u64` | Unix timestamp when current state began. |
|
||||
| `suppressed_until_epoch_secs` | `u64?` | Unix timestamp until suppression remains active. |
|
||||
| `fail_streak` | `u32` | Consecutive failure count. |
|
||||
| `recover_success_streak` | `u32` | Consecutive recovery success count. |
|
||||
|
||||
#### `RuntimeMeQualityDrainGateData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `route_quorum_ok` | `bool` | Whether route quorum condition allows drain. |
|
||||
| `redundancy_ok` | `bool` | Whether redundancy condition allows drain. |
|
||||
| `block_reason` | `string` | Current drain block reason label. |
|
||||
| `updated_at_epoch_secs` | `u64` | Unix timestamp of the latest gate update. |
|
||||
|
||||
#### `RuntimeMeQualityDcRttData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -712,6 +816,43 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `event_type` | `string` | Event kind identifier. |
|
||||
| `context` | `string` | Context text (truncated to implementation-defined max length). |
|
||||
|
||||
### `RuntimeEdgeTlsFingerprintsData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. |
|
||||
| `reason` | `string?` | `feature_disabled` when endpoint is disabled. |
|
||||
| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. |
|
||||
| `data` | `RuntimeEdgeTlsFingerprintsPayload?` | Null when unavailable. |
|
||||
|
||||
#### `RuntimeEdgeTlsFingerprintsPayload`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `limit` | `usize` | Effective Top-N row count. |
|
||||
| `retention_secs` | `u64` | In-memory retention window, derived from `general.beobachten_minutes`. |
|
||||
| `capacity` | `usize` | Maximum retained fingerprint buckets. |
|
||||
| `dropped_total` | `u64` | Buckets dropped because the collector was full. |
|
||||
| `parse_error_total` | `u64` | Complete ClientHello records that could not be fingerprinted. |
|
||||
| `by_fingerprint` | `RuntimeEdgeTlsFingerprintRow[]` | Global JA3/JA4 leaderboard. |
|
||||
| `by_ip` | `RuntimeEdgeTlsFingerprintRow[]` | Source-IP scoped leaderboard. |
|
||||
| `by_cidr` | `RuntimeEdgeTlsFingerprintRow[]` | Source CIDR scoped leaderboard (`/24` for IPv4, `/56` for IPv6). |
|
||||
| `by_user` | `RuntimeEdgeTlsFingerprintRow[]` | Authenticated user scoped leaderboard. |
|
||||
|
||||
#### `RuntimeEdgeTlsFingerprintRow`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `scope` | `string?` | IP, CIDR, or username; absent in `by_fingerprint`. |
|
||||
| `ja3` | `string` | JA3 MD5 hash. |
|
||||
| `ja3_raw` | `string` | Raw JA3 field string. |
|
||||
| `ja4` | `string` | JA4 TLS client fingerprint. |
|
||||
| `ja4_raw` | `string` | Raw JA4 material used for the hashed parts. |
|
||||
| `total` | `u64` | Complete ClientHello observations for this bucket. |
|
||||
| `auth_success` | `u64` | TLS-authenticated observations for this bucket. |
|
||||
| `bad_or_probe` | `u64` | Complete ClientHello observations later classified as bad/probe. |
|
||||
| `first_seen_epoch_secs` | `u64` | First observation timestamp. |
|
||||
| `last_seen_epoch_secs` | `u64` | Last observation timestamp. |
|
||||
|
||||
JA3 follows the Salesforce ClientHello field order. JA4 follows the FoxIO TLS-client `a_b_c` format; GREASE values are excluded and no high-cardinality Prometheus labels are emitted for fingerprints.
|
||||
|
||||
### `ZeroAllData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -728,11 +869,24 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `uptime_seconds` | `f64` | Process uptime. |
|
||||
| `connections_total` | `u64` | Total accepted connections. |
|
||||
| `connections_bad_total` | `u64` | Failed/invalid connections. |
|
||||
| `connections_bad_by_class` | `ClassCount[]` | Failed/invalid connections grouped by class. |
|
||||
| `handshake_failures_by_class` | `ClassCount[]` | Handshake failures grouped by class. |
|
||||
| `handshake_timeouts_total` | `u64` | Handshake timeouts. |
|
||||
| `accept_permit_timeout_total` | `u64` | Listener admission permit acquisition timeouts. |
|
||||
| `configured_users` | `usize` | Configured user count. |
|
||||
| `telemetry_core_enabled` | `bool` | Core telemetry toggle. |
|
||||
| `telemetry_user_enabled` | `bool` | User telemetry toggle. |
|
||||
| `telemetry_me_level` | `string` | ME telemetry level (`off|normal|verbose`). |
|
||||
| `conntrack_control_enabled` | `bool` | Whether conntrack control is enabled by policy. |
|
||||
| `conntrack_control_available` | `bool` | Whether conntrack control backend is currently available. |
|
||||
| `conntrack_pressure_active` | `bool` | Current conntrack pressure flag. |
|
||||
| `conntrack_event_queue_depth` | `u64` | Current conntrack close-event queue depth. |
|
||||
| `conntrack_rule_apply_ok` | `bool` | Last conntrack rule application state. |
|
||||
| `conntrack_delete_attempt_total` | `u64` | Conntrack delete attempts. |
|
||||
| `conntrack_delete_success_total` | `u64` | Successful conntrack deletes. |
|
||||
| `conntrack_delete_not_found_total` | `u64` | Conntrack delete misses. |
|
||||
| `conntrack_delete_error_total` | `u64` | Conntrack delete errors. |
|
||||
| `conntrack_close_event_drop_total` | `u64` | Dropped conntrack close events. |
|
||||
|
||||
#### `ZeroUpstreamData`
|
||||
| Field | Type | Description |
|
||||
@@ -819,6 +973,24 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `route_drop_queue_full_total` | `u64` | Route drops due to full queue (total). |
|
||||
| `route_drop_queue_full_base_total` | `u64` | Route drops in base queue mode. |
|
||||
| `route_drop_queue_full_high_total` | `u64` | Route drops in high queue mode. |
|
||||
| `d2c_batches_total` | `u64` | ME D->C batch flushes. |
|
||||
| `d2c_batch_frames_total` | `u64` | ME D->C frames included in batches. |
|
||||
| `d2c_batch_bytes_total` | `u64` | ME D->C payload bytes included in batches. |
|
||||
| `d2c_flush_reason_queue_drain_total` | `u64` | Flushes caused by queue drain. |
|
||||
| `d2c_flush_reason_batch_frames_total` | `u64` | Flushes caused by frame-count batch limit. |
|
||||
| `d2c_flush_reason_batch_bytes_total` | `u64` | Flushes caused by byte-count batch limit. |
|
||||
| `d2c_flush_reason_max_delay_total` | `u64` | Flushes caused by max-delay budget. |
|
||||
| `d2c_flush_reason_ack_immediate_total` | `u64` | Flushes caused by immediate ACK policy. |
|
||||
| `d2c_flush_reason_close_total` | `u64` | Flushes caused by close path. |
|
||||
| `d2c_data_frames_total` | `u64` | ME D->C data frames. |
|
||||
| `d2c_ack_frames_total` | `u64` | ME D->C ACK frames. |
|
||||
| `d2c_payload_bytes_total` | `u64` | ME D->C payload bytes. |
|
||||
| `d2c_write_mode_coalesced_total` | `u64` | Coalesced D->C writes. |
|
||||
| `d2c_write_mode_split_total` | `u64` | Split D->C writes. |
|
||||
| `d2c_quota_reject_pre_write_total` | `u64` | D->C quota rejects before write. |
|
||||
| `d2c_quota_reject_post_write_total` | `u64` | D->C quota rejects after write. |
|
||||
| `d2c_frame_buf_shrink_total` | `u64` | D->C frame-buffer shrink operations. |
|
||||
| `d2c_frame_buf_shrink_bytes_total` | `u64` | Bytes released by D->C frame-buffer shrink operations. |
|
||||
| `socks_kdf_strict_reject_total` | `u64` | SOCKS KDF strict rejects. |
|
||||
| `socks_kdf_compat_fallback_total` | `u64` | SOCKS KDF compat fallbacks. |
|
||||
| `endpoint_quarantine_total` | `u64` | Endpoint quarantine activations. |
|
||||
@@ -978,6 +1150,8 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `required_writers` | `usize` | Required writers based on current floor policy. |
|
||||
| `alive_writers` | `usize` | Writers currently alive. |
|
||||
| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. |
|
||||
| `fresh_alive_writers` | `usize` | Alive writers that match freshness requirements. |
|
||||
| `fresh_coverage_pct` | `f64` | `fresh_alive_writers / required_writers * 100`. |
|
||||
|
||||
#### `MeWriterStatus`
|
||||
| Field | Type | Description |
|
||||
@@ -992,6 +1166,12 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `bound_clients` | `usize` | Number of currently bound clients. |
|
||||
| `idle_for_secs` | `u64?` | Idle age in seconds if idle. |
|
||||
| `rtt_ema_ms` | `f64?` | RTT exponential moving average. |
|
||||
| `matches_active_generation` | `bool` | Whether this writer belongs to the active pool generation. |
|
||||
| `in_desired_map` | `bool` | Whether this writer's endpoint remains in desired topology. |
|
||||
| `allow_drain_fallback` | `bool` | Whether drain fallback is allowed for this writer. |
|
||||
| `drain_started_at_epoch_secs` | `u64?` | Unix timestamp when drain started. |
|
||||
| `drain_deadline_epoch_secs` | `u64?` | Unix timestamp of drain deadline. |
|
||||
| `drain_over_ttl` | `bool` | Whether drain has exceeded its TTL. |
|
||||
|
||||
### `DcStatusData`
|
||||
| Field | Type | Description |
|
||||
@@ -1016,6 +1196,8 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `floor_capped` | `bool` | `true` when computed floor target was capped by active limits. |
|
||||
| `alive_writers` | `usize` | Alive writers in this DC. |
|
||||
| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. |
|
||||
| `fresh_alive_writers` | `usize` | Fresh alive writers in this DC. |
|
||||
| `fresh_coverage_pct` | `f64` | `fresh_alive_writers / required_writers * 100`. |
|
||||
| `rtt_ms` | `f64?` | Aggregated RTT for DC. |
|
||||
| `load` | `usize` | Active client sessions bound to this DC. |
|
||||
|
||||
@@ -1029,10 +1211,14 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | Username. |
|
||||
| `enabled` | `bool` | Effective user enable flag. Missing config entry is reported as `true`. |
|
||||
| `in_runtime` | `bool` | Whether current runtime config already contains this user. |
|
||||
| `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). |
|
||||
| `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. |
|
||||
| `expiration_rfc3339` | `string?` | Optional expiration timestamp. |
|
||||
| `data_quota_bytes` | `u64?` | Optional data quota. |
|
||||
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bytes per second. |
|
||||
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bytes per second. |
|
||||
| `max_unique_ips` | `usize?` | Optional unique IP limit. |
|
||||
| `current_connections` | `u64` | Current live connections. |
|
||||
| `active_unique_ips` | `usize` | Current active unique source IPs. |
|
||||
@@ -1042,12 +1228,25 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `total_octets` | `u64` | Total traffic octets for this user. |
|
||||
| `links` | `UserLinks` | Active connection links derived from current config. |
|
||||
|
||||
### `UserActiveIps`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | Username with at least one active tracked source IP. |
|
||||
| `active_ips` | `ip[]` | Active source IPs for this user. |
|
||||
|
||||
#### `UserLinks`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `classic` | `string[]` | Active `tg://proxy` links for classic mode. |
|
||||
| `secure` | `string[]` | Active `tg://proxy` links for secure/DD mode. |
|
||||
| `tls` | `string[]` | Active `tg://proxy` links for EE-TLS mode (for each host+TLS domain). |
|
||||
| `tls_domains` | `TlsDomainLink[]` | Extra TLS-domain links as explicit domain/link pairs for `censorship.tls_domains`. |
|
||||
|
||||
#### `TlsDomainLink`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `string` | TLS domain represented by the link. |
|
||||
| `link` | `string` | `tg://proxy` link for this domain. |
|
||||
|
||||
Link generation uses active config and enabled modes:
|
||||
- Link port is `general.links.public_port` when configured; otherwise `server.port`.
|
||||
@@ -1067,13 +1266,29 @@ Link generation uses active config and enabled modes:
|
||||
| `user` | `UserInfo` | Created or updated user view. |
|
||||
| `secret` | `string` | Effective user secret. |
|
||||
|
||||
### `DeleteUserResponse`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | Deleted username. |
|
||||
| `in_runtime` | `bool` | `true` when runtime config still contains the user and hot-reload has not applied deletion yet. |
|
||||
|
||||
### `ResetUserQuotaResponse`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | User whose runtime quota counter was reset. |
|
||||
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
|
||||
| `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. |
|
||||
|
||||
## Mutation Semantics
|
||||
|
||||
| Endpoint | Notes |
|
||||
| --- | --- |
|
||||
| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). |
|
||||
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged. Current implementation persists full config document on success. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | Currently returns `404` in runtime route matcher; request schema is reserved for intended behavior. |
|
||||
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |
|
||||
| `POST /v1/users/{username}/enable` | Enables the user idempotently by removing the `access.user_enabled[username]` override and updating the runtime admission state immediately. |
|
||||
| `POST /v1/users/{username}/disable` | Disables the user idempotently by writing `access.user_enabled[username] = false`, updating runtime admission immediately, and cancelling active sessions for that username. |
|
||||
| `POST /v1/users/{username}/reset-quota` | Resets the runtime quota counter for the route username, persists quota state to `general.quota_state_path`, and does not modify user config. |
|
||||
| `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. |
|
||||
|
||||
All mutating endpoints:
|
||||
@@ -1082,6 +1297,12 @@ All mutating endpoints:
|
||||
- Return new `revision` after successful write.
|
||||
- Use process-local mutation lock + atomic write (`tmp + rename`) for config persistence.
|
||||
|
||||
Docker deployment note:
|
||||
- Mutating endpoints require `config.toml` to live inside a writable mounted directory.
|
||||
- Do not mount `config.toml` as a single bind-mounted file when API mutations are enabled; atomic `tmp + rename` writes can fail with `Device or resource busy`.
|
||||
- Mount the config directory instead, for example `./config:/etc/telemt:rw`, and start Telemt with `/etc/telemt/config.toml`.
|
||||
- A read-only single-file mount remains valid only for read-only deployments or when `[server.api].read_only=true`.
|
||||
|
||||
Delete path cleanup guarantees:
|
||||
- Config cleanup removes only the requested username keys.
|
||||
- Runtime unique-IP cleanup removes only this user's limiter and tracked IP state.
|
||||
@@ -1110,16 +1331,17 @@ Additional runtime endpoint behavior:
|
||||
| `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload |
|
||||
| `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload |
|
||||
| `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
|
||||
| `/v1/runtime/tls-fingerprints` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
|
||||
|
||||
## ME Fallback Behavior Exposed Via API
|
||||
|
||||
When `general.use_middle_proxy=true` and `general.me2dc_fallback=true`:
|
||||
- Startup does not block on full ME pool readiness; initialization can continue in background.
|
||||
- Startup opens Direct-DC routing first, then initializes ME in background and switches new sessions to Middle mode after ME readiness is observed.
|
||||
- Runtime initialization payload can expose ME stage `background_init` until pool becomes ready.
|
||||
- Admission/routing decision uses two readiness grace windows for "ME not ready" periods:
|
||||
`80s` before first-ever readiness is observed (startup grace),
|
||||
direct startup fallback before first-ever readiness is observed,
|
||||
`6s` after readiness has been observed at least once (runtime failover timeout).
|
||||
- While in fallback window breach, new sessions are routed via Direct-DC; when ME becomes ready, routing returns to Middle mode for new sessions.
|
||||
- While fallback is active, new sessions are routed via Direct-DC; when ME becomes ready, routing returns to Middle mode. Direct sessions affected by the cutover are closed with the existing staggered delay so clients reconnect through the current route.
|
||||
|
||||
## Serialization Rules
|
||||
|
||||
@@ -1148,5 +1370,4 @@ When `general.use_middle_proxy=true` and `general.me2dc_fallback=true`:
|
||||
|
||||
## Known Limitations (Current Release)
|
||||
|
||||
- `POST /v1/users/{username}/rotate-secret` is currently unreachable in route matcher and returns `404`.
|
||||
- API runtime controls under `server.api` are documented as restart-required; hot-reload behavior for these fields is not strictly uniform in all change combinations.
|
||||
|
||||
507
docs/Architecture/Fronting-splitting/TLS_JA3_JA4_ANALYSIS.ru.md
Normal file
507
docs/Architecture/Fronting-splitting/TLS_JA3_JA4_ANALYSIS.ru.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# JA3 и JA4 анализ в Telemt
|
||||
|
||||
Этот документ описывает, как использовать JA3/JA4 telemetry в Telemt для диагностики блокировок, которые происходят на основе TLS ClientHello, особенно JA4 TLS client fingerprint.
|
||||
|
||||
Цель документа практическая: помочь оператору понять, какой клиентский TLS-отпечаток реально доходит до Telemt, как он распределён по IP/CIDR/пользователям, и как отделить JA4-based фильтрацию от блокировки по IP, SNI, домену, server flight или активному сканированию.
|
||||
|
||||
## Коротко
|
||||
|
||||
JA3 и JA4 описывают форму TLS ClientHello. ClientHello отправляет клиент, поэтому JA3/JA4 в этом контексте являются fingerprint'ами клиентской TLS-реализации, а не Telemt как сервера.
|
||||
|
||||
Telemt собирает JA3/JA4 только из уже прочитанного полного ClientHello:
|
||||
|
||||
- без packet capture;
|
||||
- без MITM;
|
||||
- без расшифровки TLS;
|
||||
- без дополнительных сетевых чтений;
|
||||
- без Prometheus labels с высокой кардинальностью;
|
||||
- с ограниченным in-memory TTL/cap collector.
|
||||
|
||||
Собранные данные доступны:
|
||||
|
||||
- через API: `GET /v1/runtime/tls-fingerprints`;
|
||||
- через `/beobachten`, если `general.beobachten=true`.
|
||||
|
||||
Основная польза:
|
||||
|
||||
- увидеть, какие JA4 реально используют клиенты;
|
||||
- понять, один ли fingerprint страдает у всех пользователей;
|
||||
- отделить проблему клиента от проблемы IP/ASN/домена;
|
||||
- увидеть, доходят ли проблемные соединения до Telemt вообще;
|
||||
- сравнить successful TLS-auth и bad/probe поток для одного fingerprint;
|
||||
- собрать evidence для последующего изменения клиента, маршрута или deployment-профиля.
|
||||
|
||||
## Что такое JA3
|
||||
|
||||
JA3 - старый и широко совместимый способ получить hash от TLS ClientHello.
|
||||
|
||||
JA3 строится из ClientHello fields:
|
||||
|
||||
```text
|
||||
SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat
|
||||
```
|
||||
|
||||
Значения внутри полей записываются в порядке, в котором они пришли в ClientHello. GREASE values исключаются. Итоговая строка хэшируется MD5, поэтому в API есть два поля:
|
||||
|
||||
- `ja3` - MD5 hash;
|
||||
- `ja3_raw` - исходная строка, из которой получен hash.
|
||||
|
||||
Практическое значение JA3 в 2026 году ограничено тем, что современные TLS-клиенты и браузерные стеки могут менять порядок extensions. Поэтому JA3 полезен как совместимый исторический сигнал, но для диагностики современных блокировок обычно важнее JA4.
|
||||
|
||||
## Что такое JA4
|
||||
|
||||
JA4 TLS client fingerprint - более структурированный fingerprint ClientHello.
|
||||
|
||||
JA4 в Telemt считается для TLS-over-TCP ClientHello и имеет форму:
|
||||
|
||||
```text
|
||||
t<version><sni_marker><cipher_count><extension_count><alpn_marker>_<cipher_hash>_<extension_hash>
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
t13d1516h2_8daaf6152771_e5627efa2ab1
|
||||
```
|
||||
|
||||
Части JA4:
|
||||
|
||||
| Часть | Смысл |
|
||||
| --- | --- |
|
||||
| `t` | TLS over TCP. Telemt сейчас не считает JA4 для QUIC/DTLS. |
|
||||
| `13`, `12`, `11`, `10` | TLS version, предпочтительно из `supported_versions`. |
|
||||
| `d` / `i` | Есть SNI domain (`d`) или SNI отсутствует (`i`). |
|
||||
| `15` | Количество cipher suites без GREASE, capped до `99`. |
|
||||
| `16` | Количество extensions без GREASE, capped до `99`. |
|
||||
| `h2`, `h1`, `00` | ALPN marker: первый и последний символ первого ALPN value или `00`. |
|
||||
| `cipher_hash` | SHA256 от отсортированного списка ciphers, первые 12 hex chars. |
|
||||
| `extension_hash` | SHA256 от отсортированных extensions плюс signature algorithms, первые 12 hex chars. |
|
||||
|
||||
Важное отличие JA4 от JA3: JA4 нормализует часть полей, поэтому он устойчивее к простому изменению порядка extensions. Это делает JA4 удобным для фильтров и одновременно полезным для диагностики таких фильтров.
|
||||
|
||||
## Где Telemt видит ClientHello
|
||||
|
||||
В TLS/FakeTLS режиме Telemt получает первые bytes соединения и определяет, похоже ли оно на TLS handshake. Если record является полным ClientHello и проходит bounds checks, Telemt один раз парсит его для JA3/JA4.
|
||||
|
||||
Дальше возможны три исхода:
|
||||
|
||||
1. **Успешный MTProxy/FakeTLS клиент**
|
||||
- Telemt принимает TLS-auth;
|
||||
- fingerprint записывается в global/IP/CIDR scopes;
|
||||
- после успешной TLS-auth Telemt добавляет user scope.
|
||||
|
||||
2. **Bad client или probe**
|
||||
- ClientHello полный, но auth не проходит;
|
||||
- fingerprint записывается в global/IP/CIDR scopes;
|
||||
- user scope не записывается;
|
||||
- `bad_or_probe` увеличивается.
|
||||
|
||||
3. **Неполный или обрезанный ClientHello**
|
||||
- fingerprint не считается;
|
||||
- такие случаи остаются в существующих bad-class counters.
|
||||
|
||||
Если фильтр режет трафик до того, как TCP connection или ClientHello дошли до процесса Telemt, Telemt не увидит этот fingerprint. Это важнейшее диагностическое отличие: отсутствие fingerprint'а во время жалобы пользователя часто означает блокировку до приложения, а не проблему внутри Telemt.
|
||||
|
||||
## Включение сбора
|
||||
|
||||
Collector включается, когда включён хотя бы один потребитель:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
beobachten = true
|
||||
beobachten_minutes = 10
|
||||
```
|
||||
|
||||
или:
|
||||
|
||||
```toml
|
||||
[server.api]
|
||||
runtime_edge_enabled = true
|
||||
runtime_edge_top_n = 50
|
||||
```
|
||||
|
||||
Практически:
|
||||
|
||||
- для файлового/metrics endpoint анализа достаточно `general.beobachten=true`;
|
||||
- для API snapshot нужен `server.api.runtime_edge_enabled=true`;
|
||||
- `general.beobachten_minutes` задаёт retention window для fingerprint buckets;
|
||||
- `server.api.runtime_edge_top_n` задаёт default Top-N размер API snapshot.
|
||||
|
||||
## API snapshot
|
||||
|
||||
Endpoint:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints
|
||||
```
|
||||
|
||||
С явным лимитом:
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100'
|
||||
```
|
||||
|
||||
Если API защищён header'ом:
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100'
|
||||
```
|
||||
|
||||
Если `runtime_edge_enabled=false`, endpoint возвращает payload с:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"reason": "feature_disabled"
|
||||
}
|
||||
```
|
||||
|
||||
### Структура payload
|
||||
|
||||
Основные поля:
|
||||
|
||||
| Поле | Смысл |
|
||||
| --- | --- |
|
||||
| `retention_secs` | Текущее TTL окно collector'а. |
|
||||
| `capacity` | Максимум retained buckets. |
|
||||
| `dropped_total` | Сколько новых buckets отброшено из-за cap. |
|
||||
| `parse_error_total` | Сколько полных ClientHello не удалось распарсить. |
|
||||
| `by_fingerprint` | Top fingerprints глобально. |
|
||||
| `by_ip` | Top fingerprints по exact source IP. |
|
||||
| `by_cidr` | Top fingerprints по source prefix: IPv4 `/24`, IPv6 `/56`. |
|
||||
| `by_user` | Top fingerprints по authenticated user. |
|
||||
|
||||
Строка snapshot:
|
||||
|
||||
| Поле | Смысл |
|
||||
| --- | --- |
|
||||
| `scope` | IP, CIDR или username. В `by_fingerprint` отсутствует. |
|
||||
| `ja3` | JA3 hash. |
|
||||
| `ja3_raw` | Raw JA3 string. |
|
||||
| `ja4` | JA4 TLS client fingerprint. |
|
||||
| `ja4_raw` | Raw JA4 material. |
|
||||
| `total` | Сколько полных ClientHello попало в этот bucket. |
|
||||
| `auth_success` | Сколько из них успешно прошли TLS-auth. |
|
||||
| `bad_or_probe` | Сколько были bad/probe после полного ClientHello. |
|
||||
| `first_seen_epoch_secs` | Первый timestamp bucket'а. |
|
||||
| `last_seen_epoch_secs` | Последний timestamp bucket'а. |
|
||||
|
||||
### Быстрый просмотр через jq
|
||||
|
||||
Top JA4 глобально:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints \
|
||||
| jq -r '.data.data.by_fingerprint[] | [.ja4, .total, .auth_success, .bad_or_probe] | @tsv'
|
||||
```
|
||||
|
||||
Top JA4 по пользователям:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100 \
|
||||
| jq -r '.data.data.by_user[] | [.scope, .ja4, .total, .auth_success] | @tsv'
|
||||
```
|
||||
|
||||
Top JA4 по CIDR:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100 \
|
||||
| jq -r '.data.data.by_cidr[] | [.scope, .ja4, .total, .auth_success, .bad_or_probe] | @tsv'
|
||||
```
|
||||
|
||||
Ошибки парсинга и drops:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints \
|
||||
| jq '.data.data | {retention_secs, capacity, dropped_total, parse_error_total}'
|
||||
```
|
||||
|
||||
## Beobachten output
|
||||
|
||||
Если включён endpoint metrics, `/beobachten` содержит обычные forensic buckets и, когда есть данные, append-only секцию TLS fingerprints:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:9090/beobachten
|
||||
```
|
||||
|
||||
Фрагмент:
|
||||
|
||||
```text
|
||||
[tls_fingerprints]
|
||||
retention_secs=600 capacity=65536 dropped_total=0 parse_error_total=0
|
||||
[tls_fingerprints.by_fingerprint]
|
||||
ja4=t13d1516h2_8daaf6152771_e5627efa2ab1 ja3=... total=42 auth_success=41 bad_or_probe=1 first_seen=... last_seen=...
|
||||
[tls_fingerprints.by_cidr]
|
||||
scope=203.0.113.0/24 ja4=t13d1516h2_8daaf6152771_e5627efa2ab1 ja3=... total=10 auth_success=10 bad_or_probe=0 first_seen=... last_seen=...
|
||||
```
|
||||
|
||||
`/beobachten` удобен для быстрой операторской диагностики без API client. API удобнее для автоматической корреляции.
|
||||
|
||||
## Как анализировать JA4-based блокировку
|
||||
|
||||
### 1. Зафиксировать симптом
|
||||
|
||||
Перед анализом нужно записать:
|
||||
|
||||
- какие пользователи жалуются;
|
||||
- какая версия Telegram client используется;
|
||||
- какая платформа: Desktop, Android, iOS;
|
||||
- какой источник сети: mobile ISP, home ISP, corporate network, country/region;
|
||||
- работает ли тот же пользователь через другой network path;
|
||||
- работает ли другой пользователь с того же IP/CIDR;
|
||||
- видит ли Telemt новые ClientHello от проблемного пользователя в момент попытки.
|
||||
|
||||
JA4 без контекста почти всегда недостаточен. Фильтры часто используют сочетание:
|
||||
|
||||
- JA4;
|
||||
- destination IP;
|
||||
- SNI;
|
||||
- порт;
|
||||
- ASN/source network;
|
||||
- rate или connection pattern;
|
||||
- reputation домена/IP;
|
||||
- active probing result.
|
||||
|
||||
### 2. Проверить, доходит ли ClientHello до Telemt
|
||||
|
||||
Во время попытки подключения проблемного пользователя смотрите:
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=200' \
|
||||
| jq '.data.data.by_user, .data.data.by_ip, .data.data.by_cidr'
|
||||
```
|
||||
|
||||
Интерпретация:
|
||||
|
||||
| Наблюдение | Вероятный вывод |
|
||||
| --- | --- |
|
||||
| Нет новых rows для IP/CIDR пользователя | Блокировка до Telemt: routing, firewall, ISP/DPI drop, IP block, SYN/TCP reset, UDP/TCP path issue. |
|
||||
| Есть `by_ip`/`by_cidr`, но нет `by_user` | ClientHello дошёл, но TLS-auth/MTProxy layer не дошёл до успешного пользователя. Возможны bad key, probe, wrong client, active scanner, обрыв после ClientHello. |
|
||||
| Есть `by_user.auth_success` | Клиентский JA4 дошёл и был принят Telemt. Если пользователь всё равно видит проблему, искать нужно дальше: relay path, Telegram upstream, quota, route mode, session cancellation, ME/direct routing. |
|
||||
| Резко растёт `bad_or_probe` для одного JA4 | Вероятны сканеры или неправильные клиенты с тем же fingerprint family. |
|
||||
|
||||
### 3. Сравнить working и blocked случаи
|
||||
|
||||
Снимите snapshot во время working case и blocked case:
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=500' > tls-fp-working.json
|
||||
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=500' > tls-fp-blocked.json
|
||||
```
|
||||
|
||||
Сравните:
|
||||
|
||||
- появился ли тот же `ja4` в blocked сети;
|
||||
- меняется ли `ja4` между версиями клиента;
|
||||
- меняется ли только IP/CIDR при том же `ja4`;
|
||||
- есть ли `auth_success` для того же `ja4` из других сетей;
|
||||
- отличается ли `bad_or_probe` между сетями.
|
||||
|
||||
Ключевая матрица:
|
||||
|
||||
| Working JA4 | Blocked JA4 | Вывод |
|
||||
| --- | --- | --- |
|
||||
| Same | Same, но blocked network не доходит до Telemt | Вероятна фильтрация по JA4 + destination/IP/SNI/network до приложения. |
|
||||
| Same | Same, доходит и `auth_success>0` | JA4 ClientHello не является точкой отказа; искать post-auth проблему. |
|
||||
| Different | Blocked только один JA4 | Вероятен client-version/platform-specific fingerprint block. |
|
||||
| Same | `bad_or_probe` растёт, `auth_success=0` | Возможно, доходит не тот клиент/secret или фильтр/прокси ломает поток после ClientHello. |
|
||||
|
||||
### 4. Разделить client JA4 и server fingerprint
|
||||
|
||||
JA4 ClientHello - это клиентская сторона. Настройки Telemt вроде TLS-front server flight, `mask_host`, ticket-tail или CCS replay не меняют ClientHello, который отправляет Telegram client.
|
||||
|
||||
Если фильтр принимает решение строго после ClientHello, то серверные улучшения могут не помочь. В этом случае полезные действия:
|
||||
|
||||
- проверить обновление Telegram client;
|
||||
- сравнить платформы и версии клиента;
|
||||
- проверить, меняется ли JA4 на другой версии;
|
||||
- проверить, блокируется ли тот же JA4 к другому destination;
|
||||
- проверить, блокируется ли другой JA4 к тому же Telemt IP/SNI;
|
||||
- собрать evidence для client-side fingerprint fix.
|
||||
|
||||
Если ClientHello проходит, а блокировка возникает после server response, тогда уже важны:
|
||||
|
||||
- форма FakeTLS server flight;
|
||||
- TLS front profile fidelity;
|
||||
- `mask_host` поведение для non-auth clients;
|
||||
- certificate/provenance fallback для сканеров;
|
||||
- TCP relay behavior;
|
||||
- upstream route к Telegram.
|
||||
|
||||
### 5. Коррелировать с packet capture
|
||||
|
||||
Telemt collector показывает только то, что процесс увидел. Для подтверждения фильтрации до Telemt нужен внешний capture.
|
||||
|
||||
На сервере:
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -w telemt-clienthello.pcap host CLIENT_IP and port 443
|
||||
```
|
||||
|
||||
Быстрый tshark вывод ClientHello fields:
|
||||
|
||||
```bash
|
||||
tshark -r telemt-clienthello.pcap -Y "tls.handshake.type == 1" -T fields \
|
||||
-e frame.time_epoch \
|
||||
-e ip.src \
|
||||
-e ip.dst \
|
||||
-e tcp.srcport \
|
||||
-e tcp.dstport \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extensions_alpn_str
|
||||
```
|
||||
|
||||
Если на клиентской стороне capture видит ClientHello, а серверный capture не видит, проблема в сети между клиентом и сервером. Если серверный capture видит ClientHello, но Telemt API не видит fingerprint, проверьте порт, listener, PROXY protocol, TLS record fragmentation и bounds/errors.
|
||||
|
||||
## Практические сценарии
|
||||
|
||||
### Сценарий A: один JA4 перестал работать у многих пользователей
|
||||
|
||||
Признаки:
|
||||
|
||||
- один `ja4` доминирует в жалобах;
|
||||
- у разных source CIDR нет `auth_success`;
|
||||
- working пользователи используют другой JA4;
|
||||
- обновление клиента меняет поведение.
|
||||
|
||||
Вероятный вывод: фильтр на стороне сети научился распознавать конкретный ClientHello family.
|
||||
|
||||
Действия:
|
||||
|
||||
- сравнить Telegram client versions;
|
||||
- проверить, не используют ли пользователи старые клиенты;
|
||||
- собрать `ja4`, `ja4_raw`, platform/version, source network;
|
||||
- проверить тот же client через другую сеть;
|
||||
- проверить другой client version через ту же сеть.
|
||||
|
||||
### Сценарий B: один CIDR не работает, JA4 обычный
|
||||
|
||||
Признаки:
|
||||
|
||||
- тот же `ja4` успешно работает из других сетей;
|
||||
- проблемный `/24` или `/56` не доходит до Telemt или не получает `auth_success`;
|
||||
- нет общей корреляции по версии клиента.
|
||||
|
||||
Вероятный вывод: проблема не в JA4 alone, а в source network policy или destination reputation.
|
||||
|
||||
Действия:
|
||||
|
||||
- сменить route/VPS/IP;
|
||||
- проверить port;
|
||||
- проверить SNI/domain reputation;
|
||||
- сравнить с другим Telemt endpoint;
|
||||
- смотреть server-side packet capture.
|
||||
|
||||
### Сценарий C: много `bad_or_probe` на одном JA4
|
||||
|
||||
Признаки:
|
||||
|
||||
- `bad_or_probe` высокий;
|
||||
- `by_user` пустой или слабый;
|
||||
- source IP/CIDR разнообразные;
|
||||
- попытки не соответствуют реальным пользователям.
|
||||
|
||||
Вероятный вывод: активное сканирование или нерелевантный TLS traffic с похожим ClientHello.
|
||||
|
||||
Действия:
|
||||
|
||||
- смотреть `/beobachten` по IP classes;
|
||||
- проверить `unknown_tls_sni` и bad-client counters;
|
||||
- убедиться, что fallback `mask_host` отвечает правдоподобно;
|
||||
- не делать вывод о блокировке пользователей только по global `bad_or_probe`.
|
||||
|
||||
### Сценарий D: `auth_success` есть, но пользователь жалуется
|
||||
|
||||
Признаки:
|
||||
|
||||
- fingerprint присутствует в `by_user`;
|
||||
- `auth_success` растёт;
|
||||
- соединение проходит TLS-auth.
|
||||
|
||||
Вероятный вывод: JA4 ClientHello не является причиной отказа в этом случае.
|
||||
|
||||
Действия:
|
||||
|
||||
- проверить user enabled/disabled status;
|
||||
- проверить quota;
|
||||
- проверить direct/ME route;
|
||||
- проверить upstream health;
|
||||
- проверить runtime events;
|
||||
- смотреть relay/session logs.
|
||||
|
||||
## Что нельзя вывести из JA3/JA4
|
||||
|
||||
JA3/JA4 не говорят:
|
||||
|
||||
- почему сеть приняла решение о блокировке;
|
||||
- какой именно vendor DPI используется;
|
||||
- был ли block только по JA4 или по связке JA4+IP+SNI;
|
||||
- что произошло с соединением после TLS-auth;
|
||||
- как выглядит server-side TLS fingerprint;
|
||||
- как ведёт себя HTTP layer после TLS.
|
||||
|
||||
JA3/JA4 также не являются уникальной идентичностью человека. Это fingerprint клиентской TLS-реализации и её настроек. Один fingerprint может быть у большого числа пользователей.
|
||||
|
||||
## Ограничения collector'а Telemt
|
||||
|
||||
- Считается только TLS ClientHello, который полностью дошёл до Telemt.
|
||||
- QUIC/DTLS/HTTP JA4 variants не собираются.
|
||||
- Truncated ClientHello не fingerprint'ится.
|
||||
- User scope появляется только после успешной TLS-auth.
|
||||
- `by_ip` и `by_cidr` отражают source address после нормализации/PROXY protocol path, если он используется.
|
||||
- Collector bounded: при большом количестве уникальных buckets возможен рост `dropped_total`.
|
||||
- Retention зависит от `general.beobachten_minutes`.
|
||||
- Данные runtime in-memory; это snapshot для диагностики, а не долговременное хранилище.
|
||||
|
||||
## Рекомендованный workflow расследования
|
||||
|
||||
1. Включить `runtime_edge_enabled=true` и разумный `runtime_edge_top_n`, например `100`.
|
||||
2. Зафиксировать baseline в период нормальной работы.
|
||||
3. Во время жалобы снять API snapshot и `/beobachten`.
|
||||
4. Сравнить `by_user`, `by_ip`, `by_cidr`, `by_fingerprint`.
|
||||
5. Проверить, появляется ли problematic source в Telemt вообще.
|
||||
6. Если не появляется, снять packet capture на сервере и клиенте.
|
||||
7. Если появляется без `auth_success`, проверить secret/client/proxy link и bad/probe counters.
|
||||
8. Если появляется с `auth_success`, исключить JA4 ClientHello как primary cause и перейти к relay/upstream/runtime диагностике.
|
||||
9. Если один JA4 стабильно коррелирует с block, собрать client version/platform evidence.
|
||||
10. Проверить, меняет ли обновление клиента JA4 и результат подключения.
|
||||
|
||||
## Минимальный incident report
|
||||
|
||||
Для полезного отчёта по JA4-based блокировке соберите:
|
||||
|
||||
```text
|
||||
time_window:
|
||||
telemt_version:
|
||||
server_ip:
|
||||
server_port:
|
||||
tls_domain:
|
||||
mask_host:
|
||||
client_platform:
|
||||
client_version:
|
||||
source_network:
|
||||
source_ip_or_cidr:
|
||||
ja4:
|
||||
ja4_raw:
|
||||
ja3:
|
||||
total:
|
||||
auth_success:
|
||||
bad_or_probe:
|
||||
seen_in_by_user: yes/no
|
||||
seen_in_by_ip: yes/no
|
||||
seen_in_by_cidr: yes/no
|
||||
server_tcpdump_seen_clienthello: yes/no
|
||||
client_tcpdump_sent_clienthello: yes/no
|
||||
works_from_other_network: yes/no
|
||||
works_with_other_client_version: yes/no
|
||||
```
|
||||
|
||||
Этот набор обычно достаточен, чтобы отличить client fingerprint block от IP/SNI/reputation block и от post-auth проблем Telemt.
|
||||
|
||||
## Источники форматов
|
||||
|
||||
- JA3 reference: https://github.com/salesforce/ja3
|
||||
- JA4 technical details: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
|
||||
|
||||
@@ -10,6 +10,8 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
>
|
||||
> The configuration parameters detailed in this document are intended for advanced users and fine-tuning purposes. Modifying these settings without a clear understanding of their function may lead to application instability or other unexpected behavior. Please proceed with caution and at your own risk.
|
||||
|
||||
> `Hot-Reload` marks whether a changed value is applied by the config watcher without restarting the process; `✘` means restart is required for runtime effect.
|
||||
|
||||
# Table of contents
|
||||
- [Top-level keys](#top-level-keys)
|
||||
- [general](#general)
|
||||
@@ -29,12 +31,16 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
|
||||
# Top-level keys
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`include`](#include) | `String` (special directive) | — |
|
||||
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) |
|
||||
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` |
|
||||
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`include`](#include) | `String` (special directive) | — | `✔` |
|
||||
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
|
||||
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
|
||||
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
|
||||
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` | `✘` |
|
||||
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` | `✘` |
|
||||
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` | `✘` |
|
||||
|
||||
## include
|
||||
- **Constraints / validation**: Must be a single-line directive in the form `include = "path/to/file.toml"`. Includes are expanded before TOML parsing. Maximum include depth is 10.
|
||||
@@ -79,145 +85,152 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
|
||||
# [general]
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`data_path`](#data_path) | `String` | — |
|
||||
| [`prefer_ipv6`](#prefer_ipv6) | `bool` | `false` |
|
||||
| [`fast_mode`](#fast_mode) | `bool` | `true` |
|
||||
| [`use_middle_proxy`](#use_middle_proxy) | `bool` | `true` |
|
||||
| [`proxy_secret_path`](#proxy_secret_path) | `String` | `"proxy-secret"` |
|
||||
| [`proxy_config_v4_cache_path`](#proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` |
|
||||
| [`proxy_config_v6_cache_path`](#proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` |
|
||||
| [`ad_tag`](#ad_tag) | `String` | — |
|
||||
| [`middle_proxy_nat_ip`](#middle_proxy_nat_ip) | `IpAddr` | — |
|
||||
| [`middle_proxy_nat_probe`](#middle_proxy_nat_probe) | `bool` | `true` |
|
||||
| [`middle_proxy_nat_stun`](#middle_proxy_nat_stun) | `String` | — |
|
||||
| [`middle_proxy_nat_stun_servers`](#middle_proxy_nat_stun_servers) | `String[]` | `[]` |
|
||||
| [`stun_nat_probe_concurrency`](#stun_nat_probe_concurrency) | `usize` | `8` |
|
||||
| [`middle_proxy_pool_size`](#middle_proxy_pool_size) | `usize` | `8` |
|
||||
| [`middle_proxy_warm_standby`](#middle_proxy_warm_standby) | `usize` | `16` |
|
||||
| [`me_init_retry_attempts`](#me_init_retry_attempts) | `u32` | `0` |
|
||||
| [`me2dc_fallback`](#me2dc_fallback) | `bool` | `true` |
|
||||
| [`me2dc_fast`](#me2dc_fast) | `bool` | `false` |
|
||||
| [`me_keepalive_enabled`](#me_keepalive_enabled) | `bool` | `true` |
|
||||
| [`me_keepalive_interval_secs`](#me_keepalive_interval_secs) | `u64` | `8` |
|
||||
| [`me_keepalive_jitter_secs`](#me_keepalive_jitter_secs) | `u64` | `2` |
|
||||
| [`me_keepalive_payload_random`](#me_keepalive_payload_random) | `bool` | `true` |
|
||||
| [`rpc_proxy_req_every`](#rpc_proxy_req_every) | `u64` | `0` |
|
||||
| [`me_writer_cmd_channel_capacity`](#me_writer_cmd_channel_capacity) | `usize` | `4096` |
|
||||
| [`me_route_channel_capacity`](#me_route_channel_capacity) | `usize` | `768` |
|
||||
| [`me_c2me_channel_capacity`](#me_c2me_channel_capacity) | `usize` | `1024` |
|
||||
| [`me_c2me_send_timeout_ms`](#me_c2me_send_timeout_ms) | `u64` | `4000` |
|
||||
| [`me_reader_route_data_wait_ms`](#me_reader_route_data_wait_ms) | `u64` | `2` |
|
||||
| [`me_d2c_flush_batch_max_frames`](#me_d2c_flush_batch_max_frames) | `usize` | `32` |
|
||||
| [`me_d2c_flush_batch_max_bytes`](#me_d2c_flush_batch_max_bytes) | `usize` | `131072` |
|
||||
| [`me_d2c_flush_batch_max_delay_us`](#me_d2c_flush_batch_max_delay_us) | `u64` | `500` |
|
||||
| [`me_d2c_ack_flush_immediate`](#me_d2c_ack_flush_immediate) | `bool` | `true` |
|
||||
| [`me_quota_soft_overshoot_bytes`](#me_quota_soft_overshoot_bytes) | `u64` | `65536` |
|
||||
| [`me_d2c_frame_buf_shrink_threshold_bytes`](#me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` |
|
||||
| [`direct_relay_copy_buf_c2s_bytes`](#direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` |
|
||||
| [`direct_relay_copy_buf_s2c_bytes`](#direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` |
|
||||
| [`crypto_pending_buffer`](#crypto_pending_buffer) | `usize` | `262144` |
|
||||
| [`max_client_frame`](#max_client_frame) | `usize` | `16777216` |
|
||||
| [`desync_all_full`](#desync_all_full) | `bool` | `false` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` |
|
||||
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` |
|
||||
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` |
|
||||
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` |
|
||||
| [`hardswap`](#hardswap) | `bool` | `true` |
|
||||
| [`me_warmup_stagger_enabled`](#me_warmup_stagger_enabled) | `bool` | `true` |
|
||||
| [`me_warmup_step_delay_ms`](#me_warmup_step_delay_ms) | `u64` | `500` |
|
||||
| [`me_warmup_step_jitter_ms`](#me_warmup_step_jitter_ms) | `u64` | `300` |
|
||||
| [`me_reconnect_max_concurrent_per_dc`](#me_reconnect_max_concurrent_per_dc) | `u32` | `8` |
|
||||
| [`me_reconnect_backoff_base_ms`](#me_reconnect_backoff_base_ms) | `u64` | `500` |
|
||||
| [`me_reconnect_backoff_cap_ms`](#me_reconnect_backoff_cap_ms) | `u64` | `30000` |
|
||||
| [`me_reconnect_fast_retry_count`](#me_reconnect_fast_retry_count) | `u32` | `16` |
|
||||
| [`me_single_endpoint_shadow_writers`](#me_single_endpoint_shadow_writers) | `u8` | `2` |
|
||||
| [`me_single_endpoint_outage_mode_enabled`](#me_single_endpoint_outage_mode_enabled) | `bool` | `true` |
|
||||
| [`me_single_endpoint_outage_disable_quarantine`](#me_single_endpoint_outage_disable_quarantine) | `bool` | `true` |
|
||||
| [`me_single_endpoint_outage_backoff_min_ms`](#me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` |
|
||||
| [`me_single_endpoint_outage_backoff_max_ms`](#me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` |
|
||||
| [`me_single_endpoint_shadow_rotate_every_secs`](#me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` |
|
||||
| [`me_floor_mode`](#me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` |
|
||||
| [`me_adaptive_floor_idle_secs`](#me_adaptive_floor_idle_secs) | `u64` | `90` |
|
||||
| [`me_adaptive_floor_min_writers_single_endpoint`](#me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` |
|
||||
| [`me_adaptive_floor_min_writers_multi_endpoint`](#me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` |
|
||||
| [`me_adaptive_floor_recover_grace_secs`](#me_adaptive_floor_recover_grace_secs) | `u64` | `180` |
|
||||
| [`me_adaptive_floor_writers_per_core_total`](#me_adaptive_floor_writers_per_core_total) | `u16` | `48` |
|
||||
| [`me_adaptive_floor_cpu_cores_override`](#me_adaptive_floor_cpu_cores_override) | `u16` | `0` |
|
||||
| [`me_adaptive_floor_max_extra_writers_single_per_core`](#me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` |
|
||||
| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` |
|
||||
| [`me_adaptive_floor_max_active_writers_per_core`](#me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` |
|
||||
| [`me_adaptive_floor_max_warm_writers_per_core`](#me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` |
|
||||
| [`me_adaptive_floor_max_active_writers_global`](#me_adaptive_floor_max_active_writers_global) | `u32` | `256` |
|
||||
| [`me_adaptive_floor_max_warm_writers_global`](#me_adaptive_floor_max_warm_writers_global) | `u32` | `256` |
|
||||
| [`upstream_connect_retry_attempts`](#upstream_connect_retry_attempts) | `u32` | `2` |
|
||||
| [`upstream_connect_retry_backoff_ms`](#upstream_connect_retry_backoff_ms) | `u64` | `100` |
|
||||
| [`upstream_connect_budget_ms`](#upstream_connect_budget_ms) | `u64` | `3000` |
|
||||
| [`upstream_unhealthy_fail_threshold`](#upstream_unhealthy_fail_threshold) | `u32` | `5` |
|
||||
| [`upstream_connect_failfast_hard_errors`](#upstream_connect_failfast_hard_errors) | `bool` | `false` |
|
||||
| [`stun_iface_mismatch_ignore`](#stun_iface_mismatch_ignore) | `bool` | `false` |
|
||||
| [`unknown_dc_log_path`](#unknown_dc_log_path) | `String` | `"unknown-dc.txt"` |
|
||||
| [`unknown_dc_file_log_enabled`](#unknown_dc_file_log_enabled) | `bool` | `false` |
|
||||
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
|
||||
| [`disable_colors`](#disable_colors) | `bool` | `false` |
|
||||
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
|
||||
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` |
|
||||
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` |
|
||||
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
|
||||
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
|
||||
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
|
||||
| [`me_health_interval_ms_unhealthy`](#me_health_interval_ms_unhealthy) | `u64` | `1000` |
|
||||
| [`me_health_interval_ms_healthy`](#me_health_interval_ms_healthy) | `u64` | `3000` |
|
||||
| [`me_admission_poll_ms`](#me_admission_poll_ms) | `u64` | `1000` |
|
||||
| [`me_warn_rate_limit_ms`](#me_warn_rate_limit_ms) | `u64` | `5000` |
|
||||
| [`me_route_no_writer_mode`](#me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` |
|
||||
| [`me_route_no_writer_wait_ms`](#me_route_no_writer_wait_ms) | `u64` | `250` |
|
||||
| [`me_route_hybrid_max_wait_ms`](#me_route_hybrid_max_wait_ms) | `u64` | `3000` |
|
||||
| [`me_route_blocking_send_timeout_ms`](#me_route_blocking_send_timeout_ms) | `u64` | `250` |
|
||||
| [`me_route_inline_recovery_attempts`](#me_route_inline_recovery_attempts) | `u32` | `3` |
|
||||
| [`me_route_inline_recovery_wait_ms`](#me_route_inline_recovery_wait_ms) | `u64` | `3000` |
|
||||
| [`fast_mode_min_tls_record`](#fast_mode_min_tls_record) | `usize` | `0` |
|
||||
| [`update_every`](#update_every) | `u64` | `300` |
|
||||
| [`me_reinit_every_secs`](#me_reinit_every_secs) | `u64` | `900` |
|
||||
| [`me_hardswap_warmup_delay_min_ms`](#me_hardswap_warmup_delay_min_ms) | `u64` | `1000` |
|
||||
| [`me_hardswap_warmup_delay_max_ms`](#me_hardswap_warmup_delay_max_ms) | `u64` | `2000` |
|
||||
| [`me_hardswap_warmup_extra_passes`](#me_hardswap_warmup_extra_passes) | `u8` | `3` |
|
||||
| [`me_hardswap_warmup_pass_backoff_base_ms`](#me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` |
|
||||
| [`me_config_stable_snapshots`](#me_config_stable_snapshots) | `u8` | `2` |
|
||||
| [`me_config_apply_cooldown_secs`](#me_config_apply_cooldown_secs) | `u64` | `300` |
|
||||
| [`me_snapshot_require_http_2xx`](#me_snapshot_require_http_2xx) | `bool` | `true` |
|
||||
| [`me_snapshot_reject_empty_map`](#me_snapshot_reject_empty_map) | `bool` | `true` |
|
||||
| [`me_snapshot_min_proxy_for_lines`](#me_snapshot_min_proxy_for_lines) | `u32` | `1` |
|
||||
| [`proxy_secret_stable_snapshots`](#proxy_secret_stable_snapshots) | `u8` | `2` |
|
||||
| [`proxy_secret_rotate_runtime`](#proxy_secret_rotate_runtime) | `bool` | `true` |
|
||||
| [`me_secret_atomic_snapshot`](#me_secret_atomic_snapshot) | `bool` | `true` |
|
||||
| [`proxy_secret_len_max`](#proxy_secret_len_max) | `usize` | `256` |
|
||||
| [`me_pool_drain_ttl_secs`](#me_pool_drain_ttl_secs) | `u64` | `90` |
|
||||
| [`me_instadrain`](#me_instadrain) | `bool` | `false` |
|
||||
| [`me_pool_drain_threshold`](#me_pool_drain_threshold) | `u64` | `32` |
|
||||
| [`me_pool_drain_soft_evict_enabled`](#me_pool_drain_soft_evict_enabled) | `bool` | `true` |
|
||||
| [`me_pool_drain_soft_evict_grace_secs`](#me_pool_drain_soft_evict_grace_secs) | `u64` | `10` |
|
||||
| [`me_pool_drain_soft_evict_per_writer`](#me_pool_drain_soft_evict_per_writer) | `u8` | `2` |
|
||||
| [`me_pool_drain_soft_evict_budget_per_core`](#me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` |
|
||||
| [`me_pool_drain_soft_evict_cooldown_ms`](#me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` |
|
||||
| [`me_bind_stale_mode`](#me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` |
|
||||
| [`me_bind_stale_ttl_secs`](#me_bind_stale_ttl_secs) | `u64` | `90` |
|
||||
| [`me_pool_min_fresh_ratio`](#me_pool_min_fresh_ratio) | `f32` | `0.8` |
|
||||
| [`me_reinit_drain_timeout_secs`](#me_reinit_drain_timeout_secs) | `u64` | `90` |
|
||||
| [`proxy_secret_auto_reload_secs`](#proxy_secret_auto_reload_secs) | `u64` | `3600` |
|
||||
| [`proxy_config_auto_reload_secs`](#proxy_config_auto_reload_secs) | `u64` | `3600` |
|
||||
| [`me_reinit_singleflight`](#me_reinit_singleflight) | `bool` | `true` |
|
||||
| [`me_reinit_trigger_channel`](#me_reinit_trigger_channel) | `usize` | `64` |
|
||||
| [`me_reinit_coalesce_window_ms`](#me_reinit_coalesce_window_ms) | `u64` | `200` |
|
||||
| [`me_deterministic_writer_sort`](#me_deterministic_writer_sort) | `bool` | `true` |
|
||||
| [`me_writer_pick_mode`](#me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` |
|
||||
| [`me_writer_pick_sample_size`](#me_writer_pick_sample_size) | `u8` | `3` |
|
||||
| [`ntp_check`](#ntp_check) | `bool` | `true` |
|
||||
| [`ntp_servers`](#ntp_servers) | `String[]` | `["pool.ntp.org"]` |
|
||||
| [`auto_degradation_enabled`](#auto_degradation_enabled) | `bool` | `true` |
|
||||
| [`degradation_min_unavailable_dc_groups`](#degradation_min_unavailable_dc_groups) | `u8` | `2` |
|
||||
| [`rst_on_close`](#rst_on_close) | `"off"`, `"errors"`, or `"always"` | `"off"` |
|
||||
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`data_path`](#data_path) | `String` | — | `✘` |
|
||||
| [`quota_state_path`](#quota_state_path) | `Path` | `"telemt.limit.json"` | `✘` |
|
||||
| [`config_strict`](#config_strict) | `bool` | `false` | `✘` |
|
||||
| [`prefer_ipv6`](#prefer_ipv6) | `bool` | `false` | `✘` |
|
||||
| [`fast_mode`](#fast_mode) | `bool` | `true` | `✘` |
|
||||
| [`use_middle_proxy`](#use_middle_proxy) | `bool` | `true` | `✘` |
|
||||
| [`proxy_secret_path`](#proxy_secret_path) | `String` | `"proxy-secret"` | `✘` |
|
||||
| [`proxy_secret_url`](#proxy_secret_url) | `String` | `"https://core.telegram.org/getProxySecret"` | `✘` |
|
||||
| [`proxy_config_v4_cache_path`](#proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` | `✘` |
|
||||
| [`proxy_config_v4_url`](#proxy_config_v4_url) | `String` | `"https://core.telegram.org/getProxyConfig"` | `✘` |
|
||||
| [`proxy_config_v6_cache_path`](#proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` | `✘` |
|
||||
| [`proxy_config_v6_url`](#proxy_config_v6_url) | `String` | `"https://core.telegram.org/getProxyConfigV6"` | `✘` |
|
||||
| [`ad_tag`](#ad_tag) | `String` | — | `✔` |
|
||||
| [`middle_proxy_nat_ip`](#middle_proxy_nat_ip) | `IpAddr` | — | `✘` |
|
||||
| [`middle_proxy_nat_probe`](#middle_proxy_nat_probe) | `bool` | `true` | `✘` |
|
||||
| [`middle_proxy_nat_stun`](#middle_proxy_nat_stun) | `String` | — | `✘` |
|
||||
| [`middle_proxy_nat_stun_servers`](#middle_proxy_nat_stun_servers) | `String[]` | `[]` | `✘` |
|
||||
| [`stun_nat_probe_concurrency`](#stun_nat_probe_concurrency) | `usize` | `8` | `✘` |
|
||||
| [`middle_proxy_pool_size`](#middle_proxy_pool_size) | `usize` | `8` | `✘` |
|
||||
| [`middle_proxy_warm_standby`](#middle_proxy_warm_standby) | `usize` | `16` | `✘` |
|
||||
| [`me_init_retry_attempts`](#me_init_retry_attempts) | `u32` | `0` | `✘` |
|
||||
| [`me2dc_fallback`](#me2dc_fallback) | `bool` | `true` | `✘` |
|
||||
| [`me2dc_fast`](#me2dc_fast) | `bool` | `false` | `✘` |
|
||||
| [`me_keepalive_enabled`](#me_keepalive_enabled) | `bool` | `true` | `✘` |
|
||||
| [`me_keepalive_interval_secs`](#me_keepalive_interval_secs) | `u64` | `8` | `✘` |
|
||||
| [`me_keepalive_jitter_secs`](#me_keepalive_jitter_secs) | `u64` | `2` | `✘` |
|
||||
| [`me_keepalive_payload_random`](#me_keepalive_payload_random) | `bool` | `true` | `✘` |
|
||||
| [`rpc_proxy_req_every`](#rpc_proxy_req_every) | `u64` | `0` | `✘` |
|
||||
| [`me_writer_cmd_channel_capacity`](#me_writer_cmd_channel_capacity) | `usize` | `4096` | `✘` |
|
||||
| [`me_route_channel_capacity`](#me_route_channel_capacity) | `usize` | `768` | `✘` |
|
||||
| [`me_c2me_channel_capacity`](#me_c2me_channel_capacity) | `usize` | `1024` | `✘` |
|
||||
| [`me_c2me_send_timeout_ms`](#me_c2me_send_timeout_ms) | `u64` | `4000` | `✘` |
|
||||
| [`me_reader_route_data_wait_ms`](#me_reader_route_data_wait_ms) | `u64` | `2` | `✔` |
|
||||
| [`me_d2c_flush_batch_max_frames`](#me_d2c_flush_batch_max_frames) | `usize` | `32` | `✔` |
|
||||
| [`me_d2c_flush_batch_max_bytes`](#me_d2c_flush_batch_max_bytes) | `usize` | `131072` | `✔` |
|
||||
| [`me_d2c_flush_batch_max_delay_us`](#me_d2c_flush_batch_max_delay_us) | `u64` | `500` | `✔` |
|
||||
| [`me_d2c_ack_flush_immediate`](#me_d2c_ack_flush_immediate) | `bool` | `true` | `✔` |
|
||||
| [`me_quota_soft_overshoot_bytes`](#me_quota_soft_overshoot_bytes) | `u64` | `65536` | `✔` |
|
||||
| [`me_d2c_frame_buf_shrink_threshold_bytes`](#me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` | `✔` |
|
||||
| [`direct_relay_copy_buf_c2s_bytes`](#direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` | `✔` |
|
||||
| [`direct_relay_copy_buf_s2c_bytes`](#direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` | `✔` |
|
||||
| [`crypto_pending_buffer`](#crypto_pending_buffer) | `usize` | `262144` | `✘` |
|
||||
| [`max_client_frame`](#max_client_frame) | `usize` | `16777216` | `✘` |
|
||||
| [`desync_all_full`](#desync_all_full) | `bool` | `false` | `✔` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
|
||||
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` | `✘` |
|
||||
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` | `✘` |
|
||||
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` | `✘` |
|
||||
| [`hardswap`](#hardswap) | `bool` | `true` | `✔` |
|
||||
| [`me_warmup_stagger_enabled`](#me_warmup_stagger_enabled) | `bool` | `true` | `✘` |
|
||||
| [`me_warmup_step_delay_ms`](#me_warmup_step_delay_ms) | `u64` | `500` | `✘` |
|
||||
| [`me_warmup_step_jitter_ms`](#me_warmup_step_jitter_ms) | `u64` | `300` | `✘` |
|
||||
| [`me_reconnect_max_concurrent_per_dc`](#me_reconnect_max_concurrent_per_dc) | `u32` | `8` | `✘` |
|
||||
| [`me_reconnect_backoff_base_ms`](#me_reconnect_backoff_base_ms) | `u64` | `500` | `✘` |
|
||||
| [`me_reconnect_backoff_cap_ms`](#me_reconnect_backoff_cap_ms) | `u64` | `30000` | `✘` |
|
||||
| [`me_reconnect_fast_retry_count`](#me_reconnect_fast_retry_count) | `u32` | `16` | `✘` |
|
||||
| [`me_single_endpoint_shadow_writers`](#me_single_endpoint_shadow_writers) | `u8` | `2` | `✔` |
|
||||
| [`me_single_endpoint_outage_mode_enabled`](#me_single_endpoint_outage_mode_enabled) | `bool` | `true` | `✔` |
|
||||
| [`me_single_endpoint_outage_disable_quarantine`](#me_single_endpoint_outage_disable_quarantine) | `bool` | `true` | `✔` |
|
||||
| [`me_single_endpoint_outage_backoff_min_ms`](#me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` | `✔` |
|
||||
| [`me_single_endpoint_outage_backoff_max_ms`](#me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` | `✔` |
|
||||
| [`me_single_endpoint_shadow_rotate_every_secs`](#me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` | `✔` |
|
||||
| [`me_floor_mode`](#me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` | `✔` |
|
||||
| [`me_adaptive_floor_idle_secs`](#me_adaptive_floor_idle_secs) | `u64` | `90` | `✔` |
|
||||
| [`me_adaptive_floor_min_writers_single_endpoint`](#me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` | `✔` |
|
||||
| [`me_adaptive_floor_min_writers_multi_endpoint`](#me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` | `✔` |
|
||||
| [`me_adaptive_floor_recover_grace_secs`](#me_adaptive_floor_recover_grace_secs) | `u64` | `180` | `✔` |
|
||||
| [`me_adaptive_floor_writers_per_core_total`](#me_adaptive_floor_writers_per_core_total) | `u16` | `48` | `✔` |
|
||||
| [`me_adaptive_floor_cpu_cores_override`](#me_adaptive_floor_cpu_cores_override) | `u16` | `0` | `✔` |
|
||||
| [`me_adaptive_floor_max_extra_writers_single_per_core`](#me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` | `✔` |
|
||||
| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` | `✔` |
|
||||
| [`me_adaptive_floor_max_active_writers_per_core`](#me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` | `✔` |
|
||||
| [`me_adaptive_floor_max_warm_writers_per_core`](#me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` | `✔` |
|
||||
| [`me_adaptive_floor_max_active_writers_global`](#me_adaptive_floor_max_active_writers_global) | `u32` | `256` | `✔` |
|
||||
| [`me_adaptive_floor_max_warm_writers_global`](#me_adaptive_floor_max_warm_writers_global) | `u32` | `256` | `✔` |
|
||||
| [`upstream_connect_retry_attempts`](#upstream_connect_retry_attempts) | `u32` | `2` | `✘` |
|
||||
| [`upstream_connect_retry_backoff_ms`](#upstream_connect_retry_backoff_ms) | `u64` | `100` | `✘` |
|
||||
| [`upstream_connect_budget_ms`](#upstream_connect_budget_ms) | `u64` | `3000` | `✘` |
|
||||
| [`tg_connect`](#tg_connect) | `u64` | `10` | `✘` |
|
||||
| [`upstream_unhealthy_fail_threshold`](#upstream_unhealthy_fail_threshold) | `u32` | `5` | `✘` |
|
||||
| [`upstream_connect_failfast_hard_errors`](#upstream_connect_failfast_hard_errors) | `bool` | `false` | `✘` |
|
||||
| [`stun_iface_mismatch_ignore`](#stun_iface_mismatch_ignore) | `bool` | `false` | `✘` |
|
||||
| [`unknown_dc_log_path`](#unknown_dc_log_path) | `String` | `"unknown-dc.txt"` | `✘` |
|
||||
| [`unknown_dc_file_log_enabled`](#unknown_dc_file_log_enabled) | `bool` | `false` | `✘` |
|
||||
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` | `✔` |
|
||||
| [`disable_colors`](#disable_colors) | `bool` | `false` | `✘` |
|
||||
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` | `✔` |
|
||||
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` | `✔` |
|
||||
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` | `✔` |
|
||||
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` | `✔` |
|
||||
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` | `✔` |
|
||||
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` | `✔` |
|
||||
| [`me_health_interval_ms_unhealthy`](#me_health_interval_ms_unhealthy) | `u64` | `1000` | `✔` |
|
||||
| [`me_health_interval_ms_healthy`](#me_health_interval_ms_healthy) | `u64` | `3000` | `✔` |
|
||||
| [`me_admission_poll_ms`](#me_admission_poll_ms) | `u64` | `1000` | `✔` |
|
||||
| [`me_warn_rate_limit_ms`](#me_warn_rate_limit_ms) | `u64` | `5000` | `✔` |
|
||||
| [`me_route_no_writer_mode`](#me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` | `✘` |
|
||||
| [`me_route_no_writer_wait_ms`](#me_route_no_writer_wait_ms) | `u64` | `250` | `✘` |
|
||||
| [`me_route_hybrid_max_wait_ms`](#me_route_hybrid_max_wait_ms) | `u64` | `3000` | `✘` |
|
||||
| [`me_route_blocking_send_timeout_ms`](#me_route_blocking_send_timeout_ms) | `u64` | `250` | `✘` |
|
||||
| [`me_route_inline_recovery_attempts`](#me_route_inline_recovery_attempts) | `u32` | `3` | `✘` |
|
||||
| [`me_route_inline_recovery_wait_ms`](#me_route_inline_recovery_wait_ms) | `u64` | `3000` | `✘` |
|
||||
| [`fast_mode_min_tls_record`](#fast_mode_min_tls_record) | `usize` | `0` | `✘` |
|
||||
| [`update_every`](#update_every) | `u64` | `300` | `✔` |
|
||||
| [`me_reinit_every_secs`](#me_reinit_every_secs) | `u64` | `900` | `✔` |
|
||||
| [`me_hardswap_warmup_delay_min_ms`](#me_hardswap_warmup_delay_min_ms) | `u64` | `1000` | `✔` |
|
||||
| [`me_hardswap_warmup_delay_max_ms`](#me_hardswap_warmup_delay_max_ms) | `u64` | `2000` | `✔` |
|
||||
| [`me_hardswap_warmup_extra_passes`](#me_hardswap_warmup_extra_passes) | `u8` | `3` | `✔` |
|
||||
| [`me_hardswap_warmup_pass_backoff_base_ms`](#me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` | `✔` |
|
||||
| [`me_config_stable_snapshots`](#me_config_stable_snapshots) | `u8` | `2` | `✔` |
|
||||
| [`me_config_apply_cooldown_secs`](#me_config_apply_cooldown_secs) | `u64` | `300` | `✔` |
|
||||
| [`me_snapshot_require_http_2xx`](#me_snapshot_require_http_2xx) | `bool` | `true` | `✔` |
|
||||
| [`me_snapshot_reject_empty_map`](#me_snapshot_reject_empty_map) | `bool` | `true` | `✔` |
|
||||
| [`me_snapshot_min_proxy_for_lines`](#me_snapshot_min_proxy_for_lines) | `u32` | `1` | `✔` |
|
||||
| [`proxy_secret_stable_snapshots`](#proxy_secret_stable_snapshots) | `u8` | `2` | `✔` |
|
||||
| [`proxy_secret_rotate_runtime`](#proxy_secret_rotate_runtime) | `bool` | `true` | `✔` |
|
||||
| [`me_secret_atomic_snapshot`](#me_secret_atomic_snapshot) | `bool` | `true` | `✔` |
|
||||
| [`proxy_secret_len_max`](#proxy_secret_len_max) | `usize` | `256` | `✔` |
|
||||
| [`me_pool_drain_ttl_secs`](#me_pool_drain_ttl_secs) | `u64` | `90` | `✔` |
|
||||
| [`me_instadrain`](#me_instadrain) | `bool` | `false` | `✔` |
|
||||
| [`me_pool_drain_threshold`](#me_pool_drain_threshold) | `u64` | `32` | `✔` |
|
||||
| [`me_pool_drain_soft_evict_enabled`](#me_pool_drain_soft_evict_enabled) | `bool` | `true` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_grace_secs`](#me_pool_drain_soft_evict_grace_secs) | `u64` | `10` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_per_writer`](#me_pool_drain_soft_evict_per_writer) | `u8` | `2` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_budget_per_core`](#me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_cooldown_ms`](#me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` | `✘` |
|
||||
| [`me_bind_stale_mode`](#me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` | `✔` |
|
||||
| [`me_bind_stale_ttl_secs`](#me_bind_stale_ttl_secs) | `u64` | `90` | `✔` |
|
||||
| [`me_pool_min_fresh_ratio`](#me_pool_min_fresh_ratio) | `f32` | `0.8` | `✔` |
|
||||
| [`me_reinit_drain_timeout_secs`](#me_reinit_drain_timeout_secs) | `u64` | `90` | `✔` |
|
||||
| [`proxy_secret_auto_reload_secs`](#proxy_secret_auto_reload_secs) | `u64` | `3600` | `✔` |
|
||||
| [`proxy_config_auto_reload_secs`](#proxy_config_auto_reload_secs) | `u64` | `3600` | `✔` |
|
||||
| [`me_reinit_singleflight`](#me_reinit_singleflight) | `bool` | `true` | `✔` |
|
||||
| [`me_reinit_trigger_channel`](#me_reinit_trigger_channel) | `usize` | `64` | `✘` |
|
||||
| [`me_reinit_coalesce_window_ms`](#me_reinit_coalesce_window_ms) | `u64` | `200` | `✔` |
|
||||
| [`me_deterministic_writer_sort`](#me_deterministic_writer_sort) | `bool` | `true` | `✔` |
|
||||
| [`me_writer_pick_mode`](#me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` | `✔` |
|
||||
| [`me_writer_pick_sample_size`](#me_writer_pick_sample_size) | `u8` | `3` | `✔` |
|
||||
| [`ntp_check`](#ntp_check) | `bool` | `true` | `✘` |
|
||||
| [`ntp_servers`](#ntp_servers) | `String[]` | `["pool.ntp.org"]` | `✘` |
|
||||
| [`auto_degradation_enabled`](#auto_degradation_enabled) | `bool` | `true` | `✘` |
|
||||
| [`degradation_min_unavailable_dc_groups`](#degradation_min_unavailable_dc_groups) | `u8` | `2` | `✘` |
|
||||
| [`rst_on_close`](#rst_on_close) | `"off"`, `"errors"`, or `"always"` | `"off"` | `✘` |
|
||||
|
||||
## data_path
|
||||
- **Constraints / validation**: `String` (optional).
|
||||
@@ -228,6 +241,24 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
[general]
|
||||
data_path = "/var/lib/telemt"
|
||||
```
|
||||
## quota_state_path
|
||||
- **Constraints / validation**: `Path`. Relative paths are resolved from the process working directory.
|
||||
- **Description**: JSON state file used to persist runtime per-user quota consumption.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
quota_state_path = "telemt.limit.json"
|
||||
```
|
||||
## config_strict
|
||||
- **Constraints / validation**: `bool`.
|
||||
- **Description**: Rejects unknown TOML keys during config load. Startup fails fast; hot-reload rejects the new snapshot and keeps the current config.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
config_strict = true
|
||||
```
|
||||
## prefer_ipv6
|
||||
- **Constraints / validation**: Deprecated. Use `network.prefer`.
|
||||
- **Description**: Deprecated legacy IPv6 preference flag migrated to `network.prefer`.
|
||||
@@ -392,7 +423,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
```
|
||||
## me2dc_fallback
|
||||
- **Constraints / validation**: `bool`.
|
||||
- **Description**: Allows fallback from ME mode to direct DC when ME startup fails.
|
||||
- **Description**: Allows Direct-DC fallback when ME is unavailable. With `use_middle_proxy = true`, startup opens Direct-DC routing first and moves new sessions to ME after ME readiness is observed.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -401,14 +432,14 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
```
|
||||
## me2dc_fast
|
||||
- **Constraints / validation**: `bool`. Active only when `use_middle_proxy = true` and `me2dc_fallback = true`.
|
||||
- **Description**: Fast ME->Direct fallback mode for new sessions.
|
||||
- **Description**: Fast ME->Direct fallback mode for new sessions after ME was ready at least once. Initial direct-first startup fallback is controlled by `me2dc_fallback`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
use_middle_proxy = true
|
||||
me2dc_fallback = true
|
||||
me2dc_fast = false
|
||||
me2dc_fast = true
|
||||
```
|
||||
## me_keepalive_enabled
|
||||
- **Constraints / validation**: `bool`.
|
||||
@@ -601,7 +632,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
```
|
||||
## beobachten
|
||||
- **Constraints / validation**: `bool`.
|
||||
- **Description**: Enables per-IP forensic observation buckets.
|
||||
- **Description**: Enables per-IP forensic observation buckets and appends TLS JA3/JA4 fingerprint snapshots to Beobachten output when available.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -610,7 +641,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
```
|
||||
## beobachten_minutes
|
||||
- **Constraints / validation**: Must be `> 0` (minutes).
|
||||
- **Description**: Retention window (minutes) for per-IP observation buckets.
|
||||
- **Description**: Retention window (minutes) for per-IP observation buckets and in-memory TLS fingerprint buckets.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -905,6 +936,15 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
[general]
|
||||
upstream_connect_budget_ms = 3000
|
||||
```
|
||||
## tg_connect
|
||||
- **Constraints / validation**: Must be `> 0` (seconds).
|
||||
- **Description**: Upstream Telegram connect timeout.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
tg_connect = 10
|
||||
```
|
||||
## upstream_unhealthy_fail_threshold
|
||||
- **Constraints / validation**: Must be `> 0`.
|
||||
- **Description**: Consecutive failed requests before upstream is marked unhealthy.
|
||||
@@ -1520,11 +1560,11 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
# [general.modes]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`classic`](#classic) | `bool` | `false` |
|
||||
| [`secure`](#secure) | `bool` | `false` |
|
||||
| [`tls`](#tls) | `bool` | `true` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`classic`](#classic) | `bool` | `false` | `✘` |
|
||||
| [`secure`](#secure) | `bool` | `false` | `✘` |
|
||||
| [`tls`](#tls) | `bool` | `true` | `✘` |
|
||||
|
||||
## classic
|
||||
- **Constraints / validation**: `bool`.
|
||||
@@ -1558,11 +1598,11 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
# [general.links]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`show`](#show) | `"*"` or `String[]` | `"*"` |
|
||||
| [`public_host`](#public_host) | `String` | — |
|
||||
| [`public_port`](#public_port) | `u16` | — |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`show`](#show) | `"*"` or `String[]` | `"*"` | `✘` |
|
||||
| [`public_host`](#public_host) | `String` | — | `✘` |
|
||||
| [`public_port`](#public_port) | `u16` | — | `✘` |
|
||||
|
||||
## show
|
||||
- **Constraints / validation**: `"*"` or `String[]`. An empty array means "show none".
|
||||
@@ -1598,11 +1638,11 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
# [general.telemetry]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`core_enabled`](#core_enabled) | `bool` | `true` |
|
||||
| [`user_enabled`](#user_enabled) | `bool` | `true` |
|
||||
| [`me_level`](#me_level) | `"silent"`, `"normal"`, or `"debug"` | `"normal"` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`core_enabled`](#core_enabled) | `bool` | `true` | `✔` |
|
||||
| [`user_enabled`](#user_enabled) | `bool` | `true` | `✔` |
|
||||
| [`me_level`](#me_level) | `"silent"`, `"normal"`, or `"debug"` | `"normal"` | `✔` |
|
||||
|
||||
## core_enabled
|
||||
- **Constraints / validation**: `bool`.
|
||||
@@ -1636,18 +1676,18 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
# [network]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`ipv4`](#ipv4) | `bool` | `true` |
|
||||
| [`ipv6`](#ipv6) | `bool` | `false` |
|
||||
| [`prefer`](#prefer) | `u8` | `4` |
|
||||
| [`multipath`](#multipath) | `bool` | `false` |
|
||||
| [`stun_use`](#stun_use) | `bool` | `true` |
|
||||
| [`stun_servers`](#stun_servers) | `String[]` | Built-in STUN list (13 hosts) |
|
||||
| [`stun_tcp_fallback`](#stun_tcp_fallback) | `bool` | `true` |
|
||||
| [`http_ip_detect_urls`](#http_ip_detect_urls) | `String[]` | `["https://ifconfig.me/ip", "https://api.ipify.org"]` |
|
||||
| [`cache_public_ip_path`](#cache_public_ip_path) | `String` | `"cache/public_ip.txt"` |
|
||||
| [`dns_overrides`](#dns_overrides) | `String[]` | `[]` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`ipv4`](#ipv4) | `bool` | `true` | `✘` |
|
||||
| [`ipv6`](#ipv6) | `bool` | `false` | `✘` |
|
||||
| [`prefer`](#prefer) | `u8` | `4` | `✘` |
|
||||
| [`multipath`](#multipath) | `bool` | `false` | `✘` |
|
||||
| [`stun_use`](#stun_use) | `bool` | `true` | `✘` |
|
||||
| [`stun_servers`](#stun_servers) | `String[]` | Built-in STUN list (13 hosts) | `✘` |
|
||||
| [`stun_tcp_fallback`](#stun_tcp_fallback) | `bool` | `true` | `✘` |
|
||||
| [`http_ip_detect_urls`](#http_ip_detect_urls) | `String[]` | `["https://ifconfig.me/ip", "https://api.ipify.org"]` | `✘` |
|
||||
| [`cache_public_ip_path`](#cache_public_ip_path) | `String` | `"cache/public_ip.txt"` | `✘` |
|
||||
| [`dns_overrides`](#dns_overrides) | `String[]` | `[]` | `✔` |
|
||||
|
||||
## ipv4
|
||||
- **Constraints / validation**: `bool`.
|
||||
@@ -1757,23 +1797,28 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
# [server]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`port`](#port) | `u16` | `443` |
|
||||
| [`listen_addr_ipv4`](#listen_addr_ipv4) | `String` | `"0.0.0.0"` |
|
||||
| [`listen_addr_ipv6`](#listen_addr_ipv6) | `String` | `"::"` |
|
||||
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — |
|
||||
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — |
|
||||
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) |
|
||||
| [`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[]` | `[]` |
|
||||
| [`metrics_port`](#metrics_port) | `u16` | — |
|
||||
| [`metrics_listen`](#metrics_listen) | `String` | — |
|
||||
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` |
|
||||
| [`max_connections`](#max_connections) | `u32` | `10000` |
|
||||
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` |
|
||||
| [`listen_backlog`](#listen_backlog) | `u32` | `1024` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`port`](#port) | `u16` | `443` | `✘` |
|
||||
| [`listen_addr_ipv4`](#listen_addr_ipv4) | `String` | `"0.0.0.0"` | `✘` |
|
||||
| [`listen_addr_ipv6`](#listen_addr_ipv6) | `String` | `"::"` | `✘` |
|
||||
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `✘` |
|
||||
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` |
|
||||
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` |
|
||||
| [`client_mss`](#client_mss) | `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[]` | `[]` | `✘` |
|
||||
| [`metrics_port`](#metrics_port) | `u16` | — | `✘` |
|
||||
| [`metrics_listen`](#metrics_listen) | `String` | — | `✘` |
|
||||
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` | `✘` |
|
||||
| [`api`](#serverapi) | `Table` | built-in defaults | `✘` |
|
||||
| [`admin_api`](#serverapi) | `Table` | alias for `api` | `✘` |
|
||||
| [`listeners`](#serverlisteners) | `Table[]` | derived from legacy listener fields | `✘` |
|
||||
| [`max_connections`](#max_connections) | `u32` | `10000` | `✘` |
|
||||
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` | `✘` |
|
||||
| [`listen_backlog`](#listen_backlog) | `u32` | `1024` | `✘` |
|
||||
| [`conntrack_control`](#serverconntrack_control) | `Table` | built-in defaults | `✘` |
|
||||
|
||||
## port
|
||||
- **Constraints / validation**: `u16`.
|
||||
@@ -1843,6 +1888,16 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
listen_unix_sock = "/run/telemt.sock"
|
||||
listen_tcp = true
|
||||
```
|
||||
## 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.
|
||||
- **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
```
|
||||
## 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.
|
||||
@@ -1930,16 +1985,16 @@ Note: When `server.proxy_protocol` is enabled, incoming PROXY protocol headers a
|
||||
Note: The conntrack-control worker runs **only on Linux**. On other operating systems it is not started; if `inline_conntrack_control` is `true`, a warning is logged. Effective operation also requires **CAP_NET_ADMIN** and a usable backend (`nft` or `iptables` / `ip6tables` on `PATH`). The `conntrack` utility is used for optional table entry deletes under pressure.
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`inline_conntrack_control`](#inline_conntrack_control) | `bool` | `true` |
|
||||
| [`mode`](#mode) | `String` | `"tracked"` |
|
||||
| [`backend`](#backend) | `String` | `"auto"` |
|
||||
| [`profile`](#profile) | `String` | `"balanced"` |
|
||||
| [`hybrid_listener_ips`](#hybrid_listener_ips) | `IpAddr[]` | `[]` |
|
||||
| [`pressure_high_watermark_pct`](#pressure_high_watermark_pct) | `u8` | `85` |
|
||||
| [`pressure_low_watermark_pct`](#pressure_low_watermark_pct) | `u8` | `70` |
|
||||
| [`delete_budget_per_sec`](#delete_budget_per_sec) | `u64` | `4096` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`inline_conntrack_control`](#inline_conntrack_control) | `bool` | `true` | `✘` |
|
||||
| [`mode`](#mode) | `String` | `"tracked"` | `✘` |
|
||||
| [`backend`](#backend) | `String` | `"auto"` | `✘` |
|
||||
| [`profile`](#profile) | `String` | `"balanced"` | `✘` |
|
||||
| [`hybrid_listener_ips`](#hybrid_listener_ips) | `IpAddr[]` | `[]` | `✘` |
|
||||
| [`pressure_high_watermark_pct`](#pressure_high_watermark_pct) | `u8` | `85` | `✘` |
|
||||
| [`pressure_low_watermark_pct`](#pressure_low_watermark_pct) | `u8` | `70` | `✘` |
|
||||
| [`delete_budget_per_sec`](#delete_budget_per_sec) | `u64` | `4096` | `✘` |
|
||||
|
||||
## inline_conntrack_control
|
||||
- **Constraints / validation**: `bool`.
|
||||
@@ -2021,21 +2076,21 @@ Note: The conntrack-control worker runs **only on Linux**. On other operating sy
|
||||
Note: This section also accepts the legacy alias `[server.admin_api]` (same schema as `[server.api]`).
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`enabled`](#enabled) | `bool` | `true` |
|
||||
| [`listen`](#listen) | `String` | `"0.0.0.0:9091"` |
|
||||
| [`whitelist`](#whitelist) | `IpNetwork[]` | `["127.0.0.0/8"]` |
|
||||
| [`auth_header`](#auth_header) | `String` | `""` |
|
||||
| [`request_body_limit_bytes`](#request_body_limit_bytes) | `usize` | `65536` |
|
||||
| [`minimal_runtime_enabled`](#minimal_runtime_enabled) | `bool` | `true` |
|
||||
| [`minimal_runtime_cache_ttl_ms`](#minimal_runtime_cache_ttl_ms) | `u64` | `1000` |
|
||||
| [`runtime_edge_enabled`](#runtime_edge_enabled) | `bool` | `false` |
|
||||
| [`runtime_edge_cache_ttl_ms`](#runtime_edge_cache_ttl_ms) | `u64` | `1000` |
|
||||
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` |
|
||||
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` |
|
||||
| [`read_only`](#read_only) | `bool` | `false` |
|
||||
| [`gray_action`](#gray_action) | `"drop"`, `"api"`, or `"200"` | `"drop"` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`enabled`](#enabled) | `bool` | `true` | `✘` |
|
||||
| [`listen`](#listen) | `String` | `"0.0.0.0:9091"` | `✘` |
|
||||
| [`whitelist`](#whitelist) | `IpNetwork[]` | `["127.0.0.0/8"]` | `✘` |
|
||||
| [`auth_header`](#auth_header) | `String` | `""` | `✘` |
|
||||
| [`request_body_limit_bytes`](#request_body_limit_bytes) | `usize` | `65536` | `✘` |
|
||||
| [`minimal_runtime_enabled`](#minimal_runtime_enabled) | `bool` | `true` | `✘` |
|
||||
| [`minimal_runtime_cache_ttl_ms`](#minimal_runtime_cache_ttl_ms) | `u64` | `1000` | `✘` |
|
||||
| [`runtime_edge_enabled`](#runtime_edge_enabled) | `bool` | `false` | `✘` |
|
||||
| [`runtime_edge_cache_ttl_ms`](#runtime_edge_cache_ttl_ms) | `u64` | `1000` | `✘` |
|
||||
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` | `✘` |
|
||||
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` | `✘` |
|
||||
| [`read_only`](#read_only) | `bool` | `false` | `✘` |
|
||||
| [`gray_action`](#gray_action) | `"drop"`, `"api"`, or `"200"` | `"drop"` | `✘` |
|
||||
|
||||
## enabled
|
||||
- **Constraints / validation**: `bool`.
|
||||
@@ -2129,7 +2184,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
```
|
||||
## runtime_edge_top_n
|
||||
- **Constraints / validation**: `1..=1000`.
|
||||
- **Description**: Top-N size for edge connection leaderboard.
|
||||
- **Description**: Top-N size for edge connection and TLS fingerprint leaderboard snapshots.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
@@ -2159,13 +2214,15 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
# [[server.listeners]]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`ip`](#ip) | `IpAddr` | — |
|
||||
| [`announce`](#announce) | `String` | — |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — |
|
||||
| [`reuse_allow`](#reuse_allow) | `bool` | `false` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`ip`](#ip) | `IpAddr` | — | `✘` |
|
||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
| [`reuse_allow`](#reuse_allow) | `bool` | `false` | `✘` |
|
||||
|
||||
## ip
|
||||
- **Constraints / validation**: Required field. Must be an `IpAddr`.
|
||||
@@ -2176,6 +2233,27 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
```
|
||||
## port (server.listeners)
|
||||
- **Constraints / validation**: `u16` (optional). When omitted, falls back to `server.port`.
|
||||
- **Description**: Per-listener TCP port.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
```
|
||||
## client_mss (server.listeners)
|
||||
- **Constraints / validation**: `String` (optional). Same values as `[server].client_mss`.
|
||||
- **Description**: Per-listener MSS override. When omitted, inherits `[server].client_mss`; when set to an empty string, disables MSS shaping for this listener even if the global value is set. Changes require listener restart/rebind.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
client_mss = "256"
|
||||
```
|
||||
## announce
|
||||
- **Constraints / validation**: `String` (optional). Must not be empty when set.
|
||||
- **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`.
|
||||
@@ -2209,8 +2287,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
ip = "0.0.0.0"
|
||||
proxy_protocol = true
|
||||
```
|
||||
## reuse_allow"
|
||||
- `reuse_allow`
|
||||
## reuse_allow
|
||||
- **Constraints / validation**: `bool`.
|
||||
- **Description**: Enables `SO_REUSEPORT` for multi-instance bind sharing (allows multiple telemt instances to listen on the same `ip:port`).
|
||||
- **Example**:
|
||||
@@ -2225,18 +2302,18 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
# [timeouts]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`client_handshake`](#client_handshake) | `u64` | `30` |
|
||||
| [`relay_idle_policy_v2_enabled`](#relay_idle_policy_v2_enabled) | `bool` | `true` |
|
||||
| [`relay_client_idle_soft_secs`](#relay_client_idle_soft_secs) | `u64` | `120` |
|
||||
| [`relay_client_idle_hard_secs`](#relay_client_idle_hard_secs) | `u64` | `360` |
|
||||
| [`relay_idle_grace_after_downstream_activity_secs`](#relay_idle_grace_after_downstream_activity_secs) | `u64` | `30` |
|
||||
| [`tg_connect`](#tg_connect) | `u64` | `10` |
|
||||
| [`client_keepalive`](#client_keepalive) | `u64` | `15` |
|
||||
| [`client_ack`](#client_ack) | `u64` | `90` |
|
||||
| [`me_one_retry`](#me_one_retry) | `u8` | `12` |
|
||||
| [`me_one_timeout_ms`](#me_one_timeout_ms) | `u64` | `1200` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`client_first_byte_idle_secs`](#client_first_byte_idle_secs) | `u64` | `300` | `✘` |
|
||||
| [`client_handshake`](#client_handshake) | `u64` | `30` | `✘` |
|
||||
| [`relay_idle_policy_v2_enabled`](#relay_idle_policy_v2_enabled) | `bool` | `true` | `✘` |
|
||||
| [`relay_client_idle_soft_secs`](#relay_client_idle_soft_secs) | `u64` | `120` | `✘` |
|
||||
| [`relay_client_idle_hard_secs`](#relay_client_idle_hard_secs) | `u64` | `360` | `✘` |
|
||||
| [`relay_idle_grace_after_downstream_activity_secs`](#relay_idle_grace_after_downstream_activity_secs) | `u64` | `30` | `✘` |
|
||||
| [`client_keepalive`](#client_keepalive) | `u64` | `15` | `✘` |
|
||||
| [`client_ack`](#client_ack) | `u64` | `90` | `✘` |
|
||||
| [`me_one_retry`](#me_one_retry) | `u8` | `12` | `✘` |
|
||||
| [`me_one_timeout_ms`](#me_one_timeout_ms) | `u64` | `1200` | `✘` |
|
||||
|
||||
## client_handshake
|
||||
- **Constraints / validation**: Must be `> 0`. Value is in seconds. Also used as an upper bound for some TLS emulation delays (see `censorship.server_hello_delay_max_ms`).
|
||||
@@ -2292,15 +2369,6 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
[timeouts]
|
||||
relay_idle_grace_after_downstream_activity_secs = 30
|
||||
```
|
||||
## tg_connect
|
||||
- **Constraints / validation**: `u64`. Value is in seconds.
|
||||
- **Description**: Upstream Telegram connect timeout (seconds).
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[timeouts]
|
||||
tg_connect = 10
|
||||
```
|
||||
## client_keepalive
|
||||
- **Constraints / validation**: `u64`. Value is in seconds.
|
||||
- **Description**: Client keepalive timeout (seconds).
|
||||
@@ -2342,40 +2410,40 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
# [censorship]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
|
||||
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
|
||||
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` |
|
||||
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` |
|
||||
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
|
||||
| [`mask`](#mask) | `bool` | `true` |
|
||||
| [`mask_host`](#mask_host) | `String` | — |
|
||||
| [`mask_port`](#mask_port) | `u16` | `443` |
|
||||
| [`mask_unix_sock`](#mask_unix_sock) | `String` | — |
|
||||
| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` |
|
||||
| [`tls_emulation`](#tls_emulation) | `bool` | `true` |
|
||||
| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` |
|
||||
| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` |
|
||||
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
|
||||
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
|
||||
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
|
||||
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` |
|
||||
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
|
||||
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
|
||||
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
|
||||
| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` |
|
||||
| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` |
|
||||
| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` |
|
||||
| [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` |
|
||||
| [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` |
|
||||
| [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` |
|
||||
| [`mask_relay_timeout_ms`](#mask_relay_timeout_ms) | `u64` | `60_000` |
|
||||
| [`mask_relay_idle_timeout_ms`](#mask_relay_idle_timeout_ms) | `u64` | `5_000` |
|
||||
| [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` |
|
||||
| [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` |
|
||||
| [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` |
|
||||
| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `✘` |
|
||||
| [`tls_domains`](#tls_domains) | `String[]` | `[]` | `✘` |
|
||||
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` | `✘` |
|
||||
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` | `✘` |
|
||||
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults | `✘` |
|
||||
| [`mask`](#mask) | `bool` | `true` | `✘` |
|
||||
| [`mask_host`](#mask_host) | `String` | — | `✘` |
|
||||
| [`mask_port`](#mask_port) | `u16` | `443` | `✘` |
|
||||
| [`mask_unix_sock`](#mask_unix_sock) | `String` | — | `✘` |
|
||||
| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` | `✘` |
|
||||
| [`tls_emulation`](#tls_emulation) | `bool` | `true` | `✘` |
|
||||
| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` | `✘` |
|
||||
| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` | `✘` |
|
||||
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` | `✘` |
|
||||
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` | `✘` |
|
||||
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` | `✘` |
|
||||
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` | `✘` |
|
||||
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` | `✘` |
|
||||
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` | `✘` |
|
||||
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` | `✘` |
|
||||
| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` | `✘` |
|
||||
| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` | `✘` |
|
||||
| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` | `✘` |
|
||||
| [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` | `✘` |
|
||||
| [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` | `✘` |
|
||||
| [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` | `✘` |
|
||||
| [`mask_relay_timeout_ms`](#mask_relay_timeout_ms) | `u64` | `60_000` | `✘` |
|
||||
| [`mask_relay_idle_timeout_ms`](#mask_relay_idle_timeout_ms) | `u64` | `5_000` | `✘` |
|
||||
| [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` | `✘` |
|
||||
| [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` | `✘` |
|
||||
| [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` | `✘` |
|
||||
| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` | `✘` |
|
||||
|
||||
## tls_domain
|
||||
- **Constraints / validation**: Must be a non-empty domain name. Must not contain spaces or `/`.
|
||||
@@ -2459,6 +2527,18 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
||||
[censorship]
|
||||
mask_port = 443
|
||||
```
|
||||
## exclusive_mask
|
||||
- **Constraints / validation**: TOML map. Keys must be SNI domain names. Values must be `host:port` with `port > 0`; IPv6 literals must be bracketed.
|
||||
- **Description**: Per-SNI TCP mask targets for fallback traffic. When a TLS ClientHello SNI matches a key, Telemt relays that unauthenticated connection to the mapped target. Other fallback traffic keeps using the existing `mask_host`/`mask_port` or SNI-aware default masking behavior.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[censorship]
|
||||
tls_domains = ["petrovich.ru", "bsi.bund.de", "telekom.com"]
|
||||
|
||||
[censorship.exclusive_mask]
|
||||
"bsi.bund.de" = "127.0.0.1:443"
|
||||
```
|
||||
## mask_unix_sock
|
||||
- **Constraints / validation**: `String` (optional).
|
||||
- Must not be empty when set.
|
||||
@@ -2797,15 +2877,15 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
# [censorship.tls_fetch]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`profiles`](#profiles) | `String[]` | `["modern_chrome_like", "modern_firefox_like", "compat_tls12", "legacy_minimal"]` |
|
||||
| [`strict_route`](#strict_route) | `bool` | `true` |
|
||||
| [`attempt_timeout_ms`](#attempt_timeout_ms) | `u64` | `5000` |
|
||||
| [`total_budget_ms`](#total_budget_ms) | `u64` | `15000` |
|
||||
| [`grease_enabled`](#grease_enabled) | `bool` | `false` |
|
||||
| [`deterministic`](#deterministic) | `bool` | `false` |
|
||||
| [`profile_cache_ttl_secs`](#profile_cache_ttl_secs) | `u64` | `600` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`profiles`](#profiles) | `String[]` | `["modern_chrome_like", "modern_firefox_like", "compat_tls12", "legacy_minimal"]` | `✘` |
|
||||
| [`strict_route`](#strict_route) | `bool` | `true` | `✘` |
|
||||
| [`attempt_timeout_ms`](#attempt_timeout_ms) | `u64` | `5000` | `✘` |
|
||||
| [`total_budget_ms`](#total_budget_ms) | `u64` | `15000` | `✘` |
|
||||
| [`grease_enabled`](#grease_enabled) | `bool` | `false` | `✘` |
|
||||
| [`deterministic`](#deterministic) | `bool` | `false` | `✘` |
|
||||
| [`profile_cache_ttl_secs`](#profile_cache_ttl_secs) | `u64` | `600` | `✘` |
|
||||
|
||||
## profiles
|
||||
- **Constraints / validation**: `String[]`. Empty list falls back to defaults; values are deduplicated preserving order.
|
||||
@@ -2874,24 +2954,25 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
# [access]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` |
|
||||
| [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` |
|
||||
| [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` |
|
||||
| [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` |
|
||||
| [`user_expirations`](#user_expirations) | `Map<String, DateTime<Utc>>` | `{}` |
|
||||
| [`user_data_quota`](#user_data_quota) | `Map<String, u64>` | `{}` |
|
||||
| [`user_max_unique_ips`](#user_max_unique_ips) | `Map<String, usize>` | `{}` |
|
||||
| [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` |
|
||||
| [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` |
|
||||
| [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` |
|
||||
| [`user_source_deny`](#user_source_deny) | `Map<String, IpNetwork[]>` | `{}` |
|
||||
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` |
|
||||
| [`replay_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>` | `{}` |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` | `✔` |
|
||||
| [`user_enabled`](#user_enabled-1) | `Map<String, bool>` | `{}` | `✔` |
|
||||
| [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` | `✔` |
|
||||
| [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` | `✔` |
|
||||
| [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` | `✔` |
|
||||
| [`user_expirations`](#user_expirations) | `Map<String, DateTime<Utc>>` | `{}` | `✔` |
|
||||
| [`user_data_quota`](#user_data_quota) | `Map<String, u64>` | `{}` | `✔` |
|
||||
| [`user_max_unique_ips`](#user_max_unique_ips) | `Map<String, usize>` | `{}` | `✔` |
|
||||
| [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` | `✔` |
|
||||
| [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` | `✔` |
|
||||
| [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` | `✔` |
|
||||
| [`user_source_deny`](#user_source_deny) | `Map<String, IpNetwork[]>` | `{}` | `✘` |
|
||||
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` | `✘` |
|
||||
| [`replay_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>` | `{}` | `✔` |
|
||||
|
||||
## users
|
||||
- **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**.
|
||||
@@ -2903,6 +2984,16 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
alice = "00112233445566778899aabbccddeeff"
|
||||
bob = "0123456789abcdef0123456789abcdef"
|
||||
```
|
||||
## user_enabled
|
||||
- **Constraints / validation**: `Map<String, bool>`.
|
||||
- **Description**: Optional per-user enable overrides. Missing users are enabled by default. A value of `false` disables new sessions for that user; setting the value to `true` is accepted but equivalent to removing the override. API enable operations remove the override, while disable operations write `false`.
|
||||
- **Runtime behavior**: Hot reload applies this map immediately. Users disabled through API or config reload are rejected after successful authentication and active runtime sessions for that username are cancelled.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[access.user_enabled]
|
||||
alice = false
|
||||
```
|
||||
## user_ad_tags
|
||||
- **Constraints / validation**: Each value must be **exactly 32 hex characters** (same format as `general.ad_tag`). An all-zero tag is allowed but logs a warning.
|
||||
- **Description**: Per-user sponsored-channel ad tag override. When a user has an entry here, it takes precedence over `general.ad_tag`.
|
||||
@@ -3055,19 +3146,24 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
# [[upstreams]]
|
||||
|
||||
|
||||
| Key | Type | Default |
|
||||
| --- | ---- | ------- |
|
||||
| [`type`](#type) | `"direct"`, `"socks4"`, `"socks5"`, or `"shadowsocks"` | — |
|
||||
| [`weight`](#weight) | `u16` | `1` |
|
||||
| [`enabled`](#enabled) | `bool` | `true` |
|
||||
| [`scopes`](#scopes) | `String` | `""` |
|
||||
| [`interface`](#interface) | `String` | — |
|
||||
| [`bind_addresses`](#bind_addresses) | `String[]` | — |
|
||||
| [`url`](#url) | `String` | — |
|
||||
| [`address`](#address) | `String` | — |
|
||||
| [`user_id`](#user_id) | `String` | — |
|
||||
| [`username`](#username) | `String` | — |
|
||||
| [`password`](#password) | `String` | — |
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`type`](#type) | `"direct"`, `"socks4"`, `"socks5"`, or `"shadowsocks"` | — | `✘` |
|
||||
| [`weight`](#weight) | `u16` | `1` | `✘` |
|
||||
| [`enabled`](#enabled) | `bool` | `true` | `✘` |
|
||||
| [`scopes`](#scopes) | `String` | `""` | `✘` |
|
||||
| [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `✘` |
|
||||
| [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `✘` |
|
||||
| [`prefer`](#prefer-upstreams) | `4` or `6` | effective `[network].prefer` | `✘` |
|
||||
| [`interface`](#interface) | `String` | — | `✘` |
|
||||
| [`bind_addresses`](#bind_addresses) | `String[]` | — | `✘` |
|
||||
| [`bindtodevice`](#bindtodevice) | `String` | — | `✘` |
|
||||
| [`force_bind`](#force_bind) | `String` | — | `✘` |
|
||||
| [`url`](#url) | `String` | — | `✘` |
|
||||
| [`address`](#address) | `String` | — | `✘` |
|
||||
| [`user_id`](#user_id) | `String` | — | `✘` |
|
||||
| [`username`](#username) | `String` | — | `✘` |
|
||||
| [`password`](#password) | `String` | — | `✘` |
|
||||
|
||||
## type
|
||||
- **Constraints / validation**: Required field. Must be one of: `"direct"`, `"socks4"`, `"socks5"`, `"shadowsocks"`.
|
||||
@@ -3118,6 +3214,38 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
address = "10.0.0.10:1080"
|
||||
scopes = "me, fetch, dc2"
|
||||
```
|
||||
## ipv4 (upstreams)
|
||||
- **Constraints / validation**: `bool` (optional).
|
||||
- **Description**: Allows IPv4 DC targets for this upstream. When omitted, Telemt auto-detects support from runtime connectivity state.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
ipv4 = true
|
||||
```
|
||||
## ipv6 (upstreams)
|
||||
- **Constraints / validation**: `bool` (optional).
|
||||
- **Description**: Allows IPv6 DC targets for this upstream. When omitted, Telemt auto-detects support from runtime connectivity state. Set this to `true` when the upstream proxy is reachable from the local host over IPv4 but the proxy itself can connect to Telegram DCs over IPv6.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
ipv6 = false
|
||||
```
|
||||
## prefer (upstreams)
|
||||
- **Constraints / validation**: Optional integer. Must be `4` or `6`.
|
||||
- **Description**: Overrides the IP family preference for Telegram DC targets selected through this upstream. When omitted, the upstream inherits the effective global `[network].prefer` decision. Use `prefer = 6` together with `ipv6 = true` for a SOCKS or Shadowsocks upstream that can egress over IPv6 even when the local Telemt host is IPv4-only.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "socks5"
|
||||
address = "192.0.2.10:1080"
|
||||
ipv6 = true
|
||||
prefer = 6
|
||||
```
|
||||
## interface
|
||||
- **Constraints / validation**: `String` (optional).
|
||||
- For `"direct"`: may be an IP address (used as explicit local bind) or an OS interface name (resolved to an IP at runtime; Unix only).
|
||||
@@ -3148,6 +3276,26 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
|
||||
type = "direct"
|
||||
bind_addresses = ["192.0.2.10", "192.0.2.11"]
|
||||
```
|
||||
## bindtodevice
|
||||
- **Constraints / validation**: `String` (optional). Applies only to `type = "direct"` and is Linux-only.
|
||||
- **Description**: Hard interface pinning via `SO_BINDTODEVICE` for outgoing direct TCP connects.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
bindtodevice = "eth0"
|
||||
```
|
||||
## force_bind
|
||||
- **Constraints / validation**: `String` (optional). Alias for `bindtodevice`.
|
||||
- **Description**: Backward-compatible alias for Linux `SO_BINDTODEVICE` hard interface pinning.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
force_bind = "eth0"
|
||||
```
|
||||
## url
|
||||
- **Constraints / validation**: Applies only to `type = "shadowsocks"`.
|
||||
- Must be a valid Shadowsocks URL accepted by the `shadowsocks` crate.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
>
|
||||
> Параметры конфигурации, подробно описанные в этом документе, предназначены для опытных пользователей и для целей тонкой настройки. Изменение этих параметров без четкого понимания их функции может привести к нестабильности приложения или другому неожиданному поведению. Пожалуйста, действуйте осторожно и на свой страх и риск.
|
||||
|
||||
> `Hot-Reload` показывает, применяет ли config watcher изменение без перезапуска процесса; `✘` означает, что для runtime-эффекта нужен перезапуск.
|
||||
|
||||
# Содержание
|
||||
- [Ключи верхнего уровня](#top-level-keys)
|
||||
- [general](#general)
|
||||
@@ -29,12 +31,16 @@
|
||||
|
||||
# Ключи верхнего уровня
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`include`](#include) | `String` (специальная директива) | — |
|
||||
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) |
|
||||
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` |
|
||||
| [`default_dc`](#default_dc) | `u8` | — (эффективный резервный вариант: `2` в ME маршрутизации) |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`include`](#include) | `String` (специальная директива) | — | `✔` |
|
||||
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
|
||||
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
|
||||
| [`default_dc`](#default_dc) | `u8` | — (эффективный резервный вариант: `2` в ME маршрутизации) | `✘` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
|
||||
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` | `✘` |
|
||||
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` | `✘` |
|
||||
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` | `✘` |
|
||||
|
||||
## include
|
||||
- **Ограничения / валидация**: значение должно быть одной строкой в виде `include = "path/to/file.toml"`. Значения параметра обрабатываются перед анализом TOML. Максимальное количество - 10.
|
||||
@@ -79,145 +85,152 @@
|
||||
|
||||
# [general]
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`data_path`](#data_path) | `String` | — |
|
||||
| [`prefer_ipv6`](#prefer_ipv6) | `bool` | `false` |
|
||||
| [`fast_mode`](#fast_mode) | `bool` | `true` |
|
||||
| [`use_middle_proxy`](#use_middle_proxy) | `bool` | `true` |
|
||||
| [`proxy_secret_path`](#proxy_secret_path) | `String` | `"proxy-secret"` |
|
||||
| [`proxy_config_v4_cache_path`](#proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` |
|
||||
| [`proxy_config_v6_cache_path`](#proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` |
|
||||
| [`ad_tag`](#ad_tag) | `String` | — |
|
||||
| [`middle_proxy_nat_ip`](#middle_proxy_nat_ip) | `IpAddr` | — |
|
||||
| [`middle_proxy_nat_probe`](#middle_proxy_nat_probe) | `bool` | `true` |
|
||||
| [`middle_proxy_nat_stun`](#middle_proxy_nat_stun) | `String` | — |
|
||||
| [`middle_proxy_nat_stun_servers`](#middle_proxy_nat_stun_servers) | `String[]` | `[]` |
|
||||
| [`stun_nat_probe_concurrency`](#stun_nat_probe_concurrency) | `usize` | `8` |
|
||||
| [`middle_proxy_pool_size`](#middle_proxy_pool_size) | `usize` | `8` |
|
||||
| [`middle_proxy_warm_standby`](#middle_proxy_warm_standby) | `usize` | `16` |
|
||||
| [`me_init_retry_attempts`](#me_init_retry_attempts) | `u32` | `0` |
|
||||
| [`me2dc_fallback`](#me2dc_fallback) | `bool` | `true` |
|
||||
| [`me2dc_fast`](#me2dc_fast) | `bool` | `false` |
|
||||
| [`me_keepalive_enabled`](#me_keepalive_enabled) | `bool` | `true` |
|
||||
| [`me_keepalive_interval_secs`](#me_keepalive_interval_secs) | `u64` | `8` |
|
||||
| [`me_keepalive_jitter_secs`](#me_keepalive_jitter_secs) | `u64` | `2` |
|
||||
| [`me_keepalive_payload_random`](#me_keepalive_payload_random) | `bool` | `true` |
|
||||
| [`rpc_proxy_req_every`](#rpc_proxy_req_every) | `u64` | `0` |
|
||||
| [`me_writer_cmd_channel_capacity`](#me_writer_cmd_channel_capacity) | `usize` | `4096` |
|
||||
| [`me_route_channel_capacity`](#me_route_channel_capacity) | `usize` | `768` |
|
||||
| [`me_c2me_channel_capacity`](#me_c2me_channel_capacity) | `usize` | `1024` |
|
||||
| [`me_c2me_send_timeout_ms`](#me_c2me_send_timeout_ms) | `u64` | `4000` |
|
||||
| [`me_reader_route_data_wait_ms`](#me_reader_route_data_wait_ms) | `u64` | `2` |
|
||||
| [`me_d2c_flush_batch_max_frames`](#me_d2c_flush_batch_max_frames) | `usize` | `32` |
|
||||
| [`me_d2c_flush_batch_max_bytes`](#me_d2c_flush_batch_max_bytes) | `usize` | `131072` |
|
||||
| [`me_d2c_flush_batch_max_delay_us`](#me_d2c_flush_batch_max_delay_us) | `u64` | `500` |
|
||||
| [`me_d2c_ack_flush_immediate`](#me_d2c_ack_flush_immediate) | `bool` | `true` |
|
||||
| [`me_quota_soft_overshoot_bytes`](#me_quota_soft_overshoot_bytes) | `u64` | `65536` |
|
||||
| [`me_d2c_frame_buf_shrink_threshold_bytes`](#me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` |
|
||||
| [`direct_relay_copy_buf_c2s_bytes`](#direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` |
|
||||
| [`direct_relay_copy_buf_s2c_bytes`](#direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` |
|
||||
| [`crypto_pending_buffer`](#crypto_pending_buffer) | `usize` | `262144` |
|
||||
| [`max_client_frame`](#max_client_frame) | `usize` | `16777216` |
|
||||
| [`desync_all_full`](#desync_all_full) | `bool` | `false` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` |
|
||||
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` |
|
||||
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` |
|
||||
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` |
|
||||
| [`hardswap`](#hardswap) | `bool` | `true` |
|
||||
| [`me_warmup_stagger_enabled`](#me_warmup_stagger_enabled) | `bool` | `true` |
|
||||
| [`me_warmup_step_delay_ms`](#me_warmup_step_delay_ms) | `u64` | `500` |
|
||||
| [`me_warmup_step_jitter_ms`](#me_warmup_step_jitter_ms) | `u64` | `300` |
|
||||
| [`me_reconnect_max_concurrent_per_dc`](#me_reconnect_max_concurrent_per_dc) | `u32` | `8` |
|
||||
| [`me_reconnect_backoff_base_ms`](#me_reconnect_backoff_base_ms) | `u64` | `500` |
|
||||
| [`me_reconnect_backoff_cap_ms`](#me_reconnect_backoff_cap_ms) | `u64` | `30000` |
|
||||
| [`me_reconnect_fast_retry_count`](#me_reconnect_fast_retry_count) | `u32` | `16` |
|
||||
| [`me_single_endpoint_shadow_writers`](#me_single_endpoint_shadow_writers) | `u8` | `2` |
|
||||
| [`me_single_endpoint_outage_mode_enabled`](#me_single_endpoint_outage_mode_enabled) | `bool` | `true` |
|
||||
| [`me_single_endpoint_outage_disable_quarantine`](#me_single_endpoint_outage_disable_quarantine) | `bool` | `true` |
|
||||
| [`me_single_endpoint_outage_backoff_min_ms`](#me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` |
|
||||
| [`me_single_endpoint_outage_backoff_max_ms`](#me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` |
|
||||
| [`me_single_endpoint_shadow_rotate_every_secs`](#me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` |
|
||||
| [`me_floor_mode`](#me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` |
|
||||
| [`me_adaptive_floor_idle_secs`](#me_adaptive_floor_idle_secs) | `u64` | `90` |
|
||||
| [`me_adaptive_floor_min_writers_single_endpoint`](#me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` |
|
||||
| [`me_adaptive_floor_min_writers_multi_endpoint`](#me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` |
|
||||
| [`me_adaptive_floor_recover_grace_secs`](#me_adaptive_floor_recover_grace_secs) | `u64` | `180` |
|
||||
| [`me_adaptive_floor_writers_per_core_total`](#me_adaptive_floor_writers_per_core_total) | `u16` | `48` |
|
||||
| [`me_adaptive_floor_cpu_cores_override`](#me_adaptive_floor_cpu_cores_override) | `u16` | `0` |
|
||||
| [`me_adaptive_floor_max_extra_writers_single_per_core`](#me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` |
|
||||
| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` |
|
||||
| [`me_adaptive_floor_max_active_writers_per_core`](#me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` |
|
||||
| [`me_adaptive_floor_max_warm_writers_per_core`](#me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` |
|
||||
| [`me_adaptive_floor_max_active_writers_global`](#me_adaptive_floor_max_active_writers_global) | `u32` | `256` |
|
||||
| [`me_adaptive_floor_max_warm_writers_global`](#me_adaptive_floor_max_warm_writers_global) | `u32` | `256` |
|
||||
| [`upstream_connect_retry_attempts`](#upstream_connect_retry_attempts) | `u32` | `2` |
|
||||
| [`upstream_connect_retry_backoff_ms`](#upstream_connect_retry_backoff_ms) | `u64` | `100` |
|
||||
| [`upstream_connect_budget_ms`](#upstream_connect_budget_ms) | `u64` | `3000` |
|
||||
| [`upstream_unhealthy_fail_threshold`](#upstream_unhealthy_fail_threshold) | `u32` | `5` |
|
||||
| [`upstream_connect_failfast_hard_errors`](#upstream_connect_failfast_hard_errors) | `bool` | `false` |
|
||||
| [`stun_iface_mismatch_ignore`](#stun_iface_mismatch_ignore) | `bool` | `false` |
|
||||
| [`unknown_dc_log_path`](#unknown_dc_log_path) | `String` | `"unknown-dc.txt"` |
|
||||
| [`unknown_dc_file_log_enabled`](#unknown_dc_file_log_enabled) | `bool` | `false` |
|
||||
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
|
||||
| [`disable_colors`](#disable_colors) | `bool` | `false` |
|
||||
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
|
||||
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` |
|
||||
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` |
|
||||
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
|
||||
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
|
||||
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
|
||||
| [`me_health_interval_ms_unhealthy`](#me_health_interval_ms_unhealthy) | `u64` | `1000` |
|
||||
| [`me_health_interval_ms_healthy`](#me_health_interval_ms_healthy) | `u64` | `3000` |
|
||||
| [`me_admission_poll_ms`](#me_admission_poll_ms) | `u64` | `1000` |
|
||||
| [`me_warn_rate_limit_ms`](#me_warn_rate_limit_ms) | `u64` | `5000` |
|
||||
| [`me_route_no_writer_mode`](#me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` |
|
||||
| [`me_route_no_writer_wait_ms`](#me_route_no_writer_wait_ms) | `u64` | `250` |
|
||||
| [`me_route_hybrid_max_wait_ms`](#me_route_hybrid_max_wait_ms) | `u64` | `3000` |
|
||||
| [`me_route_blocking_send_timeout_ms`](#me_route_blocking_send_timeout_ms) | `u64` | `250` |
|
||||
| [`me_route_inline_recovery_attempts`](#me_route_inline_recovery_attempts) | `u32` | `3` |
|
||||
| [`me_route_inline_recovery_wait_ms`](#me_route_inline_recovery_wait_ms) | `u64` | `3000` |
|
||||
| [`fast_mode_min_tls_record`](#fast_mode_min_tls_record) | `usize` | `0` |
|
||||
| [`update_every`](#update_every) | `u64` | `300` |
|
||||
| [`me_reinit_every_secs`](#me_reinit_every_secs) | `u64` | `900` |
|
||||
| [`me_hardswap_warmup_delay_min_ms`](#me_hardswap_warmup_delay_min_ms) | `u64` | `1000` |
|
||||
| [`me_hardswap_warmup_delay_max_ms`](#me_hardswap_warmup_delay_max_ms) | `u64` | `2000` |
|
||||
| [`me_hardswap_warmup_extra_passes`](#me_hardswap_warmup_extra_passes) | `u8` | `3` |
|
||||
| [`me_hardswap_warmup_pass_backoff_base_ms`](#me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` |
|
||||
| [`me_config_stable_snapshots`](#me_config_stable_snapshots) | `u8` | `2` |
|
||||
| [`me_config_apply_cooldown_secs`](#me_config_apply_cooldown_secs) | `u64` | `300` |
|
||||
| [`me_snapshot_require_http_2xx`](#me_snapshot_require_http_2xx) | `bool` | `true` |
|
||||
| [`me_snapshot_reject_empty_map`](#me_snapshot_reject_empty_map) | `bool` | `true` |
|
||||
| [`me_snapshot_min_proxy_for_lines`](#me_snapshot_min_proxy_for_lines) | `u32` | `1` |
|
||||
| [`proxy_secret_stable_snapshots`](#proxy_secret_stable_snapshots) | `u8` | `2` |
|
||||
| [`proxy_secret_rotate_runtime`](#proxy_secret_rotate_runtime) | `bool` | `true` |
|
||||
| [`me_secret_atomic_snapshot`](#me_secret_atomic_snapshot) | `bool` | `true` |
|
||||
| [`proxy_secret_len_max`](#proxy_secret_len_max) | `usize` | `256` |
|
||||
| [`me_pool_drain_ttl_secs`](#me_pool_drain_ttl_secs) | `u64` | `90` |
|
||||
| [`me_instadrain`](#me_instadrain) | `bool` | `false` |
|
||||
| [`me_pool_drain_threshold`](#me_pool_drain_threshold) | `u64` | `32` |
|
||||
| [`me_pool_drain_soft_evict_enabled`](#me_pool_drain_soft_evict_enabled) | `bool` | `true` |
|
||||
| [`me_pool_drain_soft_evict_grace_secs`](#me_pool_drain_soft_evict_grace_secs) | `u64` | `10` |
|
||||
| [`me_pool_drain_soft_evict_per_writer`](#me_pool_drain_soft_evict_per_writer) | `u8` | `2` |
|
||||
| [`me_pool_drain_soft_evict_budget_per_core`](#me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` |
|
||||
| [`me_pool_drain_soft_evict_cooldown_ms`](#me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` |
|
||||
| [`me_bind_stale_mode`](#me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` |
|
||||
| [`me_bind_stale_ttl_secs`](#me_bind_stale_ttl_secs) | `u64` | `90` |
|
||||
| [`me_pool_min_fresh_ratio`](#me_pool_min_fresh_ratio) | `f32` | `0.8` |
|
||||
| [`me_reinit_drain_timeout_secs`](#me_reinit_drain_timeout_secs) | `u64` | `90` |
|
||||
| [`proxy_secret_auto_reload_secs`](#proxy_secret_auto_reload_secs) | `u64` | `3600` |
|
||||
| [`proxy_config_auto_reload_secs`](#proxy_config_auto_reload_secs) | `u64` | `3600` |
|
||||
| [`me_reinit_singleflight`](#me_reinit_singleflight) | `bool` | `true` |
|
||||
| [`me_reinit_trigger_channel`](#me_reinit_trigger_channel) | `usize` | `64` |
|
||||
| [`me_reinit_coalesce_window_ms`](#me_reinit_coalesce_window_ms) | `u64` | `200` |
|
||||
| [`me_deterministic_writer_sort`](#me_deterministic_writer_sort) | `bool` | `true` |
|
||||
| [`me_writer_pick_mode`](#me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` |
|
||||
| [`me_writer_pick_sample_size`](#me_writer_pick_sample_size) | `u8` | `3` |
|
||||
| [`ntp_check`](#ntp_check) | `bool` | `true` |
|
||||
| [`ntp_servers`](#ntp_servers) | `String[]` | `["pool.ntp.org"]` |
|
||||
| [`auto_degradation_enabled`](#auto_degradation_enabled) | `bool` | `true` |
|
||||
| [`degradation_min_unavailable_dc_groups`](#degradation_min_unavailable_dc_groups) | `u8` | `2` |
|
||||
| [`rst_on_close`](#rst_on_close) | `"off"`, `"errors"` или `"always"` | `"off"` |
|
||||
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`data_path`](#data_path) | `String` | — | `✘` |
|
||||
| [`quota_state_path`](#quota_state_path) | `Path` | `"telemt.limit.json"` | `✘` |
|
||||
| [`config_strict`](#config_strict) | `bool` | `false` | `✘` |
|
||||
| [`prefer_ipv6`](#prefer_ipv6) | `bool` | `false` | `✘` |
|
||||
| [`fast_mode`](#fast_mode) | `bool` | `true` | `✘` |
|
||||
| [`use_middle_proxy`](#use_middle_proxy) | `bool` | `true` | `✘` |
|
||||
| [`proxy_secret_path`](#proxy_secret_path) | `String` | `"proxy-secret"` | `✘` |
|
||||
| [`proxy_secret_url`](#proxy_secret_url) | `String` | `"https://core.telegram.org/getProxySecret"` | `✘` |
|
||||
| [`proxy_config_v4_cache_path`](#proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` | `✘` |
|
||||
| [`proxy_config_v4_url`](#proxy_config_v4_url) | `String` | `"https://core.telegram.org/getProxyConfig"` | `✘` |
|
||||
| [`proxy_config_v6_cache_path`](#proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` | `✘` |
|
||||
| [`proxy_config_v6_url`](#proxy_config_v6_url) | `String` | `"https://core.telegram.org/getProxyConfigV6"` | `✘` |
|
||||
| [`ad_tag`](#ad_tag) | `String` | — | `✔` |
|
||||
| [`middle_proxy_nat_ip`](#middle_proxy_nat_ip) | `IpAddr` | — | `✘` |
|
||||
| [`middle_proxy_nat_probe`](#middle_proxy_nat_probe) | `bool` | `true` | `✘` |
|
||||
| [`middle_proxy_nat_stun`](#middle_proxy_nat_stun) | `String` | — | `✘` |
|
||||
| [`middle_proxy_nat_stun_servers`](#middle_proxy_nat_stun_servers) | `String[]` | `[]` | `✘` |
|
||||
| [`stun_nat_probe_concurrency`](#stun_nat_probe_concurrency) | `usize` | `8` | `✘` |
|
||||
| [`middle_proxy_pool_size`](#middle_proxy_pool_size) | `usize` | `8` | `✘` |
|
||||
| [`middle_proxy_warm_standby`](#middle_proxy_warm_standby) | `usize` | `16` | `✘` |
|
||||
| [`me_init_retry_attempts`](#me_init_retry_attempts) | `u32` | `0` | `✘` |
|
||||
| [`me2dc_fallback`](#me2dc_fallback) | `bool` | `true` | `✘` |
|
||||
| [`me2dc_fast`](#me2dc_fast) | `bool` | `false` | `✘` |
|
||||
| [`me_keepalive_enabled`](#me_keepalive_enabled) | `bool` | `true` | `✘` |
|
||||
| [`me_keepalive_interval_secs`](#me_keepalive_interval_secs) | `u64` | `8` | `✘` |
|
||||
| [`me_keepalive_jitter_secs`](#me_keepalive_jitter_secs) | `u64` | `2` | `✘` |
|
||||
| [`me_keepalive_payload_random`](#me_keepalive_payload_random) | `bool` | `true` | `✘` |
|
||||
| [`rpc_proxy_req_every`](#rpc_proxy_req_every) | `u64` | `0` | `✘` |
|
||||
| [`me_writer_cmd_channel_capacity`](#me_writer_cmd_channel_capacity) | `usize` | `4096` | `✘` |
|
||||
| [`me_route_channel_capacity`](#me_route_channel_capacity) | `usize` | `768` | `✘` |
|
||||
| [`me_c2me_channel_capacity`](#me_c2me_channel_capacity) | `usize` | `1024` | `✘` |
|
||||
| [`me_c2me_send_timeout_ms`](#me_c2me_send_timeout_ms) | `u64` | `4000` | `✘` |
|
||||
| [`me_reader_route_data_wait_ms`](#me_reader_route_data_wait_ms) | `u64` | `2` | `✔` |
|
||||
| [`me_d2c_flush_batch_max_frames`](#me_d2c_flush_batch_max_frames) | `usize` | `32` | `✔` |
|
||||
| [`me_d2c_flush_batch_max_bytes`](#me_d2c_flush_batch_max_bytes) | `usize` | `131072` | `✔` |
|
||||
| [`me_d2c_flush_batch_max_delay_us`](#me_d2c_flush_batch_max_delay_us) | `u64` | `500` | `✔` |
|
||||
| [`me_d2c_ack_flush_immediate`](#me_d2c_ack_flush_immediate) | `bool` | `true` | `✔` |
|
||||
| [`me_quota_soft_overshoot_bytes`](#me_quota_soft_overshoot_bytes) | `u64` | `65536` | `✔` |
|
||||
| [`me_d2c_frame_buf_shrink_threshold_bytes`](#me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` | `✔` |
|
||||
| [`direct_relay_copy_buf_c2s_bytes`](#direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` | `✔` |
|
||||
| [`direct_relay_copy_buf_s2c_bytes`](#direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` | `✔` |
|
||||
| [`crypto_pending_buffer`](#crypto_pending_buffer) | `usize` | `262144` | `✘` |
|
||||
| [`max_client_frame`](#max_client_frame) | `usize` | `16777216` | `✘` |
|
||||
| [`desync_all_full`](#desync_all_full) | `bool` | `false` | `✔` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
|
||||
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` | `✘` |
|
||||
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` | `✘` |
|
||||
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` | `✘` |
|
||||
| [`hardswap`](#hardswap) | `bool` | `true` | `✔` |
|
||||
| [`me_warmup_stagger_enabled`](#me_warmup_stagger_enabled) | `bool` | `true` | `✘` |
|
||||
| [`me_warmup_step_delay_ms`](#me_warmup_step_delay_ms) | `u64` | `500` | `✘` |
|
||||
| [`me_warmup_step_jitter_ms`](#me_warmup_step_jitter_ms) | `u64` | `300` | `✘` |
|
||||
| [`me_reconnect_max_concurrent_per_dc`](#me_reconnect_max_concurrent_per_dc) | `u32` | `8` | `✘` |
|
||||
| [`me_reconnect_backoff_base_ms`](#me_reconnect_backoff_base_ms) | `u64` | `500` | `✘` |
|
||||
| [`me_reconnect_backoff_cap_ms`](#me_reconnect_backoff_cap_ms) | `u64` | `30000` | `✘` |
|
||||
| [`me_reconnect_fast_retry_count`](#me_reconnect_fast_retry_count) | `u32` | `16` | `✘` |
|
||||
| [`me_single_endpoint_shadow_writers`](#me_single_endpoint_shadow_writers) | `u8` | `2` | `✔` |
|
||||
| [`me_single_endpoint_outage_mode_enabled`](#me_single_endpoint_outage_mode_enabled) | `bool` | `true` | `✔` |
|
||||
| [`me_single_endpoint_outage_disable_quarantine`](#me_single_endpoint_outage_disable_quarantine) | `bool` | `true` | `✔` |
|
||||
| [`me_single_endpoint_outage_backoff_min_ms`](#me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` | `✔` |
|
||||
| [`me_single_endpoint_outage_backoff_max_ms`](#me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` | `✔` |
|
||||
| [`me_single_endpoint_shadow_rotate_every_secs`](#me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` | `✔` |
|
||||
| [`me_floor_mode`](#me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` | `✔` |
|
||||
| [`me_adaptive_floor_idle_secs`](#me_adaptive_floor_idle_secs) | `u64` | `90` | `✔` |
|
||||
| [`me_adaptive_floor_min_writers_single_endpoint`](#me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` | `✔` |
|
||||
| [`me_adaptive_floor_min_writers_multi_endpoint`](#me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` | `✔` |
|
||||
| [`me_adaptive_floor_recover_grace_secs`](#me_adaptive_floor_recover_grace_secs) | `u64` | `180` | `✔` |
|
||||
| [`me_adaptive_floor_writers_per_core_total`](#me_adaptive_floor_writers_per_core_total) | `u16` | `48` | `✔` |
|
||||
| [`me_adaptive_floor_cpu_cores_override`](#me_adaptive_floor_cpu_cores_override) | `u16` | `0` | `✔` |
|
||||
| [`me_adaptive_floor_max_extra_writers_single_per_core`](#me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` | `✔` |
|
||||
| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` | `✔` |
|
||||
| [`me_adaptive_floor_max_active_writers_per_core`](#me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` | `✔` |
|
||||
| [`me_adaptive_floor_max_warm_writers_per_core`](#me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` | `✔` |
|
||||
| [`me_adaptive_floor_max_active_writers_global`](#me_adaptive_floor_max_active_writers_global) | `u32` | `256` | `✔` |
|
||||
| [`me_adaptive_floor_max_warm_writers_global`](#me_adaptive_floor_max_warm_writers_global) | `u32` | `256` | `✔` |
|
||||
| [`upstream_connect_retry_attempts`](#upstream_connect_retry_attempts) | `u32` | `2` | `✘` |
|
||||
| [`upstream_connect_retry_backoff_ms`](#upstream_connect_retry_backoff_ms) | `u64` | `100` | `✘` |
|
||||
| [`upstream_connect_budget_ms`](#upstream_connect_budget_ms) | `u64` | `3000` | `✘` |
|
||||
| [`tg_connect`](#tg_connect) | `u64` | `10` | `✘` |
|
||||
| [`upstream_unhealthy_fail_threshold`](#upstream_unhealthy_fail_threshold) | `u32` | `5` | `✘` |
|
||||
| [`upstream_connect_failfast_hard_errors`](#upstream_connect_failfast_hard_errors) | `bool` | `false` | `✘` |
|
||||
| [`stun_iface_mismatch_ignore`](#stun_iface_mismatch_ignore) | `bool` | `false` | `✘` |
|
||||
| [`unknown_dc_log_path`](#unknown_dc_log_path) | `String` | `"unknown-dc.txt"` | `✘` |
|
||||
| [`unknown_dc_file_log_enabled`](#unknown_dc_file_log_enabled) | `bool` | `false` | `✘` |
|
||||
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` | `✔` |
|
||||
| [`disable_colors`](#disable_colors) | `bool` | `false` | `✘` |
|
||||
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` | `✔` |
|
||||
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` | `✔` |
|
||||
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` | `✔` |
|
||||
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` | `✔` |
|
||||
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` | `✔` |
|
||||
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` | `✔` |
|
||||
| [`me_health_interval_ms_unhealthy`](#me_health_interval_ms_unhealthy) | `u64` | `1000` | `✔` |
|
||||
| [`me_health_interval_ms_healthy`](#me_health_interval_ms_healthy) | `u64` | `3000` | `✔` |
|
||||
| [`me_admission_poll_ms`](#me_admission_poll_ms) | `u64` | `1000` | `✔` |
|
||||
| [`me_warn_rate_limit_ms`](#me_warn_rate_limit_ms) | `u64` | `5000` | `✔` |
|
||||
| [`me_route_no_writer_mode`](#me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` | `✘` |
|
||||
| [`me_route_no_writer_wait_ms`](#me_route_no_writer_wait_ms) | `u64` | `250` | `✘` |
|
||||
| [`me_route_hybrid_max_wait_ms`](#me_route_hybrid_max_wait_ms) | `u64` | `3000` | `✘` |
|
||||
| [`me_route_blocking_send_timeout_ms`](#me_route_blocking_send_timeout_ms) | `u64` | `250` | `✘` |
|
||||
| [`me_route_inline_recovery_attempts`](#me_route_inline_recovery_attempts) | `u32` | `3` | `✘` |
|
||||
| [`me_route_inline_recovery_wait_ms`](#me_route_inline_recovery_wait_ms) | `u64` | `3000` | `✘` |
|
||||
| [`fast_mode_min_tls_record`](#fast_mode_min_tls_record) | `usize` | `0` | `✘` |
|
||||
| [`update_every`](#update_every) | `u64` | `300` | `✔` |
|
||||
| [`me_reinit_every_secs`](#me_reinit_every_secs) | `u64` | `900` | `✔` |
|
||||
| [`me_hardswap_warmup_delay_min_ms`](#me_hardswap_warmup_delay_min_ms) | `u64` | `1000` | `✔` |
|
||||
| [`me_hardswap_warmup_delay_max_ms`](#me_hardswap_warmup_delay_max_ms) | `u64` | `2000` | `✔` |
|
||||
| [`me_hardswap_warmup_extra_passes`](#me_hardswap_warmup_extra_passes) | `u8` | `3` | `✔` |
|
||||
| [`me_hardswap_warmup_pass_backoff_base_ms`](#me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` | `✔` |
|
||||
| [`me_config_stable_snapshots`](#me_config_stable_snapshots) | `u8` | `2` | `✔` |
|
||||
| [`me_config_apply_cooldown_secs`](#me_config_apply_cooldown_secs) | `u64` | `300` | `✔` |
|
||||
| [`me_snapshot_require_http_2xx`](#me_snapshot_require_http_2xx) | `bool` | `true` | `✔` |
|
||||
| [`me_snapshot_reject_empty_map`](#me_snapshot_reject_empty_map) | `bool` | `true` | `✔` |
|
||||
| [`me_snapshot_min_proxy_for_lines`](#me_snapshot_min_proxy_for_lines) | `u32` | `1` | `✔` |
|
||||
| [`proxy_secret_stable_snapshots`](#proxy_secret_stable_snapshots) | `u8` | `2` | `✔` |
|
||||
| [`proxy_secret_rotate_runtime`](#proxy_secret_rotate_runtime) | `bool` | `true` | `✔` |
|
||||
| [`me_secret_atomic_snapshot`](#me_secret_atomic_snapshot) | `bool` | `true` | `✔` |
|
||||
| [`proxy_secret_len_max`](#proxy_secret_len_max) | `usize` | `256` | `✔` |
|
||||
| [`me_pool_drain_ttl_secs`](#me_pool_drain_ttl_secs) | `u64` | `90` | `✔` |
|
||||
| [`me_instadrain`](#me_instadrain) | `bool` | `false` | `✔` |
|
||||
| [`me_pool_drain_threshold`](#me_pool_drain_threshold) | `u64` | `32` | `✔` |
|
||||
| [`me_pool_drain_soft_evict_enabled`](#me_pool_drain_soft_evict_enabled) | `bool` | `true` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_grace_secs`](#me_pool_drain_soft_evict_grace_secs) | `u64` | `10` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_per_writer`](#me_pool_drain_soft_evict_per_writer) | `u8` | `2` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_budget_per_core`](#me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` | `✘` |
|
||||
| [`me_pool_drain_soft_evict_cooldown_ms`](#me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` | `✘` |
|
||||
| [`me_bind_stale_mode`](#me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` | `✔` |
|
||||
| [`me_bind_stale_ttl_secs`](#me_bind_stale_ttl_secs) | `u64` | `90` | `✔` |
|
||||
| [`me_pool_min_fresh_ratio`](#me_pool_min_fresh_ratio) | `f32` | `0.8` | `✔` |
|
||||
| [`me_reinit_drain_timeout_secs`](#me_reinit_drain_timeout_secs) | `u64` | `90` | `✔` |
|
||||
| [`proxy_secret_auto_reload_secs`](#proxy_secret_auto_reload_secs) | `u64` | `3600` | `✔` |
|
||||
| [`proxy_config_auto_reload_secs`](#proxy_config_auto_reload_secs) | `u64` | `3600` | `✔` |
|
||||
| [`me_reinit_singleflight`](#me_reinit_singleflight) | `bool` | `true` | `✔` |
|
||||
| [`me_reinit_trigger_channel`](#me_reinit_trigger_channel) | `usize` | `64` | `✘` |
|
||||
| [`me_reinit_coalesce_window_ms`](#me_reinit_coalesce_window_ms) | `u64` | `200` | `✔` |
|
||||
| [`me_deterministic_writer_sort`](#me_deterministic_writer_sort) | `bool` | `true` | `✔` |
|
||||
| [`me_writer_pick_mode`](#me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` | `✔` |
|
||||
| [`me_writer_pick_sample_size`](#me_writer_pick_sample_size) | `u8` | `3` | `✔` |
|
||||
| [`ntp_check`](#ntp_check) | `bool` | `true` | `✘` |
|
||||
| [`ntp_servers`](#ntp_servers) | `String[]` | `["pool.ntp.org"]` | `✘` |
|
||||
| [`auto_degradation_enabled`](#auto_degradation_enabled) | `bool` | `true` | `✘` |
|
||||
| [`degradation_min_unavailable_dc_groups`](#degradation_min_unavailable_dc_groups) | `u8` | `2` | `✘` |
|
||||
| [`rst_on_close`](#rst_on_close) | `"off"`, `"errors"` или `"always"` | `"off"` | `✘` |
|
||||
|
||||
## data_path
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр).
|
||||
@@ -228,6 +241,24 @@
|
||||
[general]
|
||||
data_path = "/var/lib/telemt"
|
||||
```
|
||||
## quota_state_path
|
||||
- **Ограничения / валидация**: `Path`. Относительные пути разрешаются от рабочего каталога процесса.
|
||||
- **Описание**: JSON-файл состояния для сохранения runtime-расхода квот по пользователям.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
quota_state_path = "telemt.limit.json"
|
||||
```
|
||||
## config_strict
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
- **Описание**: Отклоняет неизвестные TOML-ключи во время загрузки конфигурации. При запуске процесс завершается с ошибкой; при hot-reload новый снимок отклоняется, а текущая конфигурация сохраняется.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
config_strict = true
|
||||
```
|
||||
## prefer_ipv6
|
||||
- **Ограничения / валидация**: Устарело. Используйте `network.prefer`.
|
||||
- **Описание**: Устаревший флаг предпочтения IPv6 перенесен в `network.prefer`.
|
||||
@@ -392,7 +423,7 @@
|
||||
```
|
||||
## me2dc_fallback
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
- **Описание**: Перейти из режима ME в режим прямого соединения (DC) в случае сбоя запуска ME.
|
||||
- **Описание**: Разрешает fallback на прямой DC, когда ME недоступен. При `use_middle_proxy = true` запуск сначала открывает маршрутизацию через Direct-DC, а новые сеансы переводятся на ME после подтверждения готовности ME.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -401,14 +432,14 @@
|
||||
```
|
||||
## me2dc_fast
|
||||
- **Ограничения / валидация**: `bool`. Используется только, когда `use_middle_proxy = true` и `me2dc_fallback = true`.
|
||||
- **Описание**: Режим для быстрого перехода между режимами ME->DC для новых сеансов.
|
||||
- **Описание**: Быстрый fallback ME->Direct для новых сеансов после того, как ME уже был готов хотя бы один раз. Начальный direct-first fallback управляется `me2dc_fallback`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
use_middle_proxy = true
|
||||
me2dc_fallback = true
|
||||
me2dc_fast = false
|
||||
me2dc_fast = true
|
||||
```
|
||||
## me_keepalive_enabled
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
@@ -601,7 +632,7 @@
|
||||
```
|
||||
## beobachten
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
- **Описание**: Включает "криминалистическое" наблюдения для каждого IP-адреса. Анализирует поведение всех подключений и записывает возможные типы клиентов, которые посылают active-probing запросы.
|
||||
- **Описание**: Включает "криминалистическое" наблюдения для каждого IP-адреса. Анализирует поведение всех подключений, записывает возможные типы клиентов, которые посылают active-probing запросы, и добавляет snapshot’ы TLS JA3/JA4 fingerprint’ов в Beobachten output, когда есть данные.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -610,7 +641,7 @@
|
||||
```
|
||||
## beobachten_minutes
|
||||
- **Ограничения / валидация**: Должно быть `> 0` (минут).
|
||||
- **Описание**: Время хранения (минуты) для сегментов наблюдения по каждому IP-адресу.
|
||||
- **Описание**: Время хранения (минуты) для сегментов наблюдения по каждому IP-адресу и in-memory bucket’ов TLS fingerprint’ов.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -905,6 +936,15 @@
|
||||
[general]
|
||||
upstream_connect_budget_ms = 3000
|
||||
```
|
||||
## tg_connect
|
||||
- **Ограничения / валидация**: Должно быть `> 0` (секунды).
|
||||
- **Описание**: Таймаут подключения к upstream-серверам Telegram.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
tg_connect = 10
|
||||
```
|
||||
## upstream_unhealthy_fail_threshold
|
||||
- **Ограничения / валидация**: Должно быть `> 0`.
|
||||
- **Описание**: Количество неудачных запросов подряд, после которого upstream помечается, как неработоспособный.
|
||||
@@ -1522,11 +1562,11 @@
|
||||
# [general.modes]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`classic`](#classic) | `bool` | `false` |
|
||||
| [`secure`](#secure) | `bool` | `false` |
|
||||
| [`tls`](#tls) | `bool` | `true` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`classic`](#classic) | `bool` | `false` | `✘` |
|
||||
| [`secure`](#secure) | `bool` | `false` | `✘` |
|
||||
| [`tls`](#tls) | `bool` | `true` | `✘` |
|
||||
|
||||
## classic
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
@@ -1560,11 +1600,11 @@
|
||||
# [general.links]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`show`](#show) | `"*"` or `String[]` | `"*"` |
|
||||
| [`public_host`](#public_host) | `String` | — |
|
||||
| [`public_port`](#public_port) | `u16` | — |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`show`](#show) | `"*"` or `String[]` | `"*"` | `✘` |
|
||||
| [`public_host`](#public_host) | `String` | — | `✘` |
|
||||
| [`public_port`](#public_port) | `u16` | — | `✘` |
|
||||
|
||||
## show
|
||||
- **Ограничения / валидация**: `"*"` или `String[]`. Пустое значение означает, что нельзя показывать никому.
|
||||
@@ -1600,11 +1640,11 @@
|
||||
# [general.telemetry]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`core_enabled`](#core_enabled) | `bool` | `true` |
|
||||
| [`user_enabled`](#user_enabled) | `bool` | `true` |
|
||||
| [`me_level`](#me_level) | `"silent"`, `"normal"`, or `"debug"` | `"normal"` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`core_enabled`](#core_enabled) | `bool` | `true` | `✔` |
|
||||
| [`user_enabled`](#user_enabled) | `bool` | `true` | `✔` |
|
||||
| [`me_level`](#me_level) | `"silent"`, `"normal"`, or `"debug"` | `"normal"` | `✔` |
|
||||
|
||||
## core_enabled
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
@@ -1638,18 +1678,18 @@
|
||||
# [network]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`ipv4`](#ipv4) | `bool` | `true` |
|
||||
| [`ipv6`](#ipv6) | `bool` | `false` |
|
||||
| [`prefer`](#prefer) | `u8` | `4` |
|
||||
| [`multipath`](#multipath) | `bool` | `false` |
|
||||
| [`stun_use`](#stun_use) | `bool` | `true` |
|
||||
| [`stun_servers`](#stun_servers) | `String[]` | Встроенный STUN-лист (13 записей) |
|
||||
| [`stun_tcp_fallback`](#stun_tcp_fallback) | `bool` | `true` |
|
||||
| [`http_ip_detect_urls`](#http_ip_detect_urls) | `String[]` | `["https://ifconfig.me/ip", "https://api.ipify.org"]` |
|
||||
| [`cache_public_ip_path`](#cache_public_ip_path) | `String` | `"cache/public_ip.txt"` |
|
||||
| [`dns_overrides`](#dns_overrides) | `String[]` | `[]` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`ipv4`](#ipv4) | `bool` | `true` | `✘` |
|
||||
| [`ipv6`](#ipv6) | `bool` | `false` | `✘` |
|
||||
| [`prefer`](#prefer) | `u8` | `4` | `✘` |
|
||||
| [`multipath`](#multipath) | `bool` | `false` | `✘` |
|
||||
| [`stun_use`](#stun_use) | `bool` | `true` | `✘` |
|
||||
| [`stun_servers`](#stun_servers) | `String[]` | Встроенный STUN-лист (13 записей) | `✘` |
|
||||
| [`stun_tcp_fallback`](#stun_tcp_fallback) | `bool` | `true` | `✘` |
|
||||
| [`http_ip_detect_urls`](#http_ip_detect_urls) | `String[]` | `["https://ifconfig.me/ip", "https://api.ipify.org"]` | `✘` |
|
||||
| [`cache_public_ip_path`](#cache_public_ip_path) | `String` | `"cache/public_ip.txt"` | `✘` |
|
||||
| [`dns_overrides`](#dns_overrides) | `String[]` | `[]` | `✔` |
|
||||
|
||||
## ipv4
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
@@ -1759,23 +1799,28 @@
|
||||
# [server]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`port`](#port) | `u16` | `443` |
|
||||
| [`listen_addr_ipv4`](#listen_addr_ipv4) | `String` | `"0.0.0.0"` |
|
||||
| [`listen_addr_ipv6`](#listen_addr_ipv6) | `String` | `"::"` |
|
||||
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — |
|
||||
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — |
|
||||
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) |
|
||||
| [`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[]` | `[]` |
|
||||
| [`metrics_port`](#metrics_port) | `u16` | — |
|
||||
| [`metrics_listen`](#metrics_listen) | `String` | — |
|
||||
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` |
|
||||
| [`max_connections`](#max_connections) | `u32` | `10000` |
|
||||
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` |
|
||||
| [`listen_backlog`](#listen_backlog) | `u32` | `1024` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`port`](#port) | `u16` | `443` | `✘` |
|
||||
| [`listen_addr_ipv4`](#listen_addr_ipv4) | `String` | `"0.0.0.0"` | `✘` |
|
||||
| [`listen_addr_ipv6`](#listen_addr_ipv6) | `String` | `"::"` | `✘` |
|
||||
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `✘` |
|
||||
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` |
|
||||
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` |
|
||||
| [`client_mss`](#client_mss) | `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[]` | `[]` | `✘` |
|
||||
| [`metrics_port`](#metrics_port) | `u16` | — | `✘` |
|
||||
| [`metrics_listen`](#metrics_listen) | `String` | — | `✘` |
|
||||
| [`metrics_whitelist`](#metrics_whitelist) | `IpNetwork[]` | `["127.0.0.1/32", "::1/128"]` | `✘` |
|
||||
| [`api`](#serverapi) | `Table` | встроенные значения | `✘` |
|
||||
| [`admin_api`](#serverapi) | `Table` | алиас для `api` | `✘` |
|
||||
| [`listeners`](#serverlisteners) | `Table[]` | выводится из legacy listener-полей | `✘` |
|
||||
| [`max_connections`](#max_connections) | `u32` | `10000` | `✘` |
|
||||
| [`accept_permit_timeout_ms`](#accept_permit_timeout_ms) | `u64` | `250` | `✘` |
|
||||
| [`listen_backlog`](#listen_backlog) | `u32` | `1024` | `✘` |
|
||||
| [`conntrack_control`](#serverconntrack_control) | `Table` | встроенные значения | `✘` |
|
||||
|
||||
## port
|
||||
- **Ограничения / валидация**: `u16`.
|
||||
@@ -1845,6 +1890,16 @@
|
||||
listen_unix_sock = "/run/telemt.sock"
|
||||
listen_tcp = true
|
||||
```
|
||||
## 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’ов.
|
||||
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
```
|
||||
## proxy_protocol
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
- **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка.
|
||||
@@ -1931,16 +1986,16 @@
|
||||
Примечание. Рабочий процесс `conntrack-control` работает **только в Linux**. В других операционных системах не запускается; если inline_conntrack_control имеет значение `true`, в логи записывается предупреждение. Для эффективной работы также требуется **CAP_NET_ADMIN** и пригодный к использованию бэкенд (nft или iptables/ip6tables в PATH). Утилита `conntrack` используется для удаления необязательных записей таблицы под нагрузкой.
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`inline_conntrack_control`](#inline_conntrack_control) | `bool` | `true` |
|
||||
| [`mode`](#mode) | `String` | `"tracked"` |
|
||||
| [`backend`](#backend) | `String` | `"auto"` |
|
||||
| [`profile`](#profile) | `String` | `"balanced"` |
|
||||
| [`hybrid_listener_ips`](#hybrid_listener_ips) | `IpAddr[]` | `[]` |
|
||||
| [`pressure_high_watermark_pct`](#pressure_high_watermark_pct) | `u8` | `85` |
|
||||
| [`pressure_low_watermark_pct`](#pressure_low_watermark_pct) | `u8` | `70` |
|
||||
| [`delete_budget_per_sec`](#delete_budget_per_sec) | `u64` | `4096` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`inline_conntrack_control`](#inline_conntrack_control) | `bool` | `true` | `✘` |
|
||||
| [`mode`](#mode) | `String` | `"tracked"` | `✘` |
|
||||
| [`backend`](#backend) | `String` | `"auto"` | `✘` |
|
||||
| [`profile`](#profile) | `String` | `"balanced"` | `✘` |
|
||||
| [`hybrid_listener_ips`](#hybrid_listener_ips) | `IpAddr[]` | `[]` | `✘` |
|
||||
| [`pressure_high_watermark_pct`](#pressure_high_watermark_pct) | `u8` | `85` | `✘` |
|
||||
| [`pressure_low_watermark_pct`](#pressure_low_watermark_pct) | `u8` | `70` | `✘` |
|
||||
| [`delete_budget_per_sec`](#delete_budget_per_sec) | `u64` | `4096` | `✘` |
|
||||
|
||||
## inline_conntrack_control
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
@@ -2027,21 +2082,21 @@
|
||||
Примечание: В этом разделе также задается устаревший параметр `[server.admin_api]` (аналогично `[server.api]`).
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`enabled`](#enabled) | `bool` | `true` |
|
||||
| [`listen`](#listen) | `String` | `"0.0.0.0:9091"` |
|
||||
| [`whitelist`](#whitelist) | `IpNetwork[]` | `["127.0.0.0/8"]` |
|
||||
| [`auth_header`](#auth_header) | `String` | `""` |
|
||||
| [`request_body_limit_bytes`](#request_body_limit_bytes) | `usize` | `65536` |
|
||||
| [`minimal_runtime_enabled`](#minimal_runtime_enabled) | `bool` | `true` |
|
||||
| [`minimal_runtime_cache_ttl_ms`](#minimal_runtime_cache_ttl_ms) | `u64` | `1000` |
|
||||
| [`runtime_edge_enabled`](#runtime_edge_enabled) | `bool` | `false` |
|
||||
| [`runtime_edge_cache_ttl_ms`](#runtime_edge_cache_ttl_ms) | `u64` | `1000` |
|
||||
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` |
|
||||
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` |
|
||||
| [`read_only`](#read_only) | `bool` | `false` |
|
||||
| [`gray_action`](#gray_action) | `"drop"`, `"api"`, or `"200"` | `"drop"` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`enabled`](#enabled) | `bool` | `true` | `✘` |
|
||||
| [`listen`](#listen) | `String` | `"0.0.0.0:9091"` | `✘` |
|
||||
| [`whitelist`](#whitelist) | `IpNetwork[]` | `["127.0.0.0/8"]` | `✘` |
|
||||
| [`auth_header`](#auth_header) | `String` | `""` | `✘` |
|
||||
| [`request_body_limit_bytes`](#request_body_limit_bytes) | `usize` | `65536` | `✘` |
|
||||
| [`minimal_runtime_enabled`](#minimal_runtime_enabled) | `bool` | `true` | `✘` |
|
||||
| [`minimal_runtime_cache_ttl_ms`](#minimal_runtime_cache_ttl_ms) | `u64` | `1000` | `✘` |
|
||||
| [`runtime_edge_enabled`](#runtime_edge_enabled) | `bool` | `false` | `✘` |
|
||||
| [`runtime_edge_cache_ttl_ms`](#runtime_edge_cache_ttl_ms) | `u64` | `1000` | `✘` |
|
||||
| [`runtime_edge_top_n`](#runtime_edge_top_n) | `usize` | `10` | `✘` |
|
||||
| [`runtime_edge_events_capacity`](#runtime_edge_events_capacity) | `usize` | `256` | `✘` |
|
||||
| [`read_only`](#read_only) | `bool` | `false` | `✘` |
|
||||
| [`gray_action`](#gray_action) | `"drop"`, `"api"`, or `"200"` | `"drop"` | `✘` |
|
||||
|
||||
## enabled
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
@@ -2135,7 +2190,7 @@
|
||||
```
|
||||
## runtime_edge_top_n
|
||||
- **Ограничения / валидация**: `1..=1000`.
|
||||
- **Описание**: Размер выборки Top-N для рейтинга (leaderboard) edge-соединений.
|
||||
- **Описание**: Размер выборки Top-N для snapshot’ов рейтинга edge-соединений и TLS fingerprint’ов.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
@@ -2165,13 +2220,15 @@
|
||||
# [[server.listeners]]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`ip`](#ip) | `IpAddr` | — |
|
||||
| [`announce`](#announce) | `String` | — |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — |
|
||||
| [`reuse_allow`](#reuse_allow) | `bool` | `false` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`ip`](#ip) | `IpAddr` | — | `✘` |
|
||||
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
|
||||
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
|
||||
| [`announce`](#announce) | `String` | — | `✘` |
|
||||
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
|
||||
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
|
||||
| [`reuse_allow`](#reuse_allow) | `bool` | `false` | `✘` |
|
||||
|
||||
## ip
|
||||
- **Ограничения / валидация**: Обязательный параметр. Значение должно содержать IP-адрес в формате строки.
|
||||
@@ -2182,6 +2239,27 @@
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
```
|
||||
## port (server.listeners)
|
||||
- **Ограничения / валидация**: `u16` (необязательный параметр). Если не задан, используется `server.port`.
|
||||
- **Описание**: TCP-порт для конкретного listener’а.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
```
|
||||
## client_mss (server.listeners)
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Допустимые значения совпадают с `[server].client_mss`.
|
||||
- **Описание**: Per-listener override для MSS. Если параметр не задан, listener наследует `[server].client_mss`; если задана пустая строка, MSS shaping отключается только для этого listener’а, даже когда глобальный параметр задан. Изменение требует restart/rebind listener’а.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[server.listeners]]
|
||||
ip = "0.0.0.0"
|
||||
port = 443
|
||||
client_mss = "256"
|
||||
```
|
||||
## announce
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
|
||||
- **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`.
|
||||
@@ -2215,8 +2293,7 @@
|
||||
ip = "0.0.0.0"
|
||||
proxy_protocol = true
|
||||
```
|
||||
## reuse_allow"
|
||||
- `reuse_allow`
|
||||
## reuse_allow
|
||||
- **Ограничения / валидация**: `bool`.
|
||||
- **Описание**: Включает `SO_REUSEPORT` для совместного использования привязки нескольких экземпляров (позволяет нескольким экземплярам telemt прослушивать один и тот же `ip:port`).
|
||||
- **Пример**:
|
||||
@@ -2231,18 +2308,18 @@
|
||||
# [timeouts]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`client_handshake`](#client_handshake) | `u64` | `30` |
|
||||
| [`relay_idle_policy_v2_enabled`](#relay_idle_policy_v2_enabled) | `bool` | `true` |
|
||||
| [`relay_client_idle_soft_secs`](#relay_client_idle_soft_secs) | `u64` | `120` |
|
||||
| [`relay_client_idle_hard_secs`](#relay_client_idle_hard_secs) | `u64` | `360` |
|
||||
| [`relay_idle_grace_after_downstream_activity_secs`](#relay_idle_grace_after_downstream_activity_secs) | `u64` | `30` |
|
||||
| [`tg_connect`](#tg_connect) | `u64` | `10` |
|
||||
| [`client_keepalive`](#client_keepalive) | `u64` | `15` |
|
||||
| [`client_ack`](#client_ack) | `u64` | `90` |
|
||||
| [`me_one_retry`](#me_one_retry) | `u8` | `12` |
|
||||
| [`me_one_timeout_ms`](#me_one_timeout_ms) | `u64` | `1200` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`client_first_byte_idle_secs`](#client_first_byte_idle_secs) | `u64` | `300` | `✘` |
|
||||
| [`client_handshake`](#client_handshake) | `u64` | `30` | `✘` |
|
||||
| [`relay_idle_policy_v2_enabled`](#relay_idle_policy_v2_enabled) | `bool` | `true` | `✘` |
|
||||
| [`relay_client_idle_soft_secs`](#relay_client_idle_soft_secs) | `u64` | `120` | `✘` |
|
||||
| [`relay_client_idle_hard_secs`](#relay_client_idle_hard_secs) | `u64` | `360` | `✘` |
|
||||
| [`relay_idle_grace_after_downstream_activity_secs`](#relay_idle_grace_after_downstream_activity_secs) | `u64` | `30` | `✘` |
|
||||
| [`client_keepalive`](#client_keepalive) | `u64` | `15` | `✘` |
|
||||
| [`client_ack`](#client_ack) | `u64` | `90` | `✘` |
|
||||
| [`me_one_retry`](#me_one_retry) | `u8` | `12` | `✘` |
|
||||
| [`me_one_timeout_ms`](#me_one_timeout_ms) | `u64` | `1200` | `✘` |
|
||||
|
||||
## client_handshake
|
||||
- **Ограничения / валидация**: Должно быть `> 0`. Значение указано в секундах. Также используется в качестве верхней границы некоторых задержек эмуляции TLS (см. `censorship.server_hello_delay_max_ms`).
|
||||
@@ -2298,15 +2375,6 @@
|
||||
[timeouts]
|
||||
relay_idle_grace_after_downstream_activity_secs = 30
|
||||
```
|
||||
## tg_connect
|
||||
- **Ограничения / валидация**: `u64` (секунд).
|
||||
- **Описание**: Таймаут подключения к upstream-серверу Telegram (в секундах).
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[timeouts]
|
||||
tg_connect = 10
|
||||
```
|
||||
## client_keepalive
|
||||
- **Ограничения / валидация**: `u64` (секунд).
|
||||
- **Описание**: Таймаут keepalive для клиента..
|
||||
@@ -2348,40 +2416,40 @@
|
||||
# [censorship]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
|
||||
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
|
||||
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` |
|
||||
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` |
|
||||
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
|
||||
| [`mask`](#mask) | `bool` | `true` |
|
||||
| [`mask_host`](#mask_host) | `String` | — |
|
||||
| [`mask_port`](#mask_port) | `u16` | `443` |
|
||||
| [`mask_unix_sock`](#mask_unix_sock) | `String` | — |
|
||||
| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` |
|
||||
| [`tls_emulation`](#tls_emulation) | `bool` | `true` |
|
||||
| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` |
|
||||
| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` |
|
||||
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
|
||||
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
|
||||
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
|
||||
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` |
|
||||
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
|
||||
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
|
||||
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
|
||||
| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` |
|
||||
| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` |
|
||||
| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` |
|
||||
| [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` |
|
||||
| [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` |
|
||||
| [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` |
|
||||
| [`mask_relay_timeout_ms`](mask_relay_timeout_ms) | `u64` | `60_000` |
|
||||
| [`mask_relay_idle_timeout_ms`](mask_relay_idle_timeout_ms) | `u64` | `5_000` |
|
||||
| [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` |
|
||||
| [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` |
|
||||
| [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` |
|
||||
| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `✘` |
|
||||
| [`tls_domains`](#tls_domains) | `String[]` | `[]` | `✘` |
|
||||
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` | `✘` |
|
||||
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` | `✘` |
|
||||
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults | `✘` |
|
||||
| [`mask`](#mask) | `bool` | `true` | `✘` |
|
||||
| [`mask_host`](#mask_host) | `String` | — | `✘` |
|
||||
| [`mask_port`](#mask_port) | `u16` | `443` | `✘` |
|
||||
| [`mask_unix_sock`](#mask_unix_sock) | `String` | — | `✘` |
|
||||
| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` | `✘` |
|
||||
| [`tls_emulation`](#tls_emulation) | `bool` | `true` | `✘` |
|
||||
| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` | `✘` |
|
||||
| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` | `✘` |
|
||||
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` | `✘` |
|
||||
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` | `✘` |
|
||||
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` | `✘` |
|
||||
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` | `✘` |
|
||||
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` | `✘` |
|
||||
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` | `✘` |
|
||||
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` | `✘` |
|
||||
| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` | `✘` |
|
||||
| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` | `✘` |
|
||||
| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` | `✘` |
|
||||
| [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` | `✘` |
|
||||
| [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` | `✘` |
|
||||
| [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` | `✘` |
|
||||
| [`mask_relay_timeout_ms`](mask_relay_timeout_ms) | `u64` | `60_000` | `✘` |
|
||||
| [`mask_relay_idle_timeout_ms`](mask_relay_idle_timeout_ms) | `u64` | `5_000` | `✘` |
|
||||
| [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` | `✘` |
|
||||
| [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` | `✘` |
|
||||
| [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` | `✘` |
|
||||
| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` | `✘` |
|
||||
|
||||
## tls_domain
|
||||
- **Ограничения / валидация**: Не должно быть пустым. Не должно содержать пробелы или `/`.
|
||||
@@ -2464,6 +2532,18 @@
|
||||
[censorship]
|
||||
mask_port = 443
|
||||
```
|
||||
## exclusive_mask
|
||||
- **Ограничения / валидация**: TOML map. Ключи должны быть доменами SNI. Значения должны иметь формат `host:port`, где `port > 0`; IPv6 literals должны быть в квадратных скобках.
|
||||
- **Описание**: Per-SNI TCP targets для fallback-трафика. Если SNI в TLS ClientHello совпадает с ключом, Telemt проксирует это неаутентифицированное соединение на указанный target. Остальной fallback-трафик продолжает использовать существующий `mask_host`/`mask_port` или SNI-aware default masking behavior.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[censorship]
|
||||
tls_domains = ["petrovich.ru", "bsi.bund.de", "telekom.com"]
|
||||
|
||||
[censorship.exclusive_mask]
|
||||
"bsi.bund.de" = "127.0.0.1:443"
|
||||
```
|
||||
## mask_unix_sock
|
||||
- **Ограничения / валидация**: `String` (optional).
|
||||
- Значение не должно быть пустым, если задан.
|
||||
@@ -2804,15 +2884,15 @@
|
||||
# [censorship.tls_fetch]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`profiles`](#profiles) | `String[]` | `["modern_chrome_like", "modern_firefox_like", "compat_tls12", "legacy_minimal"]` |
|
||||
| [`strict_route`](#strict_route) | `bool` | `true` |
|
||||
| [`attempt_timeout_ms`](#attempt_timeout_ms) | `u64` | `5000` |
|
||||
| [`total_budget_ms`](#total_budget_ms) | `u64` | `15000` |
|
||||
| [`grease_enabled`](#grease_enabled) | `bool` | `false` |
|
||||
| [`deterministic`](#deterministic) | `bool` | `false` |
|
||||
| [`profile_cache_ttl_secs`](#profile_cache_ttl_secs) | `u64` | `600` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`profiles`](#profiles) | `String[]` | `["modern_chrome_like", "modern_firefox_like", "compat_tls12", "legacy_minimal"]` | `✘` |
|
||||
| [`strict_route`](#strict_route) | `bool` | `true` | `✘` |
|
||||
| [`attempt_timeout_ms`](#attempt_timeout_ms) | `u64` | `5000` | `✘` |
|
||||
| [`total_budget_ms`](#total_budget_ms) | `u64` | `15000` | `✘` |
|
||||
| [`grease_enabled`](#grease_enabled) | `bool` | `false` | `✘` |
|
||||
| [`deterministic`](#deterministic) | `bool` | `false` | `✘` |
|
||||
| [`profile_cache_ttl_secs`](#profile_cache_ttl_secs) | `u64` | `600` | `✘` |
|
||||
|
||||
## profiles
|
||||
- **Ограничения / валидация**: `String[]`. Пустой список возвращает значения по умолчанию; дубликаты удаляются с сохранением порядка.
|
||||
@@ -2881,23 +2961,24 @@
|
||||
# [access]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` |
|
||||
| [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` |
|
||||
| [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` |
|
||||
| [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` |
|
||||
| [`user_expirations`](#user_expirations) | `Map<String, DateTime<Utc>>` | `{}` |
|
||||
| [`user_data_quota`](#user_data_quota) | `Map<String, u64>` | `{}` |
|
||||
| [`user_max_unique_ips`](#user_max_unique_ips) | `Map<String, usize>` | `{}` |
|
||||
| [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` |
|
||||
| [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` |
|
||||
| [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` |
|
||||
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` |
|
||||
| [`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>` | `{}` |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` | `✔` |
|
||||
| [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` | `✔` |
|
||||
| [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` | `✔` |
|
||||
| [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` | `✔` |
|
||||
| [`user_expirations`](#user_expirations) | `Map<String, DateTime<Utc>>` | `{}` | `✔` |
|
||||
| [`user_data_quota`](#user_data_quota) | `Map<String, u64>` | `{}` | `✔` |
|
||||
| [`user_max_unique_ips`](#user_max_unique_ips) | `Map<String, usize>` | `{}` | `✔` |
|
||||
| [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` | `✔` |
|
||||
| [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` | `✔` |
|
||||
| [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` | `✔` |
|
||||
| [`user_source_deny`](#user_source_deny) | `Map<String, IpNetwork[]>` | `{}` | `✘` |
|
||||
| [`replay_check_len`](#replay_check_len) | `usize` | `65536` | `✘` |
|
||||
| [`replay_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>` | `{}` | `✔` |
|
||||
|
||||
## users
|
||||
- **Ограничения / валидация**: Не должно быть пустым (должен существовать хотя бы один пользователь). Каждое значение должно состоять **ровно из 32 шестнадцатеричных символов**.
|
||||
@@ -2997,6 +3078,20 @@
|
||||
[access]
|
||||
user_max_unique_ips_window_secs = 30
|
||||
```
|
||||
## user_source_deny
|
||||
- **Ограничения / валидация**: Таблица `username -> IpNetwork[]`. Каждая сеть должна разбираться как CIDR, например `203.0.113.0/24` или `2001:db8::/32`.
|
||||
- **Описание**: Deny-list исходных IP/CIDR для конкретного пользователя, применяемый **после успешной аутентификации** в TLS- и MTProto-handshake путях. Совпавший source IP отклоняется тем же fail-closed путём, что и невалидная аутентификация.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[access.user_source_deny]
|
||||
alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
|
||||
bob = ["198.51.100.42/32"]
|
||||
```
|
||||
|
||||
- **Краткая проверка**:
|
||||
- соединение пользователя `alice` с source `203.0.113.55` отклоняется, потому что совпадает с `203.0.113.0/24`;
|
||||
- соединение пользователя `alice` с source `198.51.100.10` допускается этим набором правил, потому что совпадений нет.
|
||||
## replay_check_len
|
||||
- **Ограничения / валидация**: `usize`.
|
||||
- **Описание**: Количество последних сообщений/запросов, которое система запоминает, чтобы не допустить их повторной отправки (replay).
|
||||
@@ -3047,19 +3142,24 @@
|
||||
# [[upstreams]]
|
||||
|
||||
|
||||
| Ключ | Тип | По умолчанию |
|
||||
| --- | ---- | ------- |
|
||||
| [`type`](#type) | `"direct"`, `"socks4"`, `"socks5"`, or `"shadowsocks"` | — |
|
||||
| [`weight`](#weight) | `u16` | `1` |
|
||||
| [`enabled`](#enabled) | `bool` | `true` |
|
||||
| [`scopes`](#scopes) | `String` | `""` |
|
||||
| [`interface`](#interface) | `String` | — |
|
||||
| [`bind_addresses`](#bind_addresses) | `String[]` | — |
|
||||
| [`url`](#url) | `String` | — |
|
||||
| [`address`](#address) | `String` | — |
|
||||
| [`user_id`](#user_id) | `String` | — |
|
||||
| [`username`](#username) | `String` | — |
|
||||
| [`password`](#password) | `String` | — |
|
||||
| Ключ | Тип | По умолчанию | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`type`](#type) | `"direct"`, `"socks4"`, `"socks5"`, or `"shadowsocks"` | — | `✘` |
|
||||
| [`weight`](#weight) | `u16` | `1` | `✘` |
|
||||
| [`enabled`](#enabled) | `bool` | `true` | `✘` |
|
||||
| [`scopes`](#scopes) | `String` | `""` | `✘` |
|
||||
| [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `✘` |
|
||||
| [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `✘` |
|
||||
| [`prefer`](#prefer-upstreams) | `4` или `6` | эффективный `[network].prefer` | `✘` |
|
||||
| [`interface`](#interface) | `String` | — | `✘` |
|
||||
| [`bind_addresses`](#bind_addresses) | `String[]` | — | `✘` |
|
||||
| [`bindtodevice`](#bindtodevice) | `String` | — | `✘` |
|
||||
| [`force_bind`](#force_bind) | `String` | — | `✘` |
|
||||
| [`url`](#url) | `String` | — | `✘` |
|
||||
| [`address`](#address) | `String` | — | `✘` |
|
||||
| [`user_id`](#user_id) | `String` | — | `✘` |
|
||||
| [`username`](#username) | `String` | — | `✘` |
|
||||
| [`password`](#password) | `String` | — | `✘` |
|
||||
|
||||
## type
|
||||
- **Ограничения / валидация**: Обязательный параметр.`"direct"`, `"socks4"`, `"socks5"`, `"shadowsocks"`.
|
||||
@@ -3110,6 +3210,38 @@
|
||||
address = "10.0.0.10:1080"
|
||||
scopes = "me, fetch, dc2"
|
||||
```
|
||||
## ipv4 (upstreams)
|
||||
- **Ограничения / валидация**: `bool` (необязательный параметр).
|
||||
- **Описание**: Разрешает IPv4 DC-targets для этого upstream. Если не задан, Telemt определяет поддержку автоматически по runtime-состоянию connectivity.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
ipv4 = true
|
||||
```
|
||||
## ipv6 (upstreams)
|
||||
- **Ограничения / валидация**: `bool` (необязательный параметр).
|
||||
- **Описание**: Разрешает IPv6 DC-targets для этого upstream. Если не задан, Telemt определяет поддержку автоматически по runtime-состоянию connectivity. Установите `true`, если upstream proxy доступен с локального хоста по IPv4, но сам proxy умеет подключаться к Telegram DC по IPv6.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
ipv6 = false
|
||||
```
|
||||
## prefer (upstreams)
|
||||
- **Ограничения / валидация**: Необязательное число. Должно быть `4` или `6`.
|
||||
- **Описание**: Переопределяет предпочтительное IP-семейство для Telegram DC-targets, выбранных через этот upstream. Если параметр не задан, upstream наследует эффективное глобальное решение `[network].prefer`. Используйте `prefer = 6` вместе с `ipv6 = true` для SOCKS или Shadowsocks upstream, который умеет выходить в IPv6, даже если локальный хост с Telemt работает только по IPv4.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "socks5"
|
||||
address = "192.0.2.10:1080"
|
||||
ipv6 = true
|
||||
prefer = 6
|
||||
```
|
||||
## interface
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр).
|
||||
- для `"direct"`: может быть IP-адресом (используется как явный local bind) или именем сетевого интерфейса ОС (резолвится в IP во время выполнения; только Unix).
|
||||
@@ -3140,6 +3272,26 @@
|
||||
type = "direct"
|
||||
bind_addresses = ["192.0.2.10", "192.0.2.11"]
|
||||
```
|
||||
## bindtodevice
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Применяется только для `type = "direct"` и только в Linux.
|
||||
- **Описание**: Жёсткая привязка исходящих direct TCP-connect к интерфейсу через `SO_BINDTODEVICE`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
bindtodevice = "eth0"
|
||||
```
|
||||
## force_bind
|
||||
- **Ограничения / валидация**: `String` (необязательный параметр). Алиас для `bindtodevice`.
|
||||
- **Описание**: Обратно-совместимый алиас для жёсткой Linux-привязки к интерфейсу через `SO_BINDTODEVICE`.
|
||||
- **Пример**:
|
||||
|
||||
```toml
|
||||
[[upstreams]]
|
||||
type = "direct"
|
||||
force_bind = "eth0"
|
||||
```
|
||||
## url
|
||||
- **Ограничения / валидация**: Применяется в случае, если `type = "shadowsocks"`.
|
||||
- Должен быть действительный URL-адрес Shadowsocks, принятый `shadowsocks` контейнером.
|
||||
|
||||
@@ -172,7 +172,7 @@ Those cross-DC requests are normal and happen constantly.
|
||||
> If your home DC is DC2 and DC2 goes down, you **cannot** reach DC5 even though DC5 itself is perfectly healthy.
|
||||
> The client has no valid session to route the request through.
|
||||
|
||||
This is also why an MTProxy only needs to reach Telegram's DC infrastructure as a whole.
|
||||
This is also why it is required for MTProxy to reach Telegram's DC infrastructure as a whole.
|
||||
The proxy itself doesn't care which DC your account lives on. The client negotiates the correct DC through the proxy after connecting.
|
||||
|
||||
### How many people can use one link
|
||||
|
||||
@@ -40,6 +40,8 @@ hello2 = "ad_tag2"
|
||||
> Проблема с TLS отпечатком исправлена в последних версиях клиентов Telegram для Desktop / Android / iOS.
|
||||
> Обновите свой клиент для корректной работы с MTProxy Fake-TLS!
|
||||
|
||||
- Для расследования блокировок на базе JA4 ClientHello используйте отдельную инструкцию: [`JA3 и JA4 анализ в Telemt`](Architecture/Fronting-splitting/TLS_JA3_JA4_ANALYSIS.ru.md).
|
||||
|
||||
- Мы считаем это прорывом, которому на сегодняшний день нет стабильных аналогов;
|
||||
- Исходя из этого: если `telemt` настроен правильно, **режим TLS полностью идентичен реальному «рукопожатию» + обмену данными** с указанным хостом;
|
||||
- Вот наши доказательства:
|
||||
@@ -157,7 +159,7 @@ https://github.com/telemt/telemt/discussions/167
|
||||
|
||||
## Как клиенты взаимодействуют с дата-центрами Telegram
|
||||
При регистрации аккаунта Telegram он навсегда привязывается к одному из дата-центров (DC).
|
||||
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относиться номер телефона.
|
||||
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относится номер телефона.
|
||||
Этот DC становится вашим **домашним**: именно там хранится весь контент, который вы загружаете (фото, видео, файлы, сообщения).
|
||||
И именно на нем клиент авторизуется при каждом подключении.
|
||||
|
||||
@@ -170,7 +172,7 @@ Telegram заранее определяет к какому DC привязат
|
||||
> Если ваш домашний DC — DC2, и DC2 лежит, вы **не сможете** достучаться и до DC5, даже если сам DC5 полностью исправен.
|
||||
> У клиента просто нет валидной сессии, через которую можно было бы направить запрос.
|
||||
|
||||
По той же причине MTProxy достаточно иметь доступ к инфраструктуре Telegram в целом.
|
||||
По той же причине MTProxy необходимо иметь доступ к инфраструктуре Telegram целиком, а не частично.
|
||||
Cамому MTProxy всё равно, на каком DC живёт ваш аккаунт. Клиент cам договаривается о нужном DC через прокси уже после подключения.
|
||||
|
||||
## Что такое dd и ee в контексте MTProxy?
|
||||
|
||||
@@ -254,6 +254,19 @@ docker compose down
|
||||
> - `docker-compose.yml` maps `./config.toml` to `/app/config.toml` (read-only)
|
||||
> - By default it publishes `443:443` and runs with dropped capabilities (only `NET_BIND_SERVICE` is added)
|
||||
> - If you really need host networking (usually only for some IPv6 setups) uncomment `network_mode: host`
|
||||
> - If you enable mutating Control API endpoints, mount a writable config directory instead of a single `config.toml` file. Telemt persists config changes with atomic `tmp + rename` writes, and a single bind-mounted file can fail with `Device or resource busy`.
|
||||
|
||||
Example writable config mount for Control API mutations:
|
||||
```yaml
|
||||
services:
|
||||
telemt:
|
||||
working_dir: /run/telemt
|
||||
volumes:
|
||||
- ./config:/etc/telemt:rw
|
||||
tmpfs:
|
||||
- /run/telemt:rw,mode=1777,size=4m
|
||||
command: /usr/local/bin/telemt /etc/telemt/config.toml
|
||||
```
|
||||
|
||||
**Run without Compose**
|
||||
```bash
|
||||
|
||||
@@ -235,7 +235,10 @@ curl -s http://127.0.0.1:9091/v1/users | jq -r '.data[] | "[\(.username)]", (.li
|
||||
|
||||
# Telemt через Docker Compose
|
||||
|
||||
**1. Отредактируйте `config.toml` в корневом каталоге репозитория (как минимум: порт, пользовательские секреты, tls_domain)**
|
||||
**1. Создайте директорию `config/` и поместите в неё отрдеактированный `config.toml` (указав как минимум: порт, пользовательские секреты, tls_domain):**
|
||||
```bash
|
||||
mkdir config && mv config.toml config/
|
||||
```
|
||||
**2. Запустите контейнер:**
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -249,7 +252,7 @@ docker compose logs -f telemt
|
||||
docker compose down
|
||||
```
|
||||
> [!NOTE]
|
||||
> - В `docker-compose.yml` файл `./config.toml` монтируется в `/app/config.toml` (доступно только для чтения)
|
||||
> - Директория `./config/` монтируется в `/etc/telemt/` (read-write), что позволяет API атомарно обновлять config.toml
|
||||
> - По умолчанию публикуются порты 443:443, а контейнер запускается со сброшенными привилегиями (добавлена только `NET_BIND_SERVICE`)
|
||||
> - Если вам действительно нужна сеть хоста (обычно это требуется только для некоторых конфигураций IPv6), раскомментируйте `network_mode: host`
|
||||
|
||||
|
||||
138
install.sh
138
install.sh
@@ -84,27 +84,29 @@ set_language() {
|
||||
L_INFO_KEEP_CONF="Примечание: Конфигурация сохранена. Используйте 'purge' для очистки."
|
||||
L_INFO_I_START="Начинается установка"
|
||||
L_I_STAGE_1=">>> Этап 1: Проверка окружения и зависимостей"
|
||||
L_I_STAGE_1_5=">>> Этап 1.5: Интерактивная настройка"
|
||||
L_I_STAGE_2=">>> Этап 2: Интерактивная настройка"
|
||||
L_I_PROMPT_DOM="\nПожалуйста, укажите домен TLS\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
|
||||
L_I_PROMPT_PORT="\nПожалуйста, укажите порт сервера\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
|
||||
L_WARN_NO_TTY="Интерактивный режим недоступен (нет TTY). Используется:"
|
||||
L_I_STAGE_2=">>> Этап 2: Загрузка архива"
|
||||
L_I_STAGE_3=">>> Этап 3: Загрузка архива"
|
||||
L_ERR_TMP_DIR="Не удалось создать временную директорию"
|
||||
L_ERR_TMP_INV="Временная директория недействительна"
|
||||
L_INFO_FALLBACK="Сборка x86_64-v3 не найдена, откат к стандартной x86_64..."
|
||||
L_ERR_DL_FAIL="Ошибка загрузки архива"
|
||||
L_I_STAGE_3=">>> Этап 3: Распаковка архива"
|
||||
L_I_STAGE_4=">>> Этап 4: Распаковка архива"
|
||||
L_ERR_EXTRACT="Ошибка распаковки архива."
|
||||
L_ERR_BIN_NOT_FOUND="Бинарный файл не найден в архиве"
|
||||
L_I_STAGE_4=">>> Этап 4: Настройка окружения (Юзер, Группа, Папки)"
|
||||
L_I_STAGE_5=">>> Этап 5: Установка бинарного файла"
|
||||
L_I_STAGE_6=">>> Этап 6: Генерация/Обновление конфигурации"
|
||||
L_I_STAGE_7=">>> Этап 7: Установка и запуск службы"
|
||||
L_I_STAGE_5=">>> Этап 5: Настройка окружения (Юзер, Группа, Папки)"
|
||||
L_I_STAGE_6=">>> Этап 6: Установка бинарного файла"
|
||||
L_I_STAGE_7=">>> Этап 7: Генерация/Обновление конфигурации"
|
||||
L_I_STAGE_8=">>> Этап 8: Установка и запуск службы"
|
||||
L_OUT_WARN_H="УСТАНОВКА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ"
|
||||
L_OUT_WARN_D="Служба установлена, но не запустилась.\nПожалуйста, проверьте логи.\n"
|
||||
L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА"
|
||||
L_OUT_UNINST_H="УДАЛЕНИЕ ЗАВЕРШЕНО"
|
||||
L_OUT_LINK="Ваша ссылка для подключения к Telegram Proxy:\n"
|
||||
L_ERR_INCORR_ROOT_LOGIN="Используйте 'su -' или 'sudo -i' для входа под пользователем root"
|
||||
L_OUT_LOGS="Чтобы посмотреть логи (в случае проблем), используйте команду:"
|
||||
;;
|
||||
*)
|
||||
L_ERR_DOMAIN_REQ="requires a domain argument."
|
||||
@@ -159,27 +161,29 @@ set_language() {
|
||||
L_INFO_KEEP_CONF="Note: Configuration kept. Run with 'purge' to remove completely."
|
||||
L_INFO_I_START="Starting installation of"
|
||||
L_I_STAGE_1=">>> Stage 1: Verifying environment and dependencies"
|
||||
L_I_STAGE_1_5=">>> Stage 1.5: Interactive Setup"
|
||||
L_I_STAGE_2=">>> Stage 2: Interactive Setup"
|
||||
L_I_PROMPT_DOM="\nPlease specify the TLS Domain\nPress Enter to keep default [%s]: "
|
||||
L_I_PROMPT_PORT="\nPlease specify the Server Port\nPress Enter to keep default [%s]: "
|
||||
L_WARN_NO_TTY="Interactive mode unavailable (no TTY). Using:"
|
||||
L_I_STAGE_2=">>> Stage 2: Downloading archive"
|
||||
L_I_STAGE_3=">>> Stage 3: Downloading archive"
|
||||
L_ERR_TMP_DIR="Temp directory creation failed"
|
||||
L_ERR_TMP_INV="Temp directory is invalid or was not created"
|
||||
L_INFO_FALLBACK="x86_64-v3 build not found, falling back to standard x86_64..."
|
||||
L_ERR_DL_FAIL="Download failed"
|
||||
L_I_STAGE_3=">>> Stage 3: Extracting archive"
|
||||
L_I_STAGE_4=">>> Stage 4: Extracting archive"
|
||||
L_ERR_EXTRACT="Extraction failed."
|
||||
L_ERR_BIN_NOT_FOUND="Binary not found in archive"
|
||||
L_I_STAGE_4=">>> Stage 4: Setting up environment (User, Group, Directories)"
|
||||
L_I_STAGE_5=">>> Stage 5: Installing binary"
|
||||
L_I_STAGE_6=">>> Stage 6: Generating/Updating configuration"
|
||||
L_I_STAGE_7=">>> Stage 7: Installing and starting service"
|
||||
L_I_STAGE_5=">>> Stage 5: Setting up environment (User, Group, Directories)"
|
||||
L_I_STAGE_6=">>> Stage 6: Installing binary"
|
||||
L_I_STAGE_7=">>> Stage 7: Generating/Updating configuration"
|
||||
L_I_STAGE_8=">>> Stage 8: Installing and starting service"
|
||||
L_OUT_WARN_H="INSTALLATION COMPLETED WITH WARNINGS"
|
||||
L_OUT_WARN_D="The service was installed but failed to start.\nPlease check the logs to determine the issue.\n"
|
||||
L_OUT_SUCC_H="INSTALLATION SUCCESS"
|
||||
L_OUT_UNINST_H="UNINSTALLATION COMPLETE"
|
||||
L_OUT_LINK="Your Telegram Proxy connection link:\n"
|
||||
L_ERR_INCORR_ROOT_LOGIN="Use 'su -' or 'sudo -i' to login under root"
|
||||
L_OUT_LOGS="To view logs (in case of issues), use the following command:"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -267,7 +271,10 @@ say() {
|
||||
if [ "$#" -eq 0 ] || [ -z "${1:-}" ]; then
|
||||
printf '\n'
|
||||
else
|
||||
printf '[INFO] %s\n' "$*"
|
||||
case "$*" in
|
||||
\[*\]*) printf '%s\n' "$*" ;;
|
||||
*) printf '[INFO] %s\n' "$*" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
|
||||
@@ -392,7 +399,7 @@ verify_common() {
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if [ "${USER:-}" != "root" ] && [ "${LOGNAME:-}" != "root" ]; then
|
||||
die "$L_ERR_INCORR_ROOT_LOGIN"
|
||||
fi
|
||||
else
|
||||
@@ -525,9 +532,9 @@ setup_dirs() {
|
||||
|
||||
stop_service() {
|
||||
svc="$(get_svc_mgr)"
|
||||
if [ "$svc" = "systemd" ] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
if [ "$svc" = "systemd" ] && $SUDO systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
$SUDO systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
elif [ "$svc" = "openrc" ] && rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
|
||||
elif [ "$svc" = "openrc" ] && $SUDO rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
|
||||
$SUDO rc-service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
@@ -539,7 +546,7 @@ install_binary() {
|
||||
fi
|
||||
|
||||
$SUDO mkdir -p "$INSTALL_DIR" || die "$L_ERR_MKDIR"
|
||||
|
||||
|
||||
$SUDO rm -f "$bin_dst" 2>/dev/null || true
|
||||
|
||||
if command -v install >/dev/null 2>&1; then
|
||||
@@ -609,33 +616,33 @@ install_config() {
|
||||
|
||||
tmp_conf="${TEMP_DIR}/config.tmp"
|
||||
$SUDO cat "$CONFIG_FILE" > "$tmp_conf"
|
||||
|
||||
|
||||
escaped_domain="$(printf '%s\n' "$TLS_DOMAIN" | tr -d '[:cntrl:]' | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
||||
|
||||
awk -v port="$SERVER_PORT" -v secret="$USER_SECRET" -v domain="$escaped_domain" -v ad_tag="$AD_TAG" \
|
||||
-v flag_p="$PORT_PROVIDED" -v flag_s="$SECRET_PROVIDED" -v flag_d="$DOMAIN_PROVIDED" -v flag_a="$AD_TAG_PROVIDED" '
|
||||
BEGIN { ad_tag_handled = 0 }
|
||||
|
||||
|
||||
flag_p == "1" && /^[ \t]*port[ \t]*=/ { print "port = " port; next }
|
||||
flag_s == "1" && /^[ \t]*hello[ \t]*=/ { print "hello = \"" secret "\""; next }
|
||||
flag_d == "1" && /^[ \t]*tls_domain[ \t]*=/ { print "tls_domain = \"" domain "\""; next }
|
||||
|
||||
flag_a == "1" && /^[ \t]*ad_tag[ \t]*=/ {
|
||||
if (!ad_tag_handled) {
|
||||
print "ad_tag = \"" ad_tag "\"";
|
||||
ad_tag_handled = 1;
|
||||
}
|
||||
next
|
||||
|
||||
flag_a == "1" && /^[ \t]*ad_tag[ \t]*=/ {
|
||||
if (!ad_tag_handled) {
|
||||
print "ad_tag = \"" ad_tag "\"";
|
||||
ad_tag_handled = 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
flag_a == "1" && /^\[general\]/ {
|
||||
print;
|
||||
if (!ad_tag_handled) {
|
||||
print "ad_tag = \"" ad_tag "\"";
|
||||
ad_tag_handled = 1;
|
||||
}
|
||||
next
|
||||
flag_a == "1" && /^\[general\]/ {
|
||||
print;
|
||||
if (!ad_tag_handled) {
|
||||
print "ad_tag = \"" ad_tag "\"";
|
||||
ad_tag_handled = 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
|
||||
{ print }
|
||||
' "$tmp_conf" > "${tmp_conf}.new" && mv "${tmp_conf}.new" "$tmp_conf"
|
||||
|
||||
@@ -785,11 +792,11 @@ uninstall() {
|
||||
say "$L_U_STAGE_5"
|
||||
$SUDO rm -rf "$CONFIG_DIR" "$WORK_DIR"
|
||||
$SUDO rm -f "$CONFIG_FILE"
|
||||
|
||||
|
||||
if check_os_entity passwd telemt; then
|
||||
$SUDO userdel telemt 2>/dev/null || $SUDO deluser telemt 2>/dev/null || true
|
||||
fi
|
||||
|
||||
|
||||
if check_os_entity group telemt; then
|
||||
$SUDO groupdel telemt 2>/dev/null || $SUDO delgroup telemt 2>/dev/null || true
|
||||
fi
|
||||
@@ -830,10 +837,36 @@ case "$ACTION" in
|
||||
fi
|
||||
fi
|
||||
|
||||
check_port_availability
|
||||
if [ "$PORT_PROVIDED" -eq 0 ] || [ "$DOMAIN_PROVIDED" -eq 0 ]; then
|
||||
say "$L_I_STAGE_2"
|
||||
fi
|
||||
|
||||
if [ "$PORT_PROVIDED" -eq 0 ]; then
|
||||
if [ -t 0 ] || [ -c /dev/tty ]; then
|
||||
while true; do
|
||||
printf "$L_I_PROMPT_PORT" "$SERVER_PORT"
|
||||
read -r input_port </dev/tty || input_port=""
|
||||
if [ -z "$input_port" ]; then
|
||||
break
|
||||
fi
|
||||
case "$input_port" in
|
||||
*[!0-9]*) printf '[ERROR] %s\n' "$L_ERR_PORT_NUM" >&2; continue ;;
|
||||
esac
|
||||
port_num="$(printf '%s\n' "$input_port" | sed 's/^0*//')"
|
||||
[ -z "$port_num" ] && port_num="0"
|
||||
if [ "${#port_num}" -gt 5 ] || [ "$port_num" -lt 1 ] || [ "$port_num" -gt 65535 ]; then
|
||||
printf '[ERROR] %s\n' "$L_ERR_PORT_RANGE" >&2; continue
|
||||
fi
|
||||
SERVER_PORT="$port_num"
|
||||
break
|
||||
done
|
||||
else
|
||||
say "[WARNING] $L_WARN_NO_TTY $SERVER_PORT"
|
||||
fi
|
||||
PORT_PROVIDED=1
|
||||
fi
|
||||
|
||||
if [ "$DOMAIN_PROVIDED" -eq 0 ]; then
|
||||
say "$L_I_STAGE_1_5"
|
||||
if [ -t 0 ] || [ -c /dev/tty ]; then
|
||||
printf "$L_I_PROMPT_DOM" "$TLS_DOMAIN"
|
||||
read -r input_domain </dev/tty || input_domain=""
|
||||
@@ -846,6 +879,8 @@ case "$ACTION" in
|
||||
DOMAIN_PROVIDED=1
|
||||
fi
|
||||
|
||||
check_port_availability
|
||||
|
||||
if [ "$TARGET_VERSION" != "latest" ]; then
|
||||
TARGET_VERSION="${TARGET_VERSION#v}"
|
||||
fi
|
||||
@@ -859,7 +894,7 @@ case "$ACTION" in
|
||||
DL_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}/${FILE_NAME}"
|
||||
fi
|
||||
|
||||
say "$L_I_STAGE_2"
|
||||
say "$L_I_STAGE_3"
|
||||
TEMP_DIR="$(mktemp -d)" || die "$L_ERR_TMP_DIR"
|
||||
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
|
||||
die "$L_ERR_TMP_INV"
|
||||
@@ -881,7 +916,7 @@ case "$ACTION" in
|
||||
fi
|
||||
fi
|
||||
|
||||
say "$L_I_STAGE_3"
|
||||
say "$L_I_STAGE_4"
|
||||
if ! gzip -dc "${TEMP_DIR}/${FILE_NAME}" | tar -xf - -C "$TEMP_DIR" 2>/dev/null; then
|
||||
die "$L_ERR_EXTRACT"
|
||||
fi
|
||||
@@ -889,16 +924,16 @@ case "$ACTION" in
|
||||
EXTRACTED_BIN="$(find "$TEMP_DIR" -type f -name "$BIN_NAME" -print 2>/dev/null | head -n 1 || true)"
|
||||
[ -n "$EXTRACTED_BIN" ] || die "$L_ERR_BIN_NOT_FOUND"
|
||||
|
||||
say "$L_I_STAGE_4"
|
||||
say "$L_I_STAGE_5"
|
||||
ensure_user_group; setup_dirs; stop_service
|
||||
|
||||
say "$L_I_STAGE_5"
|
||||
say "$L_I_STAGE_6"
|
||||
install_binary "$EXTRACTED_BIN" "${INSTALL_DIR}/${BIN_NAME}"
|
||||
|
||||
say "$L_I_STAGE_6"
|
||||
say "$L_I_STAGE_7"
|
||||
install_config
|
||||
|
||||
say "$L_I_STAGE_7"
|
||||
say "$L_I_STAGE_8"
|
||||
install_service
|
||||
|
||||
if [ "${SERVICE_START_FAILED:-0}" -eq 1 ]; then
|
||||
@@ -916,7 +951,7 @@ case "$ACTION" in
|
||||
if command -v curl >/dev/null 2>&1; then SERVER_IP="$(curl -s4 -m 3 ifconfig.me 2>/dev/null || curl -s4 -m 3 api.ipify.org 2>/dev/null || true)"
|
||||
elif command -v wget >/dev/null 2>&1; then SERVER_IP="$(wget -qO- -T 3 ifconfig.me 2>/dev/null || wget -qO- -T 3 api.ipify.org 2>/dev/null || true)"; fi
|
||||
[ -z "$SERVER_IP" ] && SERVER_IP="<YOUR_SERVER_IP>"
|
||||
|
||||
|
||||
if command -v xxd >/dev/null 2>&1; then HEX_DOMAIN="$(printf '%s' "$TLS_DOMAIN" | xxd -p | tr -d '\n')"
|
||||
elif command -v hexdump >/dev/null 2>&1; then HEX_DOMAIN="$(printf '%s' "$TLS_DOMAIN" | hexdump -v -e '/1 "%02x"')"
|
||||
elif command -v od >/dev/null 2>&1; then HEX_DOMAIN="$(printf '%s' "$TLS_DOMAIN" | od -A n -t x1 | tr -d ' \n')"
|
||||
@@ -927,6 +962,15 @@ case "$ACTION" in
|
||||
printf '%b\n' "$L_OUT_LINK"
|
||||
printf ' tg://proxy?server=%s&port=%s&secret=%s\n\n' "$SERVER_IP" "$SERVER_PORT" "$CLIENT_SECRET"
|
||||
|
||||
svc="$(get_svc_mgr)"
|
||||
if [ "$svc" = "systemd" ]; then
|
||||
printf '%s\n' "$L_OUT_LOGS"
|
||||
printf ' sudo journalctl -u %s -f\n\n' "$SERVICE_NAME"
|
||||
elif [ "$svc" = "openrc" ]; then
|
||||
printf '%s\n' "$L_OUT_LOGS"
|
||||
printf ' sudo tail -f /var/log/messages /var/log/syslog 2>/dev/null | grep -i %s\n\n' "$SERVICE_NAME"
|
||||
fi
|
||||
|
||||
printf '====================================================================\n'
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -7,17 +7,19 @@ use hyper::header::IF_MATCH;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::{ProxyConfig, RateLimitBps};
|
||||
|
||||
use super::model::ApiFailure;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum AccessSection {
|
||||
Users,
|
||||
UserEnabled,
|
||||
UserAdTags,
|
||||
UserMaxTcpConns,
|
||||
UserExpirations,
|
||||
UserDataQuota,
|
||||
UserRateLimits,
|
||||
UserMaxUniqueIps,
|
||||
}
|
||||
|
||||
@@ -25,10 +27,12 @@ impl AccessSection {
|
||||
fn table_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Users => "access.users",
|
||||
Self::UserEnabled => "access.user_enabled",
|
||||
Self::UserAdTags => "access.user_ad_tags",
|
||||
Self::UserMaxTcpConns => "access.user_max_tcp_conns",
|
||||
Self::UserExpirations => "access.user_expirations",
|
||||
Self::UserDataQuota => "access.user_data_quota",
|
||||
Self::UserRateLimits => "access.user_rate_limits",
|
||||
Self::UserMaxUniqueIps => "access.user_max_unique_ips",
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,15 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserEnabled => {
|
||||
let rows: BTreeMap<String, bool> = cfg
|
||||
.access
|
||||
.user_enabled
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserAdTags => {
|
||||
let rows: BTreeMap<String, String> = cfg
|
||||
.access
|
||||
@@ -169,6 +182,15 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserRateLimits => {
|
||||
let rows: BTreeMap<String, RateLimitBps> = cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_rate_limit_body(&rows)?
|
||||
}
|
||||
AccessSection::UserMaxUniqueIps => {
|
||||
let rows: BTreeMap<String, usize> = cfg
|
||||
.access
|
||||
@@ -193,10 +215,12 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
|
||||
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
||||
match section {
|
||||
AccessSection::Users => cfg.access.users.is_empty(),
|
||||
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
|
||||
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
||||
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
||||
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
||||
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
||||
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
||||
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
||||
}
|
||||
}
|
||||
@@ -206,6 +230,28 @@ fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
||||
}
|
||||
|
||||
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
||||
let mut out = String::new();
|
||||
for (key, value) in rows {
|
||||
let key = serialize_toml_key(key)?;
|
||||
out.push_str(&format!(
|
||||
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
||||
value.up_bps, value.down_bps
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
||||
let mut row = BTreeMap::new();
|
||||
row.insert(key.to_string(), 0_u8);
|
||||
let rendered = serialize_table_body(&row)?;
|
||||
rendered
|
||||
.split_once(" = ")
|
||||
.map(|(key, _)| key.to_string())
|
||||
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
||||
}
|
||||
|
||||
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 mut out = String::with_capacity(source.len() + replacement.len());
|
||||
@@ -285,3 +331,26 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_user_rate_limits_section() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.user_rate_limits.insert(
|
||||
"alice".to_string(),
|
||||
RateLimitBps {
|
||||
up_bps: 1024,
|
||||
down_bps: 2048,
|
||||
},
|
||||
);
|
||||
|
||||
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
||||
.expect("section must render");
|
||||
|
||||
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
||||
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::StatusCode;
|
||||
use hyper::body::{Bytes, Incoming};
|
||||
use hyper::header::ALLOW;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
@@ -25,6 +26,8 @@ pub(super) fn success_response<T: Serialize>(
|
||||
}
|
||||
|
||||
pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Response<Full<Bytes>> {
|
||||
let status = failure.status;
|
||||
let allow = failure.allow;
|
||||
let payload = ErrorResponse {
|
||||
ok: false,
|
||||
error: ErrorBody {
|
||||
@@ -40,11 +43,13 @@ pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Res
|
||||
)
|
||||
.into_bytes()
|
||||
});
|
||||
hyper::Response::builder()
|
||||
.status(failure.status)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(Full::new(Bytes::from(body)))
|
||||
.unwrap()
|
||||
let mut builder = hyper::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json; charset=utf-8");
|
||||
if let Some(allow) = allow {
|
||||
builder = builder.header(ALLOW, allow);
|
||||
}
|
||||
builder.body(Full::new(Bytes::from(body))).unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn read_json<T: DeserializeOwned>(
|
||||
|
||||
264
src/api/mod.rs
264
src/api/mod.rs
@@ -22,6 +22,7 @@ use tracing::{debug, info, warn};
|
||||
use crate::config::{ApiGrayAction, ProxyConfig};
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::proxy::route_mode::RouteRuntimeController;
|
||||
use crate::proxy::shared_state::ProxySharedState;
|
||||
use crate::startup::StartupTracker;
|
||||
use crate::stats::Stats;
|
||||
use crate::transport::UpstreamManager;
|
||||
@@ -41,7 +42,9 @@ mod runtime_watch;
|
||||
mod runtime_zero;
|
||||
mod users;
|
||||
|
||||
use config_store::{current_revision, load_config_from_disk, parse_if_match};
|
||||
use config_store::{
|
||||
current_revision, ensure_expected_revision, load_config_from_disk, parse_if_match,
|
||||
};
|
||||
use events::ApiEventStore;
|
||||
use http_utils::{error_response, read_json, read_optional_json, success_response};
|
||||
use model::{
|
||||
@@ -49,9 +52,10 @@ use model::{
|
||||
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
|
||||
is_valid_username,
|
||||
};
|
||||
use patch::Patch;
|
||||
use runtime_edge::{
|
||||
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
|
||||
build_runtime_events_recent_data,
|
||||
build_runtime_events_recent_data, build_runtime_tls_fingerprints_data,
|
||||
};
|
||||
use runtime_init::build_runtime_initialization_data;
|
||||
use runtime_min::{
|
||||
@@ -68,11 +72,18 @@ use runtime_zero::{
|
||||
build_limits_effective_data, build_runtime_gates_data, build_security_posture_data,
|
||||
build_system_info_data,
|
||||
};
|
||||
use users::{create_user, delete_user, patch_user, rotate_secret, users_from_config};
|
||||
use users::{
|
||||
build_user_quota_list, create_user, delete_user, patch_user, rotate_secret, set_user_enabled,
|
||||
users_from_config,
|
||||
};
|
||||
|
||||
const API_MAX_CONTROL_CONNECTIONS: usize = 1024;
|
||||
const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const ROUTE_USERNAME_ERROR: &str = "username must match [A-Za-z0-9_.-] and be 1..64 chars";
|
||||
const ALLOW_GET: &str = "GET";
|
||||
const ALLOW_POST: &str = "POST";
|
||||
const ALLOW_GET_POST: &str = "GET, POST";
|
||||
const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE";
|
||||
|
||||
pub(super) struct ApiRuntimeState {
|
||||
pub(super) process_started_at_epoch_secs: u64,
|
||||
@@ -99,6 +110,7 @@ pub(super) struct ApiShared {
|
||||
pub(super) runtime_state: Arc<ApiRuntimeState>,
|
||||
pub(super) startup_tracker: Arc<StartupTracker>,
|
||||
pub(super) route_runtime: Arc<RouteRuntimeController>,
|
||||
pub(super) proxy_shared: Arc<ProxySharedState>,
|
||||
}
|
||||
|
||||
impl ApiShared {
|
||||
@@ -123,12 +135,67 @@ fn parse_route_username(user: &str) -> Result<&str, ApiFailure> {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_action_route_matches(path: &str, suffix: &str) -> bool {
|
||||
path.strip_prefix("/v1/users/")
|
||||
.and_then(|path| path.strip_suffix(suffix))
|
||||
.map(|user| !user.is_empty() && !user.contains('/'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
|
||||
match path {
|
||||
"/v1/health"
|
||||
| "/v1/health/ready"
|
||||
| "/v1/system/info"
|
||||
| "/v1/runtime/gates"
|
||||
| "/v1/runtime/initialization"
|
||||
| "/v1/limits/effective"
|
||||
| "/v1/security/posture"
|
||||
| "/v1/security/whitelist"
|
||||
| "/v1/stats/summary"
|
||||
| "/v1/stats/zero/all"
|
||||
| "/v1/stats/upstreams"
|
||||
| "/v1/stats/minimal/all"
|
||||
| "/v1/stats/me-writers"
|
||||
| "/v1/stats/dcs"
|
||||
| "/v1/runtime/me-pool-state"
|
||||
| "/v1/runtime/me_pool_state"
|
||||
| "/v1/runtime/me-quality"
|
||||
| "/v1/runtime/me_quality"
|
||||
| "/v1/runtime/upstream-quality"
|
||||
| "/v1/runtime/upstream_quality"
|
||||
| "/v1/runtime/nat-stun"
|
||||
| "/v1/runtime/nat_stun"
|
||||
| "/v1/runtime/me-selftest"
|
||||
| "/v1/runtime/connections/summary"
|
||||
| "/v1/runtime/events/recent"
|
||||
| "/v1/runtime/tls-fingerprints"
|
||||
| "/v1/stats/users/active-ips"
|
||||
| "/v1/stats/users/quota"
|
||||
| "/v1/stats/users" => Some(ALLOW_GET),
|
||||
"/v1/users" => Some(ALLOW_GET_POST),
|
||||
_ if user_action_route_matches(path, "/reset-quota") => Some(ALLOW_POST),
|
||||
_ if user_action_route_matches(path, "/rotate-secret") => Some(ALLOW_POST),
|
||||
_ if user_action_route_matches(path, "/enable") => Some(ALLOW_POST),
|
||||
_ if user_action_route_matches(path, "/disable") => Some(ALLOW_POST),
|
||||
_ if path
|
||||
.strip_prefix("/v1/users/")
|
||||
.map(|user| !user.is_empty() && !user.contains('/'))
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
Some(ALLOW_GET_PATCH_DELETE)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
listen: SocketAddr,
|
||||
stats: Arc<Stats>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
proxy_shared: Arc<ProxySharedState>,
|
||||
upstream_manager: Arc<UpstreamManager>,
|
||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||
admission_rx: watch::Receiver<bool>,
|
||||
@@ -178,6 +245,7 @@ pub async fn serve(
|
||||
runtime_state: runtime_state.clone(),
|
||||
startup_tracker,
|
||||
route_runtime,
|
||||
proxy_shared,
|
||||
});
|
||||
|
||||
spawn_runtime_watchers(
|
||||
@@ -433,22 +501,22 @@ async fn handle(
|
||||
let data = build_dcs_data(shared.as_ref(), api_cfg).await;
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/runtime/me_pool_state") => {
|
||||
("GET", "/v1/runtime/me-pool-state") | ("GET", "/v1/runtime/me_pool_state") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = build_runtime_me_pool_state_data(shared.as_ref()).await;
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/runtime/me_quality") => {
|
||||
("GET", "/v1/runtime/me-quality") | ("GET", "/v1/runtime/me_quality") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = build_runtime_me_quality_data(shared.as_ref()).await;
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/runtime/upstream_quality") => {
|
||||
("GET", "/v1/runtime/upstream-quality") | ("GET", "/v1/runtime/upstream_quality") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = build_runtime_upstream_quality_data(shared.as_ref()).await;
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/runtime/nat_stun") => {
|
||||
("GET", "/v1/runtime/nat-stun") | ("GET", "/v1/runtime/nat_stun") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = build_runtime_nat_stun_data(shared.as_ref()).await;
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
@@ -473,6 +541,15 @@ async fn handle(
|
||||
);
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/runtime/tls-fingerprints") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let data = build_runtime_tls_fingerprints_data(
|
||||
shared.as_ref(),
|
||||
cfg.as_ref(),
|
||||
query.as_deref(),
|
||||
);
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", "/v1/stats/users/active-ips") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let usernames: Vec<_> = cfg.access.users.keys().cloned().collect();
|
||||
@@ -504,6 +581,12 @@ async fn handle(
|
||||
.await;
|
||||
Ok(success_response(StatusCode::OK, users, revision))
|
||||
}
|
||||
("GET", "/v1/stats/users/quota") => {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
let data = build_user_quota_list(&disk_cfg, shared.stats.as_ref());
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("POST", "/v1/users") => {
|
||||
if api_cfg.read_only {
|
||||
return Ok(error_response(
|
||||
@@ -517,6 +600,7 @@ async fn handle(
|
||||
}
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?;
|
||||
let requested_enabled = body.enabled;
|
||||
let result = create_user(body, expected_revision, &shared).await;
|
||||
let (mut data, revision) = match result {
|
||||
Ok(ok) => ok,
|
||||
@@ -529,6 +613,25 @@ async fn handle(
|
||||
};
|
||||
let runtime_cfg = config_rx.borrow().clone();
|
||||
data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username);
|
||||
if let Some(enabled) = requested_enabled {
|
||||
shared
|
||||
.proxy_shared
|
||||
.set_user_enabled(&data.user.username, enabled);
|
||||
if !enabled {
|
||||
let cancelled = shared
|
||||
.proxy_shared
|
||||
.cancel_user_sessions(&data.user.username);
|
||||
if cancelled > 0 {
|
||||
shared.runtime_events.record(
|
||||
"api.user.disable.runtime",
|
||||
format!(
|
||||
"username={} cancelled_sessions={}",
|
||||
data.user.username, cancelled
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.runtime_events.record(
|
||||
"api.user.create.ok",
|
||||
format!("username={}", data.user.username),
|
||||
@@ -541,6 +644,99 @@ async fn handle(
|
||||
Ok(success_response(status, data, revision))
|
||||
}
|
||||
_ => {
|
||||
if method == Method::POST
|
||||
&& let Some(base_user) = normalized_path
|
||||
.strip_prefix("/v1/users/")
|
||||
.and_then(|path| path.strip_suffix("/enable"))
|
||||
&& !base_user.is_empty()
|
||||
&& !base_user.contains('/')
|
||||
{
|
||||
let base_user = parse_route_username(base_user)?;
|
||||
if api_cfg.read_only {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"read_only",
|
||||
"API runs in read-only mode",
|
||||
),
|
||||
));
|
||||
}
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let result =
|
||||
set_user_enabled(base_user, true, expected_revision, &shared).await;
|
||||
let (mut data, revision) = match result {
|
||||
Ok(ok) => ok,
|
||||
Err(error) => {
|
||||
shared.runtime_events.record(
|
||||
"api.user.enable.failed",
|
||||
format!("username={} code={}", base_user, error.code),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let runtime_cfg = config_rx.borrow().clone();
|
||||
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
||||
shared.proxy_shared.set_user_enabled(base_user, true);
|
||||
shared
|
||||
.runtime_events
|
||||
.record("api.user.enable.ok", format!("username={}", base_user));
|
||||
let status = if data.in_runtime {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::ACCEPTED
|
||||
};
|
||||
return Ok(success_response(status, data, revision));
|
||||
}
|
||||
if method == Method::POST
|
||||
&& let Some(base_user) = normalized_path
|
||||
.strip_prefix("/v1/users/")
|
||||
.and_then(|path| path.strip_suffix("/disable"))
|
||||
&& !base_user.is_empty()
|
||||
&& !base_user.contains('/')
|
||||
{
|
||||
let base_user = parse_route_username(base_user)?;
|
||||
if api_cfg.read_only {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"read_only",
|
||||
"API runs in read-only mode",
|
||||
),
|
||||
));
|
||||
}
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let result =
|
||||
set_user_enabled(base_user, false, expected_revision, &shared).await;
|
||||
let (mut data, revision) = match result {
|
||||
Ok(ok) => ok,
|
||||
Err(error) => {
|
||||
shared.runtime_events.record(
|
||||
"api.user.disable.failed",
|
||||
format!("username={} code={}", base_user, error.code),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let runtime_cfg = config_rx.borrow().clone();
|
||||
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
||||
let newly_disabled = shared.proxy_shared.set_user_enabled(base_user, false);
|
||||
let cancelled = shared.proxy_shared.cancel_user_sessions(base_user);
|
||||
shared.runtime_events.record(
|
||||
"api.user.disable.ok",
|
||||
format!(
|
||||
"username={} newly_disabled={} cancelled_sessions={}",
|
||||
base_user, newly_disabled, cancelled
|
||||
),
|
||||
);
|
||||
let status = if data.in_runtime {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::ACCEPTED
|
||||
};
|
||||
return Ok(success_response(status, data, revision));
|
||||
}
|
||||
if method == Method::POST
|
||||
&& let Some(user) = normalized_path
|
||||
.strip_prefix("/v1/users/")
|
||||
@@ -559,6 +755,16 @@ async fn handle(
|
||||
),
|
||||
));
|
||||
}
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref())
|
||||
.await?;
|
||||
if !disk_cfg.access.users.contains_key(user) {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "User not found"),
|
||||
));
|
||||
}
|
||||
let snapshot = match crate::quota_state::reset_user_quota(
|
||||
&shared.quota_state_path,
|
||||
shared.stats.as_ref(),
|
||||
@@ -688,6 +894,11 @@ async fn handle(
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let body =
|
||||
read_json::<PatchUserRequest>(req.into_body(), body_limit).await?;
|
||||
let enabled_update = match &body.enabled {
|
||||
Patch::Unchanged => None,
|
||||
Patch::Remove => Some(true),
|
||||
Patch::Set(enabled) => Some(*enabled),
|
||||
};
|
||||
let result = patch_user(user, body, expected_revision, &shared).await;
|
||||
let (mut data, revision) = match result {
|
||||
Ok(ok) => ok,
|
||||
@@ -701,6 +912,22 @@ async fn handle(
|
||||
};
|
||||
let runtime_cfg = config_rx.borrow().clone();
|
||||
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
|
||||
if let Some(enabled) = enabled_update {
|
||||
shared
|
||||
.proxy_shared
|
||||
.set_user_enabled(&data.username, enabled);
|
||||
if !enabled {
|
||||
let cancelled =
|
||||
shared.proxy_shared.cancel_user_sessions(&data.username);
|
||||
shared.runtime_events.record(
|
||||
"api.user.disable.runtime",
|
||||
format!(
|
||||
"username={} cancelled_sessions={}",
|
||||
data.username, cancelled
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
shared
|
||||
.runtime_events
|
||||
.record("api.user.patch.ok", format!("username={}", data.username));
|
||||
@@ -734,9 +961,12 @@ async fn handle(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
shared
|
||||
.runtime_events
|
||||
.record("api.user.delete.ok", format!("username={}", deleted_user));
|
||||
shared.proxy_shared.set_user_enabled(&deleted_user, true);
|
||||
let cancelled = shared.proxy_shared.cancel_user_sessions(&deleted_user);
|
||||
shared.runtime_events.record(
|
||||
"api.user.delete.ok",
|
||||
format!("username={} cancelled_sessions={}", deleted_user, cancelled),
|
||||
);
|
||||
let runtime_cfg = config_rx.borrow().clone();
|
||||
let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user);
|
||||
let response = DeleteUserResponse {
|
||||
@@ -753,16 +983,18 @@ async fn handle(
|
||||
if method == Method::POST {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "Route not found"),
|
||||
ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
|
||||
));
|
||||
}
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"method_not_allowed",
|
||||
"Unsupported HTTP method for this route",
|
||||
),
|
||||
ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
|
||||
));
|
||||
}
|
||||
if let Some(allow) = allowed_methods_for_path(normalized_path) {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::method_not_allowed(allow),
|
||||
));
|
||||
}
|
||||
debug!(
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(super) struct ApiFailure {
|
||||
pub(super) status: StatusCode,
|
||||
pub(super) code: &'static str,
|
||||
pub(super) message: String,
|
||||
pub(super) allow: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ApiFailure {
|
||||
@@ -23,6 +24,7 @@ impl ApiFailure {
|
||||
status,
|
||||
code,
|
||||
message: message.into(),
|
||||
allow: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +35,15 @@ impl ApiFailure {
|
||||
pub(super) fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
|
||||
}
|
||||
|
||||
pub(super) fn method_not_allowed(allow: &'static str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::METHOD_NOT_ALLOWED,
|
||||
code: "method_not_allowed",
|
||||
message: "Unsupported HTTP method for this route".to_string(),
|
||||
allow: Some(allow),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -468,11 +479,14 @@ pub(super) struct TlsDomainLink {
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct UserInfo {
|
||||
pub(super) username: String,
|
||||
pub(super) enabled: bool,
|
||||
pub(super) in_runtime: bool,
|
||||
pub(super) user_ad_tag: Option<String>,
|
||||
pub(super) max_tcp_conns: Option<usize>,
|
||||
pub(super) expiration_rfc3339: Option<String>,
|
||||
pub(super) data_quota_bytes: Option<u64>,
|
||||
pub(super) rate_limit_up_bps: Option<u64>,
|
||||
pub(super) rate_limit_down_bps: Option<u64>,
|
||||
pub(super) max_unique_ips: Option<usize>,
|
||||
pub(super) current_connections: u64,
|
||||
pub(super) active_unique_ips: usize,
|
||||
@@ -508,6 +522,19 @@ pub(super) struct ResetUserQuotaResponse {
|
||||
pub(super) last_reset_epoch_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct UserQuotaListData {
|
||||
pub(super) users: Vec<UserQuotaEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct UserQuotaEntry {
|
||||
pub(super) username: String,
|
||||
pub(super) data_quota_bytes: u64,
|
||||
pub(super) used_bytes: u64,
|
||||
pub(super) last_reset_epoch_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct CreateUserRequest {
|
||||
pub(super) username: String,
|
||||
@@ -516,7 +543,10 @@ pub(super) struct CreateUserRequest {
|
||||
pub(super) max_tcp_conns: Option<usize>,
|
||||
pub(super) expiration_rfc3339: Option<String>,
|
||||
pub(super) data_quota_bytes: Option<u64>,
|
||||
pub(super) rate_limit_up_bps: Option<u64>,
|
||||
pub(super) rate_limit_down_bps: Option<u64>,
|
||||
pub(super) max_unique_ips: Option<usize>,
|
||||
pub(super) enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -531,7 +561,13 @@ pub(super) struct PatchUserRequest {
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) data_quota_bytes: Patch<u64>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) rate_limit_up_bps: Patch<u64>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) rate_limit_down_bps: Patch<u64>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) max_unique_ips: Patch<usize>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) enabled: Patch<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
|
||||
@@ -114,7 +114,9 @@ mod tests {
|
||||
"secret": "00112233445566778899aabbccddeeff",
|
||||
"max_tcp_conns": 0,
|
||||
"max_unique_ips": null,
|
||||
"data_quota_bytes": 1024
|
||||
"data_quota_bytes": 1024,
|
||||
"rate_limit_up_bps": 4096,
|
||||
"rate_limit_down_bps": null
|
||||
}"#;
|
||||
let req: PatchUserRequest = serde_json::from_str(raw).expect("valid json");
|
||||
assert_eq!(
|
||||
@@ -124,6 +126,8 @@ mod tests {
|
||||
assert!(matches!(req.max_tcp_conns, Patch::Set(0)));
|
||||
assert!(matches!(req.max_unique_ips, Patch::Remove));
|
||||
assert!(matches!(req.data_quota_bytes, Patch::Set(1024)));
|
||||
assert!(matches!(req.rate_limit_up_bps, Patch::Set(4096)));
|
||||
assert!(matches!(req.rate_limit_down_bps, Patch::Remove));
|
||||
assert!(matches!(req.expiration_rfc3339, Patch::Unchanged));
|
||||
assert!(matches!(req.user_ad_tag, Patch::Unchanged));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const FEATURE_DISABLED_REASON: &str = "feature_disabled";
|
||||
const SOURCE_UNAVAILABLE_REASON: &str = "source_unavailable";
|
||||
const EVENTS_DEFAULT_LIMIT: usize = 50;
|
||||
const EVENTS_MAX_LIMIT: usize = 1000;
|
||||
const TLS_FINGERPRINTS_MAX_LIMIT: usize = 1000;
|
||||
const RUNTIME_EDGE_RETENTION_MAX_MINUTES: u64 = 24 * 60;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub(super) struct RuntimeEdgeConnectionUserData {
|
||||
@@ -90,6 +92,44 @@ pub(super) struct RuntimeEdgeEventsData {
|
||||
pub(super) data: Option<RuntimeEdgeEventsPayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct RuntimeEdgeTlsFingerprintRow {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) scope: Option<String>,
|
||||
pub(super) ja3: String,
|
||||
pub(super) ja3_raw: String,
|
||||
pub(super) ja4: String,
|
||||
pub(super) ja4_raw: String,
|
||||
pub(super) total: u64,
|
||||
pub(super) auth_success: u64,
|
||||
pub(super) bad_or_probe: u64,
|
||||
pub(super) first_seen_epoch_secs: u64,
|
||||
pub(super) last_seen_epoch_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct RuntimeEdgeTlsFingerprintsPayload {
|
||||
pub(super) limit: usize,
|
||||
pub(super) retention_secs: u64,
|
||||
pub(super) capacity: usize,
|
||||
pub(super) dropped_total: u64,
|
||||
pub(super) parse_error_total: u64,
|
||||
pub(super) by_fingerprint: Vec<RuntimeEdgeTlsFingerprintRow>,
|
||||
pub(super) by_ip: Vec<RuntimeEdgeTlsFingerprintRow>,
|
||||
pub(super) by_cidr: Vec<RuntimeEdgeTlsFingerprintRow>,
|
||||
pub(super) by_user: Vec<RuntimeEdgeTlsFingerprintRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct RuntimeEdgeTlsFingerprintsData {
|
||||
pub(super) enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) reason: Option<&'static str>,
|
||||
pub(super) generated_at_epoch_secs: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) data: Option<RuntimeEdgeTlsFingerprintsPayload>,
|
||||
}
|
||||
|
||||
pub(super) async fn build_runtime_connections_summary_data(
|
||||
shared: &ApiShared,
|
||||
cfg: &ProxyConfig,
|
||||
@@ -162,6 +202,65 @@ pub(super) fn build_runtime_events_recent_data(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_tls_fingerprints_data(
|
||||
shared: &ApiShared,
|
||||
cfg: &ProxyConfig,
|
||||
query: Option<&str>,
|
||||
) -> RuntimeEdgeTlsFingerprintsData {
|
||||
let now_epoch_secs = now_epoch_secs();
|
||||
let api_cfg = &cfg.server.api;
|
||||
if !api_cfg.runtime_edge_enabled {
|
||||
return RuntimeEdgeTlsFingerprintsData {
|
||||
enabled: false,
|
||||
reason: Some(FEATURE_DISABLED_REASON),
|
||||
generated_at_epoch_secs: now_epoch_secs,
|
||||
data: None,
|
||||
};
|
||||
}
|
||||
|
||||
let limit = parse_recent_events_limit(
|
||||
query,
|
||||
api_cfg.runtime_edge_top_n.max(1),
|
||||
TLS_FINGERPRINTS_MAX_LIMIT,
|
||||
);
|
||||
let snapshot = shared
|
||||
.stats
|
||||
.tls_fingerprint_snapshot(runtime_edge_retention(cfg), limit);
|
||||
|
||||
RuntimeEdgeTlsFingerprintsData {
|
||||
enabled: true,
|
||||
reason: None,
|
||||
generated_at_epoch_secs: now_epoch_secs,
|
||||
data: Some(RuntimeEdgeTlsFingerprintsPayload {
|
||||
limit,
|
||||
retention_secs: snapshot.retention_secs,
|
||||
capacity: snapshot.capacity,
|
||||
dropped_total: snapshot.dropped_total,
|
||||
parse_error_total: snapshot.parse_error_total,
|
||||
by_fingerprint: snapshot
|
||||
.by_fingerprint
|
||||
.into_iter()
|
||||
.map(runtime_tls_fingerprint_row)
|
||||
.collect(),
|
||||
by_ip: snapshot
|
||||
.by_ip
|
||||
.into_iter()
|
||||
.map(runtime_tls_fingerprint_row)
|
||||
.collect(),
|
||||
by_cidr: snapshot
|
||||
.by_cidr
|
||||
.into_iter()
|
||||
.map(runtime_tls_fingerprint_row)
|
||||
.collect(),
|
||||
by_user: snapshot
|
||||
.by_user
|
||||
.into_iter()
|
||||
.map(runtime_tls_fingerprint_row)
|
||||
.collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_connections_payload_cached(
|
||||
shared: &ApiShared,
|
||||
cache_ttl_ms: u64,
|
||||
@@ -286,6 +385,35 @@ fn parse_recent_events_limit(query: Option<&str>, default_limit: usize, max_limi
|
||||
default_limit
|
||||
}
|
||||
|
||||
fn runtime_edge_retention(cfg: &ProxyConfig) -> Duration {
|
||||
let minutes = cfg
|
||||
.general
|
||||
.beobachten_minutes
|
||||
.clamp(1, RUNTIME_EDGE_RETENTION_MAX_MINUTES);
|
||||
Duration::from_secs(minutes.saturating_mul(60))
|
||||
}
|
||||
|
||||
fn runtime_tls_fingerprint_row(
|
||||
row: crate::stats::TlsFingerprintSnapshotRow,
|
||||
) -> RuntimeEdgeTlsFingerprintRow {
|
||||
RuntimeEdgeTlsFingerprintRow {
|
||||
scope: if row.scope_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(row.scope_key)
|
||||
},
|
||||
ja3: row.ja3,
|
||||
ja3_raw: row.ja3_raw,
|
||||
ja4: row.ja4,
|
||||
ja4_raw: row.ja4_raw,
|
||||
total: row.total,
|
||||
auth_success: row.auth_success,
|
||||
bad_or_probe: row.bad_or_probe,
|
||||
first_seen_epoch_secs: row.first_seen_epoch_secs,
|
||||
last_seen_epoch_secs: row.last_seen_epoch_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -178,6 +178,7 @@ pub(super) async fn build_runtime_gates_data(
|
||||
cfg: &ProxyConfig,
|
||||
) -> RuntimeGatesData {
|
||||
let startup_summary = build_runtime_startup_summary(shared).await;
|
||||
let startup_snapshot = shared.startup_tracker.snapshot().await;
|
||||
let route_state = shared.route_runtime.snapshot();
|
||||
let route_mode = route_state.mode.as_str();
|
||||
let fast_fallback_enabled =
|
||||
@@ -191,7 +192,9 @@ pub(super) async fn build_runtime_gates_data(
|
||||
None
|
||||
};
|
||||
let reroute_reason = if reroute_active {
|
||||
if fast_fallback_enabled {
|
||||
if startup_snapshot.me.status.as_str() != "ready" {
|
||||
Some("startup_direct_fallback")
|
||||
} else if fast_fallback_enabled {
|
||||
Some("fast_not_ready_fallback")
|
||||
} else {
|
||||
Some("strict_grace_fallback")
|
||||
|
||||
297
src/api/users.rs
297
src/api/users.rs
@@ -3,6 +3,7 @@ use std::net::IpAddr;
|
||||
use hyper::StatusCode;
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::RateLimitBps;
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::stats::Stats;
|
||||
|
||||
@@ -13,8 +14,9 @@ use super::config_store::{
|
||||
};
|
||||
use super::model::{
|
||||
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
||||
TlsDomainLink, UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username,
|
||||
parse_optional_expiration, parse_patch_expiration, random_user_secret,
|
||||
TlsDomainLink, UserInfo, UserLinks, UserQuotaEntry, UserQuotaListData, is_valid_ad_tag,
|
||||
is_valid_user_secret, is_valid_username, parse_optional_expiration, parse_patch_expiration,
|
||||
random_user_secret,
|
||||
};
|
||||
use super::patch::Patch;
|
||||
|
||||
@@ -27,7 +29,10 @@ pub(super) async fn create_user(
|
||||
let touches_user_max_tcp_conns = body.max_tcp_conns.is_some();
|
||||
let touches_user_expirations = body.expiration_rfc3339.is_some();
|
||||
let touches_user_data_quota = body.data_quota_bytes.is_some();
|
||||
let touches_user_rate_limits =
|
||||
body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some();
|
||||
let touches_user_max_unique_ips = body.max_unique_ips.is_some();
|
||||
let touches_user_enabled = matches!(body.enabled, Some(false));
|
||||
|
||||
if !is_valid_username(&body.username) {
|
||||
return Err(ApiFailure::bad_request(
|
||||
@@ -91,6 +96,15 @@ pub(super) async fn create_user(
|
||||
.user_data_quota
|
||||
.insert(body.username.clone(), quota);
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
cfg.access.user_rate_limits.insert(
|
||||
body.username.clone(),
|
||||
RateLimitBps {
|
||||
up_bps: body.rate_limit_up_bps.unwrap_or(0),
|
||||
down_bps: body.rate_limit_down_bps.unwrap_or(0),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let updated_limit = body.max_unique_ips;
|
||||
if let Some(limit) = updated_limit {
|
||||
@@ -98,6 +112,9 @@ pub(super) async fn create_user(
|
||||
.user_max_unique_ips
|
||||
.insert(body.username.clone(), limit);
|
||||
}
|
||||
if matches!(body.enabled, Some(false)) {
|
||||
cfg.access.user_enabled.insert(body.username.clone(), false);
|
||||
}
|
||||
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
@@ -115,9 +132,15 @@ pub(super) async fn create_user(
|
||||
if touches_user_data_quota {
|
||||
touched_sections.push(AccessSection::UserDataQuota);
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
touched_sections.push(AccessSection::UserRateLimits);
|
||||
}
|
||||
if touches_user_max_unique_ips {
|
||||
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||
}
|
||||
if touches_user_enabled {
|
||||
touched_sections.push(AccessSection::UserEnabled);
|
||||
}
|
||||
|
||||
let revision =
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||
@@ -145,6 +168,7 @@ pub(super) async fn create_user(
|
||||
.find(|entry| entry.username == body.username)
|
||||
.unwrap_or(UserInfo {
|
||||
username: body.username.clone(),
|
||||
enabled: cfg.access.is_user_enabled(&body.username),
|
||||
in_runtime: false,
|
||||
user_ad_tag: None,
|
||||
max_tcp_conns: cfg
|
||||
@@ -157,6 +181,8 @@ pub(super) async fn create_user(
|
||||
.then_some(cfg.access.user_max_tcp_conns_global_each)),
|
||||
expiration_rfc3339: None,
|
||||
data_quota_bytes: None,
|
||||
rate_limit_up_bps: body.rate_limit_up_bps.filter(|limit| *limit > 0),
|
||||
rate_limit_down_bps: body.rate_limit_down_bps.filter(|limit| *limit > 0),
|
||||
max_unique_ips: updated_limit,
|
||||
current_connections: 0,
|
||||
active_unique_ips: 0,
|
||||
@@ -181,7 +207,10 @@ pub(super) async fn patch_user(
|
||||
let touches_user_max_tcp_conns = !matches!(&body.max_tcp_conns, Patch::Unchanged);
|
||||
let touches_user_expirations = !matches!(&body.expiration_rfc3339, Patch::Unchanged);
|
||||
let touches_user_data_quota = !matches!(&body.data_quota_bytes, Patch::Unchanged);
|
||||
let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged)
|
||||
|| !matches!(&body.rate_limit_down_bps, Patch::Unchanged);
|
||||
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
|
||||
let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged);
|
||||
|
||||
if let Some(secret) = body.secret.as_ref()
|
||||
&& !is_valid_user_secret(secret)
|
||||
@@ -253,6 +282,31 @@ pub(super) async fn patch_user(
|
||||
cfg.access.user_data_quota.insert(user.to_string(), quota);
|
||||
}
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
let mut rate_limit = cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.get(user)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
match body.rate_limit_up_bps {
|
||||
Patch::Unchanged => {}
|
||||
Patch::Remove => rate_limit.up_bps = 0,
|
||||
Patch::Set(limit) => rate_limit.up_bps = limit,
|
||||
}
|
||||
match body.rate_limit_down_bps {
|
||||
Patch::Unchanged => {}
|
||||
Patch::Remove => rate_limit.down_bps = 0,
|
||||
Patch::Set(limit) => rate_limit.down_bps = limit,
|
||||
}
|
||||
if rate_limit.up_bps == 0 && rate_limit.down_bps == 0 {
|
||||
cfg.access.user_rate_limits.remove(user);
|
||||
} else {
|
||||
cfg.access
|
||||
.user_rate_limits
|
||||
.insert(user.to_string(), rate_limit);
|
||||
}
|
||||
}
|
||||
// Capture how the per-user IP limit changed, so the in-memory ip_tracker
|
||||
// can be synced (set or removed) after the config is persisted.
|
||||
let max_unique_ips_change = match body.max_unique_ips {
|
||||
@@ -268,6 +322,15 @@ pub(super) async fn patch_user(
|
||||
Some(Some(limit))
|
||||
}
|
||||
};
|
||||
match body.enabled {
|
||||
Patch::Unchanged => {}
|
||||
Patch::Remove | Patch::Set(true) => {
|
||||
cfg.access.user_enabled.remove(user);
|
||||
}
|
||||
Patch::Set(false) => {
|
||||
cfg.access.user_enabled.insert(user.to_string(), false);
|
||||
}
|
||||
}
|
||||
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
@@ -288,9 +351,15 @@ pub(super) async fn patch_user(
|
||||
if touches_user_data_quota {
|
||||
touched_sections.push(AccessSection::UserDataQuota);
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
touched_sections.push(AccessSection::UserRateLimits);
|
||||
}
|
||||
if touches_user_max_unique_ips {
|
||||
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||
}
|
||||
if touches_user_enabled {
|
||||
touched_sections.push(AccessSection::UserEnabled);
|
||||
}
|
||||
|
||||
let revision = if touched_sections.is_empty() {
|
||||
current_revision(&shared.config_path).await?
|
||||
@@ -351,10 +420,12 @@ pub(super) async fn rotate_secret(
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
let touched_sections = [
|
||||
AccessSection::Users,
|
||||
AccessSection::UserEnabled,
|
||||
AccessSection::UserAdTags,
|
||||
AccessSection::UserMaxTcpConns,
|
||||
AccessSection::UserExpirations,
|
||||
AccessSection::UserDataQuota,
|
||||
AccessSection::UserRateLimits,
|
||||
AccessSection::UserMaxUniqueIps,
|
||||
];
|
||||
let revision =
|
||||
@@ -385,6 +456,55 @@ pub(super) async fn rotate_secret(
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn set_user_enabled(
|
||||
user: &str,
|
||||
enabled: bool,
|
||||
expected_revision: Option<String>,
|
||||
shared: &ApiShared,
|
||||
) -> Result<(UserInfo, String), ApiFailure> {
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
|
||||
if !cfg.access.users.contains_key(user) {
|
||||
return Err(ApiFailure::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"User not found",
|
||||
));
|
||||
}
|
||||
|
||||
if enabled {
|
||||
cfg.access.user_enabled.remove(user);
|
||||
} else {
|
||||
cfg.access.user_enabled.insert(user.to_string(), false);
|
||||
}
|
||||
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
let revision =
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
|
||||
.await?;
|
||||
drop(_guard);
|
||||
|
||||
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||
let users = users_from_config(
|
||||
&cfg,
|
||||
&shared.stats,
|
||||
&shared.ip_tracker,
|
||||
detected_ip_v4,
|
||||
detected_ip_v6,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let user_info = users
|
||||
.into_iter()
|
||||
.find(|entry| entry.username == user)
|
||||
.ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?;
|
||||
|
||||
Ok((user_info, revision))
|
||||
}
|
||||
|
||||
pub(super) async fn delete_user(
|
||||
user: &str,
|
||||
expected_revision: Option<String>,
|
||||
@@ -410,20 +530,24 @@ pub(super) async fn delete_user(
|
||||
}
|
||||
|
||||
cfg.access.users.remove(user);
|
||||
cfg.access.user_enabled.remove(user);
|
||||
cfg.access.user_ad_tags.remove(user);
|
||||
cfg.access.user_max_tcp_conns.remove(user);
|
||||
cfg.access.user_expirations.remove(user);
|
||||
cfg.access.user_data_quota.remove(user);
|
||||
cfg.access.user_rate_limits.remove(user);
|
||||
cfg.access.user_max_unique_ips.remove(user);
|
||||
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
let touched_sections = [
|
||||
AccessSection::Users,
|
||||
AccessSection::UserEnabled,
|
||||
AccessSection::UserAdTags,
|
||||
AccessSection::UserMaxTcpConns,
|
||||
AccessSection::UserExpirations,
|
||||
AccessSection::UserDataQuota,
|
||||
AccessSection::UserRateLimits,
|
||||
AccessSection::UserMaxUniqueIps,
|
||||
];
|
||||
let revision =
|
||||
@@ -467,6 +591,7 @@ pub(super) async fn users_from_config(
|
||||
})
|
||||
.unwrap_or_else(empty_user_links);
|
||||
users.push(UserInfo {
|
||||
enabled: cfg.access.is_user_enabled(&username),
|
||||
in_runtime: runtime_cfg
|
||||
.map(|runtime| runtime.access.users.contains_key(&username))
|
||||
.unwrap_or(false),
|
||||
@@ -485,6 +610,18 @@ pub(super) async fn users_from_config(
|
||||
.get(&username)
|
||||
.map(chrono::DateTime::<chrono::Utc>::to_rfc3339),
|
||||
data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(),
|
||||
rate_limit_up_bps: cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.get(&username)
|
||||
.map(|limit| limit.up_bps)
|
||||
.filter(|limit| *limit > 0),
|
||||
rate_limit_down_bps: cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.get(&username)
|
||||
.map(|limit| limit.down_bps)
|
||||
.filter(|limit| *limit > 0),
|
||||
max_unique_ips: cfg
|
||||
.access
|
||||
.user_max_unique_ips
|
||||
@@ -506,6 +643,33 @@ pub(super) async fn users_from_config(
|
||||
users
|
||||
}
|
||||
|
||||
pub(super) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData {
|
||||
let mut names = cfg.access.users.keys().cloned().collect::<Vec<_>>();
|
||||
names.sort();
|
||||
|
||||
let snapshot = stats.user_quota_snapshot();
|
||||
let mut users = Vec::with_capacity(names.len());
|
||||
for username in names {
|
||||
let Some(&data_quota_bytes) = cfg.access.user_data_quota.get(&username) else {
|
||||
continue;
|
||||
};
|
||||
if data_quota_bytes == 0 {
|
||||
continue;
|
||||
}
|
||||
let (used_bytes, last_reset_epoch_secs) = snapshot
|
||||
.get(&username)
|
||||
.map(|entry| (entry.used_bytes, entry.last_reset_epoch_secs))
|
||||
.unwrap_or((0, 0));
|
||||
users.push(UserQuotaEntry {
|
||||
username,
|
||||
data_quota_bytes,
|
||||
used_bytes,
|
||||
last_reset_epoch_secs,
|
||||
});
|
||||
}
|
||||
UserQuotaListData { users }
|
||||
}
|
||||
|
||||
fn empty_user_links() -> UserLinks {
|
||||
UserLinks {
|
||||
classic: Vec::new(),
|
||||
@@ -758,6 +922,71 @@ mod tests {
|
||||
assert_eq!(alice.max_tcp_conns, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_from_config_reports_user_rate_limits() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.users.insert(
|
||||
"alice".to_string(),
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
);
|
||||
cfg.access.user_rate_limits.insert(
|
||||
"alice".to_string(),
|
||||
RateLimitBps {
|
||||
up_bps: 1024,
|
||||
down_bps: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let stats = Stats::new();
|
||||
let tracker = UserIpTracker::new();
|
||||
|
||||
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||
let alice = users
|
||||
.iter()
|
||||
.find(|entry| entry.username == "alice")
|
||||
.expect("alice must be present");
|
||||
|
||||
assert_eq!(alice.rate_limit_up_bps, Some(1024));
|
||||
assert_eq!(alice.rate_limit_down_bps, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_from_config_reports_user_enabled_default_and_override() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.users.insert(
|
||||
"alice".to_string(),
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
);
|
||||
cfg.access.users.insert(
|
||||
"bob".to_string(),
|
||||
"fedcba9876543210fedcba9876543210".to_string(),
|
||||
);
|
||||
cfg.access.user_enabled.insert("bob".to_string(), false);
|
||||
|
||||
let stats = Stats::new();
|
||||
let tracker = UserIpTracker::new();
|
||||
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||
let alice = users
|
||||
.iter()
|
||||
.find(|entry| entry.username == "alice")
|
||||
.expect("alice must be present");
|
||||
let bob = users
|
||||
.iter()
|
||||
.find(|entry| entry.username == "bob")
|
||||
.expect("bob must be present");
|
||||
|
||||
assert!(alice.enabled);
|
||||
assert!(!bob.enabled);
|
||||
|
||||
cfg.access.user_enabled.insert("bob".to_string(), true);
|
||||
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||
let bob = users
|
||||
.iter()
|
||||
.find(|entry| entry.username == "bob")
|
||||
.expect("bob must be present");
|
||||
assert!(bob.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() {
|
||||
let mut disk_cfg = ProxyConfig::default();
|
||||
@@ -869,4 +1098,68 @@ mod tests {
|
||||
.any(|entry| entry.domain == "front-a.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_user_quota_list_skips_users_without_positive_quota_and_sorts_by_username() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.users.insert(
|
||||
"alice".to_string(),
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
);
|
||||
cfg.access.users.insert(
|
||||
"bob".to_string(),
|
||||
"fedcba9876543210fedcba9876543210".to_string(),
|
||||
);
|
||||
cfg.access.users.insert(
|
||||
"carol".to_string(),
|
||||
"aaaabbbbccccddddeeeeffff00001111".to_string(),
|
||||
);
|
||||
// alice has a positive quota and should be listed.
|
||||
cfg.access
|
||||
.user_data_quota
|
||||
.insert("alice".to_string(), 1 << 20);
|
||||
// bob has no quota entry at all (None) — should be skipped.
|
||||
// carol has an explicit zero quota — should be skipped.
|
||||
cfg.access.user_data_quota.insert("carol".to_string(), 0);
|
||||
|
||||
let stats = Stats::new();
|
||||
// Charge some traffic against alice; carol gets traffic too but should
|
||||
// still be filtered out by the quota check.
|
||||
let alice_stats = stats.get_or_create_user_stats_handle("alice");
|
||||
stats.quota_charge_post_write(&alice_stats, 4096);
|
||||
let carol_stats = stats.get_or_create_user_stats_handle("carol");
|
||||
stats.quota_charge_post_write(&carol_stats, 99);
|
||||
|
||||
let data = build_user_quota_list(&cfg, &stats);
|
||||
|
||||
assert_eq!(data.users.len(), 1);
|
||||
let entry = &data.users[0];
|
||||
assert_eq!(entry.username, "alice");
|
||||
assert_eq!(entry.data_quota_bytes, 1 << 20);
|
||||
assert_eq!(entry.used_bytes, 4096);
|
||||
assert_eq!(entry.last_reset_epoch_secs, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_user_quota_list_orders_multiple_users_by_username_ascending() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
for name in ["charlie", "alice", "bob"] {
|
||||
cfg.access.users.insert(
|
||||
name.to_string(),
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
);
|
||||
cfg.access.user_data_quota.insert(name.to_string(), 1 << 30);
|
||||
}
|
||||
|
||||
let stats = Stats::new();
|
||||
let data = build_user_quota_list(&cfg, &stats);
|
||||
|
||||
let names: Vec<&str> = data.users.iter().map(|e| e.username.as_str()).collect();
|
||||
assert_eq!(names, vec!["alice", "bob", "charlie"]);
|
||||
for entry in &data.users {
|
||||
assert_eq!(entry.used_bytes, 0);
|
||||
assert_eq!(entry.last_reset_epoch_secs, 0);
|
||||
assert_eq!(entry.data_quota_bytes, 1 << 30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +705,9 @@ ignore_time_skew = false
|
||||
type = "direct"
|
||||
enabled = true
|
||||
weight = 10
|
||||
# Optional per-upstream DC family policy:
|
||||
# ipv6 = true
|
||||
# prefer = 6
|
||||
"#,
|
||||
username = username,
|
||||
secret = secret,
|
||||
|
||||
@@ -118,6 +118,7 @@ pub struct HotFields {
|
||||
pub me_admission_poll_ms: u64,
|
||||
pub me_warn_rate_limit_ms: u64,
|
||||
pub users: std::collections::HashMap<String, String>,
|
||||
pub user_enabled: std::collections::HashMap<String, bool>,
|
||||
pub user_ad_tags: std::collections::HashMap<String, String>,
|
||||
pub user_max_tcp_conns: std::collections::HashMap<String, usize>,
|
||||
pub user_max_tcp_conns_global_each: usize,
|
||||
@@ -247,6 +248,7 @@ impl HotFields {
|
||||
me_admission_poll_ms: cfg.general.me_admission_poll_ms,
|
||||
me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms,
|
||||
users: cfg.access.users.clone(),
|
||||
user_enabled: cfg.access.user_enabled.clone(),
|
||||
user_ad_tags: cfg.access.user_ad_tags.clone(),
|
||||
user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(),
|
||||
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
|
||||
@@ -310,6 +312,7 @@ fn listeners_equal(
|
||||
lhs.iter().zip(rhs.iter()).all(|(a, b)| {
|
||||
a.ip == b.ip
|
||||
&& a.port == b.port
|
||||
&& a.client_mss == b.client_mss
|
||||
&& a.announce == b.announce
|
||||
&& a.announce_ip == b.announce_ip
|
||||
&& a.proxy_protocol == b.proxy_protocol
|
||||
@@ -551,6 +554,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||
cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms;
|
||||
|
||||
cfg.access.users = new.access.users.clone();
|
||||
cfg.access.user_enabled = new.access.user_enabled.clone();
|
||||
cfg.access.user_ad_tags = new.access.user_ad_tags.clone();
|
||||
cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone();
|
||||
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
|
||||
@@ -605,6 +609,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|
||||
|| old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4
|
||||
|| old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6
|
||||
|| old.server.listen_tcp != new.server.listen_tcp
|
||||
|| old.server.client_mss != new.server.client_mss
|
||||
|| old.server.listen_unix_sock != new.server.listen_unix_sock
|
||||
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
|
||||
{
|
||||
@@ -617,6 +622,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|
||||
|| old.censorship.mask != new.censorship.mask
|
||||
|| old.censorship.mask_host != new.censorship.mask_host
|
||||
|| old.censorship.mask_port != new.censorship.mask_port
|
||||
|| old.censorship.exclusive_mask != new.censorship.exclusive_mask
|
||||
|| old.censorship.mask_unix_sock != new.censorship.mask_unix_sock
|
||||
|| old.censorship.fake_cert_len != new.censorship.fake_cert_len
|
||||
|| old.censorship.tls_emulation != new.censorship.tls_emulation
|
||||
@@ -1177,6 +1183,16 @@ fn log_changes(
|
||||
}
|
||||
}
|
||||
|
||||
if old_hot.user_enabled != new_hot.user_enabled {
|
||||
info!(
|
||||
"config reload: user_enabled updated ({} disabled overrides)",
|
||||
new_hot
|
||||
.user_enabled
|
||||
.values()
|
||||
.filter(|enabled| !**enabled)
|
||||
.count()
|
||||
);
|
||||
}
|
||||
if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns {
|
||||
info!(
|
||||
"config reload: user_max_tcp_conns updated ({} entries)",
|
||||
|
||||
@@ -31,6 +31,87 @@ fn is_valid_tls_domain_name(domain: &str) -> bool {
|
||||
.any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\'))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
||||
"general",
|
||||
"network",
|
||||
@@ -218,6 +299,7 @@ const SERVER_CONFIG_KEYS: &[&str] = &[
|
||||
"listen_unix_sock",
|
||||
"listen_unix_sock_perm",
|
||||
"listen_tcp",
|
||||
"client_mss",
|
||||
"proxy_protocol",
|
||||
"proxy_protocol_header_timeout_ms",
|
||||
"proxy_protocol_trusted_cidrs",
|
||||
@@ -263,6 +345,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
|
||||
const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||
"ip",
|
||||
"port",
|
||||
"client_mss",
|
||||
"announce",
|
||||
"announce_ip",
|
||||
"proxy_protocol",
|
||||
@@ -291,6 +374,7 @@ const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
|
||||
"mask",
|
||||
"mask_host",
|
||||
"mask_port",
|
||||
"exclusive_mask",
|
||||
"mask_unix_sock",
|
||||
"fake_cert_len",
|
||||
"tls_emulation",
|
||||
@@ -329,6 +413,7 @@ const TLS_FETCH_CONFIG_KEYS: &[&str] = &[
|
||||
|
||||
const ACCESS_CONFIG_KEYS: &[&str] = &[
|
||||
"users",
|
||||
"user_enabled",
|
||||
"user_ad_tags",
|
||||
"user_max_tcp_conns",
|
||||
"user_max_tcp_conns_global_each",
|
||||
@@ -924,6 +1009,14 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
||||
"upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(prefer) = upstream.prefer
|
||||
&& prefer != 4
|
||||
&& prefer != 6
|
||||
{
|
||||
return Err(ProxyError::Config(
|
||||
"upstream.prefer must be 4 or 6".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
|
||||
let parsed = ShadowsocksServerConfig::from_url(url)
|
||||
@@ -939,6 +1032,26 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
|
||||
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
|
||||
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
|
||||
warn!(
|
||||
upstream = idx,
|
||||
"upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6"
|
||||
);
|
||||
upstream.prefer = Some(6);
|
||||
}
|
||||
|
||||
if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) {
|
||||
warn!(
|
||||
upstream = idx,
|
||||
"upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4"
|
||||
);
|
||||
upstream.prefer = Some(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Main Config =============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
@@ -1822,6 +1935,20 @@ impl ProxyConfig {
|
||||
));
|
||||
}
|
||||
|
||||
config
|
||||
.server
|
||||
.client_mss_value()
|
||||
.map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?;
|
||||
for (idx, listener) in config.server.listeners.iter().enumerate() {
|
||||
if listener.client_mss.is_some() {
|
||||
listener
|
||||
.effective_client_mss(&config.server)
|
||||
.map_err(|error| {
|
||||
ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
if config.server.accept_permit_timeout_ms > 60_000 {
|
||||
return Err(ProxyError::Config(
|
||||
"server.accept_permit_timeout_ms must be within [0, 60000]".to_string(),
|
||||
@@ -1887,10 +2014,8 @@ impl ProxyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tls_domain.
|
||||
if config.censorship.tls_domain.is_empty() {
|
||||
return Err(ProxyError::Config("tls_domain cannot be empty".to_string()));
|
||||
}
|
||||
config.censorship.tls_domain =
|
||||
normalize_domain_to_ascii(&config.censorship.tls_domain, "censorship.tls_domain")?;
|
||||
|
||||
// Validate mask_unix_sock.
|
||||
if let Some(ref sock_path) = config.censorship.mask_unix_sock {
|
||||
@@ -1918,11 +2043,30 @@ impl ProxyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mask_host) = config.censorship.mask_host.as_mut() {
|
||||
*mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?;
|
||||
}
|
||||
|
||||
// Default mask_host to tls_domain if not set and no unix socket configured.
|
||||
if config.censorship.mask_host.is_none() && config.censorship.mask_unix_sock.is_none() {
|
||||
config.censorship.mask_host = Some(config.censorship.tls_domain.clone());
|
||||
}
|
||||
|
||||
for (domain, target) in &config.censorship.exclusive_mask {
|
||||
if !is_valid_tls_domain_name(domain) {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name",
|
||||
domain
|
||||
)));
|
||||
}
|
||||
if parse_exclusive_mask_target(target).is_none() {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
|
||||
domain, target
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
|
||||
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
|
||||
|
||||
@@ -1953,8 +2097,11 @@ impl ProxyConfig {
|
||||
let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
||||
all.push(config.censorship.tls_domain.clone());
|
||||
for d in std::mem::take(&mut config.censorship.tls_domains) {
|
||||
if !d.is_empty() && !all.contains(&d) {
|
||||
all.push(d);
|
||||
if !d.is_empty() {
|
||||
let domain = normalize_domain_to_ascii(&d, "censorship.tls_domains entry")?;
|
||||
if !all.contains(&domain) {
|
||||
all.push(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
// keep primary as tls_domain; store remaining back to tls_domains
|
||||
@@ -1963,6 +2110,31 @@ impl ProxyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
let mut exclusive_mask = HashMap::with_capacity(config.censorship.exclusive_mask.len());
|
||||
let mut exclusive_mask_targets =
|
||||
HashMap::with_capacity(config.censorship.exclusive_mask.len());
|
||||
for (domain, target) in std::mem::take(&mut config.censorship.exclusive_mask) {
|
||||
let domain = normalize_domain_to_ascii(&domain, "censorship.exclusive_mask domain")?;
|
||||
let target =
|
||||
normalize_exclusive_mask_target(&target, "censorship.exclusive_mask target")?;
|
||||
let Some((host, port)) = parse_exclusive_mask_target(&target) else {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
|
||||
domain, target
|
||||
)));
|
||||
};
|
||||
exclusive_mask_targets.insert(
|
||||
domain.clone(),
|
||||
ExclusiveMaskTarget {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
},
|
||||
);
|
||||
exclusive_mask.insert(domain, target);
|
||||
}
|
||||
config.censorship.exclusive_mask = exclusive_mask;
|
||||
config.censorship.exclusive_mask_targets = exclusive_mask_targets;
|
||||
|
||||
// Migration: prefer_ipv6 -> network.prefer.
|
||||
if config.general.prefer_ipv6 {
|
||||
if config.network.prefer == 4 {
|
||||
@@ -2017,6 +2189,7 @@ impl ProxyConfig {
|
||||
config.server.listeners.push(ListenerConfig {
|
||||
ip: ipv4,
|
||||
port: Some(config.server.port),
|
||||
client_mss: None,
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
@@ -2029,6 +2202,7 @@ impl ProxyConfig {
|
||||
config.server.listeners.push(ListenerConfig {
|
||||
ip: ipv6,
|
||||
port: Some(config.server.port),
|
||||
client_mss: None,
|
||||
announce: None,
|
||||
announce_ip: None,
|
||||
proxy_protocol: None,
|
||||
@@ -2072,8 +2246,10 @@ impl ProxyConfig {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
});
|
||||
}
|
||||
normalize_upstream_family_policy(&mut config);
|
||||
|
||||
// Ensure default DC203 override is present.
|
||||
config
|
||||
@@ -2126,6 +2302,21 @@ impl ProxyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
for (domain, target) in &self.censorship.exclusive_mask {
|
||||
if !is_valid_tls_domain_name(domain) {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name",
|
||||
domain
|
||||
)));
|
||||
}
|
||||
if parse_exclusive_mask_target(target).is_none() {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
|
||||
domain, target
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
for (user, tag) in &self.access.user_ad_tags {
|
||||
let zeros = "00000000000000000000000000000000";
|
||||
if !is_valid_ad_tag(tag) {
|
||||
@@ -2287,6 +2478,7 @@ mod tests {
|
||||
assert_eq!(cfg.general.update_every, default_update_every());
|
||||
assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4());
|
||||
assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt());
|
||||
assert_eq!(cfg.server.client_mss_value(), Ok(None));
|
||||
assert_eq!(
|
||||
cfg.server.proxy_protocol_trusted_cidrs,
|
||||
default_proxy_protocol_trusted_cidrs()
|
||||
@@ -2667,6 +2859,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_mask_parses_domain_target_map() {
|
||||
let cfg = load_config_from_temp_toml(
|
||||
r#"
|
||||
[general]
|
||||
[network]
|
||||
[server]
|
||||
[access]
|
||||
[censorship]
|
||||
tls_domain = "weißbiergärten.de"
|
||||
tls_domains = ["bürgeramt.de"]
|
||||
[censorship.exclusive_mask]
|
||||
"bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443"
|
||||
"ipv6.example" = "[::1]:443"
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(cfg.censorship.tls_domain.is_ascii());
|
||||
assert!(cfg.censorship.tls_domain.contains("xn--"));
|
||||
assert_eq!(cfg.censorship.tls_domains.len(), 1);
|
||||
let normalized_extra = &cfg.censorship.tls_domains[0];
|
||||
assert!(normalized_extra.is_ascii());
|
||||
assert!(normalized_extra.contains("xn--"));
|
||||
|
||||
let normalized_target = cfg
|
||||
.censorship
|
||||
.exclusive_mask
|
||||
.get(normalized_extra)
|
||||
.expect("exclusive_mask key must match normalized tls_domains entry");
|
||||
assert!(normalized_target.is_ascii());
|
||||
assert!(normalized_target.contains("xn--"));
|
||||
assert!(normalized_target.ends_with(":443"));
|
||||
assert_eq!(
|
||||
cfg.censorship.exclusive_mask.get("ipv6.example"),
|
||||
Some(&"[::1]:443".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_gray_action_parses_and_defaults_to_drop() {
|
||||
let cfg_default: ProxyConfig = toml::from_str(
|
||||
@@ -3576,6 +3806,153 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_mss_presets_and_listener_override_are_resolved() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
client_mss = "tspu"
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "127.0.0.1"
|
||||
port = 1443
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "127.0.0.2"
|
||||
port = 1444
|
||||
client_mss = "2in8"
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "127.0.0.3"
|
||||
port = 1445
|
||||
client_mss = ""
|
||||
|
||||
[[server.listeners]]
|
||||
ip = "127.0.0.4"
|
||||
port = 1446
|
||||
client_mss = "extreme-low"
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("telemt_client_mss_valid_test.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let cfg = ProxyConfig::load(&path).unwrap();
|
||||
|
||||
assert_eq!(cfg.server.client_mss_value(), Ok(Some(92)));
|
||||
assert_eq!(
|
||||
cfg.server.listeners[0].effective_client_mss(&cfg.server),
|
||||
Ok(Some(92))
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.server.listeners[1].effective_client_mss(&cfg.server),
|
||||
Ok(Some(256))
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.server.listeners[2].effective_client_mss(&cfg.server),
|
||||
Ok(None)
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.server.listeners[3].effective_client_mss(&cfg.server),
|
||||
Ok(Some(88))
|
||||
);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_mss_custom_value_is_accepted() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
client_mss = "4096"
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("telemt_client_mss_custom_valid_test.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let cfg = ProxyConfig::load(&path).unwrap();
|
||||
|
||||
assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096)));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_mss_out_of_range_is_rejected() {
|
||||
for value in ["87", "4097"] {
|
||||
let toml = format!(
|
||||
r#"
|
||||
[server]
|
||||
client_mss = "{value}"
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#
|
||||
);
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml"));
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("server.client_mss custom value must be within [88, 4096]"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_mss_unquoted_number_is_rejected() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
client_mss = 256
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("telemt_client_mss_unquoted_number_test.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("client_mss"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_client_mss_invalid_preset_is_rejected() {
|
||||
let toml = r#"
|
||||
[[server.listeners]]
|
||||
ip = "127.0.0.1"
|
||||
port = 1443
|
||||
client_mss = "tiny"
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("telemt_listener_client_mss_invalid_test.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("server.listeners[0].client_mss"));
|
||||
assert!(err.contains("must be \"\", extreme-low, tspu, 2in8"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() {
|
||||
let toml = r#"
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static TEMP_CONFIG_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn write_temp_config(contents: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time must be after unix epoch")
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("telemt-load-mask-shape-security-{nonce}.toml"));
|
||||
let seq = TEMP_CONFIG_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let pid = std::process::id();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"telemt-load-mask-shape-security-{pid}-{seq}-{nonce}.toml"
|
||||
));
|
||||
fs::write(&path, contents).expect("temp config write must succeed");
|
||||
path
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ pub enum LogLevel {
|
||||
#[default]
|
||||
Normal,
|
||||
/// Minimal output: only warnings and errors (warn + error).
|
||||
/// Startup messages (config, DC connectivity, proxy links) are always shown
|
||||
/// via info! before the filter is applied.
|
||||
/// Proxy links may still be emitted through their dedicated target.
|
||||
Silent,
|
||||
}
|
||||
|
||||
@@ -1452,6 +1451,11 @@ pub struct ServerConfig {
|
||||
#[serde(default)]
|
||||
pub listen_tcp: Option<bool>,
|
||||
|
||||
/// Client-facing TCP MSS preset or custom value for all TCP listeners.
|
||||
/// Empty string or omitted value keeps the kernel default.
|
||||
#[serde(default)]
|
||||
pub client_mss: Option<String>,
|
||||
|
||||
/// Accept HAProxy PROXY protocol headers on incoming connections.
|
||||
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
|
||||
#[serde(default)]
|
||||
@@ -1518,6 +1522,7 @@ impl Default for ServerConfig {
|
||||
listen_unix_sock: None,
|
||||
listen_unix_sock_perm: None,
|
||||
listen_tcp: None,
|
||||
client_mss: None,
|
||||
proxy_protocol: false,
|
||||
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
|
||||
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
|
||||
@@ -1688,6 +1693,14 @@ impl Default for TlsFetchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExclusiveMaskTarget {
|
||||
/// Target host after IDNA/IP normalization.
|
||||
pub host: String,
|
||||
/// TCP port for the selected target.
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AntiCensorshipConfig {
|
||||
#[serde(default = "default_tls_domain")]
|
||||
@@ -1719,6 +1732,14 @@ pub struct AntiCensorshipConfig {
|
||||
#[serde(default = "default_mask_port")]
|
||||
pub mask_port: u16,
|
||||
|
||||
/// Per-SNI TCP mask targets. Keys are SNI domains, values are `host:port`.
|
||||
#[serde(default)]
|
||||
pub exclusive_mask: HashMap<String, String>,
|
||||
|
||||
/// Parsed runtime cache for per-SNI TCP mask targets.
|
||||
#[serde(skip)]
|
||||
pub exclusive_mask_targets: HashMap<String, ExclusiveMaskTarget>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mask_unix_sock: Option<String>,
|
||||
|
||||
@@ -1842,6 +1863,8 @@ impl Default for AntiCensorshipConfig {
|
||||
mask: default_true(),
|
||||
mask_host: None,
|
||||
mask_port: default_mask_port(),
|
||||
exclusive_mask: HashMap::new(),
|
||||
exclusive_mask_targets: HashMap::new(),
|
||||
mask_unix_sock: None,
|
||||
fake_cert_len: default_fake_cert_len(),
|
||||
tls_emulation: true,
|
||||
@@ -1875,6 +1898,9 @@ pub struct AccessConfig {
|
||||
#[serde(default = "default_access_users")]
|
||||
pub users: HashMap<String, String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub user_enabled: HashMap<String, bool>,
|
||||
|
||||
/// Per-user ad_tag (32 hex chars from @MTProxybot).
|
||||
#[serde(default)]
|
||||
pub user_ad_tags: HashMap<String, String>,
|
||||
@@ -1946,6 +1972,7 @@ impl Default for AccessConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
users: default_access_users(),
|
||||
user_enabled: HashMap::new(),
|
||||
user_ad_tags: HashMap::new(),
|
||||
user_max_tcp_conns: HashMap::new(),
|
||||
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
|
||||
@@ -1966,6 +1993,10 @@ impl Default for AccessConfig {
|
||||
}
|
||||
|
||||
impl AccessConfig {
|
||||
pub fn is_user_enabled(&self, username: &str) -> bool {
|
||||
self.user_enabled.get(username).copied().unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`.
|
||||
pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool {
|
||||
self.user_source_deny
|
||||
@@ -2040,6 +2071,20 @@ pub struct UpstreamConfig {
|
||||
/// `None` means auto-detect from runtime connectivity state.
|
||||
#[serde(default)]
|
||||
pub ipv6: Option<bool>,
|
||||
/// Per-upstream IP family preference for Telegram DC targets.
|
||||
/// `None` inherits the effective global `[network].prefer` decision.
|
||||
#[serde(default)]
|
||||
pub prefer: Option<u8>,
|
||||
}
|
||||
|
||||
impl UpstreamConfig {
|
||||
pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool {
|
||||
match self.prefer {
|
||||
Some(6) => true,
|
||||
Some(4) => false,
|
||||
_ => default_prefer_ipv6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -2048,6 +2093,10 @@ pub struct ListenerConfig {
|
||||
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
/// Per-listener client-facing TCP MSS preset or custom value.
|
||||
/// Empty string disables MSS shaping for this listener.
|
||||
#[serde(default)]
|
||||
pub client_mss: Option<String>,
|
||||
/// IP address or hostname to announce in proxy links.
|
||||
/// Takes precedence over `announce_ip` if both are set.
|
||||
#[serde(default)]
|
||||
@@ -2065,6 +2114,64 @@ pub struct ListenerConfig {
|
||||
pub reuse_allow: bool,
|
||||
}
|
||||
|
||||
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
|
||||
pub const CLIENT_MSS_EXTREME_LOW: u16 = 88;
|
||||
/// Client-facing TCP MSS preset matching TSPU-oriented deployments.
|
||||
pub const CLIENT_MSS_TSPU: u16 = 92;
|
||||
/// Client-facing TCP MSS preset for 2-in-8 segment shaping.
|
||||
pub const CLIENT_MSS_2IN8: u16 = 256;
|
||||
/// Minimum accepted custom client-facing TCP MSS value.
|
||||
pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW;
|
||||
/// Maximum accepted custom client-facing TCP MSS value.
|
||||
pub const CLIENT_MSS_MAX: u16 = 4096;
|
||||
|
||||
impl ServerConfig {
|
||||
/// Resolves the global client-facing TCP MSS setting.
|
||||
pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> {
|
||||
parse_client_mss(self.client_mss.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
impl ListenerConfig {
|
||||
/// Resolves the listener MSS override, falling back to the global server value.
|
||||
pub fn effective_client_mss(
|
||||
&self,
|
||||
server: &ServerConfig,
|
||||
) -> std::result::Result<Option<u16>, String> {
|
||||
match self.client_mss.as_deref() {
|
||||
Some(value) => parse_client_mss(Some(value)),
|
||||
None => server.client_mss_value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_client_mss(raw: Option<&str>) -> std::result::Result<Option<u16>, String> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)),
|
||||
"tspu" => return Ok(Some(CLIENT_MSS_TSPU)),
|
||||
"2in8" => return Ok(Some(CLIENT_MSS_2IN8)),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let parsed = value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?;
|
||||
if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) {
|
||||
return Err(format!(
|
||||
"custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]"
|
||||
));
|
||||
}
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
||||
// ============= ShowLink =============
|
||||
|
||||
/// Controls which users' proxy links are displayed at startup.
|
||||
|
||||
@@ -705,7 +705,7 @@ fn nofile_soft_limit() -> Option<u64> {
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
return Some(lim.rlim_cur);
|
||||
return Some(lim.rlim_cur.into());
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|
||||
@@ -245,6 +245,9 @@ pub enum ProxyError {
|
||||
InvalidSecret { user: String, reason: String },
|
||||
|
||||
// ============= User Errors =============
|
||||
#[error("User {user} disabled")]
|
||||
UserDisabled { user: String },
|
||||
|
||||
#[error("User {user} expired")]
|
||||
UserExpired { user: String },
|
||||
|
||||
|
||||
1080
src/ip_tracker.rs
1080
src/ip_tracker.rs
File diff suppressed because it is too large
Load Diff
173
src/ip_tracker/admission.rs
Normal file
173
src/ip_tracker/admission.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use super::*;
|
||||
|
||||
impl UserIpTracker {
|
||||
pub async fn set_limit_policy(&self, mode: UserMaxUniqueIpsMode, window_secs: u64) {
|
||||
self.limit_mode
|
||||
.store(Self::mode_to_u8(mode), Ordering::Relaxed);
|
||||
self.limit_window_secs
|
||||
.store(window_secs.max(1), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn set_user_limit(&self, username: &str, max_ips: usize) {
|
||||
self.max_ips.insert(username.to_string(), max_ips);
|
||||
}
|
||||
|
||||
pub async fn remove_user_limit(&self, username: &str) {
|
||||
self.max_ips.remove(username);
|
||||
}
|
||||
|
||||
pub async fn load_limits(&self, default_limit: usize, limits: &HashMap<String, usize>) {
|
||||
self.default_max_ips.store(default_limit, Ordering::Relaxed);
|
||||
self.max_ips.clear();
|
||||
for (username, limit) in limits {
|
||||
self.max_ips.insert(username.clone(), *limit);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prune_recent(
|
||||
user_recent: &mut HashMap<IpAddr, Instant>,
|
||||
now: Instant,
|
||||
window: Duration,
|
||||
) -> usize {
|
||||
if user_recent.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let before = user_recent.len();
|
||||
user_recent.retain(|_, seen_at| now.duration_since(*seen_at) <= window);
|
||||
before.saturating_sub(user_recent.len())
|
||||
}
|
||||
|
||||
pub async fn check_and_add(&self, username: &str, ip: IpAddr) -> Result<(), String> {
|
||||
self.drain_cleanup_for_user(username).await;
|
||||
self.maybe_compact_empty_users().await;
|
||||
let limit = self.user_limit(username);
|
||||
let mode = Self::mode_from_u8(self.limit_mode.load(Ordering::Relaxed));
|
||||
let window = self.limit_window();
|
||||
let now = Instant::now();
|
||||
|
||||
let shard_idx = Self::shard_idx(username);
|
||||
let mut shard = self.shards[shard_idx].write().await;
|
||||
let user_active = shard.active_ips.entry(username.to_string()).or_default();
|
||||
let active_contains_ip = user_active.contains_key(&ip);
|
||||
let active_len = user_active.len();
|
||||
let user_recent = shard.recent_ips.entry(username.to_string()).or_default();
|
||||
let pruned_recent_entries = Self::prune_recent(user_recent, now, window);
|
||||
Self::decrement_counter(&self.recent_entry_count, pruned_recent_entries);
|
||||
let recent_contains_ip = user_recent.contains_key(&ip);
|
||||
let recent_len = user_recent.len();
|
||||
|
||||
if active_contains_ip {
|
||||
if !recent_contains_ip
|
||||
&& !Self::try_increment_counter(&self.recent_entry_count, MAX_RECENT_IP_ENTRIES)
|
||||
{
|
||||
self.recent_cap_rejects.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(format!(
|
||||
"IP tracker recent entry cap reached: entries={}/{}",
|
||||
self.recent_entry_count.load(Ordering::Relaxed),
|
||||
MAX_RECENT_IP_ENTRIES
|
||||
));
|
||||
}
|
||||
let Some(count) = shard
|
||||
.active_ips
|
||||
.get_mut(username)
|
||||
.and_then(|user_active| user_active.get_mut(&ip))
|
||||
else {
|
||||
return Err(format!(
|
||||
"IP tracker active entry unavailable for user '{username}'"
|
||||
));
|
||||
};
|
||||
*count = count.saturating_add(1);
|
||||
if let Some(user_recent) = shard.recent_ips.get_mut(username) {
|
||||
user_recent.insert(ip, now);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_new_ip = !recent_contains_ip;
|
||||
|
||||
if let Some(limit) = limit {
|
||||
let active_limit_reached = active_len >= limit;
|
||||
let recent_limit_reached = recent_len >= limit && is_new_ip;
|
||||
let deny = match mode {
|
||||
UserMaxUniqueIpsMode::ActiveWindow => active_limit_reached,
|
||||
UserMaxUniqueIpsMode::TimeWindow => recent_limit_reached,
|
||||
UserMaxUniqueIpsMode::Combined => active_limit_reached || recent_limit_reached,
|
||||
};
|
||||
|
||||
if deny {
|
||||
return Err(format!(
|
||||
"IP limit reached for user '{}': active={}/{} recent={}/{} mode={:?}",
|
||||
username, active_len, limit, recent_len, limit, mode
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !Self::try_increment_counter(&self.active_entry_count, MAX_ACTIVE_IP_ENTRIES) {
|
||||
self.active_cap_rejects.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(format!(
|
||||
"IP tracker active entry cap reached: entries={}/{}",
|
||||
self.active_entry_count.load(Ordering::Relaxed),
|
||||
MAX_ACTIVE_IP_ENTRIES
|
||||
));
|
||||
}
|
||||
let mut reserved_recent = false;
|
||||
if is_new_ip {
|
||||
if !Self::try_increment_counter(&self.recent_entry_count, MAX_RECENT_IP_ENTRIES) {
|
||||
Self::decrement_counter(&self.active_entry_count, 1);
|
||||
self.recent_cap_rejects.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(format!(
|
||||
"IP tracker recent entry cap reached: entries={}/{}",
|
||||
self.recent_entry_count.load(Ordering::Relaxed),
|
||||
MAX_RECENT_IP_ENTRIES
|
||||
));
|
||||
}
|
||||
reserved_recent = true;
|
||||
}
|
||||
|
||||
let Some(user_active) = shard.active_ips.get_mut(username) else {
|
||||
Self::decrement_counter(&self.active_entry_count, 1);
|
||||
if reserved_recent {
|
||||
Self::decrement_counter(&self.recent_entry_count, 1);
|
||||
}
|
||||
return Err(format!(
|
||||
"IP tracker active entry unavailable for user '{username}'"
|
||||
));
|
||||
};
|
||||
if user_active.insert(ip, 1).is_some() {
|
||||
Self::decrement_counter(&self.active_entry_count, 1);
|
||||
}
|
||||
let Some(user_recent) = shard.recent_ips.get_mut(username) else {
|
||||
Self::decrement_counter(&self.active_entry_count, 1);
|
||||
if reserved_recent {
|
||||
Self::decrement_counter(&self.recent_entry_count, 1);
|
||||
}
|
||||
return Err(format!(
|
||||
"IP tracker recent entry unavailable for user '{username}'"
|
||||
));
|
||||
};
|
||||
if user_recent.insert(ip, now).is_some() && reserved_recent {
|
||||
Self::decrement_counter(&self.recent_entry_count, 1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_ip(&self, username: &str, ip: IpAddr) {
|
||||
self.maybe_compact_empty_users().await;
|
||||
let shard_idx = Self::shard_idx(username);
|
||||
let mut shard = self.shards[shard_idx].write().await;
|
||||
let mut removed_active_entries = 0usize;
|
||||
if let Some(user_ips) = shard.active_ips.get_mut(username) {
|
||||
if let Some(count) = user_ips.get_mut(&ip) {
|
||||
if *count > 1 {
|
||||
*count -= 1;
|
||||
} else if user_ips.remove(&ip).is_some() {
|
||||
removed_active_entries = 1;
|
||||
}
|
||||
}
|
||||
if user_ips.is_empty() {
|
||||
shard.active_ips.remove(username);
|
||||
}
|
||||
}
|
||||
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||
}
|
||||
}
|
||||
148
src/ip_tracker/cleanup.rs
Normal file
148
src/ip_tracker/cleanup.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use super::*;
|
||||
|
||||
impl UserIpTracker {
|
||||
/// Queues a deferred active IP cleanup for a later async drain.
|
||||
pub fn enqueue_cleanup(&self, user: String, ip: IpAddr) {
|
||||
self.observe_cleanup_poison_for_tests();
|
||||
let shard_idx = Self::shard_idx(&user);
|
||||
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
||||
match cleanup_shard.queue.lock() {
|
||||
Ok(mut queue) => {
|
||||
let user_queue = queue.entry(user).or_default();
|
||||
let count = user_queue.entry(ip).or_insert(0);
|
||||
if *count == 0 {
|
||||
self.cleanup_queue_len.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
*count = count.saturating_add(1);
|
||||
self.cleanup_deferred_releases
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(poisoned) => {
|
||||
let mut queue = poisoned.into_inner();
|
||||
let user_queue = queue.entry(user.clone()).or_default();
|
||||
let count = user_queue.entry(ip).or_insert(0);
|
||||
if *count == 0 {
|
||||
self.cleanup_queue_len.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
*count = count.saturating_add(1);
|
||||
self.cleanup_deferred_releases
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
cleanup_shard.queue.clear_poison();
|
||||
tracing::warn!(
|
||||
"UserIpTracker cleanup_queue lock poisoned; recovered and enqueued IP cleanup for {} ({})",
|
||||
user,
|
||||
ip
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cleanup_queue_len_for_tests(&self) -> usize {
|
||||
self.cleanup_queue_len.load(Ordering::Relaxed) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cleanup_queue_mutex_for_tests(
|
||||
&self,
|
||||
) -> Arc<Mutex<HashMap<(String, IpAddr), usize>>> {
|
||||
Arc::clone(&self.cleanup_queue_poison_probe)
|
||||
}
|
||||
|
||||
pub(crate) async fn drain_cleanup_queue(&self) {
|
||||
if self.cleanup_queue_len.load(Ordering::Relaxed) == 0 {
|
||||
return;
|
||||
}
|
||||
for shard_idx in 0..USER_IP_TRACKER_SHARDS {
|
||||
self.drain_cleanup_shard(shard_idx).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn drain_cleanup_for_user(&self, user: &str) {
|
||||
if self.cleanup_queue_len.load(Ordering::Relaxed) == 0 {
|
||||
return;
|
||||
}
|
||||
let shard_idx = Self::shard_idx(user);
|
||||
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
||||
let to_remove = match cleanup_shard.queue.lock() {
|
||||
Ok(mut queue) => queue.remove(user).unwrap_or_default(),
|
||||
Err(poisoned) => {
|
||||
let mut queue = poisoned.into_inner();
|
||||
let drained = queue.remove(user).unwrap_or_default();
|
||||
cleanup_shard.queue.clear_poison();
|
||||
drained
|
||||
}
|
||||
};
|
||||
if to_remove.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.cleanup_queue_len
|
||||
.fetch_sub(to_remove.len() as u64, Ordering::Relaxed);
|
||||
let mut shard = self.shards[shard_idx].write().await;
|
||||
let mut removed_active_entries = 0usize;
|
||||
for (ip, pending_count) in to_remove {
|
||||
removed_active_entries = removed_active_entries.saturating_add(
|
||||
Self::apply_active_cleanup(&mut shard.active_ips, user, ip, pending_count),
|
||||
);
|
||||
}
|
||||
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||
}
|
||||
|
||||
pub(super) async fn drain_cleanup_shard(&self, shard_idx: usize) {
|
||||
let Ok(_drain_guard) = self.cleanup_drain_locks[shard_idx].try_lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cleanup_shard = &self.cleanup_shards[shard_idx];
|
||||
let to_remove = {
|
||||
match cleanup_shard.queue.lock() {
|
||||
Ok(mut queue) => {
|
||||
if queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut drained =
|
||||
HashMap::with_capacity(queue.len().min(CLEANUP_DRAIN_BATCH_LIMIT));
|
||||
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
||||
let Some((user, ip, count)) = Self::pop_one_cleanup(&mut queue) else {
|
||||
break;
|
||||
};
|
||||
self.cleanup_queue_len.fetch_sub(1, Ordering::Relaxed);
|
||||
drained.insert((user, ip), count);
|
||||
}
|
||||
drained
|
||||
}
|
||||
Err(poisoned) => {
|
||||
let mut queue = poisoned.into_inner();
|
||||
if queue.is_empty() {
|
||||
cleanup_shard.queue.clear_poison();
|
||||
return;
|
||||
}
|
||||
let mut drained =
|
||||
HashMap::with_capacity(queue.len().min(CLEANUP_DRAIN_BATCH_LIMIT));
|
||||
for _ in 0..CLEANUP_DRAIN_BATCH_LIMIT {
|
||||
let Some((user, ip, count)) = Self::pop_one_cleanup(&mut queue) else {
|
||||
break;
|
||||
};
|
||||
self.cleanup_queue_len.fetch_sub(1, Ordering::Relaxed);
|
||||
drained.insert((user, ip), count);
|
||||
}
|
||||
cleanup_shard.queue.clear_poison();
|
||||
drained
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(_drain_guard);
|
||||
if to_remove.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut shard = self.shards[shard_idx].write().await;
|
||||
let mut removed_active_entries = 0usize;
|
||||
for ((user, ip), pending_count) in to_remove {
|
||||
removed_active_entries = removed_active_entries.saturating_add(
|
||||
Self::apply_active_cleanup(&mut shard.active_ips, &user, ip, pending_count),
|
||||
);
|
||||
}
|
||||
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||
}
|
||||
}
|
||||
309
src/ip_tracker/snapshot.rs
Normal file
309
src/ip_tracker/snapshot.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use super::*;
|
||||
|
||||
impl UserIpTracker {
|
||||
pub(super) async fn maybe_compact_empty_users(&self) {
|
||||
const COMPACT_INTERVAL_SECS: u64 = 60;
|
||||
let now_epoch_secs = Self::now_epoch_secs();
|
||||
let last_compact_epoch_secs = self.last_compact_epoch_secs.load(Ordering::Relaxed);
|
||||
if now_epoch_secs.saturating_sub(last_compact_epoch_secs) < COMPACT_INTERVAL_SECS {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.last_compact_epoch_secs
|
||||
.compare_exchange(
|
||||
last_compact_epoch_secs,
|
||||
now_epoch_secs,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let window = self.limit_window();
|
||||
let now = Instant::now();
|
||||
for shard_lock in self.shards.iter() {
|
||||
let mut shard = shard_lock.write().await;
|
||||
let mut pruned_recent_entries = 0usize;
|
||||
for user_recent in shard.recent_ips.values_mut() {
|
||||
pruned_recent_entries = pruned_recent_entries.saturating_add(Self::prune_recent(
|
||||
user_recent,
|
||||
now,
|
||||
window,
|
||||
));
|
||||
}
|
||||
Self::decrement_counter(&self.recent_entry_count, pruned_recent_entries);
|
||||
|
||||
let mut users = Vec::<String>::with_capacity(
|
||||
shard
|
||||
.active_ips
|
||||
.len()
|
||||
.saturating_add(shard.recent_ips.len()),
|
||||
);
|
||||
users.extend(shard.active_ips.keys().cloned());
|
||||
for user in shard.recent_ips.keys() {
|
||||
if !shard.active_ips.contains_key(user) {
|
||||
users.push(user.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for user in users {
|
||||
let active_empty = shard
|
||||
.active_ips
|
||||
.get(&user)
|
||||
.map(|ips| ips.is_empty())
|
||||
.unwrap_or(true);
|
||||
let recent_empty = shard
|
||||
.recent_ips
|
||||
.get(&user)
|
||||
.map(|ips| ips.is_empty())
|
||||
.unwrap_or(true);
|
||||
if active_empty && recent_empty {
|
||||
shard.active_ips.remove(&user);
|
||||
shard.recent_ips.remove(&user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_periodic_maintenance(self: Arc<Self>) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
self.drain_cleanup_queue().await;
|
||||
self.maybe_compact_empty_users().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn memory_stats(&self) -> UserIpTrackerMemoryStats {
|
||||
let cleanup_queue_len = self.cleanup_queue_len.load(Ordering::Relaxed) as usize;
|
||||
let mut active_users = 0usize;
|
||||
let mut recent_users = 0usize;
|
||||
let mut active_entries = 0usize;
|
||||
let mut recent_entries = 0usize;
|
||||
for shard_lock in self.shards.iter() {
|
||||
let shard = shard_lock.read().await;
|
||||
active_users = active_users.saturating_add(shard.active_ips.len());
|
||||
recent_users = recent_users.saturating_add(shard.recent_ips.len());
|
||||
active_entries =
|
||||
active_entries.saturating_add(shard.active_ips.values().map(HashMap::len).sum());
|
||||
recent_entries =
|
||||
recent_entries.saturating_add(shard.recent_ips.values().map(HashMap::len).sum());
|
||||
}
|
||||
|
||||
UserIpTrackerMemoryStats {
|
||||
active_users,
|
||||
recent_users,
|
||||
active_entries,
|
||||
recent_entries,
|
||||
cleanup_queue_len,
|
||||
active_cap_rejects: self.active_cap_rejects.load(Ordering::Relaxed),
|
||||
recent_cap_rejects: self.recent_cap_rejects.load(Ordering::Relaxed),
|
||||
cleanup_deferred_releases: self.cleanup_deferred_releases.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_recent_counts_for_users(&self, users: &[String]) -> HashMap<String, usize> {
|
||||
self.drain_cleanup_queue().await;
|
||||
self.get_recent_counts_for_users_snapshot(users).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_recent_counts_for_users_snapshot(
|
||||
&self,
|
||||
users: &[String],
|
||||
) -> HashMap<String, usize> {
|
||||
let window = self.limit_window();
|
||||
let now = Instant::now();
|
||||
|
||||
let mut counts = HashMap::with_capacity(users.len());
|
||||
for user in users {
|
||||
let shard_idx = Self::shard_idx(user);
|
||||
let shard = self.shards[shard_idx].read().await;
|
||||
let count = if let Some(user_recent) = shard.recent_ips.get(user) {
|
||||
user_recent
|
||||
.values()
|
||||
.filter(|seen_at| now.duration_since(**seen_at) <= window)
|
||||
.count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
counts.insert(user.clone(), count);
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
pub async fn get_active_ips_for_users(&self, users: &[String]) -> HashMap<String, Vec<IpAddr>> {
|
||||
self.drain_cleanup_queue().await;
|
||||
let mut out = HashMap::with_capacity(users.len());
|
||||
for user in users {
|
||||
let shard_idx = Self::shard_idx(user);
|
||||
let shard = self.shards[shard_idx].read().await;
|
||||
let mut ips = shard
|
||||
.active_ips
|
||||
.get(user)
|
||||
.map(|per_ip| per_ip.keys().copied().collect::<Vec<_>>())
|
||||
.unwrap_or_else(Vec::new);
|
||||
ips.sort();
|
||||
out.insert(user.clone(), ips);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn get_recent_ips_for_users(&self, users: &[String]) -> HashMap<String, Vec<IpAddr>> {
|
||||
self.drain_cleanup_queue().await;
|
||||
let window = self.limit_window();
|
||||
let now = Instant::now();
|
||||
|
||||
let mut out = HashMap::with_capacity(users.len());
|
||||
for user in users {
|
||||
let shard_idx = Self::shard_idx(user);
|
||||
let shard = self.shards[shard_idx].read().await;
|
||||
let mut ips = if let Some(user_recent) = shard.recent_ips.get(user) {
|
||||
user_recent
|
||||
.iter()
|
||||
.filter(|(_, seen_at)| now.duration_since(**seen_at) <= window)
|
||||
.map(|(ip, _)| *ip)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ips.sort();
|
||||
out.insert(user.clone(), ips);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn get_active_ip_count(&self, username: &str) -> usize {
|
||||
self.drain_cleanup_queue().await;
|
||||
let shard_idx = Self::shard_idx(username);
|
||||
let shard = self.shards[shard_idx].read().await;
|
||||
shard
|
||||
.active_ips
|
||||
.get(username)
|
||||
.map(|ips| ips.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn get_active_ips(&self, username: &str) -> Vec<IpAddr> {
|
||||
self.drain_cleanup_queue().await;
|
||||
let shard_idx = Self::shard_idx(username);
|
||||
let shard = self.shards[shard_idx].read().await;
|
||||
shard
|
||||
.active_ips
|
||||
.get(username)
|
||||
.map(|ips| ips.keys().copied().collect())
|
||||
.unwrap_or_else(Vec::new)
|
||||
}
|
||||
|
||||
pub async fn get_stats(&self) -> Vec<(String, usize, usize)> {
|
||||
self.drain_cleanup_queue().await;
|
||||
self.get_stats_snapshot().await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_stats_snapshot(&self) -> Vec<(String, usize, usize)> {
|
||||
let mut active_counts = Vec::new();
|
||||
for shard_lock in self.shards.iter() {
|
||||
let shard = shard_lock.read().await;
|
||||
active_counts.extend(
|
||||
shard
|
||||
.active_ips
|
||||
.iter()
|
||||
.map(|(username, user_ips)| (username.clone(), user_ips.len())),
|
||||
);
|
||||
}
|
||||
|
||||
let mut stats = Vec::with_capacity(active_counts.len());
|
||||
for (username, active_count) in active_counts {
|
||||
let limit = self.user_limit(&username).unwrap_or(0);
|
||||
stats.push((username, active_count, limit));
|
||||
}
|
||||
|
||||
stats.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
stats
|
||||
}
|
||||
|
||||
pub async fn clear_user_ips(&self, username: &str) {
|
||||
let shard_idx = Self::shard_idx(username);
|
||||
let mut shard = self.shards[shard_idx].write().await;
|
||||
let removed_active_entries = shard
|
||||
.active_ips
|
||||
.remove(username)
|
||||
.map(|ips| ips.len())
|
||||
.unwrap_or(0);
|
||||
Self::decrement_counter(&self.active_entry_count, removed_active_entries);
|
||||
|
||||
let removed_recent_entries = shard
|
||||
.recent_ips
|
||||
.remove(username)
|
||||
.map(|ips| ips.len())
|
||||
.unwrap_or(0);
|
||||
Self::decrement_counter(&self.recent_entry_count, removed_recent_entries);
|
||||
}
|
||||
|
||||
pub async fn clear_all(&self) {
|
||||
for shard_lock in self.shards.iter() {
|
||||
let mut shard = shard_lock.write().await;
|
||||
shard.active_ips.clear();
|
||||
shard.recent_ips.clear();
|
||||
}
|
||||
self.active_entry_count.store(0, Ordering::Relaxed);
|
||||
self.recent_entry_count.store(0, Ordering::Relaxed);
|
||||
for cleanup_shard in self.cleanup_shards.iter() {
|
||||
match cleanup_shard.queue.lock() {
|
||||
Ok(mut queue) => queue.clear(),
|
||||
Err(poisoned) => {
|
||||
poisoned.into_inner().clear();
|
||||
cleanup_shard.queue.clear_poison();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cleanup_queue_len.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn is_ip_active(&self, username: &str, ip: IpAddr) -> bool {
|
||||
self.drain_cleanup_queue().await;
|
||||
let shard_idx = Self::shard_idx(username);
|
||||
let shard = self.shards[shard_idx].read().await;
|
||||
shard
|
||||
.active_ips
|
||||
.get(username)
|
||||
.map(|ips| ips.contains_key(&ip))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn get_user_limit(&self, username: &str) -> Option<usize> {
|
||||
self.user_limit(username)
|
||||
}
|
||||
|
||||
pub async fn format_stats(&self) -> String {
|
||||
let stats = self.get_stats().await;
|
||||
|
||||
if stats.is_empty() {
|
||||
return String::from("No active users");
|
||||
}
|
||||
|
||||
let mut output = String::from("User IP Statistics:\n");
|
||||
output.push_str("==================\n");
|
||||
|
||||
for (username, active_count, limit) in stats {
|
||||
output.push_str(&format!(
|
||||
"User: {:<20} Active IPs: {}/{}\n",
|
||||
username,
|
||||
active_count,
|
||||
if limit > 0 {
|
||||
limit.to_string()
|
||||
} else {
|
||||
"unlimited".to_string()
|
||||
}
|
||||
));
|
||||
|
||||
let ips = self.get_active_ips(&username).await;
|
||||
for ip in ips {
|
||||
output.push_str(&format!(" - {}\n", ip));
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
385
src/ip_tracker/tests.rs
Normal file
385
src/ip_tracker/tests.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn test_ipv4(oct1: u8, oct2: u8, oct3: u8, oct4: u8) -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::new(oct1, oct2, oct3, oct4))
|
||||
}
|
||||
|
||||
fn test_ipv6() -> IpAddr {
|
||||
IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_basic_ip_limit() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 2).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
let ip3 = test_ipv4(192, 168, 1, 3);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip3).await.is_err());
|
||||
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_window_rejects_new_ip_and_keeps_existing_session() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 1).await;
|
||||
tracker
|
||||
.set_limit_policy(UserMaxUniqueIpsMode::ActiveWindow, 30)
|
||||
.await;
|
||||
|
||||
let ip1 = test_ipv4(10, 10, 10, 1);
|
||||
let ip2 = test_ipv4(10, 10, 10, 2);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.is_ip_active("test_user", ip1).await);
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
|
||||
|
||||
// Existing session remains active; only new unique IP is denied.
|
||||
assert!(tracker.is_ip_active("test_user", ip1).await);
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_from_same_ip() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 2).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_ip_disconnect_keeps_active_while_other_session_alive() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 2).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 1);
|
||||
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 1);
|
||||
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ip_removal() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 2).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
let ip3 = test_ipv4(192, 168, 1, 3);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip3).await.is_err());
|
||||
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip3).await.is_ok());
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_limit() {
|
||||
let tracker = UserIpTracker::new();
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
let ip3 = test_ipv4(192, 168, 1, 3);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip3).await.is_ok());
|
||||
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_users() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("user1", 2).await;
|
||||
tracker.set_user_limit("user2", 1).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
|
||||
assert!(tracker.check_and_add("user1", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("user1", ip2).await.is_ok());
|
||||
|
||||
assert!(tracker.check_and_add("user2", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("user2", ip2).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ipv6_support() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 2).await;
|
||||
|
||||
let ipv4 = test_ipv4(192, 168, 1, 1);
|
||||
let ipv6 = test_ipv6();
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ipv4).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ipv6).await.is_ok());
|
||||
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_active_ips() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 3).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
|
||||
tracker.check_and_add("test_user", ip1).await.unwrap();
|
||||
tracker.check_and_add("test_user", ip2).await.unwrap();
|
||||
|
||||
let active_ips = tracker.get_active_ips("test_user").await;
|
||||
assert_eq!(active_ips.len(), 2);
|
||||
assert!(active_ips.contains(&ip1));
|
||||
assert!(active_ips.contains(&ip2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stats() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("user1", 3).await;
|
||||
tracker.set_user_limit("user2", 2).await;
|
||||
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
|
||||
tracker.check_and_add("user1", ip1).await.unwrap();
|
||||
tracker.check_and_add("user2", ip2).await.unwrap();
|
||||
|
||||
let stats = tracker.get_stats().await;
|
||||
assert_eq!(stats.len(), 2);
|
||||
|
||||
assert!(stats.iter().any(|(name, _, _)| name == "user1"));
|
||||
assert!(stats.iter().any(|(name, _, _)| name == "user2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_user_ips() {
|
||||
let tracker = UserIpTracker::new();
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
|
||||
tracker.check_and_add("test_user", ip1).await.unwrap();
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 1);
|
||||
|
||||
tracker.clear_user_ips("test_user").await;
|
||||
assert_eq!(tracker.get_active_ip_count("test_user").await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_ip_active() {
|
||||
let tracker = UserIpTracker::new();
|
||||
let ip1 = test_ipv4(192, 168, 1, 1);
|
||||
let ip2 = test_ipv4(192, 168, 1, 2);
|
||||
|
||||
tracker.check_and_add("test_user", ip1).await.unwrap();
|
||||
|
||||
assert!(tracker.is_ip_active("test_user", ip1).await);
|
||||
assert!(!tracker.is_ip_active("test_user", ip2).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_limits_from_config() {
|
||||
let tracker = UserIpTracker::new();
|
||||
|
||||
let mut config_limits = HashMap::new();
|
||||
config_limits.insert("user1".to_string(), 5);
|
||||
config_limits.insert("user2".to_string(), 3);
|
||||
|
||||
tracker.load_limits(0, &config_limits).await;
|
||||
|
||||
assert_eq!(tracker.get_user_limit("user1").await, Some(5));
|
||||
assert_eq!(tracker.get_user_limit("user2").await, Some(3));
|
||||
assert_eq!(tracker.get_user_limit("user3").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_limits_replaces_previous_map() {
|
||||
let tracker = UserIpTracker::new();
|
||||
|
||||
let mut first = HashMap::new();
|
||||
first.insert("user1".to_string(), 2);
|
||||
first.insert("user2".to_string(), 3);
|
||||
tracker.load_limits(0, &first).await;
|
||||
|
||||
let mut second = HashMap::new();
|
||||
second.insert("user2".to_string(), 5);
|
||||
tracker.load_limits(0, &second).await;
|
||||
|
||||
assert_eq!(tracker.get_user_limit("user1").await, None);
|
||||
assert_eq!(tracker.get_user_limit("user2").await, Some(5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_global_each_limit_applies_without_user_override() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.load_limits(2, &HashMap::new()).await;
|
||||
|
||||
let ip1 = test_ipv4(172, 16, 0, 1);
|
||||
let ip2 = test_ipv4(172, 16, 0, 2);
|
||||
let ip3 = test_ipv4(172, 16, 0, 3);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip3).await.is_err());
|
||||
assert_eq!(tracker.get_user_limit("test_user").await, Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_override_wins_over_global_each_limit() {
|
||||
let tracker = UserIpTracker::new();
|
||||
let mut limits = HashMap::new();
|
||||
limits.insert("test_user".to_string(), 1);
|
||||
tracker.load_limits(3, &limits).await;
|
||||
|
||||
let ip1 = test_ipv4(172, 17, 0, 1);
|
||||
let ip2 = test_ipv4(172, 17, 0, 2);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
|
||||
assert_eq!(tracker.get_user_limit("test_user").await, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_time_window_mode_blocks_recent_ip_churn() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 1).await;
|
||||
tracker
|
||||
.set_limit_policy(UserMaxUniqueIpsMode::TimeWindow, 30)
|
||||
.await;
|
||||
|
||||
let ip1 = test_ipv4(10, 0, 0, 1);
|
||||
let ip2 = test_ipv4(10, 0, 0, 2);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_combined_mode_enforces_active_and_recent_limits() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 1).await;
|
||||
tracker
|
||||
.set_limit_policy(UserMaxUniqueIpsMode::Combined, 30)
|
||||
.await;
|
||||
|
||||
let ip1 = test_ipv4(10, 0, 1, 1);
|
||||
let ip2 = test_ipv4(10, 0, 1, 2);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
|
||||
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_time_window_expires() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 1).await;
|
||||
tracker
|
||||
.set_limit_policy(UserMaxUniqueIpsMode::TimeWindow, 1)
|
||||
.await;
|
||||
|
||||
let ip1 = test_ipv4(10, 1, 0, 1);
|
||||
let ip2 = test_ipv4(10, 1, 0, 2);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_err());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
assert!(tracker.check_and_add("test_user", ip2).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_stats_reports_queue_and_entry_counts() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 4).await;
|
||||
let ip1 = test_ipv4(10, 2, 0, 1);
|
||||
let ip2 = test_ipv4(10, 2, 0, 2);
|
||||
|
||||
tracker.check_and_add("test_user", ip1).await.unwrap();
|
||||
tracker.check_and_add("test_user", ip2).await.unwrap();
|
||||
tracker.enqueue_cleanup("test_user".to_string(), ip1);
|
||||
|
||||
let snapshot = tracker.memory_stats().await;
|
||||
assert_eq!(snapshot.active_users, 1);
|
||||
assert_eq!(snapshot.recent_users, 1);
|
||||
assert_eq!(snapshot.active_entries, 2);
|
||||
assert_eq!(snapshot.recent_entries, 2);
|
||||
assert_eq!(snapshot.cleanup_queue_len, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_prunes_stale_recent_entries() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker
|
||||
.set_limit_policy(UserMaxUniqueIpsMode::TimeWindow, 1)
|
||||
.await;
|
||||
|
||||
let stale_user = "stale-user".to_string();
|
||||
let stale_ip = test_ipv4(10, 3, 0, 1);
|
||||
{
|
||||
let shard_idx = UserIpTracker::shard_idx(&stale_user);
|
||||
let mut shard = tracker.shards[shard_idx].write().await;
|
||||
shard
|
||||
.recent_ips
|
||||
.entry(stale_user.clone())
|
||||
.or_insert_with(HashMap::new)
|
||||
.insert(stale_ip, Instant::now() - Duration::from_secs(5));
|
||||
}
|
||||
|
||||
tracker.last_compact_epoch_secs.store(0, Ordering::Relaxed);
|
||||
tracker
|
||||
.check_and_add("trigger-user", test_ipv4(10, 3, 0, 2))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let shard_idx = UserIpTracker::shard_idx(&stale_user);
|
||||
let shard = tracker.shards[shard_idx].read().await;
|
||||
let stale_exists = shard
|
||||
.recent_ips
|
||||
.get(&stale_user)
|
||||
.map(|ips| ips.contains_key(&stale_ip))
|
||||
.unwrap_or(false);
|
||||
assert!(!stale_exists);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_time_window_allows_same_ip_reconnect() {
|
||||
let tracker = UserIpTracker::new();
|
||||
tracker.set_user_limit("test_user", 1).await;
|
||||
tracker
|
||||
.set_limit_policy(UserMaxUniqueIpsMode::TimeWindow, 1)
|
||||
.await;
|
||||
|
||||
let ip1 = test_ipv4(10, 4, 0, 1);
|
||||
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
tracker.remove_ip("test_user", ip1).await;
|
||||
assert!(tracker.check_and_add("test_user", ip1).await.is_ok());
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::{RwLock, watch};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
@@ -14,24 +14,32 @@ const RUNTIME_FALLBACK_AFTER: Duration = Duration::from_secs(6);
|
||||
pub(crate) async fn configure_admission_gate(
|
||||
config: &Arc<ProxyConfig>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
admission_tx: &watch::Sender<bool>,
|
||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||
me_ready_rx: watch::Receiver<u64>,
|
||||
) {
|
||||
if config.general.use_middle_proxy {
|
||||
if let Some(pool) = me_pool.as_ref() {
|
||||
let initial_ready = pool.admission_ready_conditional_cast().await;
|
||||
if me_pool.is_some() || config.general.me2dc_fallback {
|
||||
let initial_pool = match me_pool.as_ref() {
|
||||
Some(pool) => Some(pool.clone()),
|
||||
None => me_pool_runtime.read().await.clone(),
|
||||
};
|
||||
let initial_ready = match initial_pool.as_ref() {
|
||||
Some(pool) => pool.admission_ready_conditional_cast().await,
|
||||
None => false,
|
||||
};
|
||||
let mut fallback_enabled = config.general.me2dc_fallback;
|
||||
let mut fast_fallback_enabled = fallback_enabled && config.general.me2dc_fast;
|
||||
let (initial_gate_open, initial_route_mode, initial_fallback_reason) = if initial_ready
|
||||
{
|
||||
(true, RelayRouteMode::Middle, None)
|
||||
} else if fast_fallback_enabled {
|
||||
} else if fallback_enabled {
|
||||
(
|
||||
true,
|
||||
RelayRouteMode::Direct,
|
||||
Some("fast_not_ready_fallback"),
|
||||
Some("startup_direct_fallback"),
|
||||
)
|
||||
} else {
|
||||
(false, RelayRouteMode::Middle, None)
|
||||
@@ -49,7 +57,8 @@ pub(crate) async fn configure_admission_gate(
|
||||
warn!("Conditional-admission gate: closed / ME pool is NOT ready)");
|
||||
}
|
||||
|
||||
let pool_for_gate = pool.clone();
|
||||
let mut pool_for_gate = initial_pool;
|
||||
let pool_runtime_for_gate = me_pool_runtime.clone();
|
||||
let admission_tx_gate = admission_tx.clone();
|
||||
let route_runtime_gate = route_runtime.clone();
|
||||
let mut config_rx_gate = config_rx.clone();
|
||||
@@ -83,12 +92,27 @@ pub(crate) async fn configure_admission_gate(
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(admission_poll_ms)) => {}
|
||||
}
|
||||
let ready = pool_for_gate.admission_ready_conditional_cast().await;
|
||||
if pool_for_gate.is_none() {
|
||||
pool_for_gate = pool_runtime_for_gate.read().await.clone();
|
||||
}
|
||||
let ready = match pool_for_gate.as_ref() {
|
||||
Some(pool) => pool.admission_ready_conditional_cast().await,
|
||||
None => false,
|
||||
};
|
||||
let now = Instant::now();
|
||||
let (next_gate_open, next_route_mode, next_fallback_reason) = if ready {
|
||||
ready_observed = true;
|
||||
not_ready_since = None;
|
||||
if let Some(pool) = pool_for_gate.as_ref() {
|
||||
pool.set_runtime_ready(true);
|
||||
}
|
||||
(true, RelayRouteMode::Middle, None)
|
||||
} else if fallback_enabled && !ready_observed {
|
||||
(
|
||||
true,
|
||||
RelayRouteMode::Direct,
|
||||
Some("startup_direct_fallback"),
|
||||
)
|
||||
} else if fast_fallback_enabled {
|
||||
(
|
||||
true,
|
||||
@@ -122,7 +146,14 @@ pub(crate) async fn configure_admission_gate(
|
||||
);
|
||||
} else {
|
||||
let fallback_reason = next_fallback_reason.unwrap_or("unknown");
|
||||
if fallback_reason == "strict_grace_fallback" {
|
||||
if fallback_reason == "startup_direct_fallback" {
|
||||
warn!(
|
||||
target_mode = route_mode.as_str(),
|
||||
cutover_generation = snapshot.generation,
|
||||
fallback_reason,
|
||||
"ME pool not-ready during startup; routing new sessions via Direct-DC"
|
||||
);
|
||||
} else if fallback_reason == "strict_grace_fallback" {
|
||||
let fallback_after = if ready_observed {
|
||||
RUNTIME_FALLBACK_AFTER
|
||||
} else {
|
||||
|
||||
@@ -147,7 +147,7 @@ pub(crate) async fn run_startup_connectivity(
|
||||
.any(|r| r.rtt_ms.is_some());
|
||||
|
||||
if upstream_result.both_available {
|
||||
if prefer_ipv6 {
|
||||
if upstream_result.prefer_ipv6 {
|
||||
info!(" IPv6 in use / IPv4 is fallback");
|
||||
} else {
|
||||
info!(" IPv4 in use / IPv6 is fallback");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(clippy::items_after_test_module)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::watch;
|
||||
@@ -15,6 +16,32 @@ use crate::transport::middle_proxy::{
|
||||
save_proxy_config_cache,
|
||||
};
|
||||
|
||||
const MAESTRO_COLOR: &str = "\x1b[92m";
|
||||
const COLOR_RESET: &str = "\x1b[0m";
|
||||
|
||||
static MAESTRO_COLORS_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Enables or disables ANSI color in direct MAESTRO status lines.
|
||||
pub(crate) fn set_maestro_colors_enabled(enabled: bool) {
|
||||
MAESTRO_COLORS_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn format_maestro_line(message: impl AsRef<str>, colors_enabled: bool) -> String {
|
||||
if colors_enabled {
|
||||
format!("{MAESTRO_COLOR}MAESTRO{COLOR_RESET}: {}", message.as_ref())
|
||||
} else {
|
||||
format!("MAESTRO: {}", message.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints a direct MAESTRO status line outside the tracing subscriber.
|
||||
pub(crate) fn print_maestro_line(message: impl AsRef<str>) {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format_maestro_line(message, MAESTRO_COLORS_ENABLED.load(Ordering::Relaxed))
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_runtime_config_path(
|
||||
config_path_cli: &str,
|
||||
startup_cwd: &Path,
|
||||
@@ -267,11 +294,24 @@ mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{
|
||||
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
||||
resolve_runtime_base_dir, resolve_runtime_config_path,
|
||||
expected_handshake_close_description, format_maestro_line, is_expected_handshake_eof,
|
||||
peer_close_description, resolve_runtime_base_dir, resolve_runtime_config_path,
|
||||
};
|
||||
use crate::error::{ProxyError, StreamError};
|
||||
|
||||
#[test]
|
||||
fn maestro_line_formatter_respects_disabled_colors() {
|
||||
let plain = format_maestro_line("boot", false);
|
||||
assert_eq!(plain, "MAESTRO: boot");
|
||||
assert!(!plain.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maestro_line_formatter_keeps_color_when_enabled() {
|
||||
let colored = format_maestro_line("boot", true);
|
||||
assert!(colored.contains("\x1b[92mMAESTRO\x1b[0m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
@@ -501,7 +541,7 @@ mod tests {
|
||||
}
|
||||
|
||||
pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
||||
info!(target: "telemt::links", "--- Proxy Links ({}) ---", host);
|
||||
print_maestro_line(format!("Proxy links ({host})"));
|
||||
for user_name in config
|
||||
.general
|
||||
.links
|
||||
@@ -509,20 +549,16 @@ pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
||||
.resolve_users(&config.access.users)
|
||||
{
|
||||
if let Some(secret) = config.access.users.get(user_name) {
|
||||
info!(target: "telemt::links", "User: {}", user_name);
|
||||
print_maestro_line(format!("User: {user_name}"));
|
||||
if config.general.modes.classic {
|
||||
info!(
|
||||
target: "telemt::links",
|
||||
" Classic: tg://proxy?server={}&port={}&secret={}",
|
||||
host, port, secret
|
||||
);
|
||||
print_maestro_line(format!(
|
||||
"Classic: tg://proxy?server={host}&port={port}&secret={secret}"
|
||||
));
|
||||
}
|
||||
if config.general.modes.secure {
|
||||
info!(
|
||||
target: "telemt::links",
|
||||
" DD: tg://proxy?server={}&port={}&secret=dd{}",
|
||||
host, port, secret
|
||||
);
|
||||
print_maestro_line(format!(
|
||||
"DD: tg://proxy?server={host}&port={port}&secret=dd{secret}"
|
||||
));
|
||||
}
|
||||
if config.general.modes.tls {
|
||||
let mut domains = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
||||
@@ -535,18 +571,15 @@ pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
||||
|
||||
for domain in domains {
|
||||
let domain_hex = hex::encode(&domain);
|
||||
info!(
|
||||
target: "telemt::links",
|
||||
" EE-TLS: tg://proxy?server={}&port={}&secret=ee{}{}",
|
||||
host, port, secret, domain_hex
|
||||
);
|
||||
print_maestro_line(format!(
|
||||
"EE-TLS: tg://proxy?server={host}&port={port}&secret=ee{secret}{domain_hex}"
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(target: "telemt::links", "User '{}' in show_link not found", user_name);
|
||||
}
|
||||
}
|
||||
info!(target: "telemt::links", "------------------------");
|
||||
}
|
||||
|
||||
pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixListener;
|
||||
use tokio::sync::{Semaphore, watch};
|
||||
use tokio::sync::{RwLock, Semaphore, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::{ProxyConfig, RstOnCloseMode};
|
||||
@@ -47,6 +47,10 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
|
||||
.unwrap_or(config.server.port)
|
||||
}
|
||||
|
||||
fn mss_segment_multiplier(client_mss: u16) -> u16 {
|
||||
1460u16.div_ceil(client_mss)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn bind_listeners(
|
||||
config: &Arc<ProxyConfig>,
|
||||
@@ -63,6 +67,7 @@ pub(crate) async fn bind_listeners(
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
@@ -89,10 +94,22 @@ pub(crate) async fn bind_listeners(
|
||||
warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]");
|
||||
continue;
|
||||
}
|
||||
let client_mss = match listener_conf.effective_client_mss(&config.server) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
%addr,
|
||||
error = %error,
|
||||
"Invalid listener client MSS after config validation; using kernel default"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let options = ListenOptions {
|
||||
reuse_port: listener_conf.reuse_allow,
|
||||
ipv6_only: listener_conf.ip.is_ipv6(),
|
||||
backlog: config.server.listen_backlog,
|
||||
client_mss,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -100,6 +117,14 @@ pub(crate) async fn bind_listeners(
|
||||
Ok(socket) => {
|
||||
let listener = TcpListener::from_std(socket.into())?;
|
||||
info!("Listening on {}", addr);
|
||||
if let Some(client_mss) = client_mss {
|
||||
info!(
|
||||
%addr,
|
||||
client_mss,
|
||||
segment_multiplier = mss_segment_multiplier(client_mss),
|
||||
"Client-facing TCP MSS configured"
|
||||
);
|
||||
}
|
||||
let listener_proxy_protocol = listener_conf
|
||||
.proxy_protocol
|
||||
.unwrap_or(config.server.proxy_protocol);
|
||||
@@ -236,6 +261,7 @@ pub(crate) async fn bind_listeners(
|
||||
let buffer_pool = buffer_pool.clone();
|
||||
let rng = rng.clone();
|
||||
let me_pool = me_pool.clone();
|
||||
let me_pool_runtime = me_pool_runtime.clone();
|
||||
let route_runtime = route_runtime.clone();
|
||||
let tls_cache = tls_cache.clone();
|
||||
let ip_tracker = ip_tracker.clone();
|
||||
@@ -298,6 +324,7 @@ pub(crate) async fn bind_listeners(
|
||||
let buffer_pool = buffer_pool.clone();
|
||||
let rng = rng.clone();
|
||||
let me_pool = me_pool.clone();
|
||||
let me_pool_runtime = me_pool_runtime.clone();
|
||||
let route_runtime = route_runtime.clone();
|
||||
let tls_cache = tls_cache.clone();
|
||||
let ip_tracker = ip_tracker.clone();
|
||||
@@ -307,7 +334,8 @@ pub(crate) async fn bind_listeners(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
if let Err(e) = crate::proxy::client::handle_client_stream_with_shared(
|
||||
if let Err(e) =
|
||||
crate::proxy::client::handle_client_stream_with_shared_and_pool_runtime(
|
||||
stream,
|
||||
fake_peer,
|
||||
config,
|
||||
@@ -317,6 +345,7 @@ pub(crate) async fn bind_listeners(
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
Some(me_pool_runtime),
|
||||
route_runtime,
|
||||
tls_cache,
|
||||
ip_tracker,
|
||||
@@ -367,6 +396,7 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
@@ -383,6 +413,7 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
let buffer_pool = buffer_pool.clone();
|
||||
let rng = rng.clone();
|
||||
let me_pool = me_pool.clone();
|
||||
let me_pool_runtime = me_pool_runtime.clone();
|
||||
let route_runtime = route_runtime.clone();
|
||||
let tls_cache = tls_cache.clone();
|
||||
let ip_tracker = ip_tracker.clone();
|
||||
@@ -449,6 +480,7 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
let buffer_pool = buffer_pool.clone();
|
||||
let rng = rng.clone();
|
||||
let me_pool = me_pool.clone();
|
||||
let me_pool_runtime = me_pool_runtime.clone();
|
||||
let route_runtime = route_runtime.clone();
|
||||
let tls_cache = tls_cache.clone();
|
||||
let ip_tracker = ip_tracker.clone();
|
||||
@@ -470,6 +502,7 @@ pub(crate) fn spawn_tcp_accept_loops(
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
Some(me_pool_runtime),
|
||||
route_runtime,
|
||||
tls_cache,
|
||||
ip_tracker,
|
||||
|
||||
@@ -36,10 +36,10 @@ use crate::network::probe::{decide_network_capabilities, log_probe_result, run_p
|
||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||
use crate::proxy::shared_state::ProxySharedState;
|
||||
use crate::startup::{
|
||||
COMPONENT_API_BOOTSTRAP, COMPONENT_CONFIG_LOAD, COMPONENT_ME_POOL_CONSTRUCT,
|
||||
COMPONENT_ME_POOL_INIT_STAGE1, COMPONENT_ME_PROXY_CONFIG_V4, COMPONENT_ME_PROXY_CONFIG_V6,
|
||||
COMPONENT_ME_SECRET_FETCH, COMPONENT_NETWORK_PROBE, COMPONENT_TRACING_INIT, StartupMeStatus,
|
||||
StartupTracker,
|
||||
COMPONENT_API_BOOTSTRAP, COMPONENT_CONFIG_LOAD, COMPONENT_DC_CONNECTIVITY_PING,
|
||||
COMPONENT_ME_CONNECTIVITY_PING, COMPONENT_ME_POOL_CONSTRUCT, COMPONENT_ME_POOL_INIT_STAGE1,
|
||||
COMPONENT_ME_PROXY_CONFIG_V4, COMPONENT_ME_PROXY_CONFIG_V6, COMPONENT_ME_SECRET_FETCH,
|
||||
COMPONENT_NETWORK_PROBE, COMPONENT_TRACING_INIT, StartupMeStatus, StartupTracker,
|
||||
};
|
||||
use crate::stats::beobachten::BeobachtenStore;
|
||||
use crate::stats::telemetry::TelemetryPolicy;
|
||||
@@ -47,7 +47,10 @@ use crate::stats::{ReplayChecker, Stats};
|
||||
use crate::stream::BufferPool;
|
||||
use crate::transport::UpstreamManager;
|
||||
use crate::transport::middle_proxy::MePool;
|
||||
use helpers::{parse_cli, resolve_runtime_base_dir, resolve_runtime_config_path};
|
||||
use helpers::{
|
||||
parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path,
|
||||
set_maestro_colors_enabled,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::daemon::{DaemonOptions, PidFile, drop_privileges};
|
||||
@@ -312,6 +315,7 @@ async fn run_telemt_core(
|
||||
eprintln!("[telemt] Invalid network.dns_overrides: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
set_maestro_colors_enabled(!config.general.disable_colors);
|
||||
startup_tracker
|
||||
.complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string()))
|
||||
.await;
|
||||
@@ -325,7 +329,9 @@ async fn run_telemt_core(
|
||||
config.general.log_level.clone()
|
||||
};
|
||||
|
||||
let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new("info"));
|
||||
let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level);
|
||||
let (filter_layer, filter_handle) =
|
||||
reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
|
||||
startup_tracker
|
||||
.start_component(
|
||||
COMPONENT_TRACING_INIT,
|
||||
@@ -356,7 +362,7 @@ async fn run_telemt_core(
|
||||
destination: log_destination,
|
||||
disable_colors: true,
|
||||
};
|
||||
let (_, guard) = crate::logging::init_logging(&logging_opts, "info");
|
||||
let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec);
|
||||
_logging_guard = Some(guard);
|
||||
}
|
||||
crate::logging::LogDestination::File { .. } => {
|
||||
@@ -365,7 +371,7 @@ async fn run_telemt_core(
|
||||
destination: log_destination,
|
||||
disable_colors: true,
|
||||
};
|
||||
let (_, guard) = crate::logging::init_logging(&logging_opts, "info");
|
||||
let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec);
|
||||
_logging_guard = Some(guard);
|
||||
}
|
||||
}
|
||||
@@ -377,7 +383,7 @@ async fn run_telemt_core(
|
||||
)
|
||||
.await;
|
||||
|
||||
info!("Telemt MTProxy v{}", env!("CARGO_PKG_VERSION"));
|
||||
print_maestro_line(format!("Telemt MTProxy v{}", env!("CARGO_PKG_VERSION")));
|
||||
info!("Log level: {}", effective_log_level);
|
||||
if config.general.disable_colors {
|
||||
info!("Colors: disabled");
|
||||
@@ -458,15 +464,22 @@ async fn run_telemt_core(
|
||||
config.network.dns_overrides.len()
|
||||
);
|
||||
}
|
||||
let shared_state = ProxySharedState::new();
|
||||
shared_state.apply_user_enabled_config(&config.access.user_enabled);
|
||||
shared_state.traffic_limiter.apply_policy(
|
||||
config.access.user_rate_limits.clone(),
|
||||
config.access.cidr_rate_limits.clone(),
|
||||
);
|
||||
|
||||
let (api_config_tx, api_config_rx) = watch::channel(Arc::new(config.clone()));
|
||||
let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>));
|
||||
let initial_admission_open = !config.general.use_middle_proxy;
|
||||
let initial_direct_first = config.general.use_middle_proxy && config.general.me2dc_fallback;
|
||||
let initial_admission_open = !config.general.use_middle_proxy || initial_direct_first;
|
||||
let (admission_tx, admission_rx) = watch::channel(initial_admission_open);
|
||||
let initial_route_mode = if config.general.use_middle_proxy {
|
||||
RelayRouteMode::Middle
|
||||
} else {
|
||||
let initial_route_mode = if !config.general.use_middle_proxy || initial_direct_first {
|
||||
RelayRouteMode::Direct
|
||||
} else {
|
||||
RelayRouteMode::Middle
|
||||
};
|
||||
let route_runtime = Arc::new(RouteRuntimeController::new(initial_route_mode));
|
||||
let api_me_pool = Arc::new(RwLock::new(None::<Arc<MePool>>));
|
||||
@@ -495,6 +508,7 @@ async fn run_telemt_core(
|
||||
let me_pool_api = api_me_pool.clone();
|
||||
let upstream_manager_api = upstream_manager.clone();
|
||||
let route_runtime_api = route_runtime.clone();
|
||||
let proxy_shared_api = shared_state.clone();
|
||||
let config_rx_api = api_config_rx.clone();
|
||||
let admission_rx_api = admission_rx.clone();
|
||||
let config_path_api = config_path.clone();
|
||||
@@ -508,6 +522,7 @@ async fn run_telemt_core(
|
||||
ip_tracker_api,
|
||||
me_pool_api,
|
||||
route_runtime_api,
|
||||
proxy_shared_api,
|
||||
upstream_manager_api,
|
||||
config_rx_api,
|
||||
admission_rx_api,
|
||||
@@ -602,8 +617,9 @@ async fn run_telemt_core(
|
||||
let me_init_retry_attempts = config.general.me_init_retry_attempts;
|
||||
if use_middle_proxy && !decision.ipv4_me && !decision.ipv6_me {
|
||||
if me2dc_fallback {
|
||||
warn!("No usable IP family for Middle Proxy detected; falling back to direct DC");
|
||||
use_middle_proxy = false;
|
||||
warn!(
|
||||
"No usable IP family for Middle Proxy detected; Direct-DC startup fallback is active while ME init retries continue"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"No usable IP family for Middle Proxy detected; me2dc_fallback=false, ME init retries stay active"
|
||||
@@ -665,23 +681,34 @@ async fn run_telemt_core(
|
||||
}
|
||||
|
||||
let (me_ready_tx, me_ready_rx) = watch::channel(0_u64);
|
||||
let direct_first_startup = use_middle_proxy && me2dc_fallback;
|
||||
|
||||
let me_pool: Option<Arc<MePool>> = me_startup::initialize_me_pool(
|
||||
use_middle_proxy,
|
||||
&config,
|
||||
&decision,
|
||||
&probe,
|
||||
&startup_tracker,
|
||||
upstream_manager.clone(),
|
||||
rng.clone(),
|
||||
stats.clone(),
|
||||
api_me_pool.clone(),
|
||||
me_ready_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
let me_pool: Option<Arc<MePool>> = if direct_first_startup {
|
||||
None
|
||||
} else {
|
||||
me_startup::initialize_me_pool(
|
||||
use_middle_proxy,
|
||||
&config,
|
||||
&decision,
|
||||
&probe,
|
||||
&startup_tracker,
|
||||
upstream_manager.clone(),
|
||||
rng.clone(),
|
||||
stats.clone(),
|
||||
api_me_pool.clone(),
|
||||
me_ready_tx.clone(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
// If ME failed to initialize, force direct-only mode.
|
||||
if me_pool.is_some() {
|
||||
if direct_first_startup {
|
||||
startup_tracker.set_transport_mode("direct").await;
|
||||
startup_tracker.set_degraded(true).await;
|
||||
info!(
|
||||
"Transport: Direct DC startup fallback active; Middle-End bootstrap continues in background"
|
||||
);
|
||||
} else if me_pool.is_some() {
|
||||
startup_tracker.set_transport_mode("middle_proxy").await;
|
||||
startup_tracker.set_degraded(false).await;
|
||||
info!("Transport: Middle-End Proxy - all DC-over-RPC");
|
||||
@@ -713,24 +740,34 @@ async fn run_telemt_core(
|
||||
));
|
||||
|
||||
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
|
||||
let shared_state = ProxySharedState::new();
|
||||
shared_state.traffic_limiter.apply_policy(
|
||||
config.access.user_rate_limits.clone(),
|
||||
config.access.cidr_rate_limits.clone(),
|
||||
);
|
||||
|
||||
connectivity::run_startup_connectivity(
|
||||
&config,
|
||||
&me_pool,
|
||||
rng.clone(),
|
||||
&startup_tracker,
|
||||
upstream_manager.clone(),
|
||||
prefer_ipv6,
|
||||
&decision,
|
||||
process_started_at,
|
||||
api_me_pool.clone(),
|
||||
)
|
||||
.await;
|
||||
if direct_first_startup {
|
||||
startup_tracker
|
||||
.skip_component(
|
||||
COMPONENT_ME_CONNECTIVITY_PING,
|
||||
Some("deferred by direct-first startup".to_string()),
|
||||
)
|
||||
.await;
|
||||
startup_tracker
|
||||
.skip_component(
|
||||
COMPONENT_DC_CONNECTIVITY_PING,
|
||||
Some("background health checks active".to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
connectivity::run_startup_connectivity(
|
||||
&config,
|
||||
&me_pool,
|
||||
rng.clone(),
|
||||
&startup_tracker,
|
||||
upstream_manager.clone(),
|
||||
prefer_ipv6,
|
||||
&decision,
|
||||
process_started_at,
|
||||
api_me_pool.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let runtime_watches = runtime_tasks::spawn_runtime_tasks(
|
||||
&config,
|
||||
@@ -758,9 +795,72 @@ async fn run_telemt_core(
|
||||
let detected_ip_v4 = runtime_watches.detected_ip_v4;
|
||||
let detected_ip_v6 = runtime_watches.detected_ip_v6;
|
||||
|
||||
if direct_first_startup {
|
||||
let config_bg = config.clone();
|
||||
let decision_bg = decision.clone();
|
||||
let probe_bg = probe.clone();
|
||||
let startup_tracker_bg = startup_tracker.clone();
|
||||
let upstream_manager_bg = upstream_manager.clone();
|
||||
let rng_bg = rng.clone();
|
||||
let stats_bg = stats.clone();
|
||||
let api_me_pool_bg = api_me_pool.clone();
|
||||
let me_ready_tx_bg = me_ready_tx.clone();
|
||||
let config_rx_bg = config_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut bootstrap_attempt: u32 = 0;
|
||||
loop {
|
||||
bootstrap_attempt = bootstrap_attempt.saturating_add(1);
|
||||
let pool = me_startup::initialize_me_pool(
|
||||
true,
|
||||
config_bg.as_ref(),
|
||||
&decision_bg,
|
||||
&probe_bg,
|
||||
&startup_tracker_bg,
|
||||
upstream_manager_bg.clone(),
|
||||
rng_bg.clone(),
|
||||
stats_bg.clone(),
|
||||
api_me_pool_bg.clone(),
|
||||
me_ready_tx_bg.clone(),
|
||||
)
|
||||
.await;
|
||||
if let Some(pool) = pool {
|
||||
runtime_tasks::spawn_middle_proxy_runtime_tasks(
|
||||
config_bg.as_ref(),
|
||||
config_rx_bg,
|
||||
pool,
|
||||
rng_bg,
|
||||
me_ready_tx_bg,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if me_init_retry_attempts > 0 && bootstrap_attempt >= me_init_retry_attempts {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let startup_tracker_ready = startup_tracker.clone();
|
||||
let api_me_pool_ready = api_me_pool.clone();
|
||||
let mut me_ready_rx_transport = me_ready_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if me_ready_rx_transport.changed().await.is_ok() {
|
||||
if let Some(pool) = api_me_pool_ready.read().await.as_ref() {
|
||||
pool.set_runtime_ready(true);
|
||||
}
|
||||
startup_tracker_ready
|
||||
.set_transport_mode("middle_proxy")
|
||||
.await;
|
||||
startup_tracker_ready.set_degraded(false).await;
|
||||
info!("Transport: Middle-End Proxy restored for new sessions");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
admission::configure_admission_gate(
|
||||
&config,
|
||||
me_pool.clone(),
|
||||
api_me_pool.clone(),
|
||||
route_runtime.clone(),
|
||||
&admission_tx,
|
||||
config_rx.clone(),
|
||||
@@ -789,6 +889,7 @@ async fn run_telemt_core(
|
||||
buffer_pool.clone(),
|
||||
rng.clone(),
|
||||
me_pool.clone(),
|
||||
api_me_pool.clone(),
|
||||
route_runtime.clone(),
|
||||
tls_cache.clone(),
|
||||
ip_tracker.clone(),
|
||||
@@ -843,6 +944,7 @@ async fn run_telemt_core(
|
||||
buffer_pool.clone(),
|
||||
rng.clone(),
|
||||
me_pool.clone(),
|
||||
api_me_pool.clone(),
|
||||
route_runtime.clone(),
|
||||
tls_cache.clone(),
|
||||
ip_tracker.clone(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::reload;
|
||||
|
||||
@@ -234,6 +234,27 @@ pub(crate) async fn spawn_runtime_tasks(
|
||||
}
|
||||
});
|
||||
|
||||
let shared_user_enabled = shared_state.clone();
|
||||
let mut config_rx_user_enabled = config_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if config_rx_user_enabled.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
let cfg = config_rx_user_enabled.borrow_and_update().clone();
|
||||
for user in shared_user_enabled.apply_user_enabled_config(&cfg.access.user_enabled) {
|
||||
let cancelled = shared_user_enabled.cancel_user_sessions(&user);
|
||||
if cancelled > 0 {
|
||||
info!(
|
||||
user = %user,
|
||||
cancelled,
|
||||
"Disabled user sessions cancelled after config reload"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let beobachten_writer = beobachten.clone();
|
||||
let config_rx_beobachten = config_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -257,45 +278,7 @@ pub(crate) async fn spawn_runtime_tasks(
|
||||
});
|
||||
|
||||
if let Some(pool) = me_pool {
|
||||
let reinit_trigger_capacity = config.general.me_reinit_trigger_channel.max(1);
|
||||
let (reinit_tx, reinit_rx) = mpsc::channel::<MeReinitTrigger>(reinit_trigger_capacity);
|
||||
|
||||
let pool_clone_sched = pool.clone();
|
||||
let rng_clone_sched = rng.clone();
|
||||
let config_rx_clone_sched = config_rx.clone();
|
||||
let me_ready_tx_sched = me_ready_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::transport::middle_proxy::me_reinit_scheduler(
|
||||
pool_clone_sched,
|
||||
rng_clone_sched,
|
||||
config_rx_clone_sched,
|
||||
reinit_rx,
|
||||
me_ready_tx_sched,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let pool_clone = pool.clone();
|
||||
let config_rx_clone = config_rx.clone();
|
||||
let reinit_tx_updater = reinit_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::transport::middle_proxy::me_config_updater(
|
||||
pool_clone,
|
||||
config_rx_clone,
|
||||
reinit_tx_updater,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let config_rx_clone_rot = config_rx.clone();
|
||||
let reinit_tx_rotation = reinit_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::transport::middle_proxy::me_rotation_task(
|
||||
config_rx_clone_rot,
|
||||
reinit_tx_rotation,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
spawn_middle_proxy_runtime_tasks(config, config_rx.clone(), pool, rng, me_ready_tx);
|
||||
}
|
||||
|
||||
RuntimeWatches {
|
||||
@@ -306,19 +289,58 @@ pub(crate) async fn spawn_runtime_tasks(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_middle_proxy_runtime_tasks(
|
||||
config: &ProxyConfig,
|
||||
config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||
pool: Arc<MePool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_ready_tx: watch::Sender<u64>,
|
||||
) {
|
||||
let reinit_trigger_capacity = config.general.me_reinit_trigger_channel.max(1);
|
||||
let (reinit_tx, reinit_rx) = mpsc::channel::<MeReinitTrigger>(reinit_trigger_capacity);
|
||||
|
||||
let pool_clone_sched = pool.clone();
|
||||
let rng_clone_sched = rng.clone();
|
||||
let config_rx_clone_sched = config_rx.clone();
|
||||
let me_ready_tx_sched = me_ready_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::transport::middle_proxy::me_reinit_scheduler(
|
||||
pool_clone_sched,
|
||||
rng_clone_sched,
|
||||
config_rx_clone_sched,
|
||||
reinit_rx,
|
||||
me_ready_tx_sched,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let pool_clone = pool.clone();
|
||||
let config_rx_clone = config_rx.clone();
|
||||
let reinit_tx_updater = reinit_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::transport::middle_proxy::me_config_updater(
|
||||
pool_clone,
|
||||
config_rx_clone,
|
||||
reinit_tx_updater,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let config_rx_clone_rot = config_rx.clone();
|
||||
let reinit_tx_rotation = reinit_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::transport::middle_proxy::me_rotation_task(config_rx_clone_rot, reinit_tx_rotation)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_runtime_log_filter(
|
||||
has_rust_log: bool,
|
||||
effective_log_level: &LogLevel,
|
||||
filter_handle: reload::Handle<EnvFilter, tracing_subscriber::Registry>,
|
||||
mut log_level_rx: watch::Receiver<LogLevel>,
|
||||
) {
|
||||
let runtime_filter = if has_rust_log {
|
||||
EnvFilter::from_default_env()
|
||||
} else if matches!(effective_log_level, LogLevel::Silent) {
|
||||
EnvFilter::new("warn,telemt::links=info")
|
||||
} else {
|
||||
EnvFilter::new(effective_log_level.to_filter_str())
|
||||
};
|
||||
let runtime_filter = EnvFilter::new(log_filter_spec(has_rust_log, effective_log_level));
|
||||
filter_handle
|
||||
.reload(runtime_filter)
|
||||
.expect("Failed to switch log filter");
|
||||
@@ -329,7 +351,7 @@ pub(crate) async fn apply_runtime_log_filter(
|
||||
break;
|
||||
}
|
||||
let level = log_level_rx.borrow_and_update().clone();
|
||||
let new_filter = tracing_subscriber::EnvFilter::new(level.to_filter_str());
|
||||
let new_filter = tracing_subscriber::EnvFilter::new(log_filter_spec(false, &level));
|
||||
if let Err(e) = filter_handle.reload(new_filter) {
|
||||
tracing::error!("config reload: failed to update log filter: {}", e);
|
||||
}
|
||||
@@ -337,6 +359,17 @@ pub(crate) async fn apply_runtime_log_filter(
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn log_filter_spec(has_rust_log: bool, effective_log_level: &LogLevel) -> String {
|
||||
if has_rust_log {
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| effective_log_level.to_filter_str().to_string())
|
||||
} else if matches!(effective_log_level, LogLevel::Silent) {
|
||||
"warn,telemt::links=info".to_string()
|
||||
} else {
|
||||
effective_log_level.to_filter_str().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_metrics_if_configured(
|
||||
config: &Arc<ProxyConfig>,
|
||||
startup_tracker: &Arc<StartupTracker>,
|
||||
|
||||
@@ -55,8 +55,10 @@ pub async fn serve(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let is_ipv6 = addr.is_ipv6();
|
||||
match bind_metrics_listener(addr, is_ipv6, listen_backlog) {
|
||||
// Match `server.api.listen`: `[::]:port` is a dual-stack wildcard
|
||||
// on Linux when `net.ipv6.bindv6only=0`.
|
||||
let ipv6_only = addr.is_ipv6() && !addr.ip().is_unspecified();
|
||||
match bind_metrics_listener(addr, ipv6_only, listen_backlog) {
|
||||
Ok(listener) => {
|
||||
info!("Metrics endpoint: http://{}/metrics and /beobachten", addr);
|
||||
serve_listener(
|
||||
@@ -286,7 +288,7 @@ async fn handle<B>(
|
||||
}
|
||||
|
||||
if req.uri().path() == "/beobachten" {
|
||||
let body = render_beobachten(beobachten, config);
|
||||
let body = render_beobachten(stats, beobachten, config);
|
||||
let resp = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "text/plain; charset=utf-8")
|
||||
@@ -302,13 +304,22 @@ async fn handle<B>(
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn render_beobachten(beobachten: &BeobachtenStore, config: &ProxyConfig) -> String {
|
||||
fn render_beobachten(stats: &Stats, beobachten: &BeobachtenStore, config: &ProxyConfig) -> String {
|
||||
if !config.general.beobachten {
|
||||
return "beobachten disabled\n".to_string();
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60));
|
||||
beobachten.snapshot_text(ttl)
|
||||
let mut body = beobachten.snapshot_text(ttl);
|
||||
let tls_text = stats.tls_fingerprint_snapshot_text(ttl, 20);
|
||||
if !tls_text.is_empty() {
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
body.push('\n');
|
||||
body.push_str(&tls_text);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn tls_front_domains(config: &ProxyConfig) -> Vec<String> {
|
||||
@@ -726,6 +737,37 @@ async fn render_metrics(
|
||||
}
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_route_cutover_parked_current Sessions currently parked in route cutover stagger delay"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_route_cutover_parked_current gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_route_cutover_parked_current{{route=\"direct\"}} {}",
|
||||
stats.get_route_cutover_parked_direct_current()
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_route_cutover_parked_current{{route=\"middle\"}} {}",
|
||||
stats.get_route_cutover_parked_middle_current()
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_route_cutover_parked_total Sessions parked in route cutover stagger delay"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_route_cutover_parked_total counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_route_cutover_parked_total{{route=\"direct\"}} {}",
|
||||
stats.get_route_cutover_parked_direct_total()
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_route_cutover_parked_total{{route=\"middle\"}} {}",
|
||||
stats.get_route_cutover_parked_middle_total()
|
||||
);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_quota_refund_bytes_total Reserved quota bytes returned before commit"
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod constants;
|
||||
pub mod frame;
|
||||
pub mod obfuscation;
|
||||
pub mod tls;
|
||||
pub mod tls_fingerprint;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use constants::*;
|
||||
@@ -13,3 +14,5 @@ pub use frame::*;
|
||||
pub use obfuscation::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use tls::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use tls_fingerprint::*;
|
||||
|
||||
@@ -1385,6 +1385,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
|
||||
false,
|
||||
true,
|
||||
ClientHelloTlsVersion::Tls13,
|
||||
[0x13, 0x01],
|
||||
&rng,
|
||||
Some(b"h2".to_vec()),
|
||||
0,
|
||||
@@ -1509,12 +1510,22 @@ fn test_validate_tls_handshake_format() {
|
||||
}
|
||||
|
||||
fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8> {
|
||||
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x01]], exts, host)
|
||||
}
|
||||
|
||||
fn build_client_hello_with_ciphers_and_exts(
|
||||
cipher_suites: &[[u8; 2]],
|
||||
exts: Vec<(u16, Vec<u8>)>,
|
||||
host: &str,
|
||||
) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&TLS_VERSION);
|
||||
body.extend_from_slice(&[0u8; 32]);
|
||||
body.push(0);
|
||||
body.extend_from_slice(&2u16.to_be_bytes());
|
||||
body.extend_from_slice(&[0x13, 0x01]);
|
||||
body.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes());
|
||||
for suite in cipher_suites {
|
||||
body.extend_from_slice(suite);
|
||||
}
|
||||
body.push(1);
|
||||
body.push(0);
|
||||
|
||||
@@ -1654,6 +1665,52 @@ fn detect_client_hello_tls_version_rejects_malformed_supported_versions() {
|
||||
assert!(detect_client_hello_tls_version(&ch).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_server_hello_cipher_suite_keeps_profile_cipher_when_offered() {
|
||||
let ch = build_client_hello_with_ciphers_and_exts(
|
||||
&[[0x13, 0x01], [0x13, 0x03]],
|
||||
Vec::new(),
|
||||
"example.com",
|
||||
);
|
||||
assert_eq!(
|
||||
select_server_hello_cipher_suite(&ch, [0x13, 0x03]),
|
||||
[0x13, 0x03]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() {
|
||||
let ch = build_client_hello_with_ciphers_and_exts(
|
||||
&[[0xc0, 0x2f], [0x13, 0x03]],
|
||||
Vec::new(),
|
||||
"example.com",
|
||||
);
|
||||
assert_eq!(
|
||||
select_server_hello_cipher_suite(&ch, [0xc0, 0x2f]),
|
||||
[0x13, 0x03]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_server_hello_cipher_suite_falls_back_to_offered_tls13_suite() {
|
||||
let ch = build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
|
||||
assert_eq!(
|
||||
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
|
||||
[0x13, 0x03]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_server_hello_cipher_suite_keeps_preferred_for_malformed_clienthello() {
|
||||
let mut ch =
|
||||
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
|
||||
ch.truncate(12);
|
||||
assert_eq!(
|
||||
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
|
||||
[0x13, 0x01]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_sni_rejects_zero_length_host_name() {
|
||||
let mut sni_ext = Vec::new();
|
||||
@@ -2179,7 +2236,7 @@ fn light_fuzz_boot_time_timestamp_matrix_with_short_replay_window_obeys_boot_cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_hello_application_data_contains_alpn_marker_when_selected() {
|
||||
fn server_hello_application_data_omits_alpn_marker_when_selected() {
|
||||
let secret = b"alpn_marker_test";
|
||||
let client_digest = [0x55u8; TLS_DIGEST_LEN];
|
||||
let session_id = vec![0xAB; 32];
|
||||
@@ -2206,8 +2263,8 @@ fn server_hello_application_data_contains_alpn_marker_when_selected() {
|
||||
assert!(
|
||||
app_payload
|
||||
.windows(expected.len())
|
||||
.any(|window| window == expected),
|
||||
"first application payload must carry ALPN marker for selected protocol"
|
||||
.all(|window| window != expected),
|
||||
"first application payload must not expose plaintext ALPN marker bytes"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2303,14 +2360,14 @@ fn server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() {
|
||||
fn server_hello_omits_alpn_marker_even_when_it_would_fit_fake_cert_len() {
|
||||
let secret = b"alpn_exact_fit_test";
|
||||
let client_digest = [0x58u8; TLS_DIGEST_LEN];
|
||||
let session_id = vec![0xA5; 32];
|
||||
let rng = crate::crypto::SecureRandom::new();
|
||||
let proto = vec![b'z'; 57];
|
||||
|
||||
// marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64
|
||||
// marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64.
|
||||
let response = build_server_hello(
|
||||
secret,
|
||||
&client_digest,
|
||||
@@ -2336,7 +2393,7 @@ fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() {
|
||||
expected_marker.extend_from_slice(&proto);
|
||||
|
||||
assert_eq!(app_payload.len(), expected_marker.len());
|
||||
assert_eq!(app_payload, expected_marker.as_slice());
|
||||
assert_ne!(app_payload, expected_marker.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -105,6 +105,8 @@ mod extension_type {
|
||||
/// TLS Cipher Suites
|
||||
mod cipher_suite {
|
||||
pub const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
|
||||
pub const TLS_AES_256_GCM_SHA384: [u8; 2] = [0x13, 0x02];
|
||||
pub const TLS_CHACHA20_POLY1305_SHA256: [u8; 2] = [0x13, 0x03];
|
||||
}
|
||||
|
||||
/// TLS Named Curves
|
||||
@@ -241,6 +243,13 @@ impl ServerHelloBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_cipher_suite(mut self, cipher_suite: [u8; 2]) -> Self {
|
||||
if cipher_suite != [0, 0] {
|
||||
self.cipher_suite = cipher_suite;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Build ServerHello message (without record header)
|
||||
fn build_message(&self) -> Vec<u8> {
|
||||
let Ok(session_id_len) = u8::try_from(self.session_id.len()) else {
|
||||
@@ -520,6 +529,33 @@ pub fn build_server_hello(
|
||||
rng: &SecureRandom,
|
||||
alpn: Option<Vec<u8>>,
|
||||
new_session_tickets: u8,
|
||||
) -> Vec<u8> {
|
||||
build_server_hello_with_cipher(
|
||||
secret,
|
||||
client_digest,
|
||||
session_id,
|
||||
fake_cert_len,
|
||||
rng,
|
||||
cipher_suite::TLS_AES_128_GCM_SHA256,
|
||||
alpn,
|
||||
new_session_tickets,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build TLS ServerHello response with a caller-selected cipher suite.
|
||||
///
|
||||
/// The caller is responsible for selecting a suite that is compatible with the
|
||||
/// already-authenticated ClientHello. Keeping the selection outside this
|
||||
/// builder avoids extra ClientHello parsing in the response construction path.
|
||||
pub(crate) fn build_server_hello_with_cipher(
|
||||
secret: &[u8],
|
||||
client_digest: &[u8; TLS_DIGEST_LEN],
|
||||
session_id: &[u8],
|
||||
fake_cert_len: usize,
|
||||
rng: &SecureRandom,
|
||||
selected_cipher_suite: [u8; 2],
|
||||
alpn: Option<Vec<u8>>,
|
||||
new_session_tickets: u8,
|
||||
) -> Vec<u8> {
|
||||
const MIN_APP_DATA: usize = 64;
|
||||
const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE;
|
||||
@@ -528,6 +564,7 @@ pub fn build_server_hello(
|
||||
|
||||
// Build ServerHello
|
||||
let server_hello = ServerHelloBuilder::new(session_id.to_vec())
|
||||
.with_cipher_suite(selected_cipher_suite)
|
||||
.with_x25519_key(&x25519_key)
|
||||
.with_tls13_version()
|
||||
.build_record();
|
||||
@@ -538,28 +575,14 @@ pub fn build_server_hello(
|
||||
TLS_VERSION[0],
|
||||
TLS_VERSION[1],
|
||||
0x00,
|
||||
0x01, // length = 1
|
||||
0x01, // CCS byte
|
||||
0x01,
|
||||
0x01,
|
||||
];
|
||||
|
||||
// Build first encrypted flight mimic as opaque ApplicationData bytes.
|
||||
// Embed a compact EncryptedExtensions-like ALPN block when selected.
|
||||
// ALPN belongs inside encrypted EncryptedExtensions in real TLS 1.3.
|
||||
let mut fake_cert = Vec::with_capacity(fake_cert_len);
|
||||
if let Some(proto) = alpn
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty() && p.len() <= u8::MAX as usize)
|
||||
{
|
||||
let proto_list_len = 1usize + proto.len();
|
||||
let ext_data_len = 2usize + proto_list_len;
|
||||
let marker_len = 4usize + ext_data_len;
|
||||
if marker_len <= fake_cert_len {
|
||||
fake_cert.extend_from_slice(&0x0010u16.to_be_bytes());
|
||||
fake_cert.extend_from_slice(&(ext_data_len as u16).to_be_bytes());
|
||||
fake_cert.extend_from_slice(&(proto_list_len as u16).to_be_bytes());
|
||||
fake_cert.push(proto.len() as u8);
|
||||
fake_cert.extend_from_slice(proto);
|
||||
}
|
||||
}
|
||||
let _ = alpn;
|
||||
if fake_cert.len() < fake_cert_len {
|
||||
fake_cert.extend_from_slice(&rng.bytes(fake_cert_len - fake_cert.len()));
|
||||
} else if fake_cert.len() > fake_cert_len {
|
||||
@@ -580,7 +603,7 @@ pub fn build_server_hello(
|
||||
let ticket_count = new_session_tickets.min(4);
|
||||
if ticket_count > 0 {
|
||||
for _ in 0..ticket_count {
|
||||
let ticket_len: usize = rng.range(48) + 48; // 48-95 bytes
|
||||
let ticket_len: usize = rng.range(48) + 48;
|
||||
let mut record = Vec::with_capacity(5 + ticket_len);
|
||||
record.push(TLS_RECORD_APPLICATION);
|
||||
record.extend_from_slice(&TLS_VERSION);
|
||||
@@ -927,6 +950,112 @@ pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option<ClientHelloTl
|
||||
}
|
||||
}
|
||||
|
||||
fn client_hello_cipher_suites_range(handshake: &[u8]) -> Option<(usize, usize)> {
|
||||
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
|
||||
let record_end = 5usize.checked_add(record_len)?;
|
||||
if record_end > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut pos = 5;
|
||||
if handshake.get(pos) != Some(&0x01) {
|
||||
return None;
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
if pos + 3 > record_end {
|
||||
return None;
|
||||
}
|
||||
let handshake_len = ((handshake[pos] as usize) << 16)
|
||||
| ((handshake[pos + 1] as usize) << 8)
|
||||
| handshake[pos + 2] as usize;
|
||||
pos += 3;
|
||||
let handshake_end = pos.checked_add(handshake_len)?;
|
||||
if handshake_end > record_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
if pos + 2 + 32 > handshake_end {
|
||||
return None;
|
||||
}
|
||||
pos += 2 + 32;
|
||||
|
||||
let session_id_len = *handshake.get(pos)? as usize;
|
||||
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
|
||||
if pos + 2 > handshake_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
|
||||
if cipher_len == 0 || cipher_len % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
pos += 2;
|
||||
let cipher_end = pos.checked_add(cipher_len)?;
|
||||
if cipher_end > handshake_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((pos, cipher_end))
|
||||
}
|
||||
|
||||
fn client_hello_offers_cipher_suite(
|
||||
handshake: &[u8],
|
||||
range: (usize, usize),
|
||||
suite: [u8; 2],
|
||||
) -> bool {
|
||||
let mut pos = range.0;
|
||||
while pos + 1 < range.1 {
|
||||
if handshake[pos] == suite[0] && handshake[pos + 1] == suite[1] {
|
||||
return true;
|
||||
}
|
||||
pos += 2;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_tls13_cipher_suite(suite: [u8; 2]) -> bool {
|
||||
suite == cipher_suite::TLS_AES_128_GCM_SHA256
|
||||
|| suite == cipher_suite::TLS_AES_256_GCM_SHA384
|
||||
|| suite == cipher_suite::TLS_CHACHA20_POLY1305_SHA256
|
||||
}
|
||||
|
||||
/// Select the ServerHello cipher suite from the already-received ClientHello.
|
||||
///
|
||||
/// This is intentionally a borrowed, zero-allocation scan. It runs only for an
|
||||
/// authenticated success response and keeps malformed or unexpected ClientHello
|
||||
/// shapes on the previous fallback behavior.
|
||||
pub(crate) fn select_server_hello_cipher_suite(handshake: &[u8], preferred: [u8; 2]) -> [u8; 2] {
|
||||
let preferred = if is_tls13_cipher_suite(preferred) {
|
||||
preferred
|
||||
} else {
|
||||
cipher_suite::TLS_AES_128_GCM_SHA256
|
||||
};
|
||||
let Some(range) = client_hello_cipher_suites_range(handshake) else {
|
||||
return preferred;
|
||||
};
|
||||
|
||||
if client_hello_offers_cipher_suite(handshake, range, preferred) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
for fallback in [
|
||||
cipher_suite::TLS_AES_128_GCM_SHA256,
|
||||
cipher_suite::TLS_CHACHA20_POLY1305_SHA256,
|
||||
cipher_suite::TLS_AES_256_GCM_SHA384,
|
||||
] {
|
||||
if client_hello_offers_cipher_suite(handshake, range, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
preferred
|
||||
}
|
||||
|
||||
/// Check if bytes look like a TLS ClientHello
|
||||
pub fn is_tls_handshake(first_bytes: &[u8]) -> bool {
|
||||
if first_bytes.len() < 3 {
|
||||
|
||||
450
src/protocol/tls_fingerprint.rs
Normal file
450
src/protocol/tls_fingerprint.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
//! Passive JA3 / JA4 TLS ClientHello fingerprinting.
|
||||
|
||||
use crate::crypto::hash::md5;
|
||||
use crate::crypto::sha256;
|
||||
use crate::protocol::constants::TLS_RECORD_HANDSHAKE;
|
||||
|
||||
const EXT_SNI: u16 = 0x0000;
|
||||
const EXT_SUPPORTED_GROUPS: u16 = 0x000a;
|
||||
const EXT_EC_POINT_FORMATS: u16 = 0x000b;
|
||||
const EXT_SIGNATURE_ALGORITHMS: u16 = 0x000d;
|
||||
const EXT_ALPN: u16 = 0x0010;
|
||||
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TlsClientFingerprint {
|
||||
pub ja3: String,
|
||||
pub ja3_raw: String,
|
||||
pub ja4: String,
|
||||
pub ja4_raw: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParsedClientHello {
|
||||
legacy_version: u16,
|
||||
ciphers: Vec<u16>,
|
||||
extensions: Vec<u16>,
|
||||
supported_groups: Vec<u16>,
|
||||
ec_point_formats: Vec<u8>,
|
||||
signature_algorithms: Vec<u16>,
|
||||
supported_versions: Vec<u16>,
|
||||
alpn_first: Option<Vec<u8>>,
|
||||
sni_present: bool,
|
||||
}
|
||||
|
||||
pub fn fingerprint_client_hello(handshake: &[u8]) -> Option<TlsClientFingerprint> {
|
||||
let parsed = parse_client_hello(handshake)?;
|
||||
let ja3_raw = ja3_raw(&parsed);
|
||||
let ja3 = hex::encode(md5(ja3_raw.as_bytes()));
|
||||
let (ja4, ja4_raw) = ja4(&parsed);
|
||||
|
||||
Some(TlsClientFingerprint {
|
||||
ja3,
|
||||
ja3_raw,
|
||||
ja4,
|
||||
ja4_raw,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_client_hello(handshake: &[u8]) -> Option<ParsedClientHello> {
|
||||
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let record_len = read_u16_at(handshake, 3)? as usize;
|
||||
let record_end = 5usize.checked_add(record_len)?;
|
||||
if record_end > handshake.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut pos = 5usize;
|
||||
if *handshake.get(pos)? != 0x01 {
|
||||
return None;
|
||||
}
|
||||
pos = pos.checked_add(1)?;
|
||||
|
||||
if pos + 3 > record_end {
|
||||
return None;
|
||||
}
|
||||
let handshake_len = ((usize::from(handshake[pos])) << 16)
|
||||
| ((usize::from(handshake[pos + 1])) << 8)
|
||||
| usize::from(handshake[pos + 2]);
|
||||
pos = pos.checked_add(3)?;
|
||||
let handshake_end = pos.checked_add(handshake_len)?;
|
||||
if handshake_end > record_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
if pos + 2 + 32 > handshake_end {
|
||||
return None;
|
||||
}
|
||||
let legacy_version = read_u16_at(handshake, pos)?;
|
||||
pos = pos.checked_add(2 + 32)?;
|
||||
|
||||
let session_id_len = usize::from(*handshake.get(pos)?);
|
||||
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
|
||||
if pos + 2 > handshake_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cipher_len = read_u16_at(handshake, pos)? as usize;
|
||||
pos = pos.checked_add(2)?;
|
||||
let cipher_end = pos.checked_add(cipher_len)?;
|
||||
if cipher_end > handshake_end || cipher_len % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut ciphers = Vec::with_capacity(cipher_len / 2);
|
||||
while pos + 1 < cipher_end {
|
||||
let value = read_u16_at(handshake, pos)?;
|
||||
if !is_grease(value) {
|
||||
ciphers.push(value);
|
||||
}
|
||||
pos = pos.checked_add(2)?;
|
||||
}
|
||||
|
||||
let comp_len = usize::from(*handshake.get(pos)?);
|
||||
pos = pos.checked_add(1)?.checked_add(comp_len)?;
|
||||
if pos > handshake_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parsed = ParsedClientHello {
|
||||
legacy_version,
|
||||
ciphers,
|
||||
..ParsedClientHello::default()
|
||||
};
|
||||
|
||||
if pos == handshake_end {
|
||||
return Some(parsed);
|
||||
}
|
||||
if pos + 2 > handshake_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ext_len = read_u16_at(handshake, pos)? as usize;
|
||||
pos = pos.checked_add(2)?;
|
||||
let ext_end = pos.checked_add(ext_len)?;
|
||||
if ext_end > handshake_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
while pos + 4 <= ext_end {
|
||||
let etype = read_u16_at(handshake, pos)?;
|
||||
let elen = read_u16_at(handshake, pos + 2)? as usize;
|
||||
pos = pos.checked_add(4)?;
|
||||
let data_end = pos.checked_add(elen)?;
|
||||
if data_end > ext_end {
|
||||
return None;
|
||||
}
|
||||
let data = handshake.get(pos..data_end)?;
|
||||
|
||||
if !is_grease(etype) {
|
||||
parsed.extensions.push(etype);
|
||||
match etype {
|
||||
EXT_SNI => parsed.sni_present = true,
|
||||
EXT_SUPPORTED_GROUPS => {
|
||||
parsed.supported_groups = parse_u16_vector(data, 2)?;
|
||||
}
|
||||
EXT_EC_POINT_FORMATS => {
|
||||
parsed.ec_point_formats = parse_u8_vector(data)?;
|
||||
}
|
||||
EXT_SIGNATURE_ALGORITHMS => {
|
||||
parsed.signature_algorithms = parse_u16_vector(data, 2)?;
|
||||
}
|
||||
EXT_ALPN => {
|
||||
parsed.alpn_first = parse_alpn_first(data)?;
|
||||
}
|
||||
EXT_SUPPORTED_VERSIONS => {
|
||||
parsed.supported_versions = parse_u16_vector(data, 1)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pos = data_end;
|
||||
}
|
||||
|
||||
if pos != ext_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
fn parse_u16_vector(data: &[u8], len_prefix_len: usize) -> Option<Vec<u16>> {
|
||||
let (list_len, mut pos) = match len_prefix_len {
|
||||
1 => (usize::from(*data.first()?), 1usize),
|
||||
2 => (read_u16_at(data, 0)? as usize, 2usize),
|
||||
_ => return None,
|
||||
};
|
||||
let list_end = pos.checked_add(list_len)?;
|
||||
if list_end > data.len() || list_len % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(list_len / 2);
|
||||
while pos + 1 < list_end {
|
||||
let value = read_u16_at(data, pos)?;
|
||||
if !is_grease(value) {
|
||||
out.push(value);
|
||||
}
|
||||
pos = pos.checked_add(2)?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn parse_u8_vector(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let list_len = usize::from(*data.first()?);
|
||||
let list_start = 1usize;
|
||||
let list_end = list_start.checked_add(list_len)?;
|
||||
if list_end > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(data.get(list_start..list_end)?.to_vec())
|
||||
}
|
||||
|
||||
fn parse_alpn_first(data: &[u8]) -> Option<Option<Vec<u8>>> {
|
||||
if data.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let list_len = read_u16_at(data, 0)? as usize;
|
||||
let mut pos = 2usize;
|
||||
let list_end = pos.checked_add(list_len)?;
|
||||
if list_end > data.len() {
|
||||
return None;
|
||||
}
|
||||
if pos == list_end {
|
||||
return Some(None);
|
||||
}
|
||||
|
||||
let protocol_len = usize::from(*data.get(pos)?);
|
||||
pos = pos.checked_add(1)?;
|
||||
let protocol_end = pos.checked_add(protocol_len)?;
|
||||
if protocol_end > list_end {
|
||||
return None;
|
||||
}
|
||||
if protocol_len == 0 {
|
||||
return Some(None);
|
||||
}
|
||||
Some(Some(data.get(pos..protocol_end)?.to_vec()))
|
||||
}
|
||||
|
||||
fn ja3_raw(parsed: &ParsedClientHello) -> String {
|
||||
format!(
|
||||
"{},{},{},{},{}",
|
||||
parsed.legacy_version,
|
||||
join_decimal_u16(&parsed.ciphers),
|
||||
join_decimal_u16(&parsed.extensions),
|
||||
join_decimal_u16(&parsed.supported_groups),
|
||||
join_decimal_u8(&parsed.ec_point_formats)
|
||||
)
|
||||
}
|
||||
|
||||
fn ja4(parsed: &ParsedClientHello) -> (String, String) {
|
||||
let a = format!(
|
||||
"t{}{}{:02}{:02}{}",
|
||||
ja4_version_code(parsed),
|
||||
if parsed.sni_present { "d" } else { "i" },
|
||||
count_ja4(parsed.ciphers.len()),
|
||||
count_ja4(parsed.extensions.len()),
|
||||
ja4_alpn_marker(parsed.alpn_first.as_deref())
|
||||
);
|
||||
|
||||
let mut ciphers = parsed.ciphers.clone();
|
||||
ciphers.sort_unstable();
|
||||
let cipher_raw = join_hex_u16(&ciphers);
|
||||
let cipher_hash = if ciphers.is_empty() {
|
||||
"000000000000".to_string()
|
||||
} else {
|
||||
sha256_truncated_12(&cipher_raw)
|
||||
};
|
||||
|
||||
let mut extensions_for_hash = parsed
|
||||
.extensions
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|value| *value != EXT_SNI && *value != EXT_ALPN)
|
||||
.collect::<Vec<_>>();
|
||||
extensions_for_hash.sort_unstable();
|
||||
let extension_raw = join_hex_u16(&extensions_for_hash);
|
||||
let signature_raw = join_hex_u16(&parsed.signature_algorithms);
|
||||
let extension_hash_input = if signature_raw.is_empty() {
|
||||
extension_raw.clone()
|
||||
} else {
|
||||
format!("{extension_raw}_{signature_raw}")
|
||||
};
|
||||
let extension_hash = if extensions_for_hash.is_empty() {
|
||||
"000000000000".to_string()
|
||||
} else {
|
||||
sha256_truncated_12(&extension_hash_input)
|
||||
};
|
||||
|
||||
(
|
||||
format!("{a}_{cipher_hash}_{extension_hash}"),
|
||||
format!("{a}_{cipher_raw}_{extension_hash_input}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn ja4_version_code(parsed: &ParsedClientHello) -> &'static str {
|
||||
let version = parsed
|
||||
.supported_versions
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(parsed.legacy_version);
|
||||
match version {
|
||||
0x0304 => "13",
|
||||
0x0303 => "12",
|
||||
0x0302 => "11",
|
||||
0x0301 => "10",
|
||||
0x0300 => "s3",
|
||||
0x0002 => "s2",
|
||||
0xfeff => "d1",
|
||||
0xfefd => "d2",
|
||||
0xfefc => "d3",
|
||||
_ => "00",
|
||||
}
|
||||
}
|
||||
|
||||
fn ja4_alpn_marker(alpn_first: Option<&[u8]>) -> String {
|
||||
let Some(value) = alpn_first else {
|
||||
return "00".to_string();
|
||||
};
|
||||
let Some(first) = value.first().copied() else {
|
||||
return "00".to_string();
|
||||
};
|
||||
let last = value.last().copied().unwrap_or(first);
|
||||
if first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric() {
|
||||
return format!("{}{}", first as char, last as char);
|
||||
}
|
||||
|
||||
let encoded = hex::encode(value);
|
||||
if encoded.is_empty() {
|
||||
return "00".to_string();
|
||||
}
|
||||
let first_hex = encoded.as_bytes()[0] as char;
|
||||
let last_hex = encoded.as_bytes()[encoded.len().saturating_sub(1)] as char;
|
||||
format!("{first_hex}{last_hex}")
|
||||
}
|
||||
|
||||
fn count_ja4(count: usize) -> usize {
|
||||
count.min(99)
|
||||
}
|
||||
|
||||
fn sha256_truncated_12(input: &str) -> String {
|
||||
let mut encoded = hex::encode(sha256(input.as_bytes()));
|
||||
encoded.truncate(12);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn join_decimal_u16(values: &[u16]) -> String {
|
||||
values
|
||||
.iter()
|
||||
.map(u16::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn join_decimal_u8(values: &[u8]) -> String {
|
||||
values
|
||||
.iter()
|
||||
.map(u8::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn join_hex_u16(values: &[u16]) -> String {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| format!("{value:04x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn read_u16_at(buf: &[u8], pos: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([
|
||||
*buf.get(pos)?,
|
||||
*buf.get(pos.checked_add(1)?)?,
|
||||
]))
|
||||
}
|
||||
|
||||
fn is_grease(value: u16) -> bool {
|
||||
let high = (value >> 8) as u8;
|
||||
let low = value as u8;
|
||||
high == low && (high & 0x0f) == 0x0a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_client_hello() -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&[0x03, 0x03]);
|
||||
body.extend_from_slice(&[0x11; 32]);
|
||||
body.push(0);
|
||||
body.extend_from_slice(&10u16.to_be_bytes());
|
||||
body.extend_from_slice(&[0x0a, 0x0a, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f, 0x00, 0xff]);
|
||||
body.push(1);
|
||||
body.push(0);
|
||||
|
||||
let mut extensions = Vec::new();
|
||||
append_ext(&mut extensions, EXT_SNI, &[0, 0]);
|
||||
append_ext(&mut extensions, EXT_ALPN, &[0, 3, 2, b'h', b'2']);
|
||||
append_ext(
|
||||
&mut extensions,
|
||||
EXT_SUPPORTED_GROUPS,
|
||||
&[0, 6, 0x0a, 0x0a, 0x00, 0x17, 0x00, 0x1d],
|
||||
);
|
||||
append_ext(&mut extensions, EXT_EC_POINT_FORMATS, &[1, 0]);
|
||||
append_ext(
|
||||
&mut extensions,
|
||||
EXT_SIGNATURE_ALGORITHMS,
|
||||
&[0, 4, 0x04, 0x03, 0x08, 0x04],
|
||||
);
|
||||
append_ext(
|
||||
&mut extensions,
|
||||
EXT_SUPPORTED_VERSIONS,
|
||||
&[4, 0x03, 0x04, 0x03, 0x03],
|
||||
);
|
||||
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
|
||||
body.extend_from_slice(&extensions);
|
||||
|
||||
let mut record = Vec::new();
|
||||
record.push(TLS_RECORD_HANDSHAKE);
|
||||
record.extend_from_slice(&[0x03, 0x01]);
|
||||
record.extend_from_slice(&((body.len() + 4) as u16).to_be_bytes());
|
||||
record.push(0x01);
|
||||
record.extend_from_slice(&[
|
||||
((body.len() >> 16) & 0xff) as u8,
|
||||
((body.len() >> 8) & 0xff) as u8,
|
||||
(body.len() & 0xff) as u8,
|
||||
]);
|
||||
record.extend_from_slice(&body);
|
||||
record
|
||||
}
|
||||
|
||||
fn append_ext(out: &mut Vec<u8>, etype: u16, data: &[u8]) {
|
||||
out.extend_from_slice(&etype.to_be_bytes());
|
||||
out.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ja3_and_ja4_ignore_grease_and_remain_stable() {
|
||||
let fp = fingerprint_client_hello(&sample_client_hello())
|
||||
.expect("sample ClientHello must fingerprint");
|
||||
assert_eq!(
|
||||
fp.ja3_raw,
|
||||
"771,4865-4866-49199-255,0-16-10-11-13-43,23-29,0"
|
||||
);
|
||||
assert!(fp.ja4.starts_with("t13d0406h2_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_client_hello_returns_none() {
|
||||
let mut hello = sample_client_hello();
|
||||
hello.truncate(12);
|
||||
assert!(fingerprint_client_hello(&hello).is_none());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
@@ -97,6 +98,7 @@ use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::protocol::constants::*;
|
||||
use crate::protocol::tls;
|
||||
use crate::protocol::tls_fingerprint::{self, TlsClientFingerprint};
|
||||
use crate::stats::beobachten::BeobachtenStore;
|
||||
use crate::stats::{ReplayChecker, Stats};
|
||||
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
||||
@@ -349,6 +351,60 @@ fn record_beobachten_class(
|
||||
beobachten.record(class, peer_ip, beobachten_ttl(config));
|
||||
}
|
||||
|
||||
fn tls_fingerprint_collection_enabled(config: &ProxyConfig) -> bool {
|
||||
config.general.beobachten || config.server.api.runtime_edge_enabled
|
||||
}
|
||||
|
||||
fn observe_tls_client_fingerprint(
|
||||
stats: &Stats,
|
||||
config: &ProxyConfig,
|
||||
peer_ip: IpAddr,
|
||||
handshake: &[u8],
|
||||
) -> Option<TlsClientFingerprint> {
|
||||
if !tls_fingerprint_collection_enabled(config) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match tls_fingerprint::fingerprint_client_hello(handshake) {
|
||||
Some(fingerprint) => {
|
||||
stats.record_tls_fingerprint_observed(&fingerprint, peer_ip, beobachten_ttl(config));
|
||||
Some(fingerprint)
|
||||
}
|
||||
None => {
|
||||
stats.increment_tls_fingerprint_parse_error();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_tls_fingerprint_auth_success(
|
||||
stats: &Stats,
|
||||
config: &ProxyConfig,
|
||||
peer_ip: IpAddr,
|
||||
fingerprint: Option<&TlsClientFingerprint>,
|
||||
user: &str,
|
||||
) {
|
||||
if let Some(fingerprint) = fingerprint {
|
||||
stats.record_tls_fingerprint_auth_success(
|
||||
fingerprint,
|
||||
peer_ip,
|
||||
user,
|
||||
beobachten_ttl(config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_tls_fingerprint_bad_or_probe(
|
||||
stats: &Stats,
|
||||
config: &ProxyConfig,
|
||||
peer_ip: IpAddr,
|
||||
fingerprint: Option<&TlsClientFingerprint>,
|
||||
) {
|
||||
if let Some(fingerprint) = fingerprint {
|
||||
stats.record_tls_fingerprint_bad_or_probe(fingerprint, peer_ip, beobachten_ttl(config));
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_expected_64_got_0(kind: std::io::ErrorKind) -> Option<&'static str> {
|
||||
match kind {
|
||||
std::io::ErrorKind::UnexpectedEof => Some("expected_64_got_0_unexpected_eof"),
|
||||
@@ -452,7 +508,50 @@ where
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn handle_client_stream_with_shared<S>(
|
||||
stream: S,
|
||||
peer: SocketAddr,
|
||||
config: Arc<ProxyConfig>,
|
||||
stats: Arc<Stats>,
|
||||
upstream_manager: Arc<UpstreamManager>,
|
||||
replay_checker: Arc<ReplayChecker>,
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
beobachten: Arc<BeobachtenStore>,
|
||||
shared: Arc<ProxySharedState>,
|
||||
proxy_protocol_enabled: bool,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
handle_client_stream_with_shared_and_pool_runtime(
|
||||
stream,
|
||||
peer,
|
||||
config,
|
||||
stats,
|
||||
upstream_manager,
|
||||
replay_checker,
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
None,
|
||||
route_runtime,
|
||||
tls_cache,
|
||||
ip_tracker,
|
||||
beobachten,
|
||||
shared,
|
||||
proxy_protocol_enabled,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn handle_client_stream_with_shared_and_pool_runtime<S>(
|
||||
mut stream: S,
|
||||
peer: SocketAddr,
|
||||
config: Arc<ProxyConfig>,
|
||||
@@ -462,6 +561,7 @@ pub async fn handle_client_stream_with_shared<S>(
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Option<Arc<RwLock<Option<Arc<MePool>>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
@@ -660,6 +760,9 @@ where
|
||||
));
|
||||
}
|
||||
|
||||
let tls_fingerprint =
|
||||
observe_tls_client_fingerprint(stats.as_ref(), &config, real_peer.ip(), &handshake);
|
||||
|
||||
let (read_half, write_half) = tokio::io::split(stream);
|
||||
|
||||
let (mut tls_reader, tls_writer, tls_user) = match handle_tls_handshake_with_shared(
|
||||
@@ -670,6 +773,12 @@ where
|
||||
HandshakeResult::Success(result) => result,
|
||||
HandshakeResult::BadClient { reader, writer } => {
|
||||
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
||||
record_tls_fingerprint_bad_or_probe(
|
||||
stats.as_ref(),
|
||||
&config,
|
||||
real_peer.ip(),
|
||||
tls_fingerprint.as_ref(),
|
||||
);
|
||||
return Ok(masking_outcome(
|
||||
reader,
|
||||
writer,
|
||||
@@ -681,10 +790,23 @@ where
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => {
|
||||
record_tls_fingerprint_bad_or_probe(
|
||||
stats.as_ref(),
|
||||
&config,
|
||||
real_peer.ip(),
|
||||
tls_fingerprint.as_ref(),
|
||||
);
|
||||
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
record_tls_fingerprint_auth_success(
|
||||
stats.as_ref(),
|
||||
&config,
|
||||
real_peer.ip(),
|
||||
tls_fingerprint.as_ref(),
|
||||
tls_user.as_str(),
|
||||
);
|
||||
|
||||
debug!(peer = %peer, "Reading MTProto handshake through TLS");
|
||||
let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?;
|
||||
@@ -731,6 +853,7 @@ where
|
||||
RunningClientHandler::handle_authenticated_static_with_shared(
|
||||
crypto_reader, crypto_writer, success,
|
||||
upstream_manager, stats, config, buffer_pool, rng, me_pool,
|
||||
me_pool_runtime,
|
||||
route_runtime.clone(),
|
||||
local_addr, real_peer, ip_tracker.clone(),
|
||||
shared.clone(),
|
||||
@@ -791,6 +914,7 @@ where
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
me_pool_runtime,
|
||||
route_runtime.clone(),
|
||||
local_addr,
|
||||
real_peer,
|
||||
@@ -846,6 +970,7 @@ pub struct RunningClientHandler {
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Option<Arc<RwLock<Option<Arc<MePool>>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
@@ -891,6 +1016,7 @@ impl ClientHandler {
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
None,
|
||||
route_runtime,
|
||||
tls_cache,
|
||||
ip_tracker,
|
||||
@@ -915,6 +1041,7 @@ impl ClientHandler {
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Option<Arc<RwLock<Option<Arc<MePool>>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||
ip_tracker: Arc<UserIpTracker>,
|
||||
@@ -938,6 +1065,7 @@ impl ClientHandler {
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
me_pool_runtime,
|
||||
route_runtime,
|
||||
tls_cache,
|
||||
ip_tracker,
|
||||
@@ -1244,6 +1372,13 @@ impl RunningClientHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let tls_fingerprint = observe_tls_client_fingerprint(
|
||||
self.stats.as_ref(),
|
||||
&self.config,
|
||||
peer.ip(),
|
||||
&handshake,
|
||||
);
|
||||
|
||||
let config = self.config.clone();
|
||||
let replay_checker = self.replay_checker.clone();
|
||||
let stats = self.stats.clone();
|
||||
@@ -1267,6 +1402,12 @@ impl RunningClientHandler {
|
||||
HandshakeResult::Success(result) => result,
|
||||
HandshakeResult::BadClient { reader, writer } => {
|
||||
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
|
||||
record_tls_fingerprint_bad_or_probe(
|
||||
stats.as_ref(),
|
||||
&config,
|
||||
peer.ip(),
|
||||
tls_fingerprint.as_ref(),
|
||||
);
|
||||
return Ok(masking_outcome(
|
||||
reader,
|
||||
writer,
|
||||
@@ -1278,10 +1419,23 @@ impl RunningClientHandler {
|
||||
));
|
||||
}
|
||||
HandshakeResult::Error(e) => {
|
||||
record_tls_fingerprint_bad_or_probe(
|
||||
stats.as_ref(),
|
||||
&config,
|
||||
peer.ip(),
|
||||
tls_fingerprint.as_ref(),
|
||||
);
|
||||
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
record_tls_fingerprint_auth_success(
|
||||
stats.as_ref(),
|
||||
&config,
|
||||
peer.ip(),
|
||||
tls_fingerprint.as_ref(),
|
||||
tls_user.as_str(),
|
||||
);
|
||||
|
||||
debug!(peer = %peer, "Reading MTProto handshake through TLS");
|
||||
let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?;
|
||||
@@ -1345,6 +1499,7 @@ impl RunningClientHandler {
|
||||
buffer_pool,
|
||||
self.rng,
|
||||
self.me_pool,
|
||||
self.me_pool_runtime,
|
||||
self.route_runtime.clone(),
|
||||
local_addr,
|
||||
peer,
|
||||
@@ -1429,6 +1584,7 @@ impl RunningClientHandler {
|
||||
buffer_pool,
|
||||
self.rng,
|
||||
self.me_pool,
|
||||
self.me_pool_runtime,
|
||||
self.route_runtime.clone(),
|
||||
local_addr,
|
||||
peer,
|
||||
@@ -1472,6 +1628,7 @@ impl RunningClientHandler {
|
||||
buffer_pool,
|
||||
rng,
|
||||
me_pool,
|
||||
None,
|
||||
route_runtime,
|
||||
local_addr,
|
||||
peer_addr,
|
||||
@@ -1491,6 +1648,7 @@ impl RunningClientHandler {
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
rng: Arc<SecureRandom>,
|
||||
me_pool: Option<Arc<MePool>>,
|
||||
me_pool_runtime: Option<Arc<RwLock<Option<Arc<MePool>>>>>,
|
||||
route_runtime: Arc<RouteRuntimeController>,
|
||||
local_addr: SocketAddr,
|
||||
peer_addr: SocketAddr,
|
||||
@@ -1503,6 +1661,11 @@ impl RunningClientHandler {
|
||||
{
|
||||
let user = success.user.clone();
|
||||
|
||||
if !shared.is_user_enabled(&user) {
|
||||
warn!(user = %user, "Disabled user rejected");
|
||||
return Err(ProxyError::UserDisabled { user });
|
||||
}
|
||||
|
||||
let user_limit_reservation = match Self::acquire_user_connection_reservation_static(
|
||||
&user,
|
||||
&config,
|
||||
@@ -1521,15 +1684,31 @@ impl RunningClientHandler {
|
||||
|
||||
let route_snapshot = route_runtime.snapshot();
|
||||
let session_id = rng.u64();
|
||||
let relay_result = if config.general.use_middle_proxy
|
||||
let _user_session = shared.register_user_session(&user, session_id);
|
||||
let session_cancel = _user_session.token();
|
||||
let selected_me_pool = if config.general.use_middle_proxy
|
||||
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
||||
{
|
||||
if let Some(ref pool) = me_pool {
|
||||
Some(pool.clone())
|
||||
} else if let Some(pool_runtime) = me_pool_runtime.as_ref() {
|
||||
pool_runtime.read().await.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let relay_result = if config.general.use_middle_proxy
|
||||
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
||||
{
|
||||
if let Some(pool) = selected_me_pool {
|
||||
handle_via_middle_proxy(
|
||||
client_reader,
|
||||
client_writer,
|
||||
success,
|
||||
pool.clone(),
|
||||
pool,
|
||||
stats.clone(),
|
||||
config,
|
||||
buffer_pool,
|
||||
@@ -1538,6 +1717,7 @@ impl RunningClientHandler {
|
||||
route_runtime.subscribe(),
|
||||
route_snapshot,
|
||||
session_id,
|
||||
session_cancel.clone(),
|
||||
shared.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -1556,6 +1736,7 @@ impl RunningClientHandler {
|
||||
route_snapshot,
|
||||
session_id,
|
||||
local_addr,
|
||||
session_cancel.clone(),
|
||||
shared.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -1575,6 +1756,7 @@ impl RunningClientHandler {
|
||||
route_snapshot,
|
||||
session_id,
|
||||
local_addr,
|
||||
session_cancel,
|
||||
shared.clone(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadHalf, WriteHalf, split};
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
@@ -258,6 +259,7 @@ where
|
||||
route_snapshot,
|
||||
session_id,
|
||||
SocketAddr::from(([0, 0, 0, 0], config.server.port)),
|
||||
CancellationToken::new(),
|
||||
ProxySharedState::new(),
|
||||
)
|
||||
.await
|
||||
@@ -276,6 +278,7 @@ pub(crate) async fn handle_via_direct_with_shared<R, W>(
|
||||
route_snapshot: RouteCutoverState,
|
||||
session_id: u64,
|
||||
local_addr: SocketAddr,
|
||||
session_cancel: CancellationToken,
|
||||
shared: Arc<ProxySharedState>,
|
||||
) -> Result<()>
|
||||
where
|
||||
@@ -302,14 +305,25 @@ where
|
||||
"Ignoring invalid scope hint and falling back to default upstream selection"
|
||||
);
|
||||
}
|
||||
let tg_stream = upstream_manager
|
||||
.connect(dc_addr, Some(success.dc_idx), scope_hint)
|
||||
.await?;
|
||||
let tg_stream = tokio::select! {
|
||||
result = upstream_manager.connect(dc_addr, Some(success.dc_idx), scope_hint) => result?,
|
||||
_ = session_cancel.cancelled() => {
|
||||
return Err(ProxyError::UserDisabled {
|
||||
user: user.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
debug!(peer = %success.peer, dc_addr = %dc_addr, "Connected, performing TG handshake");
|
||||
|
||||
let (tg_reader, tg_writer) =
|
||||
do_tg_handshake_static(tg_stream, &success, &config, rng.as_ref()).await?;
|
||||
let (tg_reader, tg_writer) = tokio::select! {
|
||||
result = do_tg_handshake_static(tg_stream, &success, &config, rng.as_ref()) => result?,
|
||||
_ = session_cancel.cancelled() => {
|
||||
return Err(ProxyError::UserDisabled {
|
||||
user: user.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
debug!(peer = %success.peer, "TG handshake complete, starting relay");
|
||||
|
||||
@@ -331,20 +345,22 @@ where
|
||||
} else {
|
||||
Duration::from_secs(1800)
|
||||
};
|
||||
let relay_result = crate::proxy::relay::relay_bidirectional_with_activity_timeout_and_lease(
|
||||
client_reader,
|
||||
client_writer,
|
||||
tg_reader,
|
||||
tg_writer,
|
||||
config.general.direct_relay_copy_buf_c2s_bytes,
|
||||
config.general.direct_relay_copy_buf_s2c_bytes,
|
||||
user,
|
||||
Arc::clone(&stats),
|
||||
config.access.user_data_quota.get(user).copied(),
|
||||
buffer_pool,
|
||||
traffic_lease,
|
||||
relay_activity_timeout,
|
||||
);
|
||||
let relay_result =
|
||||
crate::proxy::relay::relay_bidirectional_with_activity_timeout_lease_and_cancel(
|
||||
client_reader,
|
||||
client_writer,
|
||||
tg_reader,
|
||||
tg_writer,
|
||||
config.general.direct_relay_copy_buf_c2s_bytes,
|
||||
config.general.direct_relay_copy_buf_s2c_bytes,
|
||||
user,
|
||||
Arc::clone(&stats),
|
||||
config.access.user_data_quota.get(user).copied(),
|
||||
buffer_pool,
|
||||
traffic_lease,
|
||||
relay_activity_timeout,
|
||||
session_cancel.clone(),
|
||||
);
|
||||
tokio::pin!(relay_result);
|
||||
let relay_result = loop {
|
||||
if let Some(cutover) =
|
||||
@@ -358,6 +374,7 @@ where
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
"Cutover affected direct session, closing client connection"
|
||||
);
|
||||
let _cutover_park_lease = stats.acquire_direct_cutover_park_lease();
|
||||
tokio::time::sleep(delay).await;
|
||||
break Err(ProxyError::RouteSwitched);
|
||||
}
|
||||
@@ -370,6 +387,11 @@ where
|
||||
break relay_result.await;
|
||||
}
|
||||
}
|
||||
_ = session_cancel.cancelled() => {
|
||||
break Err(ProxyError::UserDisabled {
|
||||
user: user.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1504,6 +1504,13 @@ where
|
||||
let validation_session_id_slice = &validation_session_id[..validation_session_id_len];
|
||||
|
||||
let response = if let Some((cached_entry, use_full_cert_payload)) = cached {
|
||||
let preferred_cipher_suite = if cached_entry.server_hello_template.cipher_suite == [0, 0] {
|
||||
[0x13, 0x01]
|
||||
} else {
|
||||
cached_entry.server_hello_template.cipher_suite
|
||||
};
|
||||
let selected_cipher_suite =
|
||||
tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite);
|
||||
emulator::build_emulated_server_hello(
|
||||
&validated_secret,
|
||||
&validation_digest,
|
||||
@@ -1512,17 +1519,20 @@ where
|
||||
use_full_cert_payload,
|
||||
config.censorship.serverhello_compact,
|
||||
client_tls_version,
|
||||
selected_cipher_suite,
|
||||
rng,
|
||||
selected_alpn.clone(),
|
||||
config.censorship.tls_new_session_tickets,
|
||||
)
|
||||
} else {
|
||||
tls::build_server_hello(
|
||||
let selected_cipher_suite = tls::select_server_hello_cipher_suite(handshake, [0x13, 0x01]);
|
||||
tls::build_server_hello_with_cipher(
|
||||
&validated_secret,
|
||||
&validation_digest,
|
||||
validation_session_id_slice,
|
||||
config.censorship.fake_cert_len,
|
||||
rng,
|
||||
selected_cipher_suite,
|
||||
selected_alpn.clone(),
|
||||
config.censorship.tls_new_session_tickets,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,12 @@ struct CopyOutcome {
|
||||
ended_by_eof: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct MaskTcpTarget<'a> {
|
||||
host: &'a str,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
async fn copy_with_idle_timeout<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
@@ -331,7 +337,9 @@ async fn wait_mask_outcome_budget(started: Instant, config: &ProxyConfig) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tls_domain_mask_host_tests {
|
||||
use super::{mask_host_for_initial_data, matching_tls_domain_for_sni};
|
||||
use super::{
|
||||
mask_host_for_initial_data, mask_tcp_target_for_initial_data, matching_tls_domain_for_sni,
|
||||
};
|
||||
use crate::config::ProxyConfig;
|
||||
|
||||
fn client_hello_with_sni(sni_host: &str) -> Vec<u8> {
|
||||
@@ -410,6 +418,25 @@ mod tls_domain_mask_host_tests {
|
||||
|
||||
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_mask_target_overrides_only_matching_sni() {
|
||||
let mut config = config_with_tls_domains();
|
||||
config
|
||||
.censorship
|
||||
.exclusive_mask
|
||||
.insert("b.com".to_string(), "origin-b.example:8443".to_string());
|
||||
let b_initial_data = client_hello_with_sni("B.COM");
|
||||
let c_initial_data = client_hello_with_sni("c.com");
|
||||
|
||||
let b_target = mask_tcp_target_for_initial_data(&config, &b_initial_data);
|
||||
let c_target = mask_tcp_target_for_initial_data(&config, &c_initial_data);
|
||||
|
||||
assert_eq!(b_target.host, "origin-b.example");
|
||||
assert_eq!(b_target.port, 8443);
|
||||
assert_eq!(c_target.host, "c.com");
|
||||
assert_eq!(c_target.port, config.censorship.mask_port);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect client type based on initial data
|
||||
@@ -458,7 +485,92 @@ fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_exclusive_mask_target(target: &str) -> Option<MaskTcpTarget<'_>> {
|
||||
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 port = target[end + 2..].parse::<u16>().ok()?;
|
||||
return (port > 0).then_some(MaskTcpTarget {
|
||||
host: &target[..=end],
|
||||
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(MaskTcpTarget { host, port })
|
||||
}
|
||||
|
||||
fn exclusive_mask_target_for_sni<'a>(
|
||||
config: &'a ProxyConfig,
|
||||
sni: &str,
|
||||
) -> Option<MaskTcpTarget<'a>> {
|
||||
if let Some(target) = config.censorship.exclusive_mask_targets.get(sni) {
|
||||
return Some(MaskTcpTarget {
|
||||
host: target.host.as_str(),
|
||||
port: target.port,
|
||||
});
|
||||
}
|
||||
if let Some(target) = config.censorship.exclusive_mask.get(sni) {
|
||||
return parse_exclusive_mask_target(target);
|
||||
}
|
||||
|
||||
if sni.bytes().any(|byte| byte.is_ascii_uppercase()) {
|
||||
let normalized_sni = sni.to_ascii_lowercase();
|
||||
if let Some(target) = config
|
||||
.censorship
|
||||
.exclusive_mask_targets
|
||||
.get(&normalized_sni)
|
||||
{
|
||||
return Some(MaskTcpTarget {
|
||||
host: target.host.as_str(),
|
||||
port: target.port,
|
||||
});
|
||||
}
|
||||
if let Some(target) = config.censorship.exclusive_mask.get(&normalized_sni) {
|
||||
return parse_exclusive_mask_target(target);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mask_host_for_initial_data<'a>(config: &'a ProxyConfig, initial_data: &[u8]) -> &'a str {
|
||||
mask_tcp_target_for_initial_data(config, initial_data).host
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mask_tcp_target_for_initial_data<'a>(
|
||||
config: &'a ProxyConfig,
|
||||
initial_data: &[u8],
|
||||
) -> MaskTcpTarget<'a> {
|
||||
let sni = tls::extract_sni_from_client_hello(initial_data);
|
||||
if let Some(target) = sni
|
||||
.as_deref()
|
||||
.and_then(|sni| exclusive_mask_target_for_sni(config, sni))
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
default_mask_tcp_target_for_initial_data(config, initial_data, sni.as_deref())
|
||||
}
|
||||
|
||||
fn default_mask_tcp_target_for_initial_data<'a>(
|
||||
config: &'a ProxyConfig,
|
||||
initial_data: &[u8],
|
||||
sni: Option<&str>,
|
||||
) -> MaskTcpTarget<'a> {
|
||||
let configured_mask_host = config
|
||||
.censorship
|
||||
.mask_host
|
||||
@@ -466,13 +578,25 @@ fn mask_host_for_initial_data<'a>(config: &'a ProxyConfig, initial_data: &[u8])
|
||||
.unwrap_or(&config.censorship.tls_domain);
|
||||
|
||||
if !configured_mask_host.eq_ignore_ascii_case(&config.censorship.tls_domain) {
|
||||
return configured_mask_host;
|
||||
return MaskTcpTarget {
|
||||
host: configured_mask_host,
|
||||
port: config.censorship.mask_port,
|
||||
};
|
||||
}
|
||||
|
||||
tls::extract_sni_from_client_hello(initial_data)
|
||||
.as_deref()
|
||||
let extracted_sni = if sni.is_none() {
|
||||
tls::extract_sni_from_client_hello(initial_data)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let host = sni
|
||||
.or(extracted_sni.as_deref())
|
||||
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
|
||||
.unwrap_or(configured_mask_host)
|
||||
.unwrap_or(configured_mask_host);
|
||||
MaskTcpTarget {
|
||||
host,
|
||||
port: config.censorship.mask_port,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_ip(ip: IpAddr) -> IpAddr {
|
||||
@@ -770,9 +894,16 @@ pub async fn handle_bad_client<R, W>(
|
||||
return;
|
||||
}
|
||||
|
||||
let client_sni = tls::extract_sni_from_client_hello(initial_data);
|
||||
let exclusive_tcp_target = client_sni
|
||||
.as_deref()
|
||||
.and_then(|sni| exclusive_mask_target_for_sni(config, sni));
|
||||
|
||||
// Connect via Unix socket or TCP
|
||||
#[cfg(unix)]
|
||||
if let Some(ref sock_path) = config.censorship.mask_unix_sock {
|
||||
if exclusive_tcp_target.is_none()
|
||||
&& let Some(ref sock_path) = config.censorship.mask_unix_sock
|
||||
{
|
||||
let outcome_started = Instant::now();
|
||||
let connect_started = Instant::now();
|
||||
debug!(
|
||||
@@ -849,8 +980,11 @@ pub async fn handle_bad_client<R, W>(
|
||||
return;
|
||||
}
|
||||
|
||||
let mask_host = mask_host_for_initial_data(config, initial_data);
|
||||
let mask_port = config.censorship.mask_port;
|
||||
let mask_target = exclusive_tcp_target.unwrap_or_else(|| {
|
||||
default_mask_tcp_target_for_initial_data(config, initial_data, client_sni.as_deref())
|
||||
});
|
||||
let mask_host = mask_target.host;
|
||||
let mask_port = mask_target.port;
|
||||
|
||||
// Fail closed when fallback points at our own listener endpoint.
|
||||
// Self-referential masking can create recursive proxy loops under
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
104
src/proxy/middle_relay/c2me.rs
Normal file
104
src/proxy/middle_relay/c2me.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::proxy::middle_relay) enum C2MeCommand {
|
||||
Data {
|
||||
payload: PooledBuffer,
|
||||
flags: u32,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
pub(super) fn should_yield_c2me_sender(sent_since_yield: usize, has_backlog: bool) -> bool {
|
||||
has_backlog && sent_since_yield >= C2ME_SENDER_FAIRNESS_BUDGET
|
||||
}
|
||||
|
||||
pub(super) fn c2me_payload_permits(payload_len: usize) -> u32 {
|
||||
payload_len
|
||||
.max(1)
|
||||
.div_ceil(C2ME_QUEUED_BYTE_PERMIT_UNIT)
|
||||
.min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
pub(super) fn c2me_queued_permit_budget(channel_capacity: usize, frame_limit: usize) -> usize {
|
||||
channel_capacity
|
||||
.saturating_mul(C2ME_QUEUED_PERMITS_PER_SLOT)
|
||||
.max(c2me_payload_permits(frame_limit) as usize)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
pub(super) async fn acquire_c2me_payload_permit(
|
||||
semaphore: &Arc<Semaphore>,
|
||||
payload_len: usize,
|
||||
send_timeout: Option<Duration>,
|
||||
stats: &Stats,
|
||||
) -> Result<OwnedSemaphorePermit> {
|
||||
let permits = c2me_payload_permits(payload_len);
|
||||
let acquire = semaphore.clone().acquire_many_owned(permits);
|
||||
match send_timeout {
|
||||
Some(send_timeout) => match timeout(send_timeout, acquire).await {
|
||||
Ok(Ok(permit)) => Ok(permit),
|
||||
Ok(Err(_)) => Err(ProxyError::Proxy("ME sender byte budget closed".into())),
|
||||
Err(_) => {
|
||||
stats.increment_me_c2me_send_timeout_total();
|
||||
Err(ProxyError::Proxy("ME sender byte budget timeout".into()))
|
||||
}
|
||||
},
|
||||
None => acquire
|
||||
.await
|
||||
.map_err(|_| ProxyError::Proxy("ME sender byte budget closed".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn enqueue_c2me_command_in(
|
||||
shared: &ProxySharedState,
|
||||
tx: &mpsc::Sender<C2MeCommand>,
|
||||
cmd: C2MeCommand,
|
||||
send_timeout: Option<Duration>,
|
||||
stats: &Stats,
|
||||
) -> std::result::Result<(), mpsc::error::SendError<C2MeCommand>> {
|
||||
match tx.try_send(cmd) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Closed(cmd)) => Err(mpsc::error::SendError(cmd)),
|
||||
Err(mpsc::error::TrySendError::Full(cmd)) => {
|
||||
stats.increment_me_c2me_send_full_total();
|
||||
stats.increment_me_c2me_send_high_water_total();
|
||||
note_relay_pressure_event_in(shared);
|
||||
// Cooperative yield reduces burst catch-up when the per-conn queue is near saturation.
|
||||
if tx.capacity() <= C2ME_SOFT_PRESSURE_MIN_FREE_SLOTS {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
let reserve_result = match send_timeout {
|
||||
Some(send_timeout) => match timeout(send_timeout, tx.reserve()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
stats.increment_me_c2me_send_timeout_total();
|
||||
return Err(mpsc::error::SendError(cmd));
|
||||
}
|
||||
},
|
||||
None => tx.reserve().await,
|
||||
};
|
||||
match reserve_result {
|
||||
Ok(permit) => {
|
||||
permit.send(cmd);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
stats.increment_me_c2me_send_timeout_total();
|
||||
Err(mpsc::error::SendError(cmd))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn enqueue_c2me_command(
|
||||
tx: &mpsc::Sender<C2MeCommand>,
|
||||
cmd: C2MeCommand,
|
||||
send_timeout: Option<Duration>,
|
||||
stats: &Stats,
|
||||
) -> std::result::Result<(), mpsc::error::SendError<C2MeCommand>> {
|
||||
let shared = ProxySharedState::new();
|
||||
enqueue_c2me_command_in(shared.as_ref(), tx, cmd, send_timeout, stats).await
|
||||
}
|
||||
458
src/proxy/middle_relay/d2c.rs
Normal file
458
src/proxy/middle_relay/d2c.rs
Normal file
@@ -0,0 +1,458 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct MeD2cFlushPolicy {
|
||||
pub(super) max_frames: usize,
|
||||
pub(super) max_bytes: usize,
|
||||
pub(super) max_delay: Duration,
|
||||
pub(super) ack_flush_immediate: bool,
|
||||
pub(super) quota_soft_overshoot_bytes: u64,
|
||||
pub(super) frame_buf_shrink_threshold_bytes: usize,
|
||||
}
|
||||
|
||||
impl MeD2cFlushPolicy {
|
||||
pub(super) fn from_config(config: &ProxyConfig) -> Self {
|
||||
Self {
|
||||
max_frames: config
|
||||
.general
|
||||
.me_d2c_flush_batch_max_frames
|
||||
.max(ME_D2C_FLUSH_BATCH_MAX_FRAMES_MIN),
|
||||
max_bytes: config
|
||||
.general
|
||||
.me_d2c_flush_batch_max_bytes
|
||||
.max(ME_D2C_FLUSH_BATCH_MAX_BYTES_MIN),
|
||||
max_delay: Duration::from_micros(config.general.me_d2c_flush_batch_max_delay_us),
|
||||
ack_flush_immediate: config.general.me_d2c_ack_flush_immediate,
|
||||
quota_soft_overshoot_bytes: config.general.me_quota_soft_overshoot_bytes,
|
||||
frame_buf_shrink_threshold_bytes: config
|
||||
.general
|
||||
.me_d2c_frame_buf_shrink_threshold_bytes
|
||||
.max(4096),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn classify_me_d2c_flush_reason(
|
||||
flush_immediately: bool,
|
||||
batch_frames: usize,
|
||||
max_frames: usize,
|
||||
batch_bytes: usize,
|
||||
max_bytes: usize,
|
||||
max_delay_fired: bool,
|
||||
) -> MeD2cFlushReason {
|
||||
if flush_immediately {
|
||||
return MeD2cFlushReason::AckImmediate;
|
||||
}
|
||||
if batch_frames >= max_frames {
|
||||
return MeD2cFlushReason::BatchFrames;
|
||||
}
|
||||
if batch_bytes >= max_bytes {
|
||||
return MeD2cFlushReason::BatchBytes;
|
||||
}
|
||||
if max_delay_fired {
|
||||
return MeD2cFlushReason::MaxDelay;
|
||||
}
|
||||
MeD2cFlushReason::QueueDrain
|
||||
}
|
||||
|
||||
pub(super) fn observe_me_d2c_flush_event(
|
||||
stats: &Stats,
|
||||
reason: MeD2cFlushReason,
|
||||
batch_frames: usize,
|
||||
batch_bytes: usize,
|
||||
flush_duration_us: Option<u64>,
|
||||
) {
|
||||
stats.increment_me_d2c_flush_reason(reason);
|
||||
if batch_frames > 0 || batch_bytes > 0 {
|
||||
stats.increment_me_d2c_batches_total();
|
||||
stats.add_me_d2c_batch_frames_total(batch_frames as u64);
|
||||
stats.add_me_d2c_batch_bytes_total(batch_bytes as u64);
|
||||
stats.observe_me_d2c_batch_frames(batch_frames as u64);
|
||||
stats.observe_me_d2c_batch_bytes(batch_bytes as u64);
|
||||
}
|
||||
if let Some(duration_us) = flush_duration_us {
|
||||
stats.observe_me_d2c_flush_duration_us(duration_us);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum MeWriterResponseOutcome {
|
||||
Continue {
|
||||
frames: usize,
|
||||
bytes: usize,
|
||||
flush_immediately: bool,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn process_me_writer_response<W>(
|
||||
response: MeResponse,
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
proto_tag: ProtoTag,
|
||||
rng: &SecureRandom,
|
||||
frame_buf: &mut Vec<u8>,
|
||||
stats: &Stats,
|
||||
user: &str,
|
||||
quota_user_stats: Option<&UserStats>,
|
||||
quota_limit: Option<u64>,
|
||||
quota_soft_overshoot_bytes: u64,
|
||||
bytes_me2c: &AtomicU64,
|
||||
conn_id: u64,
|
||||
ack_flush_immediate: bool,
|
||||
batched: bool,
|
||||
) -> Result<MeWriterResponseOutcome>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
process_me_writer_response_with_traffic_lease(
|
||||
response,
|
||||
client_writer,
|
||||
proto_tag,
|
||||
rng,
|
||||
frame_buf,
|
||||
stats,
|
||||
user,
|
||||
quota_user_stats,
|
||||
quota_limit,
|
||||
quota_soft_overshoot_bytes,
|
||||
None,
|
||||
&CancellationToken::new(),
|
||||
bytes_me2c,
|
||||
conn_id,
|
||||
ack_flush_immediate,
|
||||
batched,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn process_me_writer_response_with_traffic_lease<W>(
|
||||
response: MeResponse,
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
proto_tag: ProtoTag,
|
||||
rng: &SecureRandom,
|
||||
frame_buf: &mut Vec<u8>,
|
||||
stats: &Stats,
|
||||
user: &str,
|
||||
quota_user_stats: Option<&UserStats>,
|
||||
quota_limit: Option<u64>,
|
||||
quota_soft_overshoot_bytes: u64,
|
||||
traffic_lease: Option<&Arc<TrafficLease>>,
|
||||
cancel: &CancellationToken,
|
||||
bytes_me2c: &AtomicU64,
|
||||
conn_id: u64,
|
||||
ack_flush_immediate: bool,
|
||||
batched: bool,
|
||||
) -> Result<MeWriterResponseOutcome>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
match response {
|
||||
MeResponse::Data { flags, data, .. } => {
|
||||
if batched {
|
||||
trace!(conn_id, bytes = data.len(), flags, "ME->C data (batched)");
|
||||
} else {
|
||||
trace!(conn_id, bytes = data.len(), flags, "ME->C data");
|
||||
}
|
||||
let data_len = data.len() as u64;
|
||||
if let (Some(limit), Some(user_stats)) = (quota_limit, quota_user_stats) {
|
||||
let soft_limit = quota_soft_cap(limit, quota_soft_overshoot_bytes);
|
||||
match reserve_user_quota_with_yield(
|
||||
user_stats, data_len, soft_limit, stats, cancel, None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(MiddleQuotaReserveError::LimitExceeded) => {
|
||||
stats.increment_me_d2c_quota_reject_total(MeD2cQuotaRejectStage::PreWrite);
|
||||
return Err(ProxyError::DataQuotaExceeded {
|
||||
user: user.to_string(),
|
||||
});
|
||||
}
|
||||
Err(MiddleQuotaReserveError::Contended) => {
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME D->C quota reservation contended".into(),
|
||||
));
|
||||
}
|
||||
Err(MiddleQuotaReserveError::Cancelled) => {
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME D->C quota reservation cancelled".into(),
|
||||
));
|
||||
}
|
||||
Err(MiddleQuotaReserveError::DeadlineExceeded) => {
|
||||
return Err(ProxyError::Proxy(
|
||||
"ME D->C quota reservation deadline exceeded".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
wait_for_traffic_budget_or_cancel(
|
||||
traffic_lease,
|
||||
RateDirection::Down,
|
||||
data_len,
|
||||
cancel,
|
||||
stats,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let write_mode = match write_client_payload(
|
||||
client_writer,
|
||||
proto_tag,
|
||||
flags,
|
||||
&data,
|
||||
rng,
|
||||
frame_buf,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
if quota_limit.is_some() {
|
||||
stats.add_quota_write_fail_bytes_total(data_len);
|
||||
stats.increment_quota_write_fail_events_total();
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
bytes_me2c.fetch_add(data_len, Ordering::Relaxed);
|
||||
if let Some(user_stats) = quota_user_stats {
|
||||
stats.add_user_octets_to_handle(user_stats, data_len);
|
||||
} else {
|
||||
stats.add_user_octets_to(user, data_len);
|
||||
}
|
||||
stats.increment_me_d2c_data_frames_total();
|
||||
stats.add_me_d2c_payload_bytes_total(data_len);
|
||||
stats.increment_me_d2c_write_mode(write_mode);
|
||||
|
||||
Ok(MeWriterResponseOutcome::Continue {
|
||||
frames: 1,
|
||||
bytes: data.len(),
|
||||
flush_immediately: false,
|
||||
})
|
||||
}
|
||||
MeResponse::Ack(confirm) => {
|
||||
if batched {
|
||||
trace!(conn_id, confirm, "ME->C quickack (batched)");
|
||||
} else {
|
||||
trace!(conn_id, confirm, "ME->C quickack");
|
||||
}
|
||||
wait_for_traffic_budget_or_cancel(
|
||||
traffic_lease,
|
||||
RateDirection::Down,
|
||||
4,
|
||||
cancel,
|
||||
stats,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
write_client_ack(client_writer, proto_tag, confirm, cancel).await?;
|
||||
stats.increment_me_d2c_ack_frames_total();
|
||||
|
||||
Ok(MeWriterResponseOutcome::Continue {
|
||||
frames: 1,
|
||||
bytes: 4,
|
||||
flush_immediately: ack_flush_immediate,
|
||||
})
|
||||
}
|
||||
MeResponse::Close => {
|
||||
if batched {
|
||||
debug!(conn_id, "ME sent close (batched)");
|
||||
} else {
|
||||
debug!(conn_id, "ME sent close");
|
||||
}
|
||||
Ok(MeWriterResponseOutcome::Close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the intermediate/secure wire length while rejecting lossy casts.
|
||||
pub(in crate::proxy::middle_relay) fn compute_intermediate_secure_wire_len(
|
||||
data_len: usize,
|
||||
padding_len: usize,
|
||||
quickack: bool,
|
||||
) -> Result<(u32, usize)> {
|
||||
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 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))
|
||||
}
|
||||
|
||||
pub(super) async fn write_client_payload<W>(
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
proto_tag: ProtoTag,
|
||||
flags: u32,
|
||||
data: &[u8],
|
||||
rng: &SecureRandom,
|
||||
frame_buf: &mut Vec<u8>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<MeD2cWriteMode>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let quickack = (flags & RPC_FLAG_QUICKACK) != 0;
|
||||
|
||||
let write_mode = match proto_tag {
|
||||
ProtoTag::Abridged => {
|
||||
if !data.len().is_multiple_of(4) {
|
||||
return Err(ProxyError::Proxy(format!(
|
||||
"Abridged payload must be 4-byte aligned, got {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let len_words = data.len() / 4;
|
||||
if len_words < 0x7f {
|
||||
let mut first = len_words as u8;
|
||||
if quickack {
|
||||
first |= 0x80;
|
||||
}
|
||||
let wire_len = 1usize.saturating_add(data.len());
|
||||
if wire_len <= ME_D2C_SINGLE_WRITE_COALESCE_MAX_BYTES {
|
||||
frame_buf.clear();
|
||||
frame_buf.reserve(wire_len);
|
||||
frame_buf.push(first);
|
||||
frame_buf.extend_from_slice(data);
|
||||
write_all_client_or_cancel(client_writer, frame_buf.as_slice(), cancel).await?;
|
||||
MeD2cWriteMode::Coalesced
|
||||
} else {
|
||||
let header = [first];
|
||||
write_all_client_or_cancel(client_writer, &header, cancel).await?;
|
||||
write_all_client_or_cancel(client_writer, data, cancel).await?;
|
||||
MeD2cWriteMode::Split
|
||||
}
|
||||
} else if len_words < (1 << 24) {
|
||||
let mut first = 0x7fu8;
|
||||
if quickack {
|
||||
first |= 0x80;
|
||||
}
|
||||
let lw = (len_words as u32).to_le_bytes();
|
||||
let wire_len = 4usize.saturating_add(data.len());
|
||||
if wire_len <= ME_D2C_SINGLE_WRITE_COALESCE_MAX_BYTES {
|
||||
frame_buf.clear();
|
||||
frame_buf.reserve(wire_len);
|
||||
frame_buf.extend_from_slice(&[first, lw[0], lw[1], lw[2]]);
|
||||
frame_buf.extend_from_slice(data);
|
||||
write_all_client_or_cancel(client_writer, frame_buf.as_slice(), cancel).await?;
|
||||
MeD2cWriteMode::Coalesced
|
||||
} else {
|
||||
let header = [first, lw[0], lw[1], lw[2]];
|
||||
write_all_client_or_cancel(client_writer, &header, cancel).await?;
|
||||
write_all_client_or_cancel(client_writer, data, cancel).await?;
|
||||
MeD2cWriteMode::Split
|
||||
}
|
||||
} else {
|
||||
return Err(ProxyError::Proxy(format!(
|
||||
"Abridged frame too large: {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
ProtoTag::Intermediate | ProtoTag::Secure => {
|
||||
let padding_len = if proto_tag == ProtoTag::Secure {
|
||||
if !is_valid_secure_payload_len(data.len()) {
|
||||
return Err(ProxyError::Proxy(format!(
|
||||
"Secure payload must be 4-byte aligned, got {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
secure_padding_len(data.len(), rng)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let (len_val, total) =
|
||||
compute_intermediate_secure_wire_len(data.len(), padding_len, quickack)?;
|
||||
if total <= ME_D2C_SINGLE_WRITE_COALESCE_MAX_BYTES {
|
||||
frame_buf.clear();
|
||||
frame_buf.reserve(total);
|
||||
frame_buf.extend_from_slice(&len_val.to_le_bytes());
|
||||
frame_buf.extend_from_slice(data);
|
||||
if padding_len > 0 {
|
||||
let start = frame_buf.len();
|
||||
frame_buf.resize(start + padding_len, 0);
|
||||
rng.fill(&mut frame_buf[start..]);
|
||||
}
|
||||
write_all_client_or_cancel(client_writer, frame_buf.as_slice(), cancel).await?;
|
||||
MeD2cWriteMode::Coalesced
|
||||
} else {
|
||||
let header = len_val.to_le_bytes();
|
||||
write_all_client_or_cancel(client_writer, &header, cancel).await?;
|
||||
write_all_client_or_cancel(client_writer, data, cancel).await?;
|
||||
if padding_len > 0 {
|
||||
frame_buf.clear();
|
||||
if frame_buf.capacity() < padding_len {
|
||||
frame_buf.reserve(padding_len);
|
||||
}
|
||||
frame_buf.resize(padding_len, 0);
|
||||
rng.fill(frame_buf.as_mut_slice());
|
||||
write_all_client_or_cancel(client_writer, frame_buf.as_slice(), cancel).await?;
|
||||
}
|
||||
MeD2cWriteMode::Split
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(write_mode)
|
||||
}
|
||||
|
||||
pub(super) async fn write_client_ack<W>(
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
proto_tag: ProtoTag,
|
||||
confirm: u32,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let bytes = if proto_tag == ProtoTag::Abridged {
|
||||
confirm.to_be_bytes()
|
||||
} else {
|
||||
confirm.to_le_bytes()
|
||||
};
|
||||
write_all_client_or_cancel(client_writer, &bytes, cancel).await
|
||||
}
|
||||
|
||||
pub(super) async fn write_all_client_or_cancel<W>(
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
bytes: &[u8],
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => Err(ProxyError::MiddleClientWriterCancelled),
|
||||
result = client_writer.write_all(bytes) => result.map_err(ProxyError::Io),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn flush_client_or_cancel<W>(
|
||||
client_writer: &mut CryptoWriter<W>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => Err(ProxyError::MiddleClientWriterCancelled),
|
||||
result = client_writer.flush() => result.map_err(ProxyError::Io),
|
||||
}
|
||||
}
|
||||
406
src/proxy/middle_relay/desync.rs
Normal file
406
src/proxy/middle_relay/desync.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DesyncDedupRotationState {
|
||||
current_started_at: Option<Instant>,
|
||||
}
|
||||
|
||||
pub(in crate::proxy::middle_relay) struct RelayForensicsState {
|
||||
pub(in crate::proxy::middle_relay) trace_id: u64,
|
||||
pub(in crate::proxy::middle_relay) conn_id: u64,
|
||||
pub(in crate::proxy::middle_relay) user: String,
|
||||
pub(in crate::proxy::middle_relay) peer: SocketAddr,
|
||||
pub(in crate::proxy::middle_relay) peer_hash: u64,
|
||||
pub(in crate::proxy::middle_relay) started_at: Instant,
|
||||
pub(in crate::proxy::middle_relay) bytes_c2me: u64,
|
||||
pub(in crate::proxy::middle_relay) bytes_me2c: Arc<AtomicU64>,
|
||||
pub(in crate::proxy::middle_relay) desync_all_full: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn hash_value<T: Hash>(value: &T) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
value.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn hash_value_in<T: Hash>(shared: &ProxySharedState, value: &T) -> u64 {
|
||||
shared.middle_relay.desync_hasher.hash_one(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn hash_ip(ip: IpAddr) -> u64 {
|
||||
hash_value(&ip)
|
||||
}
|
||||
|
||||
pub(super) fn hash_ip_in(shared: &ProxySharedState, ip: IpAddr) -> u64 {
|
||||
hash_value_in(shared, &ip)
|
||||
}
|
||||
|
||||
fn should_emit_full_desync_in(
|
||||
shared: &ProxySharedState,
|
||||
key: u64,
|
||||
all_full: bool,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
if all_full {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dedup_current = &shared.middle_relay.desync_dedup;
|
||||
let dedup_previous = &shared.middle_relay.desync_dedup_previous;
|
||||
let rotation_state = &shared.middle_relay.desync_dedup_rotation_state;
|
||||
|
||||
let mut state = match rotation_state.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => {
|
||||
let mut guard = poisoned.into_inner();
|
||||
*guard = DesyncDedupRotationState::default();
|
||||
rotation_state.clear_poison();
|
||||
guard
|
||||
}
|
||||
};
|
||||
|
||||
let rotate_now = match state.current_started_at {
|
||||
Some(current_started_at) => match now.checked_duration_since(current_started_at) {
|
||||
Some(elapsed) => elapsed >= DESYNC_DEDUP_WINDOW,
|
||||
None => true,
|
||||
},
|
||||
None => true,
|
||||
};
|
||||
if rotate_now {
|
||||
dedup_previous.clear();
|
||||
for entry in dedup_current.iter() {
|
||||
dedup_previous.insert(*entry.key(), *entry.value());
|
||||
}
|
||||
dedup_current.clear();
|
||||
state.current_started_at = Some(now);
|
||||
}
|
||||
|
||||
if let Some(seen_at) = dedup_current.get(&key).map(|entry| *entry.value()) {
|
||||
let within_window = match now.checked_duration_since(seen_at) {
|
||||
Some(elapsed) => elapsed < DESYNC_DEDUP_WINDOW,
|
||||
None => true,
|
||||
};
|
||||
if within_window {
|
||||
return false;
|
||||
}
|
||||
dedup_current.insert(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(seen_at) = dedup_previous.get(&key).map(|entry| *entry.value()) {
|
||||
let within_window = match now.checked_duration_since(seen_at) {
|
||||
Some(elapsed) => elapsed < DESYNC_DEDUP_WINDOW,
|
||||
None => true,
|
||||
};
|
||||
if within_window {
|
||||
dedup_current.insert(key, seen_at);
|
||||
return false;
|
||||
}
|
||||
dedup_previous.remove(&key);
|
||||
}
|
||||
|
||||
if dedup_current.len() >= DESYNC_DEDUP_MAX_ENTRIES {
|
||||
dedup_previous.clear();
|
||||
for entry in dedup_current.iter() {
|
||||
dedup_previous.insert(*entry.key(), *entry.value());
|
||||
}
|
||||
dedup_current.clear();
|
||||
state.current_started_at = Some(now);
|
||||
dedup_current.insert(key, now);
|
||||
should_emit_full_desync_full_cache_in(shared, now)
|
||||
} else {
|
||||
dedup_current.insert(key, now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn should_emit_full_desync_full_cache_in(shared: &ProxySharedState, now: Instant) -> bool {
|
||||
let gate = &shared.middle_relay.desync_full_cache_last_emit_at;
|
||||
let Ok(mut last_emit_at) = gate.lock() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match *last_emit_at {
|
||||
None => {
|
||||
*last_emit_at = Some(now);
|
||||
true
|
||||
}
|
||||
Some(last) => {
|
||||
let Some(elapsed) = now.checked_duration_since(last) else {
|
||||
*last_emit_at = Some(now);
|
||||
return true;
|
||||
};
|
||||
if elapsed >= DESYNC_FULL_CACHE_EMIT_MIN_INTERVAL {
|
||||
*last_emit_at = Some(now);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn desync_forensics_len_bytes(len: usize) -> ([u8; 4], bool) {
|
||||
match u32::try_from(len) {
|
||||
Ok(value) => (value.to_le_bytes(), false),
|
||||
Err(_) => (u32::MAX.to_le_bytes(), true),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn report_desync_frame_too_large_in(
|
||||
shared: &ProxySharedState,
|
||||
state: &RelayForensicsState,
|
||||
proto_tag: ProtoTag,
|
||||
frame_counter: u64,
|
||||
max_frame: usize,
|
||||
len: usize,
|
||||
raw_len_bytes: Option<[u8; 4]>,
|
||||
stats: &Stats,
|
||||
) -> ProxyError {
|
||||
let (fallback_len_buf, len_buf_truncated) = desync_forensics_len_bytes(len);
|
||||
let len_buf = raw_len_bytes.unwrap_or(fallback_len_buf);
|
||||
let looks_like_tls = raw_len_bytes
|
||||
.map(|b| b[0] == 0x16 && b[1] == 0x03)
|
||||
.unwrap_or(false);
|
||||
let looks_like_http = raw_len_bytes
|
||||
.map(|b| matches!(b[0], b'G' | b'P' | b'H' | b'C' | b'D'))
|
||||
.unwrap_or(false);
|
||||
let now = Instant::now();
|
||||
let dedup_key = hash_value_in(
|
||||
shared,
|
||||
&(
|
||||
state.user.as_str(),
|
||||
state.peer_hash,
|
||||
proto_tag,
|
||||
DESYNC_ERROR_CLASS,
|
||||
),
|
||||
);
|
||||
let emit_full = should_emit_full_desync_in(shared, dedup_key, state.desync_all_full, now);
|
||||
let duration_ms = state.started_at.elapsed().as_millis() as u64;
|
||||
let bytes_me2c = state.bytes_me2c.load(Ordering::Relaxed);
|
||||
|
||||
stats.increment_desync_total();
|
||||
stats.increment_relay_protocol_desync_close_total();
|
||||
stats.observe_desync_frames_ok(frame_counter);
|
||||
if emit_full {
|
||||
stats.increment_desync_full_logged();
|
||||
warn!(
|
||||
trace_id = format_args!("0x{:016x}", state.trace_id),
|
||||
conn_id = state.conn_id,
|
||||
user = %state.user,
|
||||
peer_hash = format_args!("0x{:016x}", state.peer_hash),
|
||||
proto = ?proto_tag,
|
||||
mode = "middle_proxy",
|
||||
is_tls = true,
|
||||
duration_ms,
|
||||
bytes_c2me = state.bytes_c2me,
|
||||
bytes_me2c,
|
||||
raw_len = len,
|
||||
raw_len_hex = format_args!("0x{:08x}", len),
|
||||
raw_len_bytes_truncated = len_buf_truncated,
|
||||
raw_bytes = format_args!(
|
||||
"{:02x} {:02x} {:02x} {:02x}",
|
||||
len_buf[0], len_buf[1], len_buf[2], len_buf[3]
|
||||
),
|
||||
max_frame,
|
||||
tls_like = looks_like_tls,
|
||||
http_like = looks_like_http,
|
||||
frames_ok = frame_counter,
|
||||
dedup_window_secs = DESYNC_DEDUP_WINDOW.as_secs(),
|
||||
desync_all_full = state.desync_all_full,
|
||||
full_reason = if state.desync_all_full { "desync_all_full" } else { "first_in_dedup_window" },
|
||||
error_class = DESYNC_ERROR_CLASS,
|
||||
"Frame too large — crypto desync forensics"
|
||||
);
|
||||
debug!(
|
||||
trace_id = format_args!("0x{:016x}", state.trace_id),
|
||||
conn_id = state.conn_id,
|
||||
user = %state.user,
|
||||
peer = %state.peer,
|
||||
"Frame too large forensic peer detail"
|
||||
);
|
||||
} else {
|
||||
stats.increment_desync_suppressed();
|
||||
debug!(
|
||||
trace_id = format_args!("0x{:016x}", state.trace_id),
|
||||
conn_id = state.conn_id,
|
||||
user = %state.user,
|
||||
peer_hash = format_args!("0x{:016x}", state.peer_hash),
|
||||
proto = ?proto_tag,
|
||||
duration_ms,
|
||||
bytes_c2me = state.bytes_c2me,
|
||||
bytes_me2c,
|
||||
raw_len = len,
|
||||
frames_ok = frame_counter,
|
||||
dedup_window_secs = DESYNC_DEDUP_WINDOW.as_secs(),
|
||||
error_class = DESYNC_ERROR_CLASS,
|
||||
"Frame too large — crypto desync forensic suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
ProxyError::Proxy(format!(
|
||||
"Frame too large: {len} (max {max_frame}), frames_ok={frame_counter}, conn_id={}, trace_id=0x{:016x}",
|
||||
state.conn_id, state.trace_id
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn report_desync_frame_too_large(
|
||||
state: &RelayForensicsState,
|
||||
proto_tag: ProtoTag,
|
||||
frame_counter: u64,
|
||||
max_frame: usize,
|
||||
len: usize,
|
||||
raw_len_bytes: Option<[u8; 4]>,
|
||||
stats: &Stats,
|
||||
) -> ProxyError {
|
||||
let shared = ProxySharedState::new();
|
||||
report_desync_frame_too_large_in(
|
||||
shared.as_ref(),
|
||||
state,
|
||||
proto_tag,
|
||||
frame_counter,
|
||||
max_frame,
|
||||
len,
|
||||
raw_len_bytes,
|
||||
stats,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn should_emit_full_desync_for_testing(
|
||||
shared: &ProxySharedState,
|
||||
key: u64,
|
||||
all_full: bool,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
if all_full {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dedup_current = &shared.middle_relay.desync_dedup;
|
||||
let dedup_previous = &shared.middle_relay.desync_dedup_previous;
|
||||
|
||||
let Ok(mut state) = shared.middle_relay.desync_dedup_rotation_state.lock() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let rotate_now = match state.current_started_at {
|
||||
Some(current_started_at) => match now.checked_duration_since(current_started_at) {
|
||||
Some(elapsed) => elapsed >= DESYNC_DEDUP_WINDOW,
|
||||
None => true,
|
||||
},
|
||||
None => true,
|
||||
};
|
||||
if rotate_now {
|
||||
dedup_previous.clear();
|
||||
for entry in dedup_current.iter() {
|
||||
dedup_previous.insert(*entry.key(), *entry.value());
|
||||
}
|
||||
dedup_current.clear();
|
||||
state.current_started_at = Some(now);
|
||||
}
|
||||
|
||||
if let Some(seen_at) = dedup_current.get(&key).map(|entry| *entry.value()) {
|
||||
let within_window = match now.checked_duration_since(seen_at) {
|
||||
Some(elapsed) => elapsed < DESYNC_DEDUP_WINDOW,
|
||||
None => true,
|
||||
};
|
||||
if within_window {
|
||||
return false;
|
||||
}
|
||||
dedup_current.insert(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(seen_at) = dedup_previous.get(&key).map(|entry| *entry.value()) {
|
||||
let within_window = match now.checked_duration_since(seen_at) {
|
||||
Some(elapsed) => elapsed < DESYNC_DEDUP_WINDOW,
|
||||
None => true,
|
||||
};
|
||||
if within_window {
|
||||
dedup_current.insert(key, seen_at);
|
||||
return false;
|
||||
}
|
||||
dedup_previous.remove(&key);
|
||||
}
|
||||
|
||||
if dedup_current.len() >= DESYNC_DEDUP_MAX_ENTRIES {
|
||||
dedup_previous.clear();
|
||||
for entry in dedup_current.iter() {
|
||||
dedup_previous.insert(*entry.key(), *entry.value());
|
||||
}
|
||||
dedup_current.clear();
|
||||
state.current_started_at = Some(now);
|
||||
dedup_current.insert(key, now);
|
||||
let Ok(mut last_emit_at) = shared.middle_relay.desync_full_cache_last_emit_at.lock() else {
|
||||
return false;
|
||||
};
|
||||
return match *last_emit_at {
|
||||
None => {
|
||||
*last_emit_at = Some(now);
|
||||
true
|
||||
}
|
||||
Some(last) => {
|
||||
let Some(elapsed) = now.checked_duration_since(last) else {
|
||||
*last_emit_at = Some(now);
|
||||
return true;
|
||||
};
|
||||
if elapsed >= DESYNC_FULL_CACHE_EMIT_MIN_INTERVAL {
|
||||
*last_emit_at = Some(now);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
dedup_current.insert(key, now);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_desync_dedup_for_testing_in_shared(shared: &ProxySharedState) {
|
||||
shared.middle_relay.desync_dedup.clear();
|
||||
shared.middle_relay.desync_dedup_previous.clear();
|
||||
if let Ok(mut rotation_state) = shared.middle_relay.desync_dedup_rotation_state.lock() {
|
||||
*rotation_state = DesyncDedupRotationState::default();
|
||||
}
|
||||
if let Ok(mut last_emit_at) = shared.middle_relay.desync_full_cache_last_emit_at.lock() {
|
||||
*last_emit_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn desync_dedup_len_for_testing(shared: &ProxySharedState) -> usize {
|
||||
shared.middle_relay.desync_dedup.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn desync_dedup_insert_for_testing(shared: &ProxySharedState, key: u64, at: Instant) {
|
||||
shared.middle_relay.desync_dedup.insert(key, at);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn desync_dedup_get_for_testing(shared: &ProxySharedState, key: u64) -> Option<Instant> {
|
||||
shared
|
||||
.middle_relay
|
||||
.desync_dedup
|
||||
.get(&key)
|
||||
.map(|entry| *entry.value())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn desync_dedup_keys_for_testing(
|
||||
shared: &ProxySharedState,
|
||||
) -> std::collections::HashSet<u64> {
|
||||
shared
|
||||
.middle_relay
|
||||
.desync_dedup
|
||||
.iter()
|
||||
.map(|entry| *entry.key())
|
||||
.collect()
|
||||
}
|
||||
335
src/proxy/middle_relay/idle.rs
Normal file
335
src/proxy/middle_relay/idle.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use super::*;
|
||||
use dashmap::DashMap;
|
||||
|
||||
mod read;
|
||||
|
||||
pub(crate) use self::read::read_client_payload_with_idle_policy_in;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::read::{
|
||||
read_client_payload, read_client_payload_legacy, read_client_payload_with_idle_policy,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RelayIdleCandidateRegistry {
|
||||
pub(in crate::proxy::middle_relay) by_conn_id: DashMap<u64, RelayIdleCandidateMeta>,
|
||||
pub(in crate::proxy::middle_relay) ordered: parking_lot::Mutex<BTreeSet<(u64, u64)>>,
|
||||
pressure_event_seq: AtomicU64,
|
||||
pressure_consumed_seq: AtomicU64,
|
||||
}
|
||||
|
||||
/// Queue metadata used to preserve FIFO ordering for idle relay eviction.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(in crate::proxy::middle_relay) struct RelayIdleCandidateMeta {
|
||||
pub(in crate::proxy::middle_relay) mark_order_seq: u64,
|
||||
pub(in crate::proxy::middle_relay) mark_pressure_seq: u64,
|
||||
}
|
||||
|
||||
pub(super) fn mark_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) -> bool {
|
||||
let registry = &shared.middle_relay.relay_idle_registry;
|
||||
|
||||
if registry.by_conn_id.contains_key(&conn_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mark_order_seq = shared
|
||||
.middle_relay
|
||||
.relay_idle_mark_seq
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
let meta = RelayIdleCandidateMeta {
|
||||
mark_order_seq,
|
||||
mark_pressure_seq: registry.pressure_event_seq.load(Ordering::Relaxed),
|
||||
};
|
||||
match registry.by_conn_id.entry(conn_id) {
|
||||
dashmap::mapref::entry::Entry::Occupied(_) => false,
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
entry.insert(meta);
|
||||
registry
|
||||
.ordered
|
||||
.lock()
|
||||
.insert((meta.mark_order_seq, conn_id));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clear_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) {
|
||||
let registry = &shared.middle_relay.relay_idle_registry;
|
||||
|
||||
if let Some((_, meta)) = registry.by_conn_id.remove(&conn_id) {
|
||||
registry
|
||||
.ordered
|
||||
.lock()
|
||||
.remove(&(meta.mark_order_seq, conn_id));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_relay_pressure_event_in(shared: &ProxySharedState) {
|
||||
shared
|
||||
.middle_relay
|
||||
.relay_idle_registry
|
||||
.pressure_event_seq
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn note_global_relay_pressure(shared: &ProxySharedState) {
|
||||
note_relay_pressure_event_in(shared);
|
||||
}
|
||||
|
||||
pub(super) fn relay_pressure_event_seq_in(shared: &ProxySharedState) -> u64 {
|
||||
shared
|
||||
.middle_relay
|
||||
.relay_idle_registry
|
||||
.pressure_event_seq
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
|
||||
shared: &ProxySharedState,
|
||||
conn_id: u64,
|
||||
seen_pressure_seq: &mut u64,
|
||||
stats: &Stats,
|
||||
) -> bool {
|
||||
let registry = &shared.middle_relay.relay_idle_registry;
|
||||
|
||||
let latest_pressure_seq = registry.pressure_event_seq.load(Ordering::Relaxed);
|
||||
if latest_pressure_seq == *seen_pressure_seq {
|
||||
return false;
|
||||
}
|
||||
*seen_pressure_seq = latest_pressure_seq;
|
||||
|
||||
let consumed_pressure_seq = registry.pressure_consumed_seq.load(Ordering::Relaxed);
|
||||
if latest_pressure_seq == consumed_pressure_seq {
|
||||
return false;
|
||||
}
|
||||
|
||||
let oldest = {
|
||||
let mut ordered = registry.ordered.lock();
|
||||
loop {
|
||||
let Some((mark_order_seq, candidate_conn_id)) = ordered.iter().next().copied() else {
|
||||
// Empty queues consume the event so later candidates cannot replay stale pressure.
|
||||
let _ = registry.pressure_consumed_seq.compare_exchange(
|
||||
consumed_pressure_seq,
|
||||
latest_pressure_seq,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let Some(candidate_meta) = registry.by_conn_id.get(&candidate_conn_id) else {
|
||||
ordered.remove(&(mark_order_seq, candidate_conn_id));
|
||||
continue;
|
||||
};
|
||||
if candidate_meta.mark_order_seq != mark_order_seq {
|
||||
ordered.remove(&(mark_order_seq, candidate_conn_id));
|
||||
continue;
|
||||
}
|
||||
break Some(candidate_conn_id);
|
||||
}
|
||||
};
|
||||
if oldest != Some(conn_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(candidate_meta) = registry
|
||||
.by_conn_id
|
||||
.get(&conn_id)
|
||||
.map(|entry| *entry.value())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if latest_pressure_seq == candidate_meta.mark_pressure_seq {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Claim the global pressure budget before removal; otherwise racing sessions
|
||||
// can observe the next FIFO item and spend the same event more than once.
|
||||
if registry
|
||||
.pressure_consumed_seq
|
||||
.compare_exchange(
|
||||
consumed_pressure_seq,
|
||||
latest_pressure_seq,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some((_, meta)) = registry.by_conn_id.remove(&conn_id) {
|
||||
registry
|
||||
.ordered
|
||||
.lock()
|
||||
.remove(&(meta.mark_order_seq, conn_id));
|
||||
}
|
||||
stats.increment_relay_pressure_evict_total();
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(in crate::proxy::middle_relay) struct RelayClientIdlePolicy {
|
||||
pub(in crate::proxy::middle_relay) enabled: bool,
|
||||
pub(in crate::proxy::middle_relay) soft_idle: Duration,
|
||||
pub(in crate::proxy::middle_relay) hard_idle: Duration,
|
||||
pub(in crate::proxy::middle_relay) grace_after_downstream_activity: Duration,
|
||||
pub(in crate::proxy::middle_relay) legacy_frame_read_timeout: Duration,
|
||||
}
|
||||
|
||||
impl RelayClientIdlePolicy {
|
||||
pub(super) fn from_config(config: &ProxyConfig) -> Self {
|
||||
let frame_read_timeout =
|
||||
Duration::from_secs(config.timeouts.relay_client_idle_hard_secs.max(1));
|
||||
if !config.timeouts.relay_idle_policy_v2_enabled {
|
||||
return Self::disabled(frame_read_timeout);
|
||||
}
|
||||
|
||||
let soft_idle = Duration::from_secs(config.timeouts.relay_client_idle_soft_secs.max(1));
|
||||
let hard_idle = Duration::from_secs(config.timeouts.relay_client_idle_hard_secs.max(1));
|
||||
let grace_after_downstream_activity = Duration::from_secs(
|
||||
config
|
||||
.timeouts
|
||||
.relay_idle_grace_after_downstream_activity_secs,
|
||||
);
|
||||
|
||||
Self {
|
||||
enabled: true,
|
||||
soft_idle,
|
||||
hard_idle,
|
||||
grace_after_downstream_activity,
|
||||
legacy_frame_read_timeout: frame_read_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::proxy::middle_relay) fn disabled(frame_read_timeout: Duration) -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
soft_idle: frame_read_timeout,
|
||||
hard_idle: frame_read_timeout,
|
||||
grace_after_downstream_activity: Duration::ZERO,
|
||||
legacy_frame_read_timeout: frame_read_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_pressure_caps(&mut self, profile: ConntrackPressureProfile) {
|
||||
let pressure_soft_idle_cap = Duration::from_secs(profile.middle_soft_idle_cap_secs());
|
||||
let pressure_hard_idle_cap = Duration::from_secs(profile.middle_hard_idle_cap_secs());
|
||||
|
||||
self.soft_idle = self.soft_idle.min(pressure_soft_idle_cap);
|
||||
self.hard_idle = self.hard_idle.min(pressure_hard_idle_cap);
|
||||
if self.soft_idle > self.hard_idle {
|
||||
self.soft_idle = self.hard_idle;
|
||||
}
|
||||
self.legacy_frame_read_timeout = self.legacy_frame_read_timeout.min(pressure_hard_idle_cap);
|
||||
if self.grace_after_downstream_activity > self.hard_idle {
|
||||
self.grace_after_downstream_activity = self.hard_idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(in crate::proxy::middle_relay) struct RelayClientIdleState {
|
||||
pub(in crate::proxy::middle_relay) last_client_frame_at: Instant,
|
||||
pub(in crate::proxy::middle_relay) soft_idle_marked: bool,
|
||||
pub(in crate::proxy::middle_relay) tiny_frame_debt: u32,
|
||||
}
|
||||
|
||||
impl RelayClientIdleState {
|
||||
pub(super) fn new(now: Instant) -> Self {
|
||||
Self {
|
||||
last_client_frame_at: now,
|
||||
soft_idle_marked: false,
|
||||
tiny_frame_debt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_client_frame(&mut self, now: Instant) {
|
||||
self.last_client_frame_at = now;
|
||||
self.soft_idle_marked = false;
|
||||
}
|
||||
|
||||
pub(super) fn on_client_tiny_frame(&mut self, now: Instant) {
|
||||
self.last_client_frame_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mark_relay_idle_candidate_for_testing(
|
||||
shared: &ProxySharedState,
|
||||
conn_id: u64,
|
||||
) -> bool {
|
||||
mark_relay_idle_candidate_in(shared, conn_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn oldest_relay_idle_candidate_for_testing(shared: &ProxySharedState) -> Option<u64> {
|
||||
let registry = &shared.middle_relay.relay_idle_registry;
|
||||
registry
|
||||
.ordered
|
||||
.lock()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|(_, conn_id)| *conn_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_relay_idle_candidate_for_testing(shared: &ProxySharedState, conn_id: u64) {
|
||||
clear_relay_idle_candidate_in(shared, conn_id);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_relay_idle_pressure_state_for_testing_in_shared(shared: &ProxySharedState) {
|
||||
let registry = &shared.middle_relay.relay_idle_registry;
|
||||
registry.by_conn_id.clear();
|
||||
registry.ordered.lock().clear();
|
||||
registry.pressure_event_seq.store(0, Ordering::Relaxed);
|
||||
registry.pressure_consumed_seq.store(0, Ordering::Relaxed);
|
||||
shared
|
||||
.middle_relay
|
||||
.relay_idle_mark_seq
|
||||
.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn note_relay_pressure_event_for_testing(shared: &ProxySharedState) {
|
||||
note_relay_pressure_event_in(shared);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn relay_pressure_event_seq_for_testing(shared: &ProxySharedState) -> u64 {
|
||||
relay_pressure_event_seq_in(shared)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn relay_idle_mark_seq_for_testing(shared: &ProxySharedState) -> u64 {
|
||||
shared
|
||||
.middle_relay
|
||||
.relay_idle_mark_seq
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn maybe_evict_idle_candidate_on_pressure_for_testing(
|
||||
shared: &ProxySharedState,
|
||||
conn_id: u64,
|
||||
seen_pressure_seq: &mut u64,
|
||||
stats: &Stats,
|
||||
) -> bool {
|
||||
maybe_evict_idle_candidate_on_pressure_in(shared, conn_id, seen_pressure_seq, stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_relay_pressure_state_for_testing(
|
||||
shared: &ProxySharedState,
|
||||
pressure_event_seq: u64,
|
||||
pressure_consumed_seq: u64,
|
||||
) {
|
||||
let registry = &shared.middle_relay.relay_idle_registry;
|
||||
registry
|
||||
.pressure_event_seq
|
||||
.store(pressure_event_seq, Ordering::Relaxed);
|
||||
registry
|
||||
.pressure_consumed_seq
|
||||
.store(pressure_consumed_seq, Ordering::Relaxed);
|
||||
}
|
||||
442
src/proxy/middle_relay/idle/read.rs
Normal file
442
src/proxy/middle_relay/idle/read.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn read_client_payload_with_idle_policy_in<R>(
|
||||
client_reader: &mut CryptoReader<R>,
|
||||
proto_tag: ProtoTag,
|
||||
max_frame: usize,
|
||||
buffer_pool: &Arc<BufferPool>,
|
||||
forensics: &RelayForensicsState,
|
||||
frame_counter: &mut u64,
|
||||
stats: &Stats,
|
||||
shared: &ProxySharedState,
|
||||
idle_policy: &RelayClientIdlePolicy,
|
||||
idle_state: &mut RelayClientIdleState,
|
||||
last_downstream_activity_ms: &AtomicU64,
|
||||
session_started_at: Instant,
|
||||
) -> Result<Option<(PooledBuffer, bool)>>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
const LEGACY_MAX_CONSECUTIVE_ZERO_LEN_FRAMES: u32 = 4;
|
||||
|
||||
async fn read_exact_with_policy<R>(
|
||||
client_reader: &mut CryptoReader<R>,
|
||||
buf: &mut [u8],
|
||||
idle_policy: &RelayClientIdlePolicy,
|
||||
idle_state: &mut RelayClientIdleState,
|
||||
last_downstream_activity_ms: &AtomicU64,
|
||||
session_started_at: Instant,
|
||||
forensics: &RelayForensicsState,
|
||||
stats: &Stats,
|
||||
shared: &ProxySharedState,
|
||||
read_label: &'static str,
|
||||
) -> Result<()>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
fn hard_deadline(
|
||||
idle_policy: &RelayClientIdlePolicy,
|
||||
idle_state: &RelayClientIdleState,
|
||||
session_started_at: Instant,
|
||||
last_downstream_activity_ms: u64,
|
||||
) -> Instant {
|
||||
let mut deadline = idle_state.last_client_frame_at + idle_policy.hard_idle;
|
||||
if idle_policy.grace_after_downstream_activity.is_zero() {
|
||||
return deadline;
|
||||
}
|
||||
|
||||
let downstream_at =
|
||||
session_started_at + Duration::from_millis(last_downstream_activity_ms);
|
||||
if downstream_at > idle_state.last_client_frame_at {
|
||||
let grace_deadline = downstream_at + idle_policy.grace_after_downstream_activity;
|
||||
if grace_deadline > deadline {
|
||||
deadline = grace_deadline;
|
||||
}
|
||||
}
|
||||
deadline
|
||||
}
|
||||
|
||||
let mut filled = 0usize;
|
||||
while filled < buf.len() {
|
||||
let timeout_window = if idle_policy.enabled {
|
||||
let now = Instant::now();
|
||||
let downstream_ms = last_downstream_activity_ms.load(Ordering::Relaxed);
|
||||
let hard_deadline =
|
||||
hard_deadline(idle_policy, idle_state, session_started_at, downstream_ms);
|
||||
if !idle_state.soft_idle_marked
|
||||
&& now.saturating_duration_since(idle_state.last_client_frame_at)
|
||||
>= idle_policy.soft_idle
|
||||
{
|
||||
idle_state.soft_idle_marked = true;
|
||||
if mark_relay_idle_candidate_in(shared, forensics.conn_id) {
|
||||
stats.increment_relay_idle_soft_mark_total();
|
||||
}
|
||||
info!(
|
||||
trace_id = format_args!("0x{:016x}", forensics.trace_id),
|
||||
conn_id = forensics.conn_id,
|
||||
user = %forensics.user,
|
||||
read_label,
|
||||
soft_idle_secs = idle_policy.soft_idle.as_secs(),
|
||||
hard_idle_secs = idle_policy.hard_idle.as_secs(),
|
||||
grace_secs = idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
"Middle-relay soft idle mark"
|
||||
);
|
||||
}
|
||||
|
||||
let soft_deadline = idle_state.last_client_frame_at + idle_policy.soft_idle;
|
||||
let next_deadline = if idle_state.soft_idle_marked {
|
||||
hard_deadline
|
||||
} else {
|
||||
soft_deadline.min(hard_deadline)
|
||||
};
|
||||
let mut remaining = next_deadline.saturating_duration_since(now);
|
||||
if remaining.is_zero() {
|
||||
remaining = Duration::from_millis(1);
|
||||
}
|
||||
remaining.min(RELAY_IDLE_IO_POLL_MAX)
|
||||
} else {
|
||||
idle_policy.legacy_frame_read_timeout
|
||||
};
|
||||
|
||||
let read_result = timeout(timeout_window, client_reader.read(&mut buf[filled..])).await;
|
||||
match read_result {
|
||||
Ok(Ok(0)) => {
|
||||
return Err(ProxyError::Io(std::io::Error::from(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
)));
|
||||
}
|
||||
Ok(Ok(n)) => {
|
||||
filled = filled.saturating_add(n);
|
||||
}
|
||||
Ok(Err(e)) => return Err(ProxyError::Io(e)),
|
||||
Err(_) if !idle_policy.enabled => {
|
||||
return Err(ProxyError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"middle-relay client frame read timeout while reading {read_label}"
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(_) => {
|
||||
let now = Instant::now();
|
||||
let downstream_ms = last_downstream_activity_ms.load(Ordering::Relaxed);
|
||||
let hard_deadline =
|
||||
hard_deadline(idle_policy, idle_state, session_started_at, downstream_ms);
|
||||
if now >= hard_deadline {
|
||||
clear_relay_idle_candidate_in(shared, forensics.conn_id);
|
||||
stats.increment_relay_idle_hard_close_total();
|
||||
let client_idle_secs = now
|
||||
.saturating_duration_since(idle_state.last_client_frame_at)
|
||||
.as_secs();
|
||||
let downstream_idle_secs = now
|
||||
.saturating_duration_since(
|
||||
session_started_at + Duration::from_millis(downstream_ms),
|
||||
)
|
||||
.as_secs();
|
||||
warn!(
|
||||
trace_id = format_args!("0x{:016x}", forensics.trace_id),
|
||||
conn_id = forensics.conn_id,
|
||||
user = %forensics.user,
|
||||
read_label,
|
||||
client_idle_secs,
|
||||
downstream_idle_secs,
|
||||
soft_idle_secs = idle_policy.soft_idle.as_secs(),
|
||||
hard_idle_secs = idle_policy.hard_idle.as_secs(),
|
||||
grace_secs = idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
"Middle-relay hard idle close"
|
||||
);
|
||||
return Err(ProxyError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"middle-relay hard idle timeout while reading {read_label}: client_idle_secs={client_idle_secs}, downstream_idle_secs={downstream_idle_secs}, soft_idle_secs={}, hard_idle_secs={}, grace_secs={}",
|
||||
idle_policy.soft_idle.as_secs(),
|
||||
idle_policy.hard_idle.as_secs(),
|
||||
idle_policy.grace_after_downstream_activity.as_secs(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut consecutive_zero_len_frames = 0u32;
|
||||
loop {
|
||||
let (len, quickack, raw_len_bytes) = match proto_tag {
|
||||
ProtoTag::Abridged => {
|
||||
let mut first = [0u8; 1];
|
||||
match read_exact_with_policy(
|
||||
client_reader,
|
||||
&mut first,
|
||||
idle_policy,
|
||||
idle_state,
|
||||
last_downstream_activity_ms,
|
||||
session_started_at,
|
||||
forensics,
|
||||
stats,
|
||||
shared,
|
||||
"abridged.first_len_byte",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(ProxyError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
let quickack = (first[0] & 0x80) != 0;
|
||||
let len_words = if (first[0] & 0x7f) == 0x7f {
|
||||
let mut ext = [0u8; 3];
|
||||
read_exact_with_policy(
|
||||
client_reader,
|
||||
&mut ext,
|
||||
idle_policy,
|
||||
idle_state,
|
||||
last_downstream_activity_ms,
|
||||
session_started_at,
|
||||
forensics,
|
||||
stats,
|
||||
shared,
|
||||
"abridged.extended_len",
|
||||
)
|
||||
.await?;
|
||||
u32::from_le_bytes([ext[0], ext[1], ext[2], 0]) as usize
|
||||
} else {
|
||||
(first[0] & 0x7f) as usize
|
||||
};
|
||||
|
||||
let len = len_words
|
||||
.checked_mul(4)
|
||||
.ok_or_else(|| ProxyError::Proxy("Abridged frame length overflow".into()))?;
|
||||
(len, quickack, None)
|
||||
}
|
||||
ProtoTag::Intermediate | ProtoTag::Secure => {
|
||||
let mut len_buf = [0u8; 4];
|
||||
match read_exact_with_policy(
|
||||
client_reader,
|
||||
&mut len_buf,
|
||||
idle_policy,
|
||||
idle_state,
|
||||
last_downstream_activity_ms,
|
||||
session_started_at,
|
||||
forensics,
|
||||
stats,
|
||||
shared,
|
||||
"len_prefix",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(ProxyError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let quickack = (len_buf[3] & 0x80) != 0;
|
||||
(
|
||||
(u32::from_le_bytes(len_buf) & 0x7fff_ffff) as usize,
|
||||
quickack,
|
||||
Some(len_buf),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if len == 0 {
|
||||
idle_state.on_client_tiny_frame(Instant::now());
|
||||
idle_state.tiny_frame_debt = idle_state
|
||||
.tiny_frame_debt
|
||||
.saturating_add(TINY_FRAME_DEBT_PER_TINY);
|
||||
if idle_state.tiny_frame_debt >= TINY_FRAME_DEBT_LIMIT {
|
||||
stats.increment_relay_protocol_desync_close_total();
|
||||
return Err(ProxyError::Proxy(format!(
|
||||
"Tiny frame overhead limit exceeded: debt={}, conn_id={}",
|
||||
idle_state.tiny_frame_debt, forensics.conn_id
|
||||
)));
|
||||
}
|
||||
|
||||
if !idle_policy.enabled {
|
||||
consecutive_zero_len_frames = consecutive_zero_len_frames.saturating_add(1);
|
||||
if consecutive_zero_len_frames > LEGACY_MAX_CONSECUTIVE_ZERO_LEN_FRAMES {
|
||||
stats.increment_relay_protocol_desync_close_total();
|
||||
return Err(ProxyError::Proxy(
|
||||
"Excessive zero-length abridged frames".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if len < 4 && proto_tag != ProtoTag::Abridged {
|
||||
warn!(
|
||||
trace_id = format_args!("0x{:016x}", forensics.trace_id),
|
||||
conn_id = forensics.conn_id,
|
||||
user = %forensics.user,
|
||||
len,
|
||||
proto = ?proto_tag,
|
||||
"Frame too small — corrupt or probe"
|
||||
);
|
||||
stats.increment_relay_protocol_desync_close_total();
|
||||
return Err(ProxyError::Proxy(format!("Frame too small: {len}")));
|
||||
}
|
||||
|
||||
if len > max_frame {
|
||||
return Err(report_desync_frame_too_large_in(
|
||||
shared,
|
||||
forensics,
|
||||
proto_tag,
|
||||
*frame_counter,
|
||||
max_frame,
|
||||
len,
|
||||
raw_len_bytes,
|
||||
stats,
|
||||
));
|
||||
}
|
||||
|
||||
let secure_payload_len = if proto_tag == ProtoTag::Secure {
|
||||
match secure_payload_len_from_wire_len(len) {
|
||||
Some(payload_len) => payload_len,
|
||||
None => {
|
||||
stats.increment_secure_padding_invalid();
|
||||
stats.increment_relay_protocol_desync_close_total();
|
||||
return Err(ProxyError::Proxy(format!(
|
||||
"Invalid secure frame length: {len}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
len
|
||||
};
|
||||
|
||||
let mut payload = buffer_pool.get();
|
||||
payload.clear();
|
||||
let current_cap = payload.capacity();
|
||||
if current_cap < len {
|
||||
payload.reserve(len - current_cap);
|
||||
}
|
||||
payload.resize(len, 0);
|
||||
read_exact_with_policy(
|
||||
client_reader,
|
||||
&mut payload[..len],
|
||||
idle_policy,
|
||||
idle_state,
|
||||
last_downstream_activity_ms,
|
||||
session_started_at,
|
||||
forensics,
|
||||
stats,
|
||||
shared,
|
||||
"payload",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Secure Intermediate: strip validated trailing padding bytes.
|
||||
if proto_tag == ProtoTag::Secure {
|
||||
payload.truncate(secure_payload_len);
|
||||
}
|
||||
*frame_counter += 1;
|
||||
idle_state.on_client_frame(Instant::now());
|
||||
idle_state.tiny_frame_debt = idle_state.tiny_frame_debt.saturating_sub(1);
|
||||
clear_relay_idle_candidate_in(shared, forensics.conn_id);
|
||||
return Ok(Some((payload, quickack)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn read_client_payload_with_idle_policy<R>(
|
||||
client_reader: &mut CryptoReader<R>,
|
||||
proto_tag: ProtoTag,
|
||||
max_frame: usize,
|
||||
buffer_pool: &Arc<BufferPool>,
|
||||
forensics: &RelayForensicsState,
|
||||
frame_counter: &mut u64,
|
||||
stats: &Stats,
|
||||
idle_policy: &RelayClientIdlePolicy,
|
||||
idle_state: &mut RelayClientIdleState,
|
||||
last_downstream_activity_ms: &AtomicU64,
|
||||
session_started_at: Instant,
|
||||
) -> Result<Option<(PooledBuffer, bool)>>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let shared = ProxySharedState::new();
|
||||
read_client_payload_with_idle_policy_in(
|
||||
client_reader,
|
||||
proto_tag,
|
||||
max_frame,
|
||||
buffer_pool,
|
||||
forensics,
|
||||
frame_counter,
|
||||
stats,
|
||||
shared.as_ref(),
|
||||
idle_policy,
|
||||
idle_state,
|
||||
last_downstream_activity_ms,
|
||||
session_started_at,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn read_client_payload_legacy<R>(
|
||||
client_reader: &mut CryptoReader<R>,
|
||||
proto_tag: ProtoTag,
|
||||
max_frame: usize,
|
||||
frame_read_timeout: Duration,
|
||||
buffer_pool: &Arc<BufferPool>,
|
||||
forensics: &RelayForensicsState,
|
||||
frame_counter: &mut u64,
|
||||
stats: &Stats,
|
||||
) -> Result<Option<(PooledBuffer, bool)>>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let now = Instant::now();
|
||||
let shared = ProxySharedState::new();
|
||||
let mut idle_state = RelayClientIdleState::new(now);
|
||||
let last_downstream_activity_ms = AtomicU64::new(0);
|
||||
let idle_policy = RelayClientIdlePolicy::disabled(frame_read_timeout);
|
||||
read_client_payload_with_idle_policy_in(
|
||||
client_reader,
|
||||
proto_tag,
|
||||
max_frame,
|
||||
buffer_pool,
|
||||
forensics,
|
||||
frame_counter,
|
||||
stats,
|
||||
shared.as_ref(),
|
||||
&idle_policy,
|
||||
&mut idle_state,
|
||||
&last_downstream_activity_ms,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn read_client_payload<R>(
|
||||
client_reader: &mut CryptoReader<R>,
|
||||
proto_tag: ProtoTag,
|
||||
max_frame: usize,
|
||||
frame_read_timeout: Duration,
|
||||
buffer_pool: &Arc<BufferPool>,
|
||||
forensics: &RelayForensicsState,
|
||||
frame_counter: &mut u64,
|
||||
stats: &Stats,
|
||||
) -> Result<Option<(PooledBuffer, bool)>>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
read_client_payload_legacy(
|
||||
client_reader,
|
||||
proto_tag,
|
||||
max_frame,
|
||||
frame_read_timeout,
|
||||
buffer_pool,
|
||||
forensics,
|
||||
frame_counter,
|
||||
stats,
|
||||
)
|
||||
.await
|
||||
}
|
||||
153
src/proxy/middle_relay/quota.rs
Normal file
153
src/proxy/middle_relay/quota.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) enum MiddleQuotaReserveError {
|
||||
LimitExceeded,
|
||||
Contended,
|
||||
Cancelled,
|
||||
DeadlineExceeded,
|
||||
}
|
||||
|
||||
pub(super) fn quota_soft_cap(limit: u64, overshoot: u64) -> u64 {
|
||||
limit.saturating_add(overshoot)
|
||||
}
|
||||
|
||||
pub(super) async fn reserve_user_quota_with_yield(
|
||||
user_stats: &UserStats,
|
||||
bytes: u64,
|
||||
limit: u64,
|
||||
stats: &Stats,
|
||||
cancel: &CancellationToken,
|
||||
deadline: Option<Instant>,
|
||||
) -> std::result::Result<u64, MiddleQuotaReserveError> {
|
||||
let mut backoff_ms = QUOTA_RESERVE_BACKOFF_MIN_MS;
|
||||
let mut backoff_rounds = 0usize;
|
||||
loop {
|
||||
for _ in 0..QUOTA_RESERVE_SPIN_RETRIES {
|
||||
match user_stats.quota_try_reserve(bytes, limit) {
|
||||
Ok(total) => return Ok(total),
|
||||
Err(QuotaReserveError::LimitExceeded) => {
|
||||
return Err(MiddleQuotaReserveError::LimitExceeded);
|
||||
}
|
||||
Err(QuotaReserveError::Contended) => {
|
||||
stats.increment_quota_contention_total();
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
|
||||
stats.increment_quota_contention_timeout_total();
|
||||
return Err(MiddleQuotaReserveError::DeadlineExceeded);
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
stats.increment_quota_acquire_cancelled_total();
|
||||
return Err(MiddleQuotaReserveError::Cancelled);
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(backoff_ms)) => {}
|
||||
}
|
||||
backoff_rounds = backoff_rounds.saturating_add(1);
|
||||
if backoff_rounds >= QUOTA_RESERVE_MAX_BACKOFF_ROUNDS {
|
||||
stats.increment_quota_contention_timeout_total();
|
||||
return Err(MiddleQuotaReserveError::Contended);
|
||||
}
|
||||
backoff_ms = backoff_ms
|
||||
.saturating_mul(2)
|
||||
.min(QUOTA_RESERVE_BACKOFF_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_traffic_budget(
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
bytes: u64,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<()> {
|
||||
if bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(lease) = lease else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut remaining = bytes;
|
||||
while remaining > 0 {
|
||||
let consume = lease.try_consume(direction, remaining);
|
||||
if consume.granted > 0 {
|
||||
remaining = remaining.saturating_sub(consume.granted);
|
||||
continue;
|
||||
}
|
||||
|
||||
let wait_started_at = Instant::now();
|
||||
if deadline.is_some_and(|deadline| wait_started_at >= deadline) {
|
||||
return Err(ProxyError::TrafficBudgetWaitDeadlineExceeded);
|
||||
}
|
||||
tokio::time::sleep(next_refill_delay()).await;
|
||||
let wait_ms = wait_started_at
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64;
|
||||
lease.observe_wait_ms(
|
||||
direction,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
wait_ms,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_traffic_budget_or_cancel(
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
bytes: u64,
|
||||
cancel: &CancellationToken,
|
||||
stats: &Stats,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<()> {
|
||||
if bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(lease) = lease else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut remaining = bytes;
|
||||
while remaining > 0 {
|
||||
let consume = lease.try_consume(direction, remaining);
|
||||
if consume.granted > 0 {
|
||||
remaining = remaining.saturating_sub(consume.granted);
|
||||
continue;
|
||||
}
|
||||
|
||||
let wait_started_at = Instant::now();
|
||||
if deadline.is_some_and(|deadline| wait_started_at >= deadline) {
|
||||
stats.increment_flow_wait_middle_rate_limit_cancelled_total();
|
||||
return Err(ProxyError::TrafficBudgetWaitDeadlineExceeded);
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
stats.increment_flow_wait_middle_rate_limit_cancelled_total();
|
||||
return Err(ProxyError::TrafficBudgetWaitCancelled);
|
||||
}
|
||||
_ = tokio::time::sleep(next_refill_delay()) => {}
|
||||
}
|
||||
let wait_ms = wait_started_at
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64;
|
||||
lease.observe_wait_ms(
|
||||
direction,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
wait_ms,
|
||||
);
|
||||
stats.observe_flow_wait_middle_rate_limit_ms(wait_ms);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
854
src/proxy/middle_relay/session.rs
Normal file
854
src/proxy/middle_relay/session.rs
Normal file
@@ -0,0 +1,854 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn handle_via_middle_proxy<R, W>(
|
||||
mut crypto_reader: CryptoReader<R>,
|
||||
crypto_writer: CryptoWriter<W>,
|
||||
success: HandshakeSuccess,
|
||||
me_pool: Arc<MePool>,
|
||||
stats: Arc<Stats>,
|
||||
config: Arc<ProxyConfig>,
|
||||
buffer_pool: Arc<BufferPool>,
|
||||
local_addr: SocketAddr,
|
||||
rng: Arc<SecureRandom>,
|
||||
mut route_rx: watch::Receiver<RouteCutoverState>,
|
||||
route_snapshot: RouteCutoverState,
|
||||
session_id: u64,
|
||||
session_cancel: CancellationToken,
|
||||
shared: Arc<ProxySharedState>,
|
||||
) -> Result<()>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let user = success.user.clone();
|
||||
if session_cancel.is_cancelled() {
|
||||
return Err(ProxyError::UserDisabled { user });
|
||||
}
|
||||
|
||||
let quota_limit = config.access.user_data_quota.get(&user).copied();
|
||||
let quota_user_stats = quota_limit.map(|_| stats.get_or_create_user_stats_handle(&user));
|
||||
let peer = success.peer;
|
||||
let traffic_lease = shared.traffic_limiter.acquire_lease(&user, peer.ip());
|
||||
let proto_tag = success.proto_tag;
|
||||
let pool_generation = me_pool.current_generation();
|
||||
|
||||
debug!(
|
||||
user = %user,
|
||||
peer = %peer,
|
||||
dc = success.dc_idx,
|
||||
proto = ?proto_tag,
|
||||
mode = "middle_proxy",
|
||||
pool_generation,
|
||||
"Routing via Middle-End"
|
||||
);
|
||||
|
||||
let (conn_id, me_rx) = me_pool.registry().register().await;
|
||||
let trace_id = session_id;
|
||||
let bytes_me2c = Arc::new(AtomicU64::new(0));
|
||||
let mut forensics = RelayForensicsState {
|
||||
trace_id,
|
||||
conn_id,
|
||||
user: user.clone(),
|
||||
peer,
|
||||
peer_hash: hash_ip_in(shared.as_ref(), peer.ip()),
|
||||
started_at: Instant::now(),
|
||||
bytes_c2me: 0,
|
||||
bytes_me2c: bytes_me2c.clone(),
|
||||
desync_all_full: config.general.desync_all_full,
|
||||
};
|
||||
|
||||
stats.increment_user_connects(&user);
|
||||
let _me_connection_lease = stats.acquire_me_connection_lease();
|
||||
|
||||
if let Some(cutover) =
|
||||
affected_cutover_state(&route_rx, RelayRouteMode::Middle, route_snapshot.generation)
|
||||
{
|
||||
let delay = cutover_stagger_delay(session_id, cutover.generation);
|
||||
warn!(
|
||||
conn_id,
|
||||
target_mode = cutover.mode.as_str(),
|
||||
cutover_generation = cutover.generation,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
"Cutover affected middle session before relay start, closing client connection"
|
||||
);
|
||||
let _cutover_park_lease = stats.acquire_middle_cutover_park_lease();
|
||||
tokio::time::sleep(delay).await;
|
||||
let _ = me_pool.send_close(conn_id).await;
|
||||
me_pool.registry().unregister(conn_id).await;
|
||||
return Err(ProxyError::RouteSwitched);
|
||||
}
|
||||
|
||||
// Per-user ad_tag from access.user_ad_tags; fallback to general.ad_tag (hot-reloadable)
|
||||
let user_tag: Option<Vec<u8>> = config
|
||||
.access
|
||||
.user_ad_tags
|
||||
.get(&user)
|
||||
.and_then(|s| hex::decode(s).ok())
|
||||
.filter(|v| v.len() == 16);
|
||||
let global_tag: Option<Vec<u8>> = config
|
||||
.general
|
||||
.ad_tag
|
||||
.as_ref()
|
||||
.and_then(|s| hex::decode(s).ok())
|
||||
.filter(|v| v.len() == 16);
|
||||
let effective_tag = user_tag.or(global_tag);
|
||||
|
||||
let proto_flags = proto_flags_for_tag(proto_tag, effective_tag.is_some());
|
||||
let effective_tag_array = effective_tag
|
||||
.as_deref()
|
||||
.and_then(|tag| <[u8; 16]>::try_from(tag).ok());
|
||||
debug!(
|
||||
trace_id = format_args!("0x{:016x}", trace_id),
|
||||
user = %user,
|
||||
conn_id,
|
||||
peer_hash = format_args!("0x{:016x}", forensics.peer_hash),
|
||||
desync_all_full = forensics.desync_all_full,
|
||||
proto_flags = format_args!("0x{:08x}", proto_flags),
|
||||
pool_generation,
|
||||
"ME relay started"
|
||||
);
|
||||
|
||||
let translated_local_addr = me_pool.translate_our_addr(local_addr);
|
||||
|
||||
let frame_limit = config.general.max_client_frame;
|
||||
let mut relay_idle_policy = RelayClientIdlePolicy::from_config(&config);
|
||||
let mut pressure_caps_applied = false;
|
||||
if shared.conntrack_pressure_active() {
|
||||
relay_idle_policy.apply_pressure_caps(config.server.conntrack_control.profile);
|
||||
pressure_caps_applied = true;
|
||||
}
|
||||
let session_started_at = forensics.started_at;
|
||||
let mut relay_idle_state = RelayClientIdleState::new(session_started_at);
|
||||
let last_downstream_activity_ms = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let c2me_channel_capacity = config
|
||||
.general
|
||||
.me_c2me_channel_capacity
|
||||
.max(C2ME_CHANNEL_CAPACITY_FALLBACK);
|
||||
let c2me_send_timeout = match config.general.me_c2me_send_timeout_ms {
|
||||
0 => None,
|
||||
timeout_ms => Some(Duration::from_millis(timeout_ms)),
|
||||
};
|
||||
let c2me_byte_budget = c2me_queued_permit_budget(c2me_channel_capacity, frame_limit);
|
||||
let c2me_byte_semaphore = Arc::new(Semaphore::new(c2me_byte_budget));
|
||||
let (c2me_tx, mut c2me_rx) = mpsc::channel::<C2MeCommand>(c2me_channel_capacity);
|
||||
let me_pool_c2me = me_pool.clone();
|
||||
let mut c2me_sender = tokio::spawn(async move {
|
||||
let mut sent_since_yield = 0usize;
|
||||
while let Some(cmd) = c2me_rx.recv().await {
|
||||
match cmd {
|
||||
C2MeCommand::Data {
|
||||
payload,
|
||||
flags,
|
||||
_permit,
|
||||
} => {
|
||||
me_pool_c2me
|
||||
.send_proxy_req_pooled(
|
||||
conn_id,
|
||||
success.dc_idx,
|
||||
peer,
|
||||
translated_local_addr,
|
||||
payload,
|
||||
flags,
|
||||
effective_tag_array,
|
||||
)
|
||||
.await?;
|
||||
sent_since_yield = sent_since_yield.saturating_add(1);
|
||||
if should_yield_c2me_sender(sent_since_yield, !c2me_rx.is_empty()) {
|
||||
sent_since_yield = 0;
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
C2MeCommand::Close => {
|
||||
let _ = me_pool_c2me.send_close(conn_id).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
|
||||
let flow_cancel = CancellationToken::new();
|
||||
let mut me_rx_task = me_rx;
|
||||
let stats_clone = stats.clone();
|
||||
let rng_clone = rng.clone();
|
||||
let user_clone = user.clone();
|
||||
let quota_user_stats_me_writer = quota_user_stats.clone();
|
||||
let traffic_lease_me_writer = traffic_lease.clone();
|
||||
let flow_cancel_me_writer = flow_cancel.clone();
|
||||
let last_downstream_activity_ms_clone = last_downstream_activity_ms.clone();
|
||||
let bytes_me2c_clone = bytes_me2c.clone();
|
||||
let d2c_flush_policy = MeD2cFlushPolicy::from_config(&config);
|
||||
let mut me_writer = tokio::spawn(async move {
|
||||
let mut writer = crypto_writer;
|
||||
let mut frame_buf = Vec::with_capacity(16 * 1024);
|
||||
let shrink_threshold = d2c_flush_policy.frame_buf_shrink_threshold_bytes;
|
||||
|
||||
fn shrink_session_vec(buf: &mut Vec<u8>, threshold: usize) {
|
||||
if buf.capacity() > threshold {
|
||||
buf.clear();
|
||||
buf.shrink_to(threshold);
|
||||
} else {
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = me_rx_task.recv() => {
|
||||
let Some(first) = msg else {
|
||||
debug!(conn_id, "ME channel closed");
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Err(ProxyError::MiddleConnectionLost);
|
||||
};
|
||||
|
||||
let mut batch_frames = 0usize;
|
||||
let mut batch_bytes = 0usize;
|
||||
let mut flush_immediately;
|
||||
let mut max_delay_fired = false;
|
||||
|
||||
let first_is_downstream_activity =
|
||||
matches!(&first, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
first,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
rng_clone.as_ref(),
|
||||
&mut frame_buf,
|
||||
stats_clone.as_ref(),
|
||||
&user_clone,
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
&flow_cancel_me_writer,
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
false,
|
||||
).await? {
|
||||
MeWriterResponseOutcome::Continue { frames, bytes, flush_immediately: immediate } => {
|
||||
if first_is_downstream_activity {
|
||||
last_downstream_activity_ms_clone
|
||||
.store(session_started_at.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
batch_frames = batch_frames.saturating_add(frames);
|
||||
batch_bytes = batch_bytes.saturating_add(bytes);
|
||||
flush_immediately = immediate;
|
||||
}
|
||||
MeWriterResponseOutcome::Close => {
|
||||
let flush_started_at = if stats_clone.telemetry_policy().me_level.allows_debug() {
|
||||
Some(Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await;
|
||||
let flush_duration_us = flush_started_at.map(|started| {
|
||||
started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
});
|
||||
observe_me_d2c_flush_event(
|
||||
stats_clone.as_ref(),
|
||||
MeD2cFlushReason::Close,
|
||||
batch_frames,
|
||||
batch_bytes,
|
||||
flush_duration_us,
|
||||
);
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
while !flush_immediately
|
||||
&& batch_frames < d2c_flush_policy.max_frames
|
||||
&& batch_bytes < d2c_flush_policy.max_bytes
|
||||
{
|
||||
let Ok(next) = me_rx_task.try_recv() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let next_is_downstream_activity =
|
||||
matches!(&next, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
next,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
rng_clone.as_ref(),
|
||||
&mut frame_buf,
|
||||
stats_clone.as_ref(),
|
||||
&user_clone,
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
&flow_cancel_me_writer,
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
true,
|
||||
).await? {
|
||||
MeWriterResponseOutcome::Continue { frames, bytes, flush_immediately: immediate } => {
|
||||
if next_is_downstream_activity {
|
||||
last_downstream_activity_ms_clone
|
||||
.store(session_started_at.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
batch_frames = batch_frames.saturating_add(frames);
|
||||
batch_bytes = batch_bytes.saturating_add(bytes);
|
||||
flush_immediately |= immediate;
|
||||
}
|
||||
MeWriterResponseOutcome::Close => {
|
||||
let flush_started_at =
|
||||
if stats_clone.telemetry_policy().me_level.allows_debug() {
|
||||
Some(Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ =
|
||||
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await;
|
||||
let flush_duration_us = flush_started_at.map(|started| {
|
||||
started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.min(u128::from(u64::MAX))
|
||||
as u64
|
||||
});
|
||||
observe_me_d2c_flush_event(
|
||||
stats_clone.as_ref(),
|
||||
MeD2cFlushReason::Close,
|
||||
batch_frames,
|
||||
batch_bytes,
|
||||
flush_duration_us,
|
||||
);
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !flush_immediately
|
||||
&& !d2c_flush_policy.max_delay.is_zero()
|
||||
&& batch_frames < d2c_flush_policy.max_frames
|
||||
&& batch_bytes < d2c_flush_policy.max_bytes
|
||||
{
|
||||
stats_clone.increment_me_d2c_batch_timeout_armed_total();
|
||||
match tokio::time::timeout(d2c_flush_policy.max_delay, me_rx_task.recv()).await {
|
||||
Ok(Some(next)) => {
|
||||
let next_is_downstream_activity =
|
||||
matches!(&next, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
next,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
rng_clone.as_ref(),
|
||||
&mut frame_buf,
|
||||
stats_clone.as_ref(),
|
||||
&user_clone,
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
&flow_cancel_me_writer,
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
true,
|
||||
).await? {
|
||||
MeWriterResponseOutcome::Continue { frames, bytes, flush_immediately: immediate } => {
|
||||
if next_is_downstream_activity {
|
||||
last_downstream_activity_ms_clone
|
||||
.store(session_started_at.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
batch_frames = batch_frames.saturating_add(frames);
|
||||
batch_bytes = batch_bytes.saturating_add(bytes);
|
||||
flush_immediately |= immediate;
|
||||
}
|
||||
MeWriterResponseOutcome::Close => {
|
||||
let flush_started_at = if stats_clone
|
||||
.telemetry_policy()
|
||||
.me_level
|
||||
.allows_debug()
|
||||
{
|
||||
Some(Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = flush_client_or_cancel(
|
||||
&mut writer,
|
||||
&flow_cancel_me_writer,
|
||||
)
|
||||
.await;
|
||||
let flush_duration_us = flush_started_at.map(|started| {
|
||||
started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.min(u128::from(u64::MAX))
|
||||
as u64
|
||||
});
|
||||
observe_me_d2c_flush_event(
|
||||
stats_clone.as_ref(),
|
||||
MeD2cFlushReason::Close,
|
||||
batch_frames,
|
||||
batch_bytes,
|
||||
flush_duration_us,
|
||||
);
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
while !flush_immediately
|
||||
&& batch_frames < d2c_flush_policy.max_frames
|
||||
&& batch_bytes < d2c_flush_policy.max_bytes
|
||||
{
|
||||
let Ok(extra) = me_rx_task.try_recv() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let extra_is_downstream_activity =
|
||||
matches!(&extra, MeResponse::Data { .. } | MeResponse::Ack(_));
|
||||
match process_me_writer_response_with_traffic_lease(
|
||||
extra,
|
||||
&mut writer,
|
||||
proto_tag,
|
||||
rng_clone.as_ref(),
|
||||
&mut frame_buf,
|
||||
stats_clone.as_ref(),
|
||||
&user_clone,
|
||||
quota_user_stats_me_writer.as_deref(),
|
||||
quota_limit,
|
||||
d2c_flush_policy.quota_soft_overshoot_bytes,
|
||||
traffic_lease_me_writer.as_ref(),
|
||||
&flow_cancel_me_writer,
|
||||
bytes_me2c_clone.as_ref(),
|
||||
conn_id,
|
||||
d2c_flush_policy.ack_flush_immediate,
|
||||
true,
|
||||
).await? {
|
||||
MeWriterResponseOutcome::Continue { frames, bytes, flush_immediately: immediate } => {
|
||||
if extra_is_downstream_activity {
|
||||
last_downstream_activity_ms_clone
|
||||
.store(session_started_at.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
batch_frames = batch_frames.saturating_add(frames);
|
||||
batch_bytes = batch_bytes.saturating_add(bytes);
|
||||
flush_immediately |= immediate;
|
||||
}
|
||||
MeWriterResponseOutcome::Close => {
|
||||
let flush_started_at = if stats_clone
|
||||
.telemetry_policy()
|
||||
.me_level
|
||||
.allows_debug()
|
||||
{
|
||||
Some(Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = flush_client_or_cancel(
|
||||
&mut writer,
|
||||
&flow_cancel_me_writer,
|
||||
)
|
||||
.await;
|
||||
let flush_duration_us = flush_started_at.map(|started| {
|
||||
started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.min(u128::from(u64::MAX))
|
||||
as u64
|
||||
});
|
||||
observe_me_d2c_flush_event(
|
||||
stats_clone.as_ref(),
|
||||
MeD2cFlushReason::Close,
|
||||
batch_frames,
|
||||
batch_bytes,
|
||||
flush_duration_us,
|
||||
);
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(conn_id, "ME channel closed");
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Err(ProxyError::MiddleConnectionLost);
|
||||
}
|
||||
Err(_) => {
|
||||
max_delay_fired = true;
|
||||
stats_clone.increment_me_d2c_batch_timeout_fired_total();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let flush_reason = classify_me_d2c_flush_reason(
|
||||
flush_immediately,
|
||||
batch_frames,
|
||||
d2c_flush_policy.max_frames,
|
||||
batch_bytes,
|
||||
d2c_flush_policy.max_bytes,
|
||||
max_delay_fired,
|
||||
);
|
||||
let flush_started_at = if stats_clone.telemetry_policy().me_level.allows_debug() {
|
||||
Some(Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?;
|
||||
let flush_duration_us = flush_started_at.map(|started| {
|
||||
started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
});
|
||||
observe_me_d2c_flush_event(
|
||||
stats_clone.as_ref(),
|
||||
flush_reason,
|
||||
batch_frames,
|
||||
batch_bytes,
|
||||
flush_duration_us,
|
||||
);
|
||||
let shrink_threshold = d2c_flush_policy.frame_buf_shrink_threshold_bytes;
|
||||
let shrink_trigger = shrink_threshold
|
||||
.saturating_mul(ME_D2C_FRAME_BUF_SHRINK_HYSTERESIS_FACTOR);
|
||||
if frame_buf.capacity() > shrink_trigger {
|
||||
let cap_before = frame_buf.capacity();
|
||||
frame_buf.shrink_to(shrink_threshold);
|
||||
let cap_after = frame_buf.capacity();
|
||||
let bytes_freed = cap_before.saturating_sub(cap_after) as u64;
|
||||
stats_clone.observe_me_d2c_frame_buf_shrink(bytes_freed);
|
||||
}
|
||||
}
|
||||
_ = &mut stop_rx => {
|
||||
debug!(conn_id, "ME writer stop signal");
|
||||
shrink_session_vec(&mut frame_buf, shrink_threshold);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut main_result: Result<()> = Ok(());
|
||||
let mut client_closed = false;
|
||||
let mut frame_counter: u64 = 0;
|
||||
let mut route_watch_open = true;
|
||||
let mut seen_pressure_seq = relay_pressure_event_seq_in(shared.as_ref());
|
||||
loop {
|
||||
if shared.conntrack_pressure_active() && !pressure_caps_applied {
|
||||
relay_idle_policy.apply_pressure_caps(config.server.conntrack_control.profile);
|
||||
pressure_caps_applied = true;
|
||||
}
|
||||
|
||||
if relay_idle_policy.enabled
|
||||
&& maybe_evict_idle_candidate_on_pressure_in(
|
||||
shared.as_ref(),
|
||||
conn_id,
|
||||
&mut seen_pressure_seq,
|
||||
stats.as_ref(),
|
||||
)
|
||||
{
|
||||
info!(
|
||||
conn_id,
|
||||
trace_id = format_args!("0x{:016x}", trace_id),
|
||||
user = %user,
|
||||
"Middle-relay pressure eviction for idle-candidate session"
|
||||
);
|
||||
let _ = enqueue_c2me_command_in(
|
||||
shared.as_ref(),
|
||||
&c2me_tx,
|
||||
C2MeCommand::Close,
|
||||
c2me_send_timeout,
|
||||
stats.as_ref(),
|
||||
)
|
||||
.await;
|
||||
main_result = Err(ProxyError::Proxy(
|
||||
"middle-relay session evicted under pressure (idle-candidate)".to_string(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(cutover) =
|
||||
affected_cutover_state(&route_rx, RelayRouteMode::Middle, route_snapshot.generation)
|
||||
{
|
||||
let delay = cutover_stagger_delay(session_id, cutover.generation);
|
||||
warn!(
|
||||
conn_id,
|
||||
target_mode = cutover.mode.as_str(),
|
||||
cutover_generation = cutover.generation,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
"Cutover affected middle session, closing client connection"
|
||||
);
|
||||
let _cutover_park_lease = stats.acquire_middle_cutover_park_lease();
|
||||
tokio::time::sleep(delay).await;
|
||||
let _ = enqueue_c2me_command_in(
|
||||
shared.as_ref(),
|
||||
&c2me_tx,
|
||||
C2MeCommand::Close,
|
||||
c2me_send_timeout,
|
||||
stats.as_ref(),
|
||||
)
|
||||
.await;
|
||||
main_result = Err(ProxyError::RouteSwitched);
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = session_cancel.cancelled() => {
|
||||
warn!(
|
||||
user = %user,
|
||||
conn_id,
|
||||
"Disabled user middle session cancelled"
|
||||
);
|
||||
let _ = enqueue_c2me_command_in(
|
||||
shared.as_ref(),
|
||||
&c2me_tx,
|
||||
C2MeCommand::Close,
|
||||
c2me_send_timeout,
|
||||
stats.as_ref(),
|
||||
)
|
||||
.await;
|
||||
main_result = Err(ProxyError::UserDisabled {
|
||||
user: user.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
changed = route_rx.changed(), if route_watch_open => {
|
||||
if changed.is_err() {
|
||||
route_watch_open = false;
|
||||
}
|
||||
}
|
||||
payload_result = read_client_payload_with_idle_policy_in(
|
||||
&mut crypto_reader,
|
||||
proto_tag,
|
||||
frame_limit,
|
||||
&buffer_pool,
|
||||
&forensics,
|
||||
&mut frame_counter,
|
||||
&stats,
|
||||
shared.as_ref(),
|
||||
&relay_idle_policy,
|
||||
&mut relay_idle_state,
|
||||
last_downstream_activity_ms.as_ref(),
|
||||
session_started_at,
|
||||
) => {
|
||||
match payload_result {
|
||||
Ok(Some((payload, quickack))) => {
|
||||
trace!(conn_id, bytes = payload.len(), "C->ME frame");
|
||||
wait_for_traffic_budget(
|
||||
traffic_lease.as_ref(),
|
||||
RateDirection::Up,
|
||||
payload.len() as u64,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
forensics.bytes_c2me = forensics
|
||||
.bytes_c2me
|
||||
.saturating_add(payload.len() as u64);
|
||||
if let (Some(limit), Some(user_stats)) =
|
||||
(quota_limit, quota_user_stats.as_deref())
|
||||
{
|
||||
match reserve_user_quota_with_yield(
|
||||
user_stats,
|
||||
payload.len() as u64,
|
||||
limit,
|
||||
stats.as_ref(),
|
||||
&flow_cancel,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(MiddleQuotaReserveError::LimitExceeded) => {
|
||||
main_result = Err(ProxyError::DataQuotaExceeded {
|
||||
user: user.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(MiddleQuotaReserveError::Contended) => {
|
||||
main_result = Err(ProxyError::Proxy(
|
||||
"ME C->ME quota reservation contended".into(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
Err(MiddleQuotaReserveError::Cancelled) => {
|
||||
main_result = Err(ProxyError::Proxy(
|
||||
"ME C->ME quota reservation cancelled".into(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
Err(MiddleQuotaReserveError::DeadlineExceeded) => {
|
||||
main_result = Err(ProxyError::Proxy(
|
||||
"ME C->ME quota reservation deadline exceeded".into(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
stats.add_user_octets_from_handle(user_stats, payload.len() as u64);
|
||||
} else {
|
||||
stats.add_user_octets_from(&user, payload.len() as u64);
|
||||
}
|
||||
let mut flags = proto_flags;
|
||||
if quickack {
|
||||
flags |= RPC_FLAG_QUICKACK;
|
||||
}
|
||||
if payload.len() >= 8 && payload[..8].iter().all(|b| *b == 0) {
|
||||
flags |= RPC_FLAG_NOT_ENCRYPTED;
|
||||
}
|
||||
let payload_permit = match acquire_c2me_payload_permit(
|
||||
&c2me_byte_semaphore,
|
||||
payload.len(),
|
||||
c2me_send_timeout,
|
||||
stats.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(permit) => permit,
|
||||
Err(e) => {
|
||||
main_result = Err(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Keep client read loop lightweight: route heavy ME send path via a dedicated task.
|
||||
if enqueue_c2me_command_in(
|
||||
shared.as_ref(),
|
||||
&c2me_tx,
|
||||
C2MeCommand::Data {
|
||||
payload,
|
||||
flags,
|
||||
_permit: payload_permit,
|
||||
},
|
||||
c2me_send_timeout,
|
||||
stats.as_ref(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
main_result = Err(ProxyError::Proxy("ME sender channel closed".into()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(conn_id, "Client EOF");
|
||||
client_closed = true;
|
||||
let _ = enqueue_c2me_command_in(
|
||||
shared.as_ref(),
|
||||
&c2me_tx,
|
||||
C2MeCommand::Close,
|
||||
c2me_send_timeout,
|
||||
stats.as_ref(),
|
||||
)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
main_result = Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(c2me_tx);
|
||||
let c2me_result = match timeout(ME_CHILD_JOIN_TIMEOUT, &mut c2me_sender).await {
|
||||
Ok(joined) => {
|
||||
joined.unwrap_or_else(|e| Err(ProxyError::Proxy(format!("ME sender join error: {e}"))))
|
||||
}
|
||||
Err(_) => {
|
||||
stats.increment_me_child_join_timeout_total();
|
||||
stats.increment_me_child_abort_total();
|
||||
c2me_sender.abort();
|
||||
Err(ProxyError::Proxy("ME sender join timeout".into()))
|
||||
}
|
||||
};
|
||||
|
||||
flow_cancel.cancel();
|
||||
let _ = stop_tx.send(());
|
||||
let mut writer_result = match timeout(ME_CHILD_JOIN_TIMEOUT, &mut me_writer).await {
|
||||
Ok(joined) => {
|
||||
joined.unwrap_or_else(|e| Err(ProxyError::Proxy(format!("ME writer join error: {e}"))))
|
||||
}
|
||||
Err(_) => {
|
||||
stats.increment_me_child_join_timeout_total();
|
||||
stats.increment_me_child_abort_total();
|
||||
me_writer.abort();
|
||||
Err(ProxyError::Proxy("ME writer join timeout".into()))
|
||||
}
|
||||
};
|
||||
|
||||
// When client closes, but ME channel stopped as unregistered - it isnt error
|
||||
if client_closed && matches!(writer_result, Err(ProxyError::MiddleConnectionLost)) {
|
||||
writer_result = Ok(());
|
||||
}
|
||||
|
||||
let result = match (main_result, c2me_result, writer_result) {
|
||||
(Ok(()), Ok(()), Ok(())) => Ok(()),
|
||||
(Err(e), _, _) => Err(e),
|
||||
(_, Err(e), _) => Err(e),
|
||||
(_, _, Err(e)) => Err(e),
|
||||
};
|
||||
|
||||
debug!(
|
||||
user = %user,
|
||||
conn_id,
|
||||
trace_id = format_args!("0x{:016x}", trace_id),
|
||||
duration_ms = forensics.started_at.elapsed().as_millis() as u64,
|
||||
bytes_c2me = forensics.bytes_c2me,
|
||||
bytes_me2c = forensics.bytes_me2c.load(Ordering::Relaxed),
|
||||
frames_ok = frame_counter,
|
||||
"ME relay cleanup"
|
||||
);
|
||||
|
||||
let close_reason = classify_conntrack_close_reason(&result);
|
||||
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
|
||||
src: peer,
|
||||
dst: local_addr,
|
||||
reason: close_reason,
|
||||
});
|
||||
if !matches!(
|
||||
publish_result,
|
||||
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
|
||||
) {
|
||||
stats.increment_conntrack_close_event_drop_total();
|
||||
}
|
||||
|
||||
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,
|
||||
pool_snapshot.allocated,
|
||||
pool_snapshot.allocated.saturating_sub(pool_snapshot.pooled),
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
fn classify_conntrack_close_reason(result: &Result<()>) -> ConntrackCloseReason {
|
||||
match result {
|
||||
Ok(()) => ConntrackCloseReason::NormalEof,
|
||||
Err(ProxyError::Io(error)) if matches!(error.kind(), std::io::ErrorKind::TimedOut) => {
|
||||
ConntrackCloseReason::Timeout
|
||||
}
|
||||
Err(ProxyError::Io(error))
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::NotConnected
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
) =>
|
||||
{
|
||||
ConntrackCloseReason::Reset
|
||||
}
|
||||
Err(ProxyError::Proxy(message))
|
||||
if message.contains("pressure") || message.contains("evicted") =>
|
||||
{
|
||||
ConntrackCloseReason::Pressure
|
||||
}
|
||||
Err(_) => ConntrackCloseReason::Other,
|
||||
}
|
||||
}
|
||||
@@ -52,18 +52,17 @@
|
||||
//! - `SharedCounters` (atomics) let the watchdog read stats without locking
|
||||
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
|
||||
use crate::stats::{Stats, UserStats};
|
||||
use crate::proxy::traffic_limiter::TrafficLease;
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::BufferPool;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::future::pending;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, copy_bidirectional_with_sizes};
|
||||
use tokio::time::{Instant, Sleep};
|
||||
use tracing::{debug, trace, warn};
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, copy_bidirectional_with_sizes};
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
// ============= Constants =============
|
||||
|
||||
@@ -85,704 +84,11 @@ fn watchdog_delta(current: u64, previous: u64) -> u64 {
|
||||
current.saturating_sub(previous)
|
||||
}
|
||||
|
||||
// ============= CombinedStream =============
|
||||
|
||||
/// Combines separate read and write halves into a single bidirectional stream.
|
||||
///
|
||||
/// `copy_bidirectional` requires `AsyncRead + AsyncWrite` on each side,
|
||||
/// but the handshake layer produces split reader/writer pairs
|
||||
/// (e.g. `CryptoReader<FakeTlsReader<OwnedReadHalf>>` + `CryptoWriter<...>`).
|
||||
///
|
||||
/// This wrapper reunifies them with zero overhead — each trait method
|
||||
/// delegates directly to the corresponding half. No buffering, no copies.
|
||||
///
|
||||
/// Safety: `poll_read` only touches `reader`, `poll_write` only touches `writer`,
|
||||
/// so there's no aliasing even though both are called on the same `&mut self`.
|
||||
struct CombinedStream<R, W> {
|
||||
reader: R,
|
||||
writer: W,
|
||||
}
|
||||
|
||||
impl<R, W> CombinedStream<R, W> {
|
||||
fn new(reader: R, writer: W) -> Self {
|
||||
Self { reader, writer }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin, W: Unpin> AsyncRead for CombinedStream<R, W> {
|
||||
#[inline]
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().reader).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Unpin, W: AsyncWrite + Unpin> AsyncWrite for CombinedStream<R, W> {
|
||||
#[inline]
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.get_mut().writer).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().writer).poll_flush(cx)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().writer).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// ============= SharedCounters =============
|
||||
|
||||
/// Atomic counters shared between the relay (via StatsIo) and the watchdog task.
|
||||
///
|
||||
/// Using `Relaxed` ordering is sufficient because:
|
||||
/// - Counters are monotonically increasing (no ABA problem)
|
||||
/// - Slight staleness in watchdog reads is harmless (±10s check interval anyway)
|
||||
/// - No ordering dependencies between different counters
|
||||
struct SharedCounters {
|
||||
/// Bytes read from client (C→S direction)
|
||||
c2s_bytes: AtomicU64,
|
||||
/// Bytes written to client (S→C direction)
|
||||
s2c_bytes: AtomicU64,
|
||||
/// Number of poll_read completions (≈ C→S chunks)
|
||||
c2s_ops: AtomicU64,
|
||||
/// Number of poll_write completions (≈ S→C chunks)
|
||||
s2c_ops: AtomicU64,
|
||||
/// Milliseconds since relay epoch of last I/O activity
|
||||
last_activity_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl SharedCounters {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
c2s_bytes: AtomicU64::new(0),
|
||||
s2c_bytes: AtomicU64::new(0),
|
||||
c2s_ops: AtomicU64::new(0),
|
||||
s2c_ops: AtomicU64::new(0),
|
||||
last_activity_ms: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record activity at this instant.
|
||||
#[inline]
|
||||
fn touch(&self, now: Instant, epoch: Instant) {
|
||||
let ms = now.duration_since(epoch).as_millis() as u64;
|
||||
self.last_activity_ms.store(ms, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// How long since last recorded activity.
|
||||
fn idle_duration(&self, now: Instant, epoch: Instant) -> Duration {
|
||||
let last_ms = self.last_activity_ms.load(Ordering::Relaxed);
|
||||
let now_ms = now.duration_since(epoch).as_millis() as u64;
|
||||
Duration::from_millis(now_ms.saturating_sub(last_ms))
|
||||
}
|
||||
}
|
||||
|
||||
// ============= StatsIo =============
|
||||
|
||||
/// Transparent I/O wrapper that tracks per-user statistics and activity.
|
||||
///
|
||||
/// Wraps the **client** side of the relay. Direction mapping:
|
||||
///
|
||||
/// | poll method | direction | stats updated |
|
||||
/// |-------------|-----------|--------------------------------------|
|
||||
/// | `poll_read` | C→S | `octets_from`, `msgs_from`, counters |
|
||||
/// | `poll_write` | S→C | `octets_to`, `msgs_to`, counters |
|
||||
///
|
||||
/// Both update the shared activity timestamp for the watchdog.
|
||||
///
|
||||
/// Note on message counts: the original code counted one `read()`/`write_all()`
|
||||
/// as one "message". Here we count `poll_read`/`poll_write` completions instead.
|
||||
/// Byte counts are identical; op counts may differ slightly due to different
|
||||
/// internal buffering in `copy_bidirectional`. This is fine for monitoring.
|
||||
struct StatsIo<S> {
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
user_stats: Arc<UserStats>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
c2s_rate_debt_bytes: u64,
|
||||
c2s_wait: RateWaitState,
|
||||
s2c_wait: RateWaitState,
|
||||
quota_wait: RateWaitState,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
quota_bytes_since_check: u64,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateWaitState {
|
||||
sleep: Option<Pin<Box<Sleep>>>,
|
||||
started_at: Option<Instant>,
|
||||
blocked_user: bool,
|
||||
blocked_cidr: bool,
|
||||
}
|
||||
|
||||
impl<S> StatsIo<S> {
|
||||
#[cfg(test)]
|
||||
fn new(
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
epoch: Instant,
|
||||
) -> Self {
|
||||
Self::new_with_traffic_lease(
|
||||
inner,
|
||||
counters,
|
||||
stats,
|
||||
user,
|
||||
None,
|
||||
quota_limit,
|
||||
quota_exceeded,
|
||||
epoch,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_traffic_lease(
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
epoch: Instant,
|
||||
) -> Self {
|
||||
// Mark initial activity so the watchdog doesn't fire before data flows
|
||||
counters.touch(Instant::now(), epoch);
|
||||
let user_stats = stats.get_or_create_user_stats_handle(&user);
|
||||
Self {
|
||||
inner,
|
||||
counters,
|
||||
stats,
|
||||
user,
|
||||
user_stats,
|
||||
traffic_lease,
|
||||
c2s_rate_debt_bytes: 0,
|
||||
c2s_wait: RateWaitState::default(),
|
||||
s2c_wait: RateWaitState::default(),
|
||||
quota_wait: RateWaitState::default(),
|
||||
quota_limit,
|
||||
quota_exceeded,
|
||||
quota_bytes_since_check: 0,
|
||||
epoch,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_wait(
|
||||
wait: &mut RateWaitState,
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
) {
|
||||
let Some(started_at) = wait.started_at.take() else {
|
||||
return;
|
||||
};
|
||||
let wait_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
|
||||
if let Some(lease) = lease {
|
||||
lease.observe_wait_ms(direction, wait.blocked_user, wait.blocked_cidr, wait_ms);
|
||||
}
|
||||
wait.blocked_user = false;
|
||||
wait.blocked_cidr = false;
|
||||
}
|
||||
|
||||
fn arm_wait(wait: &mut RateWaitState, blocked_user: bool, blocked_cidr: bool) {
|
||||
if wait.sleep.is_none() {
|
||||
wait.sleep = Some(Box::pin(tokio::time::sleep(next_refill_delay())));
|
||||
wait.started_at = Some(Instant::now());
|
||||
}
|
||||
wait.blocked_user |= blocked_user;
|
||||
wait.blocked_cidr |= blocked_cidr;
|
||||
}
|
||||
|
||||
fn poll_wait(
|
||||
wait: &mut RateWaitState,
|
||||
cx: &mut Context<'_>,
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
) -> Poll<()> {
|
||||
let Some(sleep) = wait.sleep.as_mut() else {
|
||||
return Poll::Ready(());
|
||||
};
|
||||
if sleep.as_mut().poll(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
wait.sleep = None;
|
||||
Self::record_wait(wait, lease, direction);
|
||||
Poll::Ready(())
|
||||
}
|
||||
|
||||
fn settle_c2s_rate_debt(&mut self, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let Some(lease) = self.traffic_lease.as_ref() else {
|
||||
self.c2s_rate_debt_bytes = 0;
|
||||
return Poll::Ready(());
|
||||
};
|
||||
|
||||
while self.c2s_rate_debt_bytes > 0 {
|
||||
let consume = lease.try_consume(RateDirection::Up, self.c2s_rate_debt_bytes);
|
||||
if consume.granted > 0 {
|
||||
self.c2s_rate_debt_bytes = self.c2s_rate_debt_bytes.saturating_sub(consume.granted);
|
||||
continue;
|
||||
}
|
||||
Self::arm_wait(
|
||||
&mut self.c2s_wait,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
);
|
||||
if Self::poll_wait(&mut self.c2s_wait, cx, Some(lease), RateDirection::Up).is_pending()
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
|
||||
if Self::poll_wait(&mut self.c2s_wait, cx, Some(lease), RateDirection::Up).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
Poll::Ready(())
|
||||
}
|
||||
|
||||
fn arm_quota_wait(&mut self, cx: &mut Context<'_>) -> Poll<()> {
|
||||
Self::arm_wait(&mut self.quota_wait, false, false);
|
||||
Self::poll_wait(&mut self.quota_wait, cx, None, RateDirection::Up)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct QuotaIoSentinel;
|
||||
|
||||
impl std::fmt::Display for QuotaIoSentinel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("user data quota exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for QuotaIoSentinel {}
|
||||
|
||||
fn quota_io_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, QuotaIoSentinel)
|
||||
}
|
||||
|
||||
fn is_quota_io_error(err: &io::Error) -> bool {
|
||||
err.kind() == io::ErrorKind::PermissionDenied
|
||||
&& err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<QuotaIoSentinel>())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
const QUOTA_NEAR_LIMIT_BYTES: u64 = 64 * 1024;
|
||||
const QUOTA_LARGE_CHARGE_BYTES: u64 = 16 * 1024;
|
||||
const QUOTA_ADAPTIVE_INTERVAL_MIN_BYTES: u64 = 4 * 1024;
|
||||
const QUOTA_ADAPTIVE_INTERVAL_MAX_BYTES: u64 = 64 * 1024;
|
||||
const QUOTA_RESERVE_SPIN_RETRIES: usize = 64;
|
||||
const QUOTA_RESERVE_MAX_ROUNDS: usize = 8;
|
||||
|
||||
#[inline]
|
||||
fn quota_adaptive_interval_bytes(remaining_before: u64) -> u64 {
|
||||
remaining_before.saturating_div(2).clamp(
|
||||
QUOTA_ADAPTIVE_INTERVAL_MIN_BYTES,
|
||||
QUOTA_ADAPTIVE_INTERVAL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn should_immediate_quota_check(remaining_before: u64, charge_bytes: u64) -> bool {
|
||||
remaining_before <= QUOTA_NEAR_LIMIT_BYTES || charge_bytes >= QUOTA_LARGE_CHARGE_BYTES
|
||||
}
|
||||
|
||||
fn refund_reserved_quota_bytes(user_stats: &UserStats, reserved_bytes: u64) {
|
||||
if reserved_bytes == 0 {
|
||||
return;
|
||||
}
|
||||
let mut current = user_stats.quota_used.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(reserved_bytes);
|
||||
match user_stats.quota_used.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
if this.quota_exceeded.load(Ordering::Acquire) {
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
if this.settle_c2s_rate_debt(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
if buf.remaining() == 0 {
|
||||
return Pin::new(&mut this.inner).poll_read(cx, buf);
|
||||
}
|
||||
|
||||
let mut remaining_before = None;
|
||||
let mut reserved_read_bytes = 0u64;
|
||||
let mut read_limit = buf.remaining();
|
||||
if let Some(limit) = this.quota_limit {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
read_limit = read_limit.min(remaining as usize);
|
||||
if read_limit == 0 {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
|
||||
let desired = read_limit as u64;
|
||||
let mut reserve_rounds = 0usize;
|
||||
while reserved_read_bytes == 0 {
|
||||
for _ in 0..QUOTA_RESERVE_SPIN_RETRIES {
|
||||
match this.user_stats.quota_try_reserve(desired, limit) {
|
||||
Ok(_) => {
|
||||
reserved_read_bytes = desired;
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::LimitExceeded) => {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::Contended) => {
|
||||
this.stats.increment_quota_contention_total();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reserved_read_bytes == 0 {
|
||||
reserve_rounds = reserve_rounds.saturating_add(1);
|
||||
if reserve_rounds >= QUOTA_RESERVE_MAX_ROUNDS {
|
||||
this.stats.increment_quota_contention_timeout_total();
|
||||
if this.arm_quota_wait(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
reserve_rounds = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let limited_read = read_limit < buf.remaining();
|
||||
let read_result = if limited_read {
|
||||
let mut limited_buf = ReadBuf::new(buf.initialize_unfilled_to(read_limit));
|
||||
match Pin::new(&mut this.inner).poll_read(cx, &mut limited_buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = limited_buf.filled().len();
|
||||
buf.advance(n);
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
} else {
|
||||
let before = buf.filled().len();
|
||||
match Pin::new(&mut this.inner).poll_read(cx, buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = buf.filled().len() - before;
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Poll::Ready(Ok(n)) => {
|
||||
if reserved_read_bytes > n as u64 {
|
||||
let refund_bytes = reserved_read_bytes - n as u64;
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), refund_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(refund_bytes);
|
||||
}
|
||||
if n > 0 {
|
||||
let n_to_charge = n as u64;
|
||||
|
||||
if let Some(remaining) = remaining_before {
|
||||
if should_immediate_quota_check(remaining, n_to_charge) {
|
||||
this.quota_bytes_since_check = 0;
|
||||
} else {
|
||||
this.quota_bytes_since_check =
|
||||
this.quota_bytes_since_check.saturating_add(n_to_charge);
|
||||
let interval = quota_adaptive_interval_bytes(remaining);
|
||||
if this.quota_bytes_since_check >= interval {
|
||||
this.quota_bytes_since_check = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(limit) = this.quota_limit
|
||||
&& this.user_stats.quota_used() >= limit
|
||||
{
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
// C→S: client sent data
|
||||
this.counters
|
||||
.c2s_bytes
|
||||
.fetch_add(n_to_charge, Ordering::Relaxed);
|
||||
this.counters.c2s_ops.fetch_add(1, Ordering::Relaxed);
|
||||
this.counters.touch(Instant::now(), this.epoch);
|
||||
|
||||
this.stats
|
||||
.add_user_octets_from_handle(this.user_stats.as_ref(), n_to_charge);
|
||||
this.stats
|
||||
.increment_user_msgs_from_handle(this.user_stats.as_ref());
|
||||
if this.traffic_lease.is_some() {
|
||||
this.c2s_rate_debt_bytes =
|
||||
this.c2s_rate_debt_bytes.saturating_add(n_to_charge);
|
||||
let _ = this.settle_c2s_rate_debt(cx);
|
||||
}
|
||||
|
||||
trace!(user = %this.user, bytes = n, "C->S");
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Pending => {
|
||||
if reserved_read_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_read_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_read_bytes);
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
Poll::Ready(Err(err)) => {
|
||||
if reserved_read_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_read_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_read_bytes);
|
||||
}
|
||||
Poll::Ready(Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
if this.quota_exceeded.load(Ordering::Acquire) {
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
|
||||
let mut shaper_reserved_bytes = 0u64;
|
||||
let mut write_buf = buf;
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
if !buf.is_empty() {
|
||||
loop {
|
||||
let consume = lease.try_consume(RateDirection::Down, buf.len() as u64);
|
||||
if consume.granted > 0 {
|
||||
shaper_reserved_bytes = consume.granted;
|
||||
if consume.granted < buf.len() as u64 {
|
||||
write_buf = &buf[..consume.granted as usize];
|
||||
}
|
||||
let _ = Self::poll_wait(
|
||||
&mut this.s2c_wait,
|
||||
cx,
|
||||
Some(lease),
|
||||
RateDirection::Down,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
Self::arm_wait(
|
||||
&mut this.s2c_wait,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
);
|
||||
if Self::poll_wait(&mut this.s2c_wait, cx, Some(lease), RateDirection::Down)
|
||||
.is_pending()
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = Self::poll_wait(&mut this.s2c_wait, cx, Some(lease), RateDirection::Down);
|
||||
}
|
||||
}
|
||||
|
||||
let mut remaining_before = None;
|
||||
let mut reserved_bytes = 0u64;
|
||||
if let Some(limit) = this.quota_limit {
|
||||
if !write_buf.is_empty() {
|
||||
let mut reserve_rounds = 0usize;
|
||||
while reserved_bytes == 0 {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
|
||||
let desired = remaining.min(write_buf.len() as u64);
|
||||
let mut saw_contention = false;
|
||||
for _ in 0..QUOTA_RESERVE_SPIN_RETRIES {
|
||||
match this.user_stats.quota_try_reserve(desired, limit) {
|
||||
Ok(_) => {
|
||||
reserved_bytes = desired;
|
||||
write_buf = &write_buf[..desired as usize];
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::LimitExceeded) => {
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::Contended) => {
|
||||
this.stats.increment_quota_contention_total();
|
||||
saw_contention = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reserved_bytes == 0 {
|
||||
reserve_rounds = reserve_rounds.saturating_add(1);
|
||||
if reserve_rounds >= QUOTA_RESERVE_MAX_ROUNDS {
|
||||
this.stats.increment_quota_contention_timeout_total();
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
let _ = this.arm_quota_wait(cx);
|
||||
return Poll::Pending;
|
||||
} else if saw_contention {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
match Pin::new(&mut this.inner).poll_write(cx, write_buf) {
|
||||
Poll::Ready(Ok(n)) => {
|
||||
if reserved_bytes > n as u64 {
|
||||
let refund_bytes = reserved_bytes - n as u64;
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), refund_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(refund_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > n as u64
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes - n as u64);
|
||||
}
|
||||
if n > 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
Self::record_wait(&mut this.s2c_wait, Some(lease), RateDirection::Down);
|
||||
}
|
||||
let n_to_charge = n as u64;
|
||||
|
||||
// S→C: data written to client
|
||||
this.counters
|
||||
.s2c_bytes
|
||||
.fetch_add(n_to_charge, Ordering::Relaxed);
|
||||
this.counters.s2c_ops.fetch_add(1, Ordering::Relaxed);
|
||||
this.counters.touch(Instant::now(), this.epoch);
|
||||
|
||||
this.stats
|
||||
.add_user_octets_to_handle(this.user_stats.as_ref(), n_to_charge);
|
||||
this.stats
|
||||
.increment_user_msgs_to_handle(this.user_stats.as_ref());
|
||||
|
||||
if let (Some(limit), Some(remaining)) = (this.quota_limit, remaining_before) {
|
||||
if should_immediate_quota_check(remaining, n_to_charge) {
|
||||
this.quota_bytes_since_check = 0;
|
||||
if this.user_stats.quota_used() >= limit {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
this.quota_bytes_since_check =
|
||||
this.quota_bytes_since_check.saturating_add(n_to_charge);
|
||||
let interval = quota_adaptive_interval_bytes(remaining);
|
||||
if this.quota_bytes_since_check >= interval {
|
||||
this.quota_bytes_since_check = 0;
|
||||
if this.user_stats.quota_used() >= limit {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trace!(user = %this.user, bytes = n, "S->C");
|
||||
}
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Err(err)) => {
|
||||
if reserved_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > 0
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
Poll::Ready(Err(err))
|
||||
}
|
||||
Poll::Pending => {
|
||||
if reserved_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > 0
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
mod io;
|
||||
|
||||
use self::io::{CombinedStream, SharedCounters, StatsIo, is_quota_io_error};
|
||||
#[cfg(test)]
|
||||
use self::io::{quota_adaptive_interval_bytes, should_immediate_quota_check};
|
||||
// ============= Relay =============
|
||||
|
||||
/// Relay data bidirectionally between client and server.
|
||||
@@ -887,6 +193,84 @@ pub async fn relay_bidirectional_with_activity_timeout_and_lease<CR, CW, SR, SW>
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
activity_timeout: Duration,
|
||||
) -> Result<()>
|
||||
where
|
||||
CR: AsyncRead + Unpin + Send + 'static,
|
||||
CW: AsyncWrite + Unpin + Send + 'static,
|
||||
SR: AsyncRead + Unpin + Send + 'static,
|
||||
SW: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
relay_bidirectional_with_activity_timeout_lease_cancel_inner(
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
c2s_buf_size,
|
||||
s2c_buf_size,
|
||||
user,
|
||||
stats,
|
||||
quota_limit,
|
||||
_buffer_pool,
|
||||
traffic_lease,
|
||||
activity_timeout,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn relay_bidirectional_with_activity_timeout_lease_and_cancel<CR, CW, SR, SW>(
|
||||
client_reader: CR,
|
||||
client_writer: CW,
|
||||
server_reader: SR,
|
||||
server_writer: SW,
|
||||
c2s_buf_size: usize,
|
||||
s2c_buf_size: usize,
|
||||
user: &str,
|
||||
stats: Arc<Stats>,
|
||||
quota_limit: Option<u64>,
|
||||
_buffer_pool: Arc<BufferPool>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
activity_timeout: Duration,
|
||||
session_cancel: CancellationToken,
|
||||
) -> Result<()>
|
||||
where
|
||||
CR: AsyncRead + Unpin + Send + 'static,
|
||||
CW: AsyncWrite + Unpin + Send + 'static,
|
||||
SR: AsyncRead + Unpin + Send + 'static,
|
||||
SW: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
relay_bidirectional_with_activity_timeout_lease_cancel_inner(
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
c2s_buf_size,
|
||||
s2c_buf_size,
|
||||
user,
|
||||
stats,
|
||||
quota_limit,
|
||||
_buffer_pool,
|
||||
traffic_lease,
|
||||
activity_timeout,
|
||||
Some(session_cancel),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn relay_bidirectional_with_activity_timeout_lease_cancel_inner<CR, CW, SR, SW>(
|
||||
client_reader: CR,
|
||||
client_writer: CW,
|
||||
server_reader: SR,
|
||||
server_writer: SW,
|
||||
c2s_buf_size: usize,
|
||||
s2c_buf_size: usize,
|
||||
user: &str,
|
||||
stats: Arc<Stats>,
|
||||
quota_limit: Option<u64>,
|
||||
_buffer_pool: Arc<BufferPool>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
activity_timeout: Duration,
|
||||
session_cancel: Option<CancellationToken>,
|
||||
) -> Result<()>
|
||||
where
|
||||
CR: AsyncRead + Unpin + Send + 'static,
|
||||
CW: AsyncWrite + Unpin + Send + 'static,
|
||||
@@ -983,14 +367,29 @@ where
|
||||
//
|
||||
// When the watchdog fires, select! drops the copy future,
|
||||
// releasing the &mut borrows on client and server.
|
||||
let copy_result = tokio::select! {
|
||||
enum RelayOutcome {
|
||||
Copy(std::io::Result<(u64, u64)>),
|
||||
ActivityTimeout,
|
||||
UserDisabled,
|
||||
}
|
||||
|
||||
let cancel_wait = async move {
|
||||
match session_cancel {
|
||||
Some(token) => token.cancelled().await,
|
||||
None => pending::<()>().await,
|
||||
}
|
||||
};
|
||||
tokio::pin!(cancel_wait);
|
||||
|
||||
let relay_outcome = tokio::select! {
|
||||
result = copy_bidirectional_with_sizes(
|
||||
&mut client,
|
||||
&mut server,
|
||||
c2s_buf_size.max(1),
|
||||
s2c_buf_size.max(1),
|
||||
) => Some(result),
|
||||
_ = watchdog => None, // Activity timeout — cancel relay
|
||||
) => RelayOutcome::Copy(result),
|
||||
_ = watchdog => RelayOutcome::ActivityTimeout,
|
||||
_ = &mut cancel_wait => RelayOutcome::UserDisabled,
|
||||
};
|
||||
|
||||
// ── Clean shutdown ──────────────────────────────────────────────
|
||||
@@ -1004,8 +403,8 @@ where
|
||||
let s2c_ops = counters.s2c_ops.load(Ordering::Relaxed);
|
||||
let duration = epoch.elapsed();
|
||||
|
||||
match copy_result {
|
||||
Some(Ok((c2s, s2c))) => {
|
||||
match relay_outcome {
|
||||
RelayOutcome::Copy(Ok((c2s, s2c))) => {
|
||||
// Normal completion — one side closed the connection
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
@@ -1018,7 +417,7 @@ where
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some(Err(e)) if is_quota_io_error(&e) => {
|
||||
RelayOutcome::Copy(Err(e)) if is_quota_io_error(&e) => {
|
||||
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
|
||||
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
|
||||
warn!(
|
||||
@@ -1034,7 +433,7 @@ where
|
||||
user: user_owned.clone(),
|
||||
})
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
RelayOutcome::Copy(Err(e)) => {
|
||||
// I/O error in one of the directions
|
||||
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
|
||||
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
|
||||
@@ -1050,7 +449,7 @@ where
|
||||
);
|
||||
Err(e.into())
|
||||
}
|
||||
None => {
|
||||
RelayOutcome::ActivityTimeout => {
|
||||
// Activity timeout (watchdog fired)
|
||||
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
|
||||
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
|
||||
@@ -1065,6 +464,22 @@ where
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
RelayOutcome::UserDisabled => {
|
||||
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
|
||||
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
|
||||
debug!(
|
||||
user = %user_owned,
|
||||
c2s_bytes = c2s,
|
||||
s2c_bytes = s2c,
|
||||
c2s_msgs = c2s_ops,
|
||||
s2c_msgs = s2c_ops,
|
||||
duration_secs = duration.as_secs(),
|
||||
"Relay finished (user disabled)"
|
||||
);
|
||||
Err(ProxyError::UserDisabled {
|
||||
user: user_owned.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
551
src/proxy/relay/io.rs
Normal file
551
src/proxy/relay/io.rs
Normal file
@@ -0,0 +1,551 @@
|
||||
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
|
||||
use crate::stats::{Stats, UserStats};
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio::time::{Instant, Sleep};
|
||||
use tracing::trace;
|
||||
|
||||
mod combined;
|
||||
mod counters;
|
||||
mod quota;
|
||||
|
||||
pub(super) use self::combined::CombinedStream;
|
||||
pub(super) use self::counters::SharedCounters;
|
||||
pub(super) use self::quota::is_quota_io_error;
|
||||
use self::quota::{
|
||||
QUOTA_RESERVE_MAX_ROUNDS, QUOTA_RESERVE_SPIN_RETRIES, quota_io_error,
|
||||
refund_reserved_quota_bytes,
|
||||
};
|
||||
pub(super) use self::quota::{quota_adaptive_interval_bytes, should_immediate_quota_check};
|
||||
|
||||
/// Transparent I/O wrapper that tracks per-user statistics and activity.
|
||||
///
|
||||
/// Wraps the **client** side of the relay. Direction mapping:
|
||||
///
|
||||
/// | poll method | direction | stats updated |
|
||||
/// |-------------|-----------|--------------------------------------|
|
||||
/// | `poll_read` | C→S | `octets_from`, `msgs_from`, counters |
|
||||
/// | `poll_write` | S→C | `octets_to`, `msgs_to`, counters |
|
||||
///
|
||||
/// Both update the shared activity timestamp for the watchdog.
|
||||
///
|
||||
/// Note on message counts: the original code counted one `read()`/`write_all()`
|
||||
/// as one "message". Here we count `poll_read`/`poll_write` completions instead.
|
||||
/// Byte counts are identical; op counts may differ slightly due to different
|
||||
/// internal buffering in `copy_bidirectional`. This is fine for monitoring.
|
||||
pub(super) struct StatsIo<S> {
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
user_stats: Arc<UserStats>,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
c2s_rate_debt_bytes: u64,
|
||||
c2s_wait: RateWaitState,
|
||||
s2c_wait: RateWaitState,
|
||||
quota_wait: RateWaitState,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
pub(super) quota_bytes_since_check: u64,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateWaitState {
|
||||
sleep: Option<Pin<Box<Sleep>>>,
|
||||
started_at: Option<Instant>,
|
||||
blocked_user: bool,
|
||||
blocked_cidr: bool,
|
||||
}
|
||||
|
||||
impl<S> StatsIo<S> {
|
||||
/// Creates a StatsIo wrapper without a traffic lease for relay unit tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn new(
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
epoch: Instant,
|
||||
) -> Self {
|
||||
Self::new_with_traffic_lease(
|
||||
inner,
|
||||
counters,
|
||||
stats,
|
||||
user,
|
||||
None,
|
||||
quota_limit,
|
||||
quota_exceeded,
|
||||
epoch,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn new_with_traffic_lease(
|
||||
inner: S,
|
||||
counters: Arc<SharedCounters>,
|
||||
stats: Arc<Stats>,
|
||||
user: String,
|
||||
traffic_lease: Option<Arc<TrafficLease>>,
|
||||
quota_limit: Option<u64>,
|
||||
quota_exceeded: Arc<AtomicBool>,
|
||||
epoch: Instant,
|
||||
) -> Self {
|
||||
// Mark initial activity so the watchdog doesn't fire before data flows
|
||||
counters.touch(Instant::now(), epoch);
|
||||
let user_stats = stats.get_or_create_user_stats_handle(&user);
|
||||
Self {
|
||||
inner,
|
||||
counters,
|
||||
stats,
|
||||
user,
|
||||
user_stats,
|
||||
traffic_lease,
|
||||
c2s_rate_debt_bytes: 0,
|
||||
c2s_wait: RateWaitState::default(),
|
||||
s2c_wait: RateWaitState::default(),
|
||||
quota_wait: RateWaitState::default(),
|
||||
quota_limit,
|
||||
quota_exceeded,
|
||||
quota_bytes_since_check: 0,
|
||||
epoch,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_wait(
|
||||
wait: &mut RateWaitState,
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
) {
|
||||
let Some(started_at) = wait.started_at.take() else {
|
||||
return;
|
||||
};
|
||||
let wait_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
|
||||
if let Some(lease) = lease {
|
||||
lease.observe_wait_ms(direction, wait.blocked_user, wait.blocked_cidr, wait_ms);
|
||||
}
|
||||
wait.blocked_user = false;
|
||||
wait.blocked_cidr = false;
|
||||
}
|
||||
|
||||
fn arm_wait(wait: &mut RateWaitState, blocked_user: bool, blocked_cidr: bool) {
|
||||
if wait.sleep.is_none() {
|
||||
wait.sleep = Some(Box::pin(tokio::time::sleep(next_refill_delay())));
|
||||
wait.started_at = Some(Instant::now());
|
||||
}
|
||||
wait.blocked_user |= blocked_user;
|
||||
wait.blocked_cidr |= blocked_cidr;
|
||||
}
|
||||
|
||||
fn poll_wait(
|
||||
wait: &mut RateWaitState,
|
||||
cx: &mut Context<'_>,
|
||||
lease: Option<&Arc<TrafficLease>>,
|
||||
direction: RateDirection,
|
||||
) -> Poll<()> {
|
||||
let Some(sleep) = wait.sleep.as_mut() else {
|
||||
return Poll::Ready(());
|
||||
};
|
||||
if sleep.as_mut().poll(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
wait.sleep = None;
|
||||
Self::record_wait(wait, lease, direction);
|
||||
Poll::Ready(())
|
||||
}
|
||||
|
||||
fn settle_c2s_rate_debt(&mut self, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let Some(lease) = self.traffic_lease.as_ref() else {
|
||||
self.c2s_rate_debt_bytes = 0;
|
||||
return Poll::Ready(());
|
||||
};
|
||||
|
||||
while self.c2s_rate_debt_bytes > 0 {
|
||||
let consume = lease.try_consume(RateDirection::Up, self.c2s_rate_debt_bytes);
|
||||
if consume.granted > 0 {
|
||||
self.c2s_rate_debt_bytes = self.c2s_rate_debt_bytes.saturating_sub(consume.granted);
|
||||
continue;
|
||||
}
|
||||
Self::arm_wait(
|
||||
&mut self.c2s_wait,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
);
|
||||
if Self::poll_wait(&mut self.c2s_wait, cx, Some(lease), RateDirection::Up).is_pending()
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
|
||||
if Self::poll_wait(&mut self.c2s_wait, cx, Some(lease), RateDirection::Up).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
Poll::Ready(())
|
||||
}
|
||||
|
||||
fn arm_quota_wait(&mut self, cx: &mut Context<'_>) -> Poll<()> {
|
||||
Self::arm_wait(&mut self.quota_wait, false, false);
|
||||
Self::poll_wait(&mut self.quota_wait, cx, None, RateDirection::Up)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for StatsIo<S> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
if this.quota_exceeded.load(Ordering::Acquire) {
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
if this.settle_c2s_rate_debt(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
if buf.remaining() == 0 {
|
||||
return Pin::new(&mut this.inner).poll_read(cx, buf);
|
||||
}
|
||||
|
||||
let mut remaining_before = None;
|
||||
let mut reserved_read_bytes = 0u64;
|
||||
let mut read_limit = buf.remaining();
|
||||
if let Some(limit) = this.quota_limit {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
read_limit = read_limit.min(remaining as usize);
|
||||
if read_limit == 0 {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
|
||||
let desired = read_limit as u64;
|
||||
let mut reserve_rounds = 0usize;
|
||||
while reserved_read_bytes == 0 {
|
||||
for _ in 0..QUOTA_RESERVE_SPIN_RETRIES {
|
||||
match this.user_stats.quota_try_reserve(desired, limit) {
|
||||
Ok(_) => {
|
||||
reserved_read_bytes = desired;
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::LimitExceeded) => {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::Contended) => {
|
||||
this.stats.increment_quota_contention_total();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reserved_read_bytes == 0 {
|
||||
reserve_rounds = reserve_rounds.saturating_add(1);
|
||||
if reserve_rounds >= QUOTA_RESERVE_MAX_ROUNDS {
|
||||
this.stats.increment_quota_contention_timeout_total();
|
||||
if this.arm_quota_wait(cx).is_pending() {
|
||||
return Poll::Pending;
|
||||
}
|
||||
reserve_rounds = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let limited_read = read_limit < buf.remaining();
|
||||
let read_result = if limited_read {
|
||||
let mut limited_buf = ReadBuf::new(buf.initialize_unfilled_to(read_limit));
|
||||
match Pin::new(&mut this.inner).poll_read(cx, &mut limited_buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = limited_buf.filled().len();
|
||||
buf.advance(n);
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
} else {
|
||||
let before = buf.filled().len();
|
||||
match Pin::new(&mut this.inner).poll_read(cx, buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = buf.filled().len() - before;
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Poll::Ready(Ok(n)) => {
|
||||
if reserved_read_bytes > n as u64 {
|
||||
let refund_bytes = reserved_read_bytes - n as u64;
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), refund_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(refund_bytes);
|
||||
}
|
||||
if n > 0 {
|
||||
let n_to_charge = n as u64;
|
||||
|
||||
if let Some(remaining) = remaining_before {
|
||||
if should_immediate_quota_check(remaining, n_to_charge) {
|
||||
this.quota_bytes_since_check = 0;
|
||||
} else {
|
||||
this.quota_bytes_since_check =
|
||||
this.quota_bytes_since_check.saturating_add(n_to_charge);
|
||||
let interval = quota_adaptive_interval_bytes(remaining);
|
||||
if this.quota_bytes_since_check >= interval {
|
||||
this.quota_bytes_since_check = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(limit) = this.quota_limit
|
||||
&& this.user_stats.quota_used() >= limit
|
||||
{
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
// C→S: client sent data
|
||||
this.counters
|
||||
.c2s_bytes
|
||||
.fetch_add(n_to_charge, Ordering::Relaxed);
|
||||
this.counters.c2s_ops.fetch_add(1, Ordering::Relaxed);
|
||||
this.counters.touch(Instant::now(), this.epoch);
|
||||
|
||||
this.stats
|
||||
.add_user_traffic_from_handle(this.user_stats.as_ref(), n_to_charge);
|
||||
if this.traffic_lease.is_some() {
|
||||
this.c2s_rate_debt_bytes =
|
||||
this.c2s_rate_debt_bytes.saturating_add(n_to_charge);
|
||||
let _ = this.settle_c2s_rate_debt(cx);
|
||||
}
|
||||
|
||||
trace!(user = %this.user, bytes = n, "C->S");
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Pending => {
|
||||
if reserved_read_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_read_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_read_bytes);
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
Poll::Ready(Err(err)) => {
|
||||
if reserved_read_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_read_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_read_bytes);
|
||||
}
|
||||
Poll::Ready(Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for StatsIo<S> {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
if this.quota_exceeded.load(Ordering::Acquire) {
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
|
||||
let mut shaper_reserved_bytes = 0u64;
|
||||
let mut write_buf = buf;
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
if !buf.is_empty() {
|
||||
loop {
|
||||
let consume = lease.try_consume(RateDirection::Down, buf.len() as u64);
|
||||
if consume.granted > 0 {
|
||||
shaper_reserved_bytes = consume.granted;
|
||||
if consume.granted < buf.len() as u64 {
|
||||
write_buf = &buf[..consume.granted as usize];
|
||||
}
|
||||
let _ = Self::poll_wait(
|
||||
&mut this.s2c_wait,
|
||||
cx,
|
||||
Some(lease),
|
||||
RateDirection::Down,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
Self::arm_wait(
|
||||
&mut this.s2c_wait,
|
||||
consume.blocked_user,
|
||||
consume.blocked_cidr,
|
||||
);
|
||||
if Self::poll_wait(&mut this.s2c_wait, cx, Some(lease), RateDirection::Down)
|
||||
.is_pending()
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = Self::poll_wait(&mut this.s2c_wait, cx, Some(lease), RateDirection::Down);
|
||||
}
|
||||
}
|
||||
|
||||
let mut remaining_before = None;
|
||||
let mut reserved_bytes = 0u64;
|
||||
if let Some(limit) = this.quota_limit {
|
||||
if !write_buf.is_empty() {
|
||||
let mut reserve_rounds = 0usize;
|
||||
while reserved_bytes == 0 {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
|
||||
let desired = remaining.min(write_buf.len() as u64);
|
||||
let mut saw_contention = false;
|
||||
for _ in 0..QUOTA_RESERVE_SPIN_RETRIES {
|
||||
match this.user_stats.quota_try_reserve(desired, limit) {
|
||||
Ok(_) => {
|
||||
reserved_bytes = desired;
|
||||
write_buf = &write_buf[..desired as usize];
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::LimitExceeded) => {
|
||||
break;
|
||||
}
|
||||
Err(crate::stats::QuotaReserveError::Contended) => {
|
||||
this.stats.increment_quota_contention_total();
|
||||
saw_contention = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reserved_bytes == 0 {
|
||||
reserve_rounds = reserve_rounds.saturating_add(1);
|
||||
if reserve_rounds >= QUOTA_RESERVE_MAX_ROUNDS {
|
||||
this.stats.increment_quota_contention_timeout_total();
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
let _ = this.arm_quota_wait(cx);
|
||||
return Poll::Pending;
|
||||
} else if saw_contention {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let used_before = this.user_stats.quota_used();
|
||||
let remaining = limit.saturating_sub(used_before);
|
||||
if remaining == 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
return Poll::Ready(Err(quota_io_error()));
|
||||
}
|
||||
remaining_before = Some(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
match Pin::new(&mut this.inner).poll_write(cx, write_buf) {
|
||||
Poll::Ready(Ok(n)) => {
|
||||
if reserved_bytes > n as u64 {
|
||||
let refund_bytes = reserved_bytes - n as u64;
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), refund_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(refund_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > n as u64
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes - n as u64);
|
||||
}
|
||||
if n > 0 {
|
||||
if let Some(lease) = this.traffic_lease.as_ref() {
|
||||
Self::record_wait(&mut this.s2c_wait, Some(lease), RateDirection::Down);
|
||||
}
|
||||
let n_to_charge = n as u64;
|
||||
|
||||
// S→C: data written to client
|
||||
this.counters
|
||||
.s2c_bytes
|
||||
.fetch_add(n_to_charge, Ordering::Relaxed);
|
||||
this.counters.s2c_ops.fetch_add(1, Ordering::Relaxed);
|
||||
this.counters.touch(Instant::now(), this.epoch);
|
||||
|
||||
this.stats
|
||||
.add_user_traffic_to_handle(this.user_stats.as_ref(), n_to_charge);
|
||||
|
||||
if let (Some(limit), Some(remaining)) = (this.quota_limit, remaining_before) {
|
||||
if should_immediate_quota_check(remaining, n_to_charge) {
|
||||
this.quota_bytes_since_check = 0;
|
||||
if this.user_stats.quota_used() >= limit {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
this.quota_bytes_since_check =
|
||||
this.quota_bytes_since_check.saturating_add(n_to_charge);
|
||||
let interval = quota_adaptive_interval_bytes(remaining);
|
||||
if this.quota_bytes_since_check >= interval {
|
||||
this.quota_bytes_since_check = 0;
|
||||
if this.user_stats.quota_used() >= limit {
|
||||
this.quota_exceeded.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trace!(user = %this.user, bytes = n, "S->C");
|
||||
}
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Err(err)) => {
|
||||
if reserved_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > 0
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
Poll::Ready(Err(err))
|
||||
}
|
||||
Poll::Pending => {
|
||||
if reserved_bytes > 0 {
|
||||
refund_reserved_quota_bytes(this.user_stats.as_ref(), reserved_bytes);
|
||||
this.stats.add_quota_refund_bytes_total(reserved_bytes);
|
||||
}
|
||||
if shaper_reserved_bytes > 0
|
||||
&& let Some(lease) = this.traffic_lease.as_ref()
|
||||
{
|
||||
lease.refund(RateDirection::Down, shaper_reserved_bytes);
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
61
src/proxy/relay/io/combined.rs
Normal file
61
src/proxy/relay/io/combined.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
|
||||
// ============= CombinedStream =============
|
||||
|
||||
/// Combines separate read and write halves into a single bidirectional stream.
|
||||
///
|
||||
/// `copy_bidirectional` requires `AsyncRead + AsyncWrite` on each side,
|
||||
/// but the handshake layer produces split reader/writer pairs
|
||||
/// (e.g. `CryptoReader<FakeTlsReader<OwnedReadHalf>>` + `CryptoWriter<...>`).
|
||||
///
|
||||
/// This wrapper reunifies them with zero overhead — each trait method
|
||||
/// delegates directly to the corresponding half. No buffering, no copies.
|
||||
///
|
||||
/// Safety: `poll_read` only touches `reader`, `poll_write` only touches `writer`,
|
||||
/// so there's no aliasing even though both are called on the same `&mut self`.
|
||||
pub(in crate::proxy::relay) struct CombinedStream<R, W> {
|
||||
reader: R,
|
||||
writer: W,
|
||||
}
|
||||
|
||||
impl<R, W> CombinedStream<R, W> {
|
||||
pub(in crate::proxy::relay) fn new(reader: R, writer: W) -> Self {
|
||||
Self { reader, writer }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin, W: Unpin> AsyncRead for CombinedStream<R, W> {
|
||||
#[inline]
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().reader).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Unpin, W: AsyncWrite + Unpin> AsyncWrite for CombinedStream<R, W> {
|
||||
#[inline]
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.get_mut().writer).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().writer).poll_flush(cx)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().writer).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
51
src/proxy/relay/io/counters.rs
Normal file
51
src/proxy/relay/io/counters.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time::Instant;
|
||||
|
||||
// ============= SharedCounters =============
|
||||
|
||||
/// Atomic counters shared between the relay (via StatsIo) and the watchdog task.
|
||||
///
|
||||
/// Using `Relaxed` ordering is sufficient because:
|
||||
/// - Counters are monotonically increasing (no ABA problem)
|
||||
/// - Slight staleness in watchdog reads is harmless (±10s check interval anyway)
|
||||
/// - No ordering dependencies between different counters
|
||||
pub(in crate::proxy::relay) struct SharedCounters {
|
||||
/// Bytes read from client (C→S direction)
|
||||
pub(in crate::proxy::relay) c2s_bytes: AtomicU64,
|
||||
/// Bytes written to client (S→C direction)
|
||||
pub(in crate::proxy::relay) s2c_bytes: AtomicU64,
|
||||
/// Number of poll_read completions (≈ C→S chunks)
|
||||
pub(in crate::proxy::relay) c2s_ops: AtomicU64,
|
||||
/// Number of poll_write completions (≈ S→C chunks)
|
||||
pub(in crate::proxy::relay) s2c_ops: AtomicU64,
|
||||
/// Milliseconds since relay epoch of last I/O activity
|
||||
last_activity_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl SharedCounters {
|
||||
pub(in crate::proxy::relay) fn new() -> Self {
|
||||
Self {
|
||||
c2s_bytes: AtomicU64::new(0),
|
||||
s2c_bytes: AtomicU64::new(0),
|
||||
c2s_ops: AtomicU64::new(0),
|
||||
s2c_ops: AtomicU64::new(0),
|
||||
last_activity_ms: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record activity at this instant.
|
||||
#[inline]
|
||||
pub(in crate::proxy::relay) fn touch(&self, now: Instant, epoch: Instant) {
|
||||
let ms = now.duration_since(epoch).as_millis() as u64;
|
||||
self.last_activity_ms.store(ms, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// How long since last recorded activity.
|
||||
pub(in crate::proxy::relay) fn idle_duration(&self, now: Instant, epoch: Instant) -> Duration {
|
||||
let last_ms = self.last_activity_ms.load(Ordering::Relaxed);
|
||||
let now_ms = now.duration_since(epoch).as_millis() as u64;
|
||||
Duration::from_millis(now_ms.saturating_sub(last_ms))
|
||||
}
|
||||
}
|
||||
68
src/proxy/relay/io/quota.rs
Normal file
68
src/proxy/relay/io/quota.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use crate::stats::UserStats;
|
||||
use std::io;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct QuotaIoSentinel;
|
||||
|
||||
impl std::fmt::Display for QuotaIoSentinel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("user data quota exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for QuotaIoSentinel {}
|
||||
|
||||
pub(super) fn quota_io_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, QuotaIoSentinel)
|
||||
}
|
||||
|
||||
pub(in crate::proxy::relay) fn is_quota_io_error(err: &io::Error) -> bool {
|
||||
err.kind() == io::ErrorKind::PermissionDenied
|
||||
&& err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<QuotaIoSentinel>())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
const QUOTA_NEAR_LIMIT_BYTES: u64 = 64 * 1024;
|
||||
const QUOTA_LARGE_CHARGE_BYTES: u64 = 16 * 1024;
|
||||
const QUOTA_ADAPTIVE_INTERVAL_MIN_BYTES: u64 = 4 * 1024;
|
||||
const QUOTA_ADAPTIVE_INTERVAL_MAX_BYTES: u64 = 64 * 1024;
|
||||
pub(super) const QUOTA_RESERVE_SPIN_RETRIES: usize = 64;
|
||||
pub(super) const QUOTA_RESERVE_MAX_ROUNDS: usize = 8;
|
||||
|
||||
#[inline]
|
||||
pub(in crate::proxy::relay) fn quota_adaptive_interval_bytes(remaining_before: u64) -> u64 {
|
||||
remaining_before.saturating_div(2).clamp(
|
||||
QUOTA_ADAPTIVE_INTERVAL_MIN_BYTES,
|
||||
QUOTA_ADAPTIVE_INTERVAL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(in crate::proxy::relay) fn should_immediate_quota_check(
|
||||
remaining_before: u64,
|
||||
charge_bytes: u64,
|
||||
) -> bool {
|
||||
remaining_before <= QUOTA_NEAR_LIMIT_BYTES || charge_bytes >= QUOTA_LARGE_CHARGE_BYTES
|
||||
}
|
||||
|
||||
pub(super) fn refund_reserved_quota_bytes(user_stats: &UserStats, reserved_bytes: u64) {
|
||||
if reserved_bytes == 0 {
|
||||
return;
|
||||
}
|
||||
let mut current = user_stats.quota_used.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(reserved_bytes);
|
||||
match user_stats.quota_used.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -7,6 +7,7 @@ use std::time::Instant;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState};
|
||||
use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry};
|
||||
@@ -59,7 +60,7 @@ pub(crate) struct MiddleRelaySharedState {
|
||||
pub(crate) desync_hasher: RandomState,
|
||||
pub(crate) desync_full_cache_last_emit_at: Mutex<Option<Instant>>,
|
||||
pub(crate) desync_dedup_rotation_state: Mutex<DesyncDedupRotationState>,
|
||||
pub(crate) relay_idle_registry: Mutex<RelayIdleCandidateRegistry>,
|
||||
pub(crate) relay_idle_registry: RelayIdleCandidateRegistry,
|
||||
pub(crate) relay_idle_mark_seq: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -67,10 +68,35 @@ pub(crate) struct ProxySharedState {
|
||||
pub(crate) handshake: HandshakeSharedState,
|
||||
pub(crate) middle_relay: MiddleRelaySharedState,
|
||||
pub(crate) traffic_limiter: Arc<TrafficLimiter>,
|
||||
disabled_users: DashMap<String, ()>,
|
||||
active_user_sessions: DashMap<(String, u64), CancellationToken>,
|
||||
pub(crate) conntrack_pressure_active: AtomicBool,
|
||||
pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>,
|
||||
}
|
||||
|
||||
#[must_use = "registered user sessions must be kept alive until relay completion"]
|
||||
pub(crate) struct UserSessionRegistration {
|
||||
token: CancellationToken,
|
||||
_guard: UserSessionGuard,
|
||||
}
|
||||
|
||||
impl UserSessionRegistration {
|
||||
pub(crate) fn token(&self) -> CancellationToken {
|
||||
self.token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct UserSessionGuard {
|
||||
shared: Arc<ProxySharedState>,
|
||||
key: (String, u64),
|
||||
}
|
||||
|
||||
impl Drop for UserSessionGuard {
|
||||
fn drop(&mut self) {
|
||||
self.shared.active_user_sessions.remove(&self.key);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxySharedState {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
@@ -97,15 +123,86 @@ impl ProxySharedState {
|
||||
desync_hasher: RandomState::new(),
|
||||
desync_full_cache_last_emit_at: Mutex::new(None),
|
||||
desync_dedup_rotation_state: Mutex::new(DesyncDedupRotationState::default()),
|
||||
relay_idle_registry: Mutex::new(RelayIdleCandidateRegistry::default()),
|
||||
relay_idle_registry: RelayIdleCandidateRegistry::default(),
|
||||
relay_idle_mark_seq: AtomicU64::new(0),
|
||||
},
|
||||
traffic_limiter: TrafficLimiter::new(),
|
||||
disabled_users: DashMap::new(),
|
||||
active_user_sessions: DashMap::new(),
|
||||
conntrack_pressure_active: AtomicBool::new(false),
|
||||
conntrack_close_tx: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_user_enabled(&self, user: &str) -> bool {
|
||||
!self.disabled_users.contains_key(user)
|
||||
}
|
||||
|
||||
pub(crate) fn set_user_enabled(&self, user: &str, enabled: bool) -> bool {
|
||||
if enabled {
|
||||
self.disabled_users.remove(user);
|
||||
false
|
||||
} else {
|
||||
self.disabled_users.insert(user.to_string(), ()).is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_user_enabled_config(
|
||||
&self,
|
||||
user_enabled: &HashMap<String, bool>,
|
||||
) -> Vec<String> {
|
||||
let desired_disabled = user_enabled
|
||||
.iter()
|
||||
.filter_map(|(user, enabled)| (!*enabled).then_some(user.clone()))
|
||||
.collect::<HashSet<_>>();
|
||||
let current_disabled = self
|
||||
.disabled_users
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for user in current_disabled.difference(&desired_disabled) {
|
||||
self.disabled_users.remove(user);
|
||||
}
|
||||
let newly_disabled = desired_disabled
|
||||
.difference(¤t_disabled)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for user in desired_disabled {
|
||||
self.disabled_users.insert(user, ());
|
||||
}
|
||||
newly_disabled
|
||||
}
|
||||
|
||||
pub(crate) fn register_user_session(
|
||||
self: &Arc<Self>,
|
||||
user: &str,
|
||||
session_id: u64,
|
||||
) -> UserSessionRegistration {
|
||||
let token = CancellationToken::new();
|
||||
let key = (user.to_string(), session_id);
|
||||
self.active_user_sessions.insert(key.clone(), token.clone());
|
||||
UserSessionRegistration {
|
||||
token,
|
||||
_guard: UserSessionGuard {
|
||||
shared: Arc::clone(self),
|
||||
key,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_user_sessions(&self, user: &str) -> usize {
|
||||
let tokens = self
|
||||
.active_user_sessions
|
||||
.iter()
|
||||
.filter_map(|entry| (entry.key().0 == user).then(|| entry.value().clone()))
|
||||
.collect::<Vec<_>>();
|
||||
for token in &tokens {
|
||||
token.cancel();
|
||||
}
|
||||
tokens.len()
|
||||
}
|
||||
|
||||
pub(crate) fn set_conntrack_close_sender(&self, tx: mpsc::Sender<ConntrackCloseEvent>) {
|
||||
match self.conntrack_close_tx.lock() {
|
||||
Ok(mut guard) => {
|
||||
@@ -166,3 +263,48 @@ impl ProxySharedState {
|
||||
self.conntrack_pressure_active.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_enabled_config_sync_tracks_disabled_overrides() {
|
||||
let shared = ProxySharedState::new();
|
||||
assert!(shared.is_user_enabled("alice"));
|
||||
|
||||
let mut user_enabled = HashMap::new();
|
||||
user_enabled.insert("alice".to_string(), false);
|
||||
user_enabled.insert("bob".to_string(), true);
|
||||
|
||||
let mut newly_disabled = shared.apply_user_enabled_config(&user_enabled);
|
||||
newly_disabled.sort();
|
||||
assert_eq!(newly_disabled, vec!["alice".to_string()]);
|
||||
assert!(!shared.is_user_enabled("alice"));
|
||||
assert!(shared.is_user_enabled("bob"));
|
||||
|
||||
assert!(shared.apply_user_enabled_config(&user_enabled).is_empty());
|
||||
|
||||
user_enabled.clear();
|
||||
assert!(shared.apply_user_enabled_config(&user_enabled).is_empty());
|
||||
assert!(shared.is_user_enabled("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_user_sessions_cancels_only_registered_matching_user() {
|
||||
let shared = ProxySharedState::new();
|
||||
let alice_1 = shared.register_user_session("alice", 1);
|
||||
let alice_2 = shared.register_user_session("alice", 2);
|
||||
let bob = shared.register_user_session("bob", 1);
|
||||
let alice_1_token = alice_1.token();
|
||||
let alice_2_token = alice_2.token();
|
||||
let bob_token = bob.token();
|
||||
|
||||
drop(alice_1);
|
||||
|
||||
assert_eq!(shared.cancel_user_sessions("alice"), 1);
|
||||
assert!(!alice_1_token.is_cancelled());
|
||||
assert!(alice_2_token.is_cancelled());
|
||||
assert!(!bob_token.is_cancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -35,6 +35,7 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -33,6 +33,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -46,6 +46,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -24,6 +24,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -47,6 +47,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -240,6 +241,7 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -484,6 +486,7 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -561,6 +564,7 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -21,6 +21,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -33,6 +33,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -341,6 +341,7 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -459,6 +460,7 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -586,6 +588,7 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -759,6 +762,7 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -839,6 +843,7 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load(
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1032,6 +1037,7 @@ async fn short_tls_probe_is_masked_through_client_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1123,6 +1129,7 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1212,6 +1219,7 @@ async fn handle_client_stream_increments_connects_all_exactly_once() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1308,6 +1316,7 @@ async fn running_client_handler_increments_connects_all_exactly_once() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1401,6 +1410,7 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1475,6 +1485,7 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1564,6 +1575,7 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1892,6 +1904,7 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2004,6 +2017,7 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2114,6 +2128,7 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2239,6 +2254,7 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2335,6 +2351,7 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -2437,6 +2454,7 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -3395,6 +3413,7 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -3963,6 +3982,7 @@ async fn untrusted_proxy_header_source_is_rejected() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4036,6 +4056,7 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4136,6 +4157,7 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4242,6 +4264,7 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4362,6 +4385,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4468,6 +4492,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4577,6 +4602,7 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -4681,6 +4707,7 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -32,6 +32,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -74,12 +75,17 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
let backend_reply = REPLY_404.to_vec();
|
||||
let probe = match class {
|
||||
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
|
||||
ProbeClass::PlainWebBaseline => plain_web_probe(),
|
||||
};
|
||||
|
||||
let accept_task = tokio::spawn({
|
||||
let backend_reply = backend_reply.clone();
|
||||
let expected_probe_len = probe.len();
|
||||
async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 5];
|
||||
let mut buf = vec![0u8; expected_probe_len];
|
||||
stream.read_exact(&mut buf).await.unwrap();
|
||||
stream.write_all(&backend_reply).await.unwrap();
|
||||
}
|
||||
@@ -93,6 +99,7 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
|
||||
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.censorship.mask_shape_hardening = false;
|
||||
|
||||
if matches!(class, ProbeClass::PlainWebBaseline) {
|
||||
cfg.general.modes.classic = false;
|
||||
@@ -129,11 +136,6 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
|
||||
false,
|
||||
));
|
||||
|
||||
let probe = match class {
|
||||
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
|
||||
ProbeClass::PlainWebBaseline => plain_web_probe(),
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
client_side.write_all(&probe).await.unwrap();
|
||||
client_side.shutdown().await.unwrap();
|
||||
@@ -169,11 +171,16 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
|
||||
let front_addr = front_listener.local_addr().unwrap();
|
||||
|
||||
let backend_reply = REPLY_404.to_vec();
|
||||
let probe = match class {
|
||||
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
|
||||
ProbeClass::PlainWebBaseline => plain_web_probe(),
|
||||
};
|
||||
let mask_accept_task = tokio::spawn({
|
||||
let backend_reply = backend_reply.clone();
|
||||
let expected_probe_len = probe.len();
|
||||
async move {
|
||||
let (mut stream, _) = mask_listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 5];
|
||||
let mut buf = vec![0u8; expected_probe_len];
|
||||
stream.read_exact(&mut buf).await.unwrap();
|
||||
stream.write_all(&backend_reply).await.unwrap();
|
||||
}
|
||||
@@ -187,6 +194,7 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
|
||||
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.censorship.mask_shape_hardening = false;
|
||||
|
||||
if matches!(class, ProbeClass::PlainWebBaseline) {
|
||||
cfg.general.modes.classic = false;
|
||||
@@ -239,11 +247,6 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
|
||||
})
|
||||
};
|
||||
|
||||
let probe = match class {
|
||||
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
|
||||
ProbeClass::PlainWebBaseline => plain_web_probe(),
|
||||
};
|
||||
|
||||
let mut client = TcpStream::connect(front_addr).await.unwrap();
|
||||
let started = Instant::now();
|
||||
client.write_all(&probe).await.unwrap();
|
||||
|
||||
@@ -34,6 +34,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -35,6 +35,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -49,6 +49,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -1338,6 +1338,7 @@ async fn direct_relay_abort_midflight_releases_route_gauge() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1448,6 +1449,7 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1570,6 +1572,7 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
@@ -1803,6 +1806,7 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
100,
|
||||
@@ -1897,6 +1901,7 @@ async fn adversarial_direct_relay_cutover_integrity() {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
100,
|
||||
|
||||
@@ -22,6 +22,7 @@ async fn adversarial_delayed_interface_lookup_does_not_consume_outcome_floor_bud
|
||||
|
||||
let refresh_lock = LOCAL_INTERFACE_REFRESH_LOCK.get_or_init(|| AsyncMutex::new(()));
|
||||
let held_refresh_guard = refresh_lock.lock().await;
|
||||
reset_local_interface_enumerations_for_tests();
|
||||
|
||||
let (mut client, server) = duplex(1024);
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
use super::*;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
#[test]
|
||||
fn blackhat_registry_poison_recovers_with_fail_closed_reset_and_pressure_accounting() {
|
||||
fn blackhat_registry_stale_order_entry_is_skipped_and_pressure_accounting_continues() {
|
||||
let shared = ProxySharedState::new();
|
||||
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
|
||||
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
let mut guard = shared
|
||||
.middle_relay
|
||||
.relay_idle_registry
|
||||
.lock()
|
||||
.expect("registry lock must be acquired before poison");
|
||||
guard.by_conn_id.insert(
|
||||
999,
|
||||
RelayIdleCandidateMeta {
|
||||
mark_order_seq: 1,
|
||||
mark_pressure_seq: 0,
|
||||
},
|
||||
);
|
||||
guard.ordered.insert((1, 999));
|
||||
panic!("intentional poison for idle-registry recovery");
|
||||
}));
|
||||
shared
|
||||
.middle_relay
|
||||
.relay_idle_registry
|
||||
.ordered
|
||||
.lock()
|
||||
.insert((0, 999));
|
||||
|
||||
// Helper lock must recover from poison, reset stale state, and continue.
|
||||
assert!(mark_relay_idle_candidate_for_testing(shared.as_ref(), 42));
|
||||
assert_eq!(
|
||||
oldest_relay_idle_candidate_for_testing(shared.as_ref()),
|
||||
Some(42)
|
||||
Some(999)
|
||||
);
|
||||
|
||||
let before = relay_pressure_event_seq_for_testing(shared.as_ref());
|
||||
@@ -35,25 +23,43 @@ fn blackhat_registry_poison_recovers_with_fail_closed_reset_and_pressure_account
|
||||
let after = relay_pressure_event_seq_for_testing(shared.as_ref());
|
||||
assert!(
|
||||
after > before,
|
||||
"pressure accounting must still advance after poison"
|
||||
"pressure accounting must still advance with stale ordered entries"
|
||||
);
|
||||
|
||||
let mut seen_pressure_seq = before;
|
||||
assert!(maybe_evict_idle_candidate_on_pressure_for_testing(
|
||||
shared.as_ref(),
|
||||
42,
|
||||
&mut seen_pressure_seq,
|
||||
&Stats::new()
|
||||
));
|
||||
assert_eq!(
|
||||
oldest_relay_idle_candidate_for_testing(shared.as_ref()),
|
||||
None
|
||||
);
|
||||
|
||||
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_state_helper_must_reset_poisoned_registry_for_deterministic_fifo_tests() {
|
||||
fn clear_state_helper_must_reset_split_registry_for_deterministic_fifo_tests() {
|
||||
let shared = ProxySharedState::new();
|
||||
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
|
||||
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
let _guard = shared
|
||||
.middle_relay
|
||||
.relay_idle_registry
|
||||
.lock()
|
||||
.expect("registry lock must be acquired before poison");
|
||||
panic!("intentional poison while lock held");
|
||||
}));
|
||||
shared.middle_relay.relay_idle_registry.by_conn_id.insert(
|
||||
999,
|
||||
RelayIdleCandidateMeta {
|
||||
mark_order_seq: 1,
|
||||
mark_pressure_seq: 0,
|
||||
},
|
||||
);
|
||||
shared
|
||||
.middle_relay
|
||||
.relay_idle_registry
|
||||
.ordered
|
||||
.lock()
|
||||
.insert((1, 999));
|
||||
set_relay_pressure_state_for_testing(shared.as_ref(), 7, 6);
|
||||
|
||||
clear_relay_idle_pressure_state_for_testing_in_shared(shared.as_ref());
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ fn new_client_harness() -> ClientHarness {
|
||||
selected_scope: String::new(),
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
prefer: None,
|
||||
}],
|
||||
1,
|
||||
1,
|
||||
|
||||
@@ -3,7 +3,9 @@ use crate::error::ProxyError;
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::BufferPool;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf, duplex};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
|
||||
266
src/stats/core_counters.rs
Normal file
266
src/stats/core_counters.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use super::*;
|
||||
|
||||
impl Stats {
|
||||
pub fn apply_telemetry_policy(&self, policy: TelemetryPolicy) {
|
||||
self.telemetry_core_enabled
|
||||
.store(policy.core_enabled, Ordering::Relaxed);
|
||||
self.telemetry_user_enabled
|
||||
.store(policy.user_enabled, Ordering::Relaxed);
|
||||
self.telemetry_me_level
|
||||
.store(policy.me_level.as_u8(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn telemetry_policy(&self) -> TelemetryPolicy {
|
||||
TelemetryPolicy {
|
||||
core_enabled: self.telemetry_core_enabled(),
|
||||
user_enabled: self.telemetry_user_enabled(),
|
||||
me_level: self.telemetry_me_level(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_connects_all(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.connects_all.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_connects_bad_with_class(&self, class: &'static str) {
|
||||
if !self.telemetry_core_enabled() {
|
||||
return;
|
||||
}
|
||||
self.connects_bad.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = self
|
||||
.connects_bad_classes
|
||||
.entry(class)
|
||||
.or_insert_with(|| AtomicU64::new(0));
|
||||
entry.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn increment_connects_bad(&self) {
|
||||
self.increment_connects_bad_with_class("other");
|
||||
}
|
||||
|
||||
pub fn increment_handshake_failure_class(&self, class: &'static str) {
|
||||
if !self.telemetry_core_enabled() {
|
||||
return;
|
||||
}
|
||||
let entry = self
|
||||
.handshake_failure_classes
|
||||
.entry(class)
|
||||
.or_insert_with(|| AtomicU64::new(0));
|
||||
entry.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn increment_current_connections_direct(&self) {
|
||||
self.current_connections_direct
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn decrement_current_connections_direct(&self) {
|
||||
Self::decrement_atomic_saturating(&self.current_connections_direct);
|
||||
}
|
||||
pub fn increment_current_connections_me(&self) {
|
||||
self.current_connections_me.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn decrement_current_connections_me(&self) {
|
||||
Self::decrement_atomic_saturating(&self.current_connections_me);
|
||||
}
|
||||
|
||||
pub fn acquire_direct_connection_lease(self: &Arc<Self>) -> RouteConnectionLease {
|
||||
self.increment_current_connections_direct();
|
||||
RouteConnectionLease::new(self.clone(), RouteConnectionGauge::Direct)
|
||||
}
|
||||
|
||||
pub fn acquire_me_connection_lease(self: &Arc<Self>) -> RouteConnectionLease {
|
||||
self.increment_current_connections_me();
|
||||
RouteConnectionLease::new(self.clone(), RouteConnectionGauge::Middle)
|
||||
}
|
||||
|
||||
pub(super) fn decrement_route_cutover_parked_direct(&self) {
|
||||
Self::decrement_atomic_saturating(&self.route_cutover_parked_direct_current);
|
||||
}
|
||||
|
||||
pub(super) fn decrement_route_cutover_parked_middle(&self) {
|
||||
Self::decrement_atomic_saturating(&self.route_cutover_parked_middle_current);
|
||||
}
|
||||
|
||||
pub fn acquire_direct_cutover_park_lease(self: &Arc<Self>) -> RouteCutoverParkLease {
|
||||
self.route_cutover_parked_direct_current
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.route_cutover_parked_direct_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
RouteCutoverParkLease::new(self.clone(), RouteCutoverParkGauge::Direct)
|
||||
}
|
||||
|
||||
pub fn acquire_middle_cutover_park_lease(self: &Arc<Self>) -> RouteCutoverParkLease {
|
||||
self.route_cutover_parked_middle_current
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.route_cutover_parked_middle_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
RouteCutoverParkLease::new(self.clone(), RouteCutoverParkGauge::Middle)
|
||||
}
|
||||
pub fn increment_handshake_timeouts(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.handshake_timeouts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_accept_permit_timeout_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.accept_permit_timeout_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_conntrack_control_enabled(&self, enabled: bool) {
|
||||
self.conntrack_control_enabled_gauge
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_conntrack_control_available(&self, available: bool) {
|
||||
self.conntrack_control_available_gauge
|
||||
.store(available, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_conntrack_pressure_active(&self, active: bool) {
|
||||
self.conntrack_pressure_active_gauge
|
||||
.store(active, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_conntrack_event_queue_depth(&self, depth: u64) {
|
||||
self.conntrack_event_queue_depth_gauge
|
||||
.store(depth, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_conntrack_rule_apply_ok(&self, ok: bool) {
|
||||
self.conntrack_rule_apply_ok_gauge
|
||||
.store(ok, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn increment_conntrack_delete_attempt_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.conntrack_delete_attempt_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_conntrack_delete_success_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.conntrack_delete_success_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_conntrack_delete_not_found_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.conntrack_delete_not_found_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_conntrack_delete_error_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.conntrack_delete_error_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_conntrack_close_event_drop_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.conntrack_close_event_drop_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_upstream_connect_attempt_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.upstream_connect_attempt_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_upstream_connect_success_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.upstream_connect_success_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_upstream_connect_fail_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.upstream_connect_fail_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_upstream_connect_failfast_hard_error_total(&self) {
|
||||
if self.telemetry_core_enabled() {
|
||||
self.upstream_connect_failfast_hard_error_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn observe_upstream_connect_attempts_per_request(&self, attempts: u32) {
|
||||
if !self.telemetry_core_enabled() {
|
||||
return;
|
||||
}
|
||||
match attempts {
|
||||
0 => {}
|
||||
1 => {
|
||||
self.upstream_connect_attempts_bucket_1
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
2 => {
|
||||
self.upstream_connect_attempts_bucket_2
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
3..=4 => {
|
||||
self.upstream_connect_attempts_bucket_3_4
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
_ => {
|
||||
self.upstream_connect_attempts_bucket_gt_4
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn observe_upstream_connect_duration_ms(&self, duration_ms: u64, success: bool) {
|
||||
if !self.telemetry_core_enabled() {
|
||||
return;
|
||||
}
|
||||
let bucket = match duration_ms {
|
||||
0..=100 => 0u8,
|
||||
101..=500 => 1u8,
|
||||
501..=1000 => 2u8,
|
||||
_ => 3u8,
|
||||
};
|
||||
match (success, bucket) {
|
||||
(true, 0) => {
|
||||
self.upstream_connect_duration_success_bucket_le_100ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(true, 1) => {
|
||||
self.upstream_connect_duration_success_bucket_101_500ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(true, 2) => {
|
||||
self.upstream_connect_duration_success_bucket_501_1000ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(true, _) => {
|
||||
self.upstream_connect_duration_success_bucket_gt_1000ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(false, 0) => {
|
||||
self.upstream_connect_duration_fail_bucket_le_100ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(false, 1) => {
|
||||
self.upstream_connect_duration_fail_bucket_101_500ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(false, 2) => {
|
||||
self.upstream_connect_duration_fail_bucket_501_1000ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
(false, _) => {
|
||||
self.upstream_connect_duration_fail_bucket_gt_1000ms
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
283
src/stats/core_getters.rs
Normal file
283
src/stats/core_getters.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use super::*;
|
||||
|
||||
impl Stats {
|
||||
pub fn get_connects_all(&self) -> u64 {
|
||||
self.connects_all.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_connects_bad(&self) -> u64 {
|
||||
self.connects_bad.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_connects_bad_class_counts(&self) -> Vec<(String, u64)> {
|
||||
let mut out: Vec<(String, u64)> = self
|
||||
.connects_bad_classes
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.key().to_string(),
|
||||
entry.value().load(Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get_handshake_failure_class_counts(&self) -> Vec<(String, u64)> {
|
||||
let mut out: Vec<(String, u64)> = self
|
||||
.handshake_failure_classes
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.key().to_string(),
|
||||
entry.value().load(Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get_accept_permit_timeout_total(&self) -> u64 {
|
||||
self.accept_permit_timeout_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_current_connections_direct(&self) -> u64 {
|
||||
self.current_connections_direct.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_current_connections_me(&self) -> u64 {
|
||||
self.current_connections_me.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_route_cutover_parked_direct_current(&self) -> u64 {
|
||||
self.route_cutover_parked_direct_current
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_route_cutover_parked_middle_current(&self) -> u64 {
|
||||
self.route_cutover_parked_middle_current
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_route_cutover_parked_direct_total(&self) -> u64 {
|
||||
self.route_cutover_parked_direct_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_route_cutover_parked_middle_total(&self) -> u64 {
|
||||
self.route_cutover_parked_middle_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_current_connections_total(&self) -> u64 {
|
||||
self.get_current_connections_direct()
|
||||
.saturating_add(self.get_current_connections_me())
|
||||
}
|
||||
pub fn get_conntrack_control_enabled(&self) -> bool {
|
||||
self.conntrack_control_enabled_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_control_available(&self) -> bool {
|
||||
self.conntrack_control_available_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_pressure_active(&self) -> bool {
|
||||
self.conntrack_pressure_active_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_event_queue_depth(&self) -> u64 {
|
||||
self.conntrack_event_queue_depth_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_rule_apply_ok(&self) -> bool {
|
||||
self.conntrack_rule_apply_ok_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_delete_attempt_total(&self) -> u64 {
|
||||
self.conntrack_delete_attempt_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_delete_success_total(&self) -> u64 {
|
||||
self.conntrack_delete_success_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_delete_not_found_total(&self) -> u64 {
|
||||
self.conntrack_delete_not_found_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_delete_error_total(&self) -> u64 {
|
||||
self.conntrack_delete_error_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_conntrack_close_event_drop_total(&self) -> u64 {
|
||||
self.conntrack_close_event_drop_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_keepalive_sent(&self) -> u64 {
|
||||
self.me_keepalive_sent.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_keepalive_failed(&self) -> u64 {
|
||||
self.me_keepalive_failed.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_keepalive_pong(&self) -> u64 {
|
||||
self.me_keepalive_pong.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_keepalive_timeout(&self) -> u64 {
|
||||
self.me_keepalive_timeout.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_rpc_proxy_req_signal_sent_total(&self) -> u64 {
|
||||
self.me_rpc_proxy_req_signal_sent_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_rpc_proxy_req_signal_failed_total(&self) -> u64 {
|
||||
self.me_rpc_proxy_req_signal_failed_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_rpc_proxy_req_signal_skipped_no_meta_total(&self) -> u64 {
|
||||
self.me_rpc_proxy_req_signal_skipped_no_meta_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_rpc_proxy_req_signal_response_total(&self) -> u64 {
|
||||
self.me_rpc_proxy_req_signal_response_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_rpc_proxy_req_signal_close_sent_total(&self) -> u64 {
|
||||
self.me_rpc_proxy_req_signal_close_sent_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_reconnect_attempts(&self) -> u64 {
|
||||
self.me_reconnect_attempts.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_reconnect_success(&self) -> u64 {
|
||||
self.me_reconnect_success.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_handshake_reject_total(&self) -> u64 {
|
||||
self.me_handshake_reject_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_reader_eof_total(&self) -> u64 {
|
||||
self.me_reader_eof_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_idle_close_by_peer_total(&self) -> u64 {
|
||||
self.me_idle_close_by_peer_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_relay_idle_soft_mark_total(&self) -> u64 {
|
||||
self.relay_idle_soft_mark_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_relay_idle_hard_close_total(&self) -> u64 {
|
||||
self.relay_idle_hard_close_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_relay_pressure_evict_total(&self) -> u64 {
|
||||
self.relay_pressure_evict_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_relay_protocol_desync_close_total(&self) -> u64 {
|
||||
self.relay_protocol_desync_close_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_crc_mismatch(&self) -> u64 {
|
||||
self.me_crc_mismatch.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_seq_mismatch(&self) -> u64 {
|
||||
self.me_seq_mismatch.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_endpoint_quarantine_total(&self) -> u64 {
|
||||
self.me_endpoint_quarantine_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_endpoint_quarantine_unexpected_total(&self) -> u64 {
|
||||
self.me_endpoint_quarantine_unexpected_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_endpoint_quarantine_draining_suppressed_total(&self) -> u64 {
|
||||
self.me_endpoint_quarantine_draining_suppressed_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_kdf_drift_total(&self) -> u64 {
|
||||
self.me_kdf_drift_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_kdf_port_only_drift_total(&self) -> u64 {
|
||||
self.me_kdf_port_only_drift_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_hardswap_pending_reuse_total(&self) -> u64 {
|
||||
self.me_hardswap_pending_reuse_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_hardswap_pending_ttl_expired_total(&self) -> u64 {
|
||||
self.me_hardswap_pending_ttl_expired_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_outage_enter_total(&self) -> u64 {
|
||||
self.me_single_endpoint_outage_enter_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_outage_exit_total(&self) -> u64 {
|
||||
self.me_single_endpoint_outage_exit_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_outage_reconnect_attempt_total(&self) -> u64 {
|
||||
self.me_single_endpoint_outage_reconnect_attempt_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_outage_reconnect_success_total(&self) -> u64 {
|
||||
self.me_single_endpoint_outage_reconnect_success_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_quarantine_bypass_total(&self) -> u64 {
|
||||
self.me_single_endpoint_quarantine_bypass_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_shadow_rotate_total(&self) -> u64 {
|
||||
self.me_single_endpoint_shadow_rotate_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_single_endpoint_shadow_rotate_skipped_quarantine_total(&self) -> u64 {
|
||||
self.me_single_endpoint_shadow_rotate_skipped_quarantine_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_mode_switch_total(&self) -> u64 {
|
||||
self.me_floor_mode_switch_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_mode_switch_static_to_adaptive_total(&self) -> u64 {
|
||||
self.me_floor_mode_switch_static_to_adaptive_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_mode_switch_adaptive_to_static_total(&self) -> u64 {
|
||||
self.me_floor_mode_switch_adaptive_to_static_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_cpu_cores_detected_gauge(&self) -> u64 {
|
||||
self.me_floor_cpu_cores_detected_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_cpu_cores_effective_gauge(&self) -> u64 {
|
||||
self.me_floor_cpu_cores_effective_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_global_cap_raw_gauge(&self) -> u64 {
|
||||
self.me_floor_global_cap_raw_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_global_cap_effective_gauge(&self) -> u64 {
|
||||
self.me_floor_global_cap_effective_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_target_writers_total_gauge(&self) -> u64 {
|
||||
self.me_floor_target_writers_total_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_active_cap_configured_gauge(&self) -> u64 {
|
||||
self.me_floor_active_cap_configured_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_active_cap_effective_gauge(&self) -> u64 {
|
||||
self.me_floor_active_cap_effective_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_warm_cap_configured_gauge(&self) -> u64 {
|
||||
self.me_floor_warm_cap_configured_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_warm_cap_effective_gauge(&self) -> u64 {
|
||||
self.me_floor_warm_cap_effective_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writers_active_current_gauge(&self) -> u64 {
|
||||
self.me_writers_active_current_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writers_warm_current_gauge(&self) -> u64 {
|
||||
self.me_writers_warm_current_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_cap_block_total(&self) -> u64 {
|
||||
self.me_floor_cap_block_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_swap_idle_total(&self) -> u64 {
|
||||
self.me_floor_swap_idle_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_floor_swap_idle_failed_total(&self) -> u64 {
|
||||
self.me_floor_swap_idle_failed_total.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
208
src/stats/helpers.rs
Normal file
208
src/stats/helpers.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::config::MeTelemetryLevel;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Stats {
|
||||
pub(super) fn telemetry_me_level(&self) -> MeTelemetryLevel {
|
||||
MeTelemetryLevel::from_u8(self.telemetry_me_level.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
pub(super) fn telemetry_core_enabled(&self) -> bool {
|
||||
self.telemetry_core_enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(super) fn telemetry_user_enabled(&self) -> bool {
|
||||
self.telemetry_user_enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(super) fn telemetry_me_allows_normal(&self) -> bool {
|
||||
self.telemetry_me_level().allows_normal()
|
||||
}
|
||||
|
||||
pub(super) fn telemetry_me_allows_debug(&self) -> bool {
|
||||
self.telemetry_me_level().allows_debug()
|
||||
}
|
||||
|
||||
pub(super) fn decrement_atomic_saturating(counter: &AtomicU64) {
|
||||
let mut current = counter.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current == 0 {
|
||||
break;
|
||||
}
|
||||
match counter.compare_exchange_weak(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
pub(super) fn refresh_cached_epoch_secs(&self) -> u64 {
|
||||
let now_epoch_secs = Self::now_epoch_secs();
|
||||
self.cached_epoch_secs
|
||||
.store(now_epoch_secs, Ordering::Relaxed);
|
||||
now_epoch_secs
|
||||
}
|
||||
|
||||
pub(super) fn cached_epoch_secs(&self) -> u64 {
|
||||
let cached = self.cached_epoch_secs.load(Ordering::Relaxed);
|
||||
if cached != 0 {
|
||||
return cached;
|
||||
}
|
||||
self.refresh_cached_epoch_secs()
|
||||
}
|
||||
|
||||
pub(super) fn touch_user_stats(&self, stats: &UserStats) {
|
||||
stats
|
||||
.last_seen_epoch_secs
|
||||
.store(self.cached_epoch_secs(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn get_or_create_user_stats_handle(&self, user: &str) -> Arc<UserStats> {
|
||||
if let Some(existing) = self.user_stats.get(user) {
|
||||
let handle = Arc::clone(existing.value());
|
||||
self.touch_user_stats(handle.as_ref());
|
||||
return handle;
|
||||
}
|
||||
|
||||
let entry = self.user_stats.entry(user.to_string()).or_default();
|
||||
if entry.last_seen_epoch_secs.load(Ordering::Relaxed) == 0 {
|
||||
self.touch_user_stats(entry.value().as_ref());
|
||||
}
|
||||
Arc::clone(entry.value())
|
||||
}
|
||||
|
||||
pub(crate) async fn run_periodic_user_stats_maintenance(self: Arc<Self>) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
self.maybe_cleanup_user_stats();
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn add_user_octets_from_handle(&self, user_stats: &UserStats, bytes: u64) {
|
||||
if !self.telemetry_user_enabled() {
|
||||
return;
|
||||
}
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats
|
||||
.octets_from_client
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn add_user_octets_to_handle(&self, user_stats: &UserStats, bytes: u64) {
|
||||
if !self.telemetry_user_enabled() {
|
||||
return;
|
||||
}
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats
|
||||
.octets_to_client
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn add_user_traffic_from_handle(&self, user_stats: &UserStats, bytes: u64) {
|
||||
if !self.telemetry_user_enabled() {
|
||||
return;
|
||||
}
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats
|
||||
.octets_from_client
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
user_stats.msgs_from_client.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn add_user_traffic_to_handle(&self, user_stats: &UserStats, bytes: u64) {
|
||||
if !self.telemetry_user_enabled() {
|
||||
return;
|
||||
}
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats
|
||||
.octets_to_client
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
user_stats.msgs_to_client.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn increment_user_msgs_from_handle(&self, user_stats: &UserStats) {
|
||||
if !self.telemetry_user_enabled() {
|
||||
return;
|
||||
}
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats.msgs_from_client.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn increment_user_msgs_to_handle(&self, user_stats: &UserStats) {
|
||||
if !self.telemetry_user_enabled() {
|
||||
return;
|
||||
}
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats.msgs_to_client.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Charges already committed bytes in a post-I/O path.
|
||||
///
|
||||
/// This helper is intentionally separate from `quota_try_reserve` to avoid
|
||||
/// mixing reserve and post-charge on a single I/O event.
|
||||
#[inline]
|
||||
pub(crate) fn quota_charge_post_write(&self, user_stats: &UserStats, bytes: u64) -> u64 {
|
||||
self.touch_user_stats(user_stats);
|
||||
user_stats
|
||||
.quota_used
|
||||
.fetch_add(bytes, Ordering::Relaxed)
|
||||
.saturating_add(bytes)
|
||||
}
|
||||
|
||||
pub(super) fn maybe_cleanup_user_stats(&self) {
|
||||
const USER_STATS_CLEANUP_INTERVAL_SECS: u64 = 60;
|
||||
const USER_STATS_IDLE_TTL_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
let now_epoch_secs = self.refresh_cached_epoch_secs();
|
||||
let last_cleanup_epoch_secs = self
|
||||
.user_stats_last_cleanup_epoch_secs
|
||||
.load(Ordering::Relaxed);
|
||||
if now_epoch_secs.saturating_sub(last_cleanup_epoch_secs) < USER_STATS_CLEANUP_INTERVAL_SECS
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.user_stats_last_cleanup_epoch_secs
|
||||
.compare_exchange(
|
||||
last_cleanup_epoch_secs,
|
||||
now_epoch_secs,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.user_stats.retain(|_, stats| {
|
||||
if stats.curr_connects.load(Ordering::Relaxed) > 0 {
|
||||
return true;
|
||||
}
|
||||
let last_seen_epoch_secs = stats.last_seen_epoch_secs.load(Ordering::Relaxed);
|
||||
now_epoch_secs.saturating_sub(last_seen_epoch_secs) <= USER_STATS_IDLE_TTL_SECS
|
||||
});
|
||||
}
|
||||
}
|
||||
442
src/stats/me_counters.rs
Normal file
442
src/stats/me_counters.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
use super::*;
|
||||
|
||||
impl Stats {
|
||||
pub fn increment_me_keepalive_sent(&self) {
|
||||
if self.telemetry_me_allows_debug() {
|
||||
self.me_keepalive_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_keepalive_failed(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_keepalive_failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_keepalive_pong(&self) {
|
||||
if self.telemetry_me_allows_debug() {
|
||||
self.me_keepalive_pong.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_keepalive_timeout(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_keepalive_timeout.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_keepalive_timeout_by(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_keepalive_timeout
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_rpc_proxy_req_signal_sent_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_rpc_proxy_req_signal_sent_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_rpc_proxy_req_signal_failed_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_rpc_proxy_req_signal_failed_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_rpc_proxy_req_signal_skipped_no_meta_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_rpc_proxy_req_signal_skipped_no_meta_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_rpc_proxy_req_signal_response_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_rpc_proxy_req_signal_response_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_rpc_proxy_req_signal_close_sent_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_rpc_proxy_req_signal_close_sent_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_reconnect_attempt(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_reconnect_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_reconnect_success(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_reconnect_success.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_handshake_reject_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_handshake_reject_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_handshake_error_code(&self, code: i32) {
|
||||
if !self.telemetry_me_allows_normal() {
|
||||
return;
|
||||
}
|
||||
let entry = self
|
||||
.me_handshake_error_codes
|
||||
.entry(code)
|
||||
.or_insert_with(|| AtomicU64::new(0));
|
||||
entry.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn increment_me_reader_eof_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_reader_eof_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_idle_close_by_peer_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_idle_close_by_peer_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_relay_idle_soft_mark_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.relay_idle_soft_mark_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_relay_idle_hard_close_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.relay_idle_hard_close_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_relay_pressure_evict_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.relay_pressure_evict_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_relay_protocol_desync_close_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.relay_protocol_desync_close_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_crc_mismatch(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_crc_mismatch.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_seq_mismatch(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_seq_mismatch.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_route_drop_no_conn(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_route_drop_no_conn.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_route_drop_channel_closed(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_route_drop_channel_closed
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_route_drop_queue_full(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_route_drop_queue_full
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_route_drop_queue_full_base(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_route_drop_queue_full_base
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_route_drop_queue_full_high(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_route_drop_queue_full_high
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_pressure_state_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_pressure_state_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_active_flows_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_active_flows_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_queued_bytes_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_queued_bytes_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_standing_flows_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_standing_flows_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn set_me_fair_backpressured_flows_gauge(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_fair_backpressured_flows_gauge
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_scheduler_rounds_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_scheduler_rounds_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_deficit_grants_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_deficit_grants_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_deficit_skips_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_deficit_skips_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_enqueue_rejects_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_enqueue_rejects_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_shed_drops_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_shed_drops_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_penalties_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_penalties_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_fair_downstream_stalls_total(&self, value: u64) {
|
||||
if self.telemetry_me_allows_normal() && value > 0 {
|
||||
self.me_fair_downstream_stalls_total
|
||||
.fetch_add(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_batches_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_batches_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_d2c_batch_frames_total(&self, frames: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_batch_frames_total
|
||||
.fetch_add(frames, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_d2c_batch_bytes_total(&self, bytes: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_batch_bytes_total
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_flush_reason(&self, reason: MeD2cFlushReason) {
|
||||
if !self.telemetry_me_allows_normal() {
|
||||
return;
|
||||
}
|
||||
match reason {
|
||||
MeD2cFlushReason::QueueDrain => {
|
||||
self.me_d2c_flush_reason_queue_drain_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cFlushReason::BatchFrames => {
|
||||
self.me_d2c_flush_reason_batch_frames_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cFlushReason::BatchBytes => {
|
||||
self.me_d2c_flush_reason_batch_bytes_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cFlushReason::MaxDelay => {
|
||||
self.me_d2c_flush_reason_max_delay_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cFlushReason::AckImmediate => {
|
||||
self.me_d2c_flush_reason_ack_immediate_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cFlushReason::Close => {
|
||||
self.me_d2c_flush_reason_close_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_data_frames_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_data_frames_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_ack_frames_total(&self) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_ack_frames_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn add_me_d2c_payload_bytes_total(&self, bytes: u64) {
|
||||
if self.telemetry_me_allows_normal() {
|
||||
self.me_d2c_payload_bytes_total
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_write_mode(&self, mode: MeD2cWriteMode) {
|
||||
if !self.telemetry_me_allows_normal() {
|
||||
return;
|
||||
}
|
||||
match mode {
|
||||
MeD2cWriteMode::Coalesced => {
|
||||
self.me_d2c_write_mode_coalesced_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cWriteMode::Split => {
|
||||
self.me_d2c_write_mode_split_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_quota_reject_total(&self, stage: MeD2cQuotaRejectStage) {
|
||||
if !self.telemetry_me_allows_normal() {
|
||||
return;
|
||||
}
|
||||
match stage {
|
||||
MeD2cQuotaRejectStage::PreWrite => {
|
||||
self.me_d2c_quota_reject_pre_write_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
MeD2cQuotaRejectStage::PostWrite => {
|
||||
self.me_d2c_quota_reject_post_write_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn observe_me_d2c_frame_buf_shrink(&self, bytes_freed: u64) {
|
||||
if !self.telemetry_me_allows_normal() {
|
||||
return;
|
||||
}
|
||||
self.me_d2c_frame_buf_shrink_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.me_d2c_frame_buf_shrink_bytes_total
|
||||
.fetch_add(bytes_freed, Ordering::Relaxed);
|
||||
}
|
||||
pub fn observe_me_d2c_batch_frames(&self, frames: u64) {
|
||||
if !self.telemetry_me_allows_debug() {
|
||||
return;
|
||||
}
|
||||
match frames {
|
||||
0 => {}
|
||||
1 => {
|
||||
self.me_d2c_batch_frames_bucket_1
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
2..=4 => {
|
||||
self.me_d2c_batch_frames_bucket_2_4
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
5..=8 => {
|
||||
self.me_d2c_batch_frames_bucket_5_8
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
9..=16 => {
|
||||
self.me_d2c_batch_frames_bucket_9_16
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
17..=32 => {
|
||||
self.me_d2c_batch_frames_bucket_17_32
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
_ => {
|
||||
self.me_d2c_batch_frames_bucket_gt_32
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn observe_me_d2c_batch_bytes(&self, bytes: u64) {
|
||||
if !self.telemetry_me_allows_debug() {
|
||||
return;
|
||||
}
|
||||
match bytes {
|
||||
0..=1024 => {
|
||||
self.me_d2c_batch_bytes_bucket_0_1k
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
1025..=4096 => {
|
||||
self.me_d2c_batch_bytes_bucket_1k_4k
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
4097..=16_384 => {
|
||||
self.me_d2c_batch_bytes_bucket_4k_16k
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
16_385..=65_536 => {
|
||||
self.me_d2c_batch_bytes_bucket_16k_64k
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
65_537..=131_072 => {
|
||||
self.me_d2c_batch_bytes_bucket_64k_128k
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
_ => {
|
||||
self.me_d2c_batch_bytes_bucket_gt_128k
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn observe_me_d2c_flush_duration_us(&self, duration_us: u64) {
|
||||
if !self.telemetry_me_allows_debug() {
|
||||
return;
|
||||
}
|
||||
match duration_us {
|
||||
0..=50 => {
|
||||
self.me_d2c_flush_duration_us_bucket_0_50
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
51..=200 => {
|
||||
self.me_d2c_flush_duration_us_bucket_51_200
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
201..=1000 => {
|
||||
self.me_d2c_flush_duration_us_bucket_201_1000
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
1001..=5000 => {
|
||||
self.me_d2c_flush_duration_us_bucket_1001_5000
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
5001..=20_000 => {
|
||||
self.me_d2c_flush_duration_us_bucket_5001_20000
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
_ => {
|
||||
self.me_d2c_flush_duration_us_bucket_gt_20000
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_batch_timeout_armed_total(&self) {
|
||||
if self.telemetry_me_allows_debug() {
|
||||
self.me_d2c_batch_timeout_armed_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn increment_me_d2c_batch_timeout_fired_total(&self) {
|
||||
if self.telemetry_me_allows_debug() {
|
||||
self.me_d2c_batch_timeout_fired_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
398
src/stats/me_getters.rs
Normal file
398
src/stats/me_getters.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
use super::*;
|
||||
|
||||
impl Stats {
|
||||
pub fn get_me_handshake_error_code_counts(&self) -> Vec<(i32, u64)> {
|
||||
let mut out: Vec<(i32, u64)> = self
|
||||
.me_handshake_error_codes
|
||||
.iter()
|
||||
.map(|entry| (*entry.key(), entry.value().load(Ordering::Relaxed)))
|
||||
.collect();
|
||||
out.sort_by_key(|(code, _)| *code);
|
||||
out
|
||||
}
|
||||
pub fn get_me_route_drop_no_conn(&self) -> u64 {
|
||||
self.me_route_drop_no_conn.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_route_drop_channel_closed(&self) -> u64 {
|
||||
self.me_route_drop_channel_closed.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_route_drop_queue_full(&self) -> u64 {
|
||||
self.me_route_drop_queue_full.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_route_drop_queue_full_base(&self) -> u64 {
|
||||
self.me_route_drop_queue_full_base.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_route_drop_queue_full_high(&self) -> u64 {
|
||||
self.me_route_drop_queue_full_high.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_pressure_state_gauge(&self) -> u64 {
|
||||
self.me_fair_pressure_state_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_active_flows_gauge(&self) -> u64 {
|
||||
self.me_fair_active_flows_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_queued_bytes_gauge(&self) -> u64 {
|
||||
self.me_fair_queued_bytes_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_standing_flows_gauge(&self) -> u64 {
|
||||
self.me_fair_standing_flows_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_backpressured_flows_gauge(&self) -> u64 {
|
||||
self.me_fair_backpressured_flows_gauge
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_scheduler_rounds_total(&self) -> u64 {
|
||||
self.me_fair_scheduler_rounds_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_deficit_grants_total(&self) -> u64 {
|
||||
self.me_fair_deficit_grants_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_deficit_skips_total(&self) -> u64 {
|
||||
self.me_fair_deficit_skips_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_enqueue_rejects_total(&self) -> u64 {
|
||||
self.me_fair_enqueue_rejects_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_shed_drops_total(&self) -> u64 {
|
||||
self.me_fair_shed_drops_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_penalties_total(&self) -> u64 {
|
||||
self.me_fair_penalties_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_fair_downstream_stalls_total(&self) -> u64 {
|
||||
self.me_fair_downstream_stalls_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batches_total(&self) -> u64 {
|
||||
self.me_d2c_batches_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_total(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_total(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_reason_queue_drain_total(&self) -> u64 {
|
||||
self.me_d2c_flush_reason_queue_drain_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_reason_batch_frames_total(&self) -> u64 {
|
||||
self.me_d2c_flush_reason_batch_frames_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_reason_batch_bytes_total(&self) -> u64 {
|
||||
self.me_d2c_flush_reason_batch_bytes_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_reason_max_delay_total(&self) -> u64 {
|
||||
self.me_d2c_flush_reason_max_delay_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_reason_ack_immediate_total(&self) -> u64 {
|
||||
self.me_d2c_flush_reason_ack_immediate_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_reason_close_total(&self) -> u64 {
|
||||
self.me_d2c_flush_reason_close_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_data_frames_total(&self) -> u64 {
|
||||
self.me_d2c_data_frames_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_ack_frames_total(&self) -> u64 {
|
||||
self.me_d2c_ack_frames_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_payload_bytes_total(&self) -> u64 {
|
||||
self.me_d2c_payload_bytes_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_write_mode_coalesced_total(&self) -> u64 {
|
||||
self.me_d2c_write_mode_coalesced_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_write_mode_split_total(&self) -> u64 {
|
||||
self.me_d2c_write_mode_split_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_quota_reject_pre_write_total(&self) -> u64 {
|
||||
self.me_d2c_quota_reject_pre_write_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_quota_reject_post_write_total(&self) -> u64 {
|
||||
self.me_d2c_quota_reject_post_write_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_frame_buf_shrink_total(&self) -> u64 {
|
||||
self.me_d2c_frame_buf_shrink_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_frame_buf_shrink_bytes_total(&self) -> u64 {
|
||||
self.me_d2c_frame_buf_shrink_bytes_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_bucket_1(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_bucket_1.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_bucket_2_4(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_bucket_2_4.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_bucket_5_8(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_bucket_5_8.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_bucket_9_16(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_bucket_9_16.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_bucket_17_32(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_bucket_17_32
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_frames_bucket_gt_32(&self) -> u64 {
|
||||
self.me_d2c_batch_frames_bucket_gt_32
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_bucket_0_1k(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_bucket_0_1k.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_bucket_1k_4k(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_bucket_1k_4k.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_bucket_4k_16k(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_bucket_4k_16k
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_bucket_16k_64k(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_bucket_16k_64k
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_bucket_64k_128k(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_bucket_64k_128k
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_bytes_bucket_gt_128k(&self) -> u64 {
|
||||
self.me_d2c_batch_bytes_bucket_gt_128k
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_duration_us_bucket_0_50(&self) -> u64 {
|
||||
self.me_d2c_flush_duration_us_bucket_0_50
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_duration_us_bucket_51_200(&self) -> u64 {
|
||||
self.me_d2c_flush_duration_us_bucket_51_200
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_duration_us_bucket_201_1000(&self) -> u64 {
|
||||
self.me_d2c_flush_duration_us_bucket_201_1000
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_duration_us_bucket_1001_5000(&self) -> u64 {
|
||||
self.me_d2c_flush_duration_us_bucket_1001_5000
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_duration_us_bucket_5001_20000(&self) -> u64 {
|
||||
self.me_d2c_flush_duration_us_bucket_5001_20000
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_flush_duration_us_bucket_gt_20000(&self) -> u64 {
|
||||
self.me_d2c_flush_duration_us_bucket_gt_20000
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_buffer_pool_pooled_gauge(&self) -> u64 {
|
||||
self.buffer_pool_pooled_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_buffer_pool_allocated_gauge(&self) -> u64 {
|
||||
self.buffer_pool_allocated_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_buffer_pool_in_use_gauge(&self) -> u64 {
|
||||
self.buffer_pool_in_use_gauge.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_me_c2me_send_full_total(&self) -> u64 {
|
||||
self.me_c2me_send_full_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_me_c2me_send_high_water_total(&self) -> u64 {
|
||||
self.me_c2me_send_high_water_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_me_c2me_send_timeout_total(&self) -> u64 {
|
||||
self.me_c2me_send_timeout_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_timeout_armed_total(&self) -> u64 {
|
||||
self.me_d2c_batch_timeout_armed_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_d2c_batch_timeout_fired_total(&self) -> u64 {
|
||||
self.me_d2c_batch_timeout_fired_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_sorted_rr_success_try_total(&self) -> u64 {
|
||||
self.me_writer_pick_sorted_rr_success_try_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_sorted_rr_success_fallback_total(&self) -> u64 {
|
||||
self.me_writer_pick_sorted_rr_success_fallback_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_sorted_rr_full_total(&self) -> u64 {
|
||||
self.me_writer_pick_sorted_rr_full_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_sorted_rr_closed_total(&self) -> u64 {
|
||||
self.me_writer_pick_sorted_rr_closed_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_sorted_rr_no_candidate_total(&self) -> u64 {
|
||||
self.me_writer_pick_sorted_rr_no_candidate_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_p2c_success_try_total(&self) -> u64 {
|
||||
self.me_writer_pick_p2c_success_try_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_p2c_success_fallback_total(&self) -> u64 {
|
||||
self.me_writer_pick_p2c_success_fallback_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_p2c_full_total(&self) -> u64 {
|
||||
self.me_writer_pick_p2c_full_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_p2c_closed_total(&self) -> u64 {
|
||||
self.me_writer_pick_p2c_closed_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_p2c_no_candidate_total(&self) -> u64 {
|
||||
self.me_writer_pick_p2c_no_candidate_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_blocking_fallback_total(&self) -> u64 {
|
||||
self.me_writer_pick_blocking_fallback_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_pick_mode_switch_total(&self) -> u64 {
|
||||
self.me_writer_pick_mode_switch_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_socks_kdf_strict_reject(&self) -> u64 {
|
||||
self.me_socks_kdf_strict_reject.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_socks_kdf_compat_fallback(&self) -> u64 {
|
||||
self.me_socks_kdf_compat_fallback.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_secure_padding_invalid(&self) -> u64 {
|
||||
self.secure_padding_invalid.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_total(&self) -> u64 {
|
||||
self.desync_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_full_logged(&self) -> u64 {
|
||||
self.desync_full_logged.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_suppressed(&self) -> u64 {
|
||||
self.desync_suppressed.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_frames_bucket_0(&self) -> u64 {
|
||||
self.desync_frames_bucket_0.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_frames_bucket_1_2(&self) -> u64 {
|
||||
self.desync_frames_bucket_1_2.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_frames_bucket_3_10(&self) -> u64 {
|
||||
self.desync_frames_bucket_3_10.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_desync_frames_bucket_gt_10(&self) -> u64 {
|
||||
self.desync_frames_bucket_gt_10.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_pool_swap_total(&self) -> u64 {
|
||||
self.pool_swap_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_pool_drain_active(&self) -> u64 {
|
||||
self.pool_drain_active.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_pool_force_close_total(&self) -> u64 {
|
||||
self.pool_force_close_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_pool_stale_pick_total(&self) -> u64 {
|
||||
self.pool_stale_pick_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_removed_total(&self) -> u64 {
|
||||
self.me_writer_removed_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_removed_unexpected_total(&self) -> u64 {
|
||||
self.me_writer_removed_unexpected_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_refill_triggered_total(&self) -> u64 {
|
||||
self.me_refill_triggered_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_refill_skipped_inflight_total(&self) -> u64 {
|
||||
self.me_refill_skipped_inflight_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_refill_failed_total(&self) -> u64 {
|
||||
self.me_refill_failed_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_restored_same_endpoint_total(&self) -> u64 {
|
||||
self.me_writer_restored_same_endpoint_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_writer_restored_fallback_total(&self) -> u64 {
|
||||
self.me_writer_restored_fallback_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_no_writer_failfast_total(&self) -> u64 {
|
||||
self.me_no_writer_failfast_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_hybrid_timeout_total(&self) -> u64 {
|
||||
self.me_hybrid_timeout_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_async_recovery_trigger_total(&self) -> u64 {
|
||||
self.me_async_recovery_trigger_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_inline_recovery_total(&self) -> u64 {
|
||||
self.me_inline_recovery_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_ip_reservation_rollback_tcp_limit_total(&self) -> u64 {
|
||||
self.ip_reservation_rollback_tcp_limit_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_ip_reservation_rollback_quota_limit_total(&self) -> u64 {
|
||||
self.ip_reservation_rollback_quota_limit_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_quota_refund_bytes_total(&self) -> u64 {
|
||||
self.quota_refund_bytes_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_quota_contention_total(&self) -> u64 {
|
||||
self.quota_contention_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_quota_contention_timeout_total(&self) -> u64 {
|
||||
self.quota_contention_timeout_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_quota_acquire_cancelled_total(&self) -> u64 {
|
||||
self.quota_acquire_cancelled_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_quota_write_fail_bytes_total(&self) -> u64 {
|
||||
self.quota_write_fail_bytes_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_quota_write_fail_events_total(&self) -> u64 {
|
||||
self.quota_write_fail_events_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_child_join_timeout_total(&self) -> u64 {
|
||||
self.me_child_join_timeout_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_me_child_abort_total(&self) -> u64 {
|
||||
self.me_child_abort_total.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_flow_wait_middle_rate_limit_total(&self) -> u64 {
|
||||
self.flow_wait_middle_rate_limit_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_flow_wait_middle_rate_limit_cancelled_total(&self) -> u64 {
|
||||
self.flow_wait_middle_rate_limit_cancelled_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_flow_wait_middle_rate_limit_ms_total(&self) -> u64 {
|
||||
self.flow_wait_middle_rate_limit_ms_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn get_session_drop_fallback_total(&self) -> u64 {
|
||||
self.session_drop_fallback_total.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
2961
src/stats/mod.rs
2961
src/stats/mod.rs
File diff suppressed because it is too large
Load Diff
356
src/stats/replay.rs
Normal file
356
src/stats/replay.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lru::LruCache;
|
||||
use parking_lot::Mutex;
|
||||
use tracing::debug;
|
||||
|
||||
const REPLAY_INLINE_KEY_CAP: usize = 48;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ReplayKey {
|
||||
Inline {
|
||||
len: u8,
|
||||
bytes: [u8; REPLAY_INLINE_KEY_CAP],
|
||||
},
|
||||
Heap(Arc<[u8]>),
|
||||
}
|
||||
|
||||
impl ReplayKey {
|
||||
fn from_slice(key: &[u8]) -> Self {
|
||||
if key.len() <= REPLAY_INLINE_KEY_CAP {
|
||||
let mut bytes = [0u8; REPLAY_INLINE_KEY_CAP];
|
||||
bytes[..key.len()].copy_from_slice(key);
|
||||
return Self::Inline {
|
||||
len: key.len() as u8,
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
Self::Heap(Arc::from(key))
|
||||
}
|
||||
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Inline { len, bytes } => &bytes[..*len as usize],
|
||||
Self::Heap(bytes) => bytes.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Borrow<[u8]> for ReplayKey {
|
||||
fn borrow(&self) -> &[u8] {
|
||||
self.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ReplayKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.as_slice() == other.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ReplayKey {}
|
||||
|
||||
impl Hash for ReplayKey {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.as_slice().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReplayChecker {
|
||||
handshake_shards: Vec<Mutex<ReplayShard>>,
|
||||
tls_shards: Vec<Mutex<ReplayShard>>,
|
||||
shard_mask: usize,
|
||||
window: Duration,
|
||||
tls_window: Duration,
|
||||
checks: AtomicU64,
|
||||
hits: AtomicU64,
|
||||
additions: AtomicU64,
|
||||
cleanups: AtomicU64,
|
||||
}
|
||||
|
||||
struct ReplayEntry {
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
struct ReplayShard {
|
||||
cache: LruCache<ReplayKey, ReplayEntry>,
|
||||
queue: VecDeque<(Instant, ReplayKey, u64)>,
|
||||
seq_counter: u64,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl ReplayShard {
|
||||
fn new(cap: NonZeroUsize) -> Self {
|
||||
Self {
|
||||
cache: LruCache::new(cap),
|
||||
queue: VecDeque::with_capacity(cap.get()),
|
||||
seq_counter: 0,
|
||||
capacity: cap.get(),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_seq(&mut self) -> u64 {
|
||||
self.seq_counter += 1;
|
||||
self.seq_counter
|
||||
}
|
||||
|
||||
fn cleanup(&mut self, now: Instant, window: Duration) {
|
||||
if window.is_zero() {
|
||||
self.cache.clear();
|
||||
self.queue.clear();
|
||||
return;
|
||||
}
|
||||
let cutoff = now.checked_sub(window).unwrap_or(now);
|
||||
|
||||
while let Some((ts, _, _)) = self.queue.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
self.evict_queue_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_queue_front(&mut self) {
|
||||
let Some((_, key, queue_seq)) = self.queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(entry) = self.cache.peek(key.as_slice())
|
||||
&& entry.seq == queue_seq
|
||||
{
|
||||
self.cache.pop(key.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&mut self, key: &[u8], now: Instant, window: Duration) -> bool {
|
||||
if window.is_zero() {
|
||||
return false;
|
||||
}
|
||||
self.cleanup(now, window);
|
||||
self.cache.get(key).is_some()
|
||||
}
|
||||
|
||||
fn add_owned(&mut self, key: ReplayKey, now: Instant, window: Duration) {
|
||||
if window.is_zero() {
|
||||
return;
|
||||
}
|
||||
self.cleanup(now, window);
|
||||
if self.cache.peek(key.as_slice()).is_some() {
|
||||
return;
|
||||
}
|
||||
while self.queue.len() >= self.capacity {
|
||||
self.evict_queue_front();
|
||||
}
|
||||
|
||||
let seq = self.next_seq();
|
||||
self.cache.put(key.clone(), ReplayEntry { seq });
|
||||
self.queue.push_back((now, key, seq));
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplayChecker {
|
||||
pub fn new(total_capacity: usize, window: Duration) -> Self {
|
||||
const MIN_TLS_REPLAY_WINDOW: Duration = Duration::from_secs(120);
|
||||
let num_shards = 64;
|
||||
let shard_capacity = (total_capacity / num_shards).max(1);
|
||||
let cap = NonZeroUsize::new(shard_capacity).unwrap();
|
||||
|
||||
let mut handshake_shards = Vec::with_capacity(num_shards);
|
||||
let mut tls_shards = Vec::with_capacity(num_shards);
|
||||
for _ in 0..num_shards {
|
||||
handshake_shards.push(Mutex::new(ReplayShard::new(cap)));
|
||||
tls_shards.push(Mutex::new(ReplayShard::new(cap)));
|
||||
}
|
||||
|
||||
Self {
|
||||
handshake_shards,
|
||||
tls_shards,
|
||||
shard_mask: num_shards - 1,
|
||||
window,
|
||||
tls_window: window.max(MIN_TLS_REPLAY_WINDOW),
|
||||
checks: AtomicU64::new(0),
|
||||
hits: AtomicU64::new(0),
|
||||
additions: AtomicU64::new(0),
|
||||
cleanups: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_shard_idx(&self, key: &[u8]) -> usize {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
(hasher.finish() as usize) & self.shard_mask
|
||||
}
|
||||
|
||||
fn check_and_add_internal(
|
||||
&self,
|
||||
data: &[u8],
|
||||
shards: &[Mutex<ReplayShard>],
|
||||
window: Duration,
|
||||
) -> bool {
|
||||
self.checks.fetch_add(1, Ordering::Relaxed);
|
||||
let idx = self.get_shard_idx(data);
|
||||
let owned_key = ReplayKey::from_slice(data);
|
||||
let mut shard = shards[idx].lock();
|
||||
let now = Instant::now();
|
||||
let found = shard.check(data, now, window);
|
||||
if found {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
shard.add_owned(owned_key, now, window);
|
||||
self.additions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn check_only_internal(
|
||||
&self,
|
||||
data: &[u8],
|
||||
shards: &[Mutex<ReplayShard>],
|
||||
window: Duration,
|
||||
) -> bool {
|
||||
self.checks.fetch_add(1, Ordering::Relaxed);
|
||||
let idx = self.get_shard_idx(data);
|
||||
let mut shard = shards[idx].lock();
|
||||
let found = shard.check(data, Instant::now(), window);
|
||||
if found {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn add_only(&self, data: &[u8], shards: &[Mutex<ReplayShard>], window: Duration) {
|
||||
self.additions.fetch_add(1, Ordering::Relaxed);
|
||||
let idx = self.get_shard_idx(data);
|
||||
let owned_key = ReplayKey::from_slice(data);
|
||||
let mut shard = shards[idx].lock();
|
||||
shard.add_owned(owned_key, Instant::now(), window);
|
||||
}
|
||||
|
||||
pub fn check_and_add_handshake(&self, data: &[u8]) -> bool {
|
||||
self.check_and_add_internal(data, &self.handshake_shards, self.window)
|
||||
}
|
||||
|
||||
pub fn check_and_add_tls_digest(&self, data: &[u8]) -> bool {
|
||||
self.check_and_add_internal(data, &self.tls_shards, self.tls_window)
|
||||
}
|
||||
|
||||
pub fn check_handshake(&self, data: &[u8]) -> bool {
|
||||
self.check_and_add_handshake(data)
|
||||
}
|
||||
|
||||
pub fn add_handshake(&self, data: &[u8]) {
|
||||
self.add_only(data, &self.handshake_shards, self.window)
|
||||
}
|
||||
|
||||
pub fn check_tls_digest(&self, data: &[u8]) -> bool {
|
||||
self.check_only_internal(data, &self.tls_shards, self.tls_window)
|
||||
}
|
||||
|
||||
pub fn add_tls_digest(&self, data: &[u8]) {
|
||||
self.add_only(data, &self.tls_shards, self.tls_window)
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> ReplayStats {
|
||||
let mut total_entries = 0;
|
||||
let mut total_queue_len = 0;
|
||||
for shard in &self.handshake_shards {
|
||||
let s = shard.lock();
|
||||
total_entries += s.cache.len();
|
||||
total_queue_len += s.queue.len();
|
||||
}
|
||||
for shard in &self.tls_shards {
|
||||
let s = shard.lock();
|
||||
total_entries += s.cache.len();
|
||||
total_queue_len += s.queue.len();
|
||||
}
|
||||
|
||||
ReplayStats {
|
||||
total_entries,
|
||||
total_queue_len,
|
||||
total_checks: self.checks.load(Ordering::Relaxed),
|
||||
total_hits: self.hits.load(Ordering::Relaxed),
|
||||
total_additions: self.additions.load(Ordering::Relaxed),
|
||||
total_cleanups: self.cleanups.load(Ordering::Relaxed),
|
||||
num_shards: self.handshake_shards.len() + self.tls_shards.len(),
|
||||
window_secs: self.window.as_secs(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_periodic_cleanup(&self) {
|
||||
let interval = if self.window.as_secs() > 60 {
|
||||
Duration::from_secs(30)
|
||||
} else {
|
||||
Duration::from_secs((self.window.as_secs().max(1) / 2).max(1))
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
|
||||
let now = Instant::now();
|
||||
let mut cleaned = 0usize;
|
||||
|
||||
for shard_mutex in &self.handshake_shards {
|
||||
let mut shard = shard_mutex.lock();
|
||||
let before = shard.len();
|
||||
shard.cleanup(now, self.window);
|
||||
let after = shard.len();
|
||||
cleaned += before.saturating_sub(after);
|
||||
}
|
||||
for shard_mutex in &self.tls_shards {
|
||||
let mut shard = shard_mutex.lock();
|
||||
let before = shard.len();
|
||||
shard.cleanup(now, self.tls_window);
|
||||
let after = shard.len();
|
||||
cleaned += before.saturating_sub(after);
|
||||
}
|
||||
|
||||
self.cleanups.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if cleaned > 0 {
|
||||
debug!(cleaned = cleaned, "Replay checker: periodic cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReplayStats {
|
||||
pub total_entries: usize,
|
||||
pub total_queue_len: usize,
|
||||
pub total_checks: u64,
|
||||
pub total_hits: u64,
|
||||
pub total_additions: u64,
|
||||
pub total_cleanups: u64,
|
||||
pub num_shards: usize,
|
||||
pub window_secs: u64,
|
||||
}
|
||||
|
||||
impl ReplayStats {
|
||||
pub fn hit_rate(&self) -> f64 {
|
||||
if self.total_checks == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.total_hits as f64 / self.total_checks as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ghost_ratio(&self) -> f64 {
|
||||
if self.total_entries == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.total_queue_len as f64 / self.total_entries as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user