Compare commits

...

21 Commits

Author SHA1 Message Date
Alexey 5eaccee68f Merge pull request #851 from telemt/flow
Advanced Relay Mode + Hardened KDF-Tuple + Updated secure padding expectations for VersionD + Shared MTProto framing and ME address + Fix config API corrupting nested sub-tables on save + Harden masking fallback and frame readers after flow sync + MSS Raising after handshake to cut pps + Bugfixes
2026-06-24 01:08:13 +03:00
Alexey f56895feac Bump -> 3.4.19 2026-06-24 00:53:01 +03:00
Alexey 87c82c2a63 Add bounded file logging rotation and retention #832
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-24 00:16:02 +03:00
Alexey 7e5a1841b1 Skip netfilter cleanup without CAP_NET_ADMIN by #845
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-24 00:11:11 +03:00
Alexey e994ddea00 Accept advertised logging flags in CLI by #848
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-23 23:20:12 +03:00
Alexey 5e5c82a0ad Merge pull request #837 from absolute-Idee/fix/issue-821
Update Dockerfile (issue#821)
2026-06-23 12:35:47 +03:00
Alexey 840713a359 Merge pull request #847 from AndreyOsipuk/feat/client-mss-relay
feat(server): client_mss_bulk — fragment only the handshake, restore MSS for bulk data (cuts pps)
2026-06-20 22:10:04 +03:00
Andrey Osipuk 50b67a93d6 feat(server): client_mss_bulk — raise MSS after handshake to cut pps
client_mss (e.g. "tspu", MSS=92) fragments the whole connection to evade
DPI on the ServerHello, but it also fragments bulk payload, multiplying
outgoing packets-per-second ~10x. On hosts whose abuse detection counts
pps (not bandwidth) this trips packet-flood limits.

Add an optional [server].client_mss_bulk: keep the low client_mss for the
handshake (ServerHello stays fragmented => DPI bypass intact), then raise
the client socket MSS to client_mss_bulk once the connection enters the
post-handshake (bulk transfer) phase, so bulk data uses normal-size
segments and pps drops back to normal. Same preset/int grammar as
client_mss. Opt-in: when unset, the handshake MSS is kept for the whole
connection (unchanged behavior).

Linux-only (setsockopt TCP_MAXSEG via raw fd, mirroring TCP_USER_TIMEOUT);
no-op on other unix. Documented in CONFIG_PARAMS.{en,ru}.
2026-06-19 11:11:01 +03:00
Alexey 72800e4aa7 Harden masking fallback and frame readers after flow sync
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-17 21:48:57 +03:00
Alexey 49742d38a7 Merge pull request #843 from amirotin/fix/config-api-section-corruption
Fix config API corrupting nested sub-tables on save
2026-06-15 20:55:56 +03:00
Mirotin Artem 869d8517a0 Rustfmt 2026-06-15 10:40:45 +03:00
Mirotin Artem e82ce634d6 Use tokio::fs for I/O in config API tests
The save and patch paths under test are async, so the tests now use tokio::fs instead of blocking std::fs. The config_store tests also switch to tempfile::tempdir() for panic-safe cleanup instead of manual remove_dir_all.
2026-06-15 10:05:09 +03:00
Mirotin Artem f1f46fac42 Fix config API corrupting nested sub-tables on save
render_top_level_section serialized a section in isolation, so nested sub-tables ([general.links], [general.modes]) were emitted as bare [links]/[modes] top-level headers and duplicated on load. Serialize the section inside a wrapper keyed by its name to keep dotted headers.

find_toml_table_bounds only spanned the first contiguous block, leaving scattered sub-tables behind as duplicates on repeated saves. Replace it with find_all_table_blocks and drop every block belonging to the section during upsert.

show_link is a legacy top-level scalar/array, not a [table]; the upsert machinery appended a bare key at EOF (landing inside the previous table) and duplicated it on repeat. Remove it from EDITABLE_SECTIONS; the editable general.links.show sub-table covers the case.

Add tests for dotted sub-tables, idempotent saves, non-contiguous layouts, show_link rejection, and integer/float/string coercion of public_port.
2026-06-15 09:49:47 +03:00
Alexey 37d0184a0b Implement shared MTProto framing and ME address role separation
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-15 08:50:08 +03:00
Alexey d81d7dba62 Rustfmt 2026-06-14 19:59:06 +03:00
Alexey 04b8d8365c Account for full-word paddings in roundtrip tests 2026-06-14 19:38:54 +03:00
Alexey 2e26bfb86e Updated secure padding expectations for VersionD
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-14 16:33:41 +03:00
Alexey d414c73c9b Hardened KDF-Tuple + NAT Probing + Paddings
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-14 16:15:41 +03:00
Alexey d1a97fe10f Update README.md 2026-06-14 12:03:55 +03:00
Alexey b153782597 More efficient Relay Mode
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-13 23:22:50 +03:00
Urbaev Maxim b95956d141 Update Dockerfile (issue#821) 2026-06-11 23:37:09 +03:00
51 changed files with 2924 additions and 961 deletions
Generated
+93 -270
View File
@@ -105,9 +105,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]] [[package]]
name = "asn1-rs" name = "asn1-rs"
@@ -222,9 +222,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "blake3" name = "blake3"
@@ -251,9 +251,9 @@ dependencies = [
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.0" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
@@ -281,9 +281,9 @@ checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]] [[package]]
name = "cast" name = "cast"
@@ -302,9 +302,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.63" version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -361,9 +361,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@@ -745,9 +745,6 @@ name = "deranged"
version = "0.5.8" version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "digest" name = "digest"
@@ -766,7 +763,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer 0.12.0", "block-buffer 0.12.1",
"crypto-common 0.2.2", "crypto-common 0.2.2",
] ]
@@ -875,12 +872,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1038,16 +1029,14 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasip2",
"wasip3",
] ]
[[package]] [[package]]
@@ -1062,9 +1051,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.14" version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1101,9 +1090,6 @@ name = "hashbrown"
version = "0.15.5" version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -1113,7 +1099,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash 0.2.0", "foldhash",
] ]
[[package]] [[package]]
@@ -1200,9 +1186,9 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.1" version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [ dependencies = [
"bytes", "bytes",
"itoa", "itoa",
@@ -1256,9 +1242,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.0" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1420,12 +1406,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -1455,15 +1435,13 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
name = "inotify" name = "inotify"
version = "0.11.1" version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"inotify-sys", "inotify-sys",
@@ -1593,13 +1571,12 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.99" version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
"once_cell",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -1625,9 +1602,9 @@ dependencies = [
[[package]] [[package]]
name = "kqueue" name = "kqueue"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [ dependencies = [
"kqueue-sys", "kqueue-sys",
"libc", "libc",
@@ -1649,12 +1626,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -1684,9 +1655,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.30" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "lru" name = "lru"
@@ -1730,9 +1701,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]] [[package]]
name = "memoffset" name = "memoffset"
@@ -2101,16 +2072,6 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -2147,9 +2108,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
@@ -2167,9 +2128,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"bytes", "bytes",
@@ -2203,9 +2164,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -2239,7 +2200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [ dependencies = [
"chacha20 0.10.0", "chacha20 0.10.0",
"getrandom 0.4.2", "getrandom 0.4.3",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@@ -2317,9 +2278,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -2340,9 +2301,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.10" version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
@@ -2451,9 +2412,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",
@@ -2466,9 +2427,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-native-certs" name = "rustls-native-certs"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [ dependencies = [
"openssl-probe", "openssl-probe",
"rustls-pki-types", "rustls-pki-types",
@@ -2832,9 +2793,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -2901,9 +2862,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2938,7 +2899,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "telemt" name = "telemt"
version = "3.4.18" version = "3.4.19"
dependencies = [ dependencies = [
"aes", "aes",
"anyhow", "anyhow",
@@ -3007,7 +2968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.2", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -3044,12 +3005,11 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.51"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
@@ -3059,15 +3019,15 @@ dependencies = [
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.8" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.27" version = "0.2.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@@ -3380,9 +3340,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unarray" name = "unarray"
@@ -3396,12 +3356,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "universal-hash" name = "universal-hash"
version = "0.5.1" version = "0.5.1"
@@ -3438,11 +3392,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.3",
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -3495,27 +3449,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.3+wasi-0.2.9" version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [ dependencies = [
"wit-bindgen 0.57.1", "wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
] ]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.122" version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -3526,9 +3471,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.72" version = "0.4.75"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -3536,9 +3481,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.122" version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -3546,9 +3491,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.122" version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -3559,52 +3504,18 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.122" version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.99" version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -3622,18 +3533,18 @@ dependencies = [
[[package]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.7" version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.7" version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -3907,100 +3818,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"
@@ -4038,9 +3861,9 @@ dependencies = [
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [ dependencies = [
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
@@ -4061,18 +3884,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.49" version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.49" version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4102,18 +3925,18 @@ dependencies = [
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [ dependencies = [
"zeroize_derive", "zeroize_derive",
] ]
[[package]] [[package]]
name = "zeroize_derive" name = "zeroize_derive"
version = "1.4.3" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+57 -57
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telemt" name = "telemt"
version = "3.4.18" version = "3.4.19"
edition = "2024" edition = "2024"
[features] [features]
@@ -8,89 +8,89 @@ redteam_offline_expected_fail = []
[dependencies] [dependencies]
# C # C
libc = "0.2" libc = "0.2.186"
# Async runtime # Async runtime
tokio = { version = "1.42", features = ["full", "tracing"] } tokio = { version = "1.52.3", features = ["full", "tracing"] }
tokio-util = { version = "0.7", features = ["full"] } tokio-util = { version = "0.7.18", features = ["full"] }
# Crypto # Crypto
aes = "0.8" aes = "0.8.4"
ctr = "0.9" ctr = "0.9.2"
cbc = "0.1" cbc = "0.1.2"
sha2 = "0.10" sha2 = "0.10.9"
sha1 = "0.10" sha1 = "0.10.6"
md-5 = "0.10" md-5 = "0.10.6"
hmac = "0.12" hmac = "0.12.1"
crc32fast = "1.4" crc32fast = "1.5.0"
crc32c = "0.6" crc32c = "0.6.8"
zeroize = { version = "1.8", features = ["derive"] } zeroize = { version = "1.9.0", features = ["derive"] }
subtle = "2.6" subtle = "2.6.1"
static_assertions = "1.1" static_assertions = "1.1.0"
ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] } ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] }
# Network # Network
socket2 = { version = "0.6", features = ["all"] } socket2 = { version = "0.6.4", features = ["all"] }
nix = { version = "0.31", default-features = false, features = [ nix = { version = "0.31.3", default-features = false, features = [
"net", "net",
"user", "user",
"process", "process",
"fs", "fs",
"signal", "signal",
] } ] }
shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] } shadowsocks = { version = "1.24.0", features = ["aead-cipher-2022"] }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0.150"
toml = "1.0" toml = "1.1"
x509-parser = "0.18" x509-parser = "0.18.1"
# Utils # Utils
bytes = "1.9" bytes = "1.12.0"
thiserror = "2.0" thiserror = "2.0.18"
tracing = "0.1" tracing = "0.1.44"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-appender = "0.2" tracing-appender = "0.2.5"
parking_lot = "0.12" parking_lot = "0.12.5"
dashmap = "6.1" dashmap = "6.2.1"
arc-swap = "1.7" arc-swap = "1.9.1"
lru = "0.16" lru = "0.16.4"
rand = "0.10" rand = "0.10.1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
hex = "0.4" hex = "0.4.3"
base64 = "0.22" base64 = "0.22.1"
url = "2.5" url = "2.5.8"
regex = "1.11" regex = "1.12.4"
crossbeam-queue = "0.3" crossbeam-queue = "0.3.12"
num-bigint = "0.4" num-bigint = "0.4.6"
num-traits = "0.2" num-traits = "0.2.19"
x25519-dalek = "2" x25519-dalek = "2.0.1"
anyhow = "1.0" anyhow = "1.0.102"
# HTTP # HTTP
reqwest = { version = "0.13", features = ["rustls"], default-features = false } reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
notify = "8.2" notify = "8.2.0"
ipnetwork = { version = "0.21", features = ["serde"] } ipnetwork = { version = "0.21.1", features = ["serde"] }
hyper = { version = "1", features = ["server", "http1"] } hyper = { version = "1.10.1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
http-body-util = "0.1" http-body-util = "0.1.3"
httpdate = "1.0" httpdate = "1.0.3"
tokio-rustls = { version = "0.26", default-features = false, features = [ tokio-rustls = { version = "0.26.4", default-features = false, features = [
"tls12", "tls12",
] } ] }
rustls = { version = "0.23", default-features = false, features = [ rustls = { version = "0.23.41", default-features = false, features = [
"std", "std",
"tls12", "tls12",
"ring", "ring",
] } ] }
webpki-roots = "1.0" webpki-roots = "1.0.8"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4.5"
criterion = "0.8" criterion = "0.8.2"
proptest = "1.4" proptest = "1.11.0"
futures = "0.3" futures = "0.3.32"
tempfile = "3.27.0" tempfile = "3.27.0"
[[bench]] [[bench]]
+3 -3
View File
@@ -73,7 +73,7 @@ RUN set -eux; \
WORKDIR /app WORKDIR /app
COPY --from=minimal /telemt /app/telemt COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml COPY ./config/config.toml /app/config.toml
EXPOSE 443 9090 9091 EXPOSE 443 9090 9091
@@ -99,7 +99,7 @@ RUN set -eux; \
WORKDIR /app WORKDIR /app
COPY --from=minimal /telemt /app/telemt COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml COPY ./config/config.toml /app/config.toml
EXPOSE 443 9090 9091 EXPOSE 443 9090 9091
@@ -116,7 +116,7 @@ FROM gcr.io/distroless/static-debian12 AS prod
WORKDIR /app WORKDIR /app
COPY --from=minimal /telemt /app/telemt COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml COPY ./config/config.toml /app/config.toml
USER nonroot:nonroot USER nonroot:nonroot
+1 -1
View File
@@ -8,7 +8,7 @@
> >
> From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions" > From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions"
> >
> Telegram Clients TLS ClientHello has been banned by JA3 Fingerprint: we are already looking for ways to solve this problem > Telegram Clients TLS ClientHello has been banned by JA4/JA4+ Fingerprint: we are already looking for ways to solve this problem
> >
> You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf) > You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf)
+91
View File
@@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`.
# Table of contents # Table of contents
- [Top-level keys](#top-level-keys) - [Top-level keys](#top-level-keys)
- [logging](#logging)
- [general](#general) - [general](#general)
- [general.modes](#generalmodes) - [general.modes](#generalmodes)
- [general.links](#generallinks) - [general.links](#generallinks)
@@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`.
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`include`](#include) | `String` (special directive) | — | `✔` | | [`include`](#include) | `String` (special directive) | — | `✔` |
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` | | [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
| [`logging`](#logging) | Table | default values | `✘` |
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` | | [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` | | [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` |
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` | | [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
@@ -83,6 +85,84 @@ This document lists all configuration keys accepted by `config.toml`.
default_dc = 2 default_dc = 2
``` ```
# [logging]
| Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- |
| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `` |
| [`path`](#loggingpath) | `String` | — | `` |
| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `` |
| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `` |
| [`max_files`](#loggingmax_files) | `usize` | `0` | `` |
| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `` |
## logging.destination
- **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`.
- **Description**: Selects the runtime log destination. CLI flags override this value.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
```
## logging.path
- **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty.
- **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
```
## logging.rotation
- **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`.
- **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
```
## logging.max_size_bytes
- **Constraints / validation**: `0` disables size rotation.
- **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
max_size_bytes = 104857600
```
## logging.max_files
- **Constraints / validation**: `0` disables count-based retention.
- **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
max_files = 14
```
## logging.max_age_secs
- **Constraints / validation**: `0` disables age-based retention.
- **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
max_age_secs = 1209600
```
# [general] # [general]
@@ -1806,6 +1886,7 @@ This document lists all configuration keys accepted by `config.toml`.
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` | | [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` | | [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` |
| [`client_mss`](#client_mss) | `String` | `""` | `` | | [`client_mss`](#client_mss) | `String` | `""` | `` |
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` | | [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` | | [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` |
@@ -1898,6 +1979,16 @@ This document lists all configuration keys accepted by `config.toml`.
[server] [server]
client_mss = "tspu" client_mss = "tspu"
``` ```
## client_mss_bulk
- **Constraints / validation**: `String`. Same grammar as [`client_mss`](#client_mss) (empty/omitted, presets `"extreme-low"`/`"tspu"`/`"2in8"`, or a decimal in `88..=4096`).
- **Description**: Optional bulk-phase MSS. When set, the low `client_mss` is applied only while the TLS handshake (including the DPI-inspected ServerHello) is sent; once the connection transitions to relaying, the client socket MSS is raised to `client_mss_bulk` for the bulk data phase. This keeps the anti-DPI handshake fragmentation but restores normal-size packets for payload, cutting outgoing packets-per-second by roughly the `client_mss` segment multiplier (e.g. ~10x with `"tspu"`). Useful on hosts whose abuse detection counts packets-per-second rather than bandwidth. When empty/omitted, the handshake MSS is kept for the whole connection (previous behavior). Linux only; a no-op elsewhere.
- **Example**:
```toml
[server]
client_mss = "tspu"
client_mss_bulk = "1400"
```
## proxy_protocol ## proxy_protocol
- **Constraints / validation**: `bool`. - **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. - **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header.
+11
View File
@@ -1808,6 +1808,7 @@
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` | | [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` | | [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` |
| [`client_mss`](#client_mss) | `String` | `""` | `` | | [`client_mss`](#client_mss) | `String` | `""` | `` |
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` | | [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` | | [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` |
@@ -1900,6 +1901,16 @@
[server] [server]
client_mss = "tspu" client_mss = "tspu"
``` ```
## client_mss_bulk
- **Ограничения / валидация**: `String`. Грамматика та же, что у [`client_mss`](#client_mss) (пусто/не задано, пресеты `"extreme-low"`/`"tspu"`/`"2in8"` либо десятичное число в диапазоне `88..=4096`).
- **Описание**: Необязательный MSS для bulk-фазы. Если задан, низкий `client_mss` применяется только на время TLS-handshake (включая инспектируемый DPI ServerHello); как только соединение переходит в фазу relay, MSS клиентского сокета поднимается до `client_mss_bulk` для передачи полезной нагрузки. Так сохраняется anti-DPI фрагментация handshake, но для данных возвращаются пакеты нормального размера — это снижает исходящий packets-per-second примерно во столько раз, каков segment multiplier у `client_mss` (например, ~10x для `"tspu"`). Полезно на хостингах, где abuse-детекция считает packets-per-second, а не полосу. Если пусто/не задано — MSS handshake сохраняется на всё соединение (прежнее поведение). Только Linux; на прочих платформах — no-op.
- **Пример**:
```toml
[server]
client_mss = "tspu"
client_mss_bulk = "1400"
```
## proxy_protocol ## proxy_protocol
- **Ограничения / валидация**: `bool`. - **Ограничения / валидация**: `bool`.
- **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка. - **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка.
+77
View File
@@ -313,6 +313,83 @@ mod tests {
assert_eq!(err.code, "section_not_editable"); assert_eq!(err.code, "section_not_editable");
} }
#[tokio::test]
async fn patch_rejects_show_link_section() {
// show_link is a legacy top-level scalar/array (not a [table]); it cannot
// be upserted safely and is superseded by the editable general.links.show.
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"show_link": "*"});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "section_not_editable");
}
#[tokio::test]
async fn patch_general_links_show_is_editable() {
// The supported replacement path: edit show via the general.links sub-table.
let (path, _d) = temp_config(
"[general]\nprefer_ipv6 = false\n[general.links]\nshow = \"*\"\n\
[censorship]\ntls_domain = \"a\"\n",
);
let patch: Json = serde_json::json!({"general": {"links": {"show": ["alice"]}}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(resp.changed.iter().any(|c| c == "general"));
let written = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(
parsed["general"]["links"]["show"][0].as_str(),
Some("alice"),
"{written}"
);
// No leaked top-level [links]/[modes] and no duplicate sub-tables.
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
}
#[tokio::test]
async fn patch_links_public_port_written_as_integer_not_float_or_string() {
// A JSON integer must land on disk as a bare TOML integer (443), never
// 443.0 nor "443". The write re-renders from the typed config, so the
// u16 field dictates the output format regardless of JSON quirks.
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443}}});
apply_patch_to_path(&path, &patch, None).await.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
assert!(written.contains("public_port = 443"), "{written}");
assert!(
!written.contains("443.0"),
"must not be a float:\n{written}"
);
assert!(
!written.contains("\"443\""),
"must not be a string:\n{written}"
);
let parsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(
parsed["general"]["links"]["public_port"].as_integer(),
Some(443),
"{written}"
);
}
#[tokio::test]
async fn patch_links_public_port_rejects_float() {
// 443.0 cannot deserialize into u16 -> rejected, not silently coerced.
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443.0}}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
}
#[tokio::test]
async fn patch_links_public_port_rejects_string() {
// "443" is a string, not a u16 -> rejected.
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
let patch: Json = serde_json::json!({"general": {"links": {"public_port": "443"}}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
}
#[tokio::test] #[tokio::test]
async fn patch_empty_is_rejected() { async fn patch_empty_is_rejected() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n"); let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
+201 -16
View File
@@ -105,6 +105,11 @@ pub(super) async fn save_config_to_disk(
/// - `access` : owned by the users API. /// - `access` : owned by the users API.
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners). /// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
/// - `network` : carries per-node identity (`ipv4`/`ipv6`). /// - `network` : carries per-node identity (`ipv4`/`ipv6`).
/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded
/// by the editable `general.links.show` sub-table. The
/// section-upsert machinery here only handles `[table]` /
/// `[[array-of-tables]]` blocks; a bare top-level key cannot be
/// located or replaced safely, so it is edited via `general`.
/// ///
/// A future field-level allowlist can re-admit specific safe fields /// A future field-level allowlist can re-admit specific safe fields
/// (e.g. `network.dns_overrides`) without opening the whole section. /// (e.g. `network.dns_overrides`) without opening the whole section.
@@ -113,7 +118,6 @@ pub(super) const EDITABLE_SECTIONS: &[&str] = &[
"timeouts", "timeouts",
"censorship", "censorship",
"upstreams", "upstreams",
"show_link",
"dc_overrides", "dc_overrides",
]; ];
@@ -162,10 +166,15 @@ fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String,
return Ok(out); return Ok(out);
} }
let body = toml::to_string(table) // Serialize the table *inside a wrapper keyed by `section`* so the `toml`
// crate emits correctly dotted headers for nested sub-tables, e.g.
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
// inner table alone would render bare `[modes]`/`[links]` headers, which
// would leak as duplicate top-level tables and break config load.
let mut wrapper = toml::value::Table::new();
wrapper.insert(section.to_string(), table.clone());
let mut out = toml::to_string(&toml::Value::Table(wrapper))
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?; .map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
let mut out = format!("[{}]\n", section);
out.push_str(&body);
if !out.ends_with('\n') { if !out.ends_with('\n') {
out.push('\n'); out.push('\n');
} }
@@ -328,11 +337,22 @@ fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
} }
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String { fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
if let Some((start, end)) = find_toml_table_bounds(source, table_name) { let blocks = find_all_table_blocks(source, table_name);
if let Some(&(first_start, first_end)) = blocks.first() {
// Replace the first block in place and delete any further blocks that
// also belong to this table. Telemt writes a section's sub-tables
// contiguously, but a hand-edited config may scatter them; dropping the
// extras here prevents the duplicate-table corruption that would
// otherwise break config load.
let mut out = String::with_capacity(source.len() + replacement.len()); let mut out = String::with_capacity(source.len() + replacement.len());
out.push_str(&source[..start]); out.push_str(&source[..first_start]);
out.push_str(replacement); out.push_str(replacement);
out.push_str(&source[end..]); let mut cursor = first_end;
for &(start, end) in &blocks[1..] {
out.push_str(&source[cursor..start]);
cursor = end;
}
out.push_str(&source[cursor..]);
return out; return out;
} }
@@ -347,29 +367,62 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin
out out
} }
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
/// prefixes — `access.users` must not match `access.user_enabled`.
fn header_belongs_to(header: &str, table_name: &str) -> bool {
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
Some(body) => body,
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
Some(body) => body,
None => return false,
},
};
let body = body.trim();
body == table_name
|| body
.strip_prefix(table_name)
.is_some_and(|rest| rest.starts_with('.'))
}
/// Locate the first contiguous byte range covering `table_name` and the nested
/// sub-tables immediately following it. Used for existence checks; see
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> { fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
let single = format!("[{}]", table_name); find_all_table_blocks(source, table_name).into_iter().next()
let array = format!("[[{}]]", table_name); }
/// Locate every byte range that belongs to `table_name`: the table header and
/// its nested sub-tables. Returns one range per contiguous run, so a config
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
/// ranges — letting the caller collapse them into a single rendered block.
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
let mut blocks = Vec::new();
let mut offset = 0usize; let mut offset = 0usize;
let mut start = None; let mut start: Option<usize> = None;
for line in source.split_inclusive('\n') { for line in source.split_inclusive('\n') {
// Drop any inline comment so a hand-edited header like // Drop any inline comment so a hand-edited header like
// `[censorship] # note` still matches. Section names never contain `#`. // `[censorship] # note` still matches. Section names never contain `#`.
let header = line.trim().split('#').next().unwrap_or("").trim(); let header = line.trim().split('#').next().unwrap_or("").trim();
let is_header = header.starts_with('[');
if let Some(start_offset) = start { if let Some(start_offset) = start {
let is_same_array = header == array; if is_header && !header_belongs_to(header, table_name) {
let is_new_header = header.starts_with('['); blocks.push((start_offset, offset));
if is_new_header && !is_same_array { start = None;
return Some((start_offset, offset));
} }
} else if header == single || header == array { }
if start.is_none() && header_belongs_to(header, table_name) {
start = Some(offset); start = Some(offset);
} }
offset = offset.saturating_add(line.len()); offset = offset.saturating_add(line.len());
} }
start.map(|start_offset| (start_offset, source.len())) if let Some(start_offset) = start {
blocks.push((start_offset, source.len()));
}
blocks
} }
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> { async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
@@ -467,6 +520,138 @@ mod tests {
assert!(!slice.contains("[server]")); // terminates at the next header assert!(!slice.contains("[server]")); // terminates at the next header
} }
#[tokio::test]
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
tokio::fs::write(
&path,
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
)
.await
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.general.prefer_ipv6 = true;
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
// No bare top-level [modes] / [links] headers leaked.
for line in written.lines() {
let header = line.trim();
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
}
// Sub-tables kept their dotted prefix exactly once each.
assert_eq!(
written.matches("[general.modes]").count(),
1,
"[general.modes] must appear exactly once:\n{written}"
);
assert_eq!(
written.matches("[general.links]").count(),
1,
"[general.links] must appear exactly once:\n{written}"
);
// Result parses (duplicate tables would error here).
toml::from_str::<toml::Value>(&written)
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
assert!(written.contains("[server]\nport = 443")); // untouched table kept
}
#[tokio::test]
async fn save_general_section_is_idempotent_across_repeated_saves() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
tokio::fs::write(
&path,
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
[general.links]\npublic_host = \"old.example\"\n",
)
.await
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.general.prefer_ipv6 = true;
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
assert_eq!(written.matches("[general]").count(), 1, "{written}");
toml::from_str::<toml::Value>(&written)
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
}
#[test]
fn find_bounds_spans_dotted_subtables() {
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
let bounds = find_toml_table_bounds(src, "general");
assert!(bounds.is_some(), "should locate [general] block");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[general]"));
assert!(slice.contains("[general.modes]")); // spans nested sub-tables
assert!(slice.contains("[general.links]"));
assert!(!slice.contains("[server]")); // terminates at the next unrelated header
}
#[test]
fn find_bounds_does_not_overrun_sibling_prefix() {
// access.users must not swallow access.user_enabled (dot guards the prefix).
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
let slice = &src[bounds.0..bounds.1];
assert!(slice.starts_with("[access.users]"));
assert!(!slice.contains("[access.user_enabled]"));
}
#[tokio::test]
async fn save_general_handles_non_contiguous_subtables() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
tokio::fs::write(
&path,
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
[general.modes]\ntls = true\n",
)
.await
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.general.prefer_ipv6 = true;
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(
written.matches("[general.modes]").count(),
1,
"non-contiguous [general.modes] must not duplicate:\n{written}"
);
toml::from_str::<toml::Value>(&written)
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
assert!(written.contains("[server]")); // unrelated section preserved
}
#[test] #[test]
fn render_user_rate_limits_section() { fn render_user_rate_limits_section() {
let mut cfg = ProxyConfig::default(); let mut cfg = ProxyConfig::default();
+126 -1
View File
@@ -114,6 +114,7 @@ fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String>
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"general", "general",
"logging",
"network", "network",
"server", "server",
"timeouts", "timeouts",
@@ -300,6 +301,7 @@ const SERVER_CONFIG_KEYS: &[&str] = &[
"listen_unix_sock_perm", "listen_unix_sock_perm",
"listen_tcp", "listen_tcp",
"client_mss", "client_mss",
"client_mss_bulk",
"proxy_protocol", "proxy_protocol",
"proxy_protocol_header_timeout_ms", "proxy_protocol_header_timeout_ms",
"proxy_protocol_trusted_cidrs", "proxy_protocol_trusted_cidrs",
@@ -458,6 +460,14 @@ const UPSTREAM_CONFIG_KEYS: &[&str] = &[
const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"]; const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"];
const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"]; const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"];
const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"]; const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"];
const LOGGING_CONFIG_KEYS: &[&str] = &[
"destination",
"path",
"rotation",
"max_size_bytes",
"max_files",
"max_age_secs",
];
#[derive(Debug)] #[derive(Debug)]
struct UnknownConfigKey { struct UnknownConfigKey {
@@ -499,6 +509,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
PROXY_MODES_CONFIG_KEYS, PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS, TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS, LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] { ] {
keys.extend_from_slice(group); keys.extend_from_slice(group);
} }
@@ -632,6 +643,13 @@ fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKe
&["general", "links"], &["general", "links"],
LINKS_CONFIG_KEYS, LINKS_CONFIG_KEYS,
); );
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table( check_known_table(
parsed_toml, parsed_toml,
&mut unknown, &mut unknown,
@@ -997,6 +1015,24 @@ fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
} }
} }
fn validate_logging_config(logging: &LoggingConfig) -> Result<()> {
if let Some(path) = logging.path.as_ref()
&& path.trim().is_empty()
{
return Err(ProxyError::Config(
"logging.path cannot be empty when provided".to_string(),
));
}
if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() {
return Err(ProxyError::Config(
"logging.path must be set when logging.destination=\"file\"".to_string(),
));
}
Ok(())
}
fn validate_upstreams(config: &ProxyConfig) -> Result<()> { fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| { let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| {
upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. }) upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. })
@@ -1057,13 +1093,17 @@ fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
} }
} }
// ============= Main Config ============= // Main runtime configuration loaded from TOML.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyConfig { pub struct ProxyConfig {
#[serde(default)] #[serde(default)]
pub general: GeneralConfig, pub general: GeneralConfig,
/// Runtime logging destination, rotation, and retention configuration.
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)] #[serde(default)]
pub network: NetworkConfig, pub network: NetworkConfig,
@@ -1940,6 +1980,13 @@ impl ProxyConfig {
)); ));
} }
if config.server.listen_backlog == 0 || config.server.listen_backlog > i32::MAX as u32 {
return Err(ProxyError::Config(format!(
"server.listen_backlog must be within [1, {}]",
i32::MAX
)));
}
config config
.server .server
.client_mss_value() .client_mss_value()
@@ -2280,6 +2327,7 @@ impl ProxyConfig {
.entry("203".to_string()) .entry("203".to_string())
.or_insert_with(|| vec!["91.105.192.100:443".to_string()]); .or_insert_with(|| vec!["91.105.192.100:443".to_string()]);
validate_logging_config(&config.logging)?;
validate_upstreams(&config)?; validate_upstreams(&config)?;
config.rebuild_runtime_user_auth()?; config.rebuild_runtime_user_auth()?;
@@ -2305,6 +2353,8 @@ impl ProxyConfig {
return Err(ProxyError::Config("No users configured".to_string())); return Err(ProxyError::Config("No users configured".to_string()));
} }
validate_logging_config(&self.logging)?;
if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls { if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls {
return Err(ProxyError::Config("No modes enabled".to_string())); return Err(ProxyError::Config("No modes enabled".to_string()));
} }
@@ -2401,6 +2451,21 @@ mod tests {
cfg cfg
} }
fn load_config_error_from_temp_toml(toml: &str) -> String {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("telemt_load_cfg_error_{nonce}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, toml).unwrap();
let error = ProxyConfig::load(&path).unwrap_err().to_string();
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(dir);
error
}
#[test] #[test]
fn serde_defaults_remain_unchanged_for_present_sections() { fn serde_defaults_remain_unchanged_for_present_sections() {
let toml = r#" let toml = r#"
@@ -2411,6 +2476,7 @@ mod tests {
"#; "#;
let cfg: ProxyConfig = toml::from_str(toml).unwrap(); let cfg: ProxyConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.logging, LoggingConfig::default());
assert_eq!(cfg.network.ipv6, default_network_ipv6()); assert_eq!(cfg.network.ipv6, default_network_ipv6());
assert_eq!(cfg.network.stun_use, default_true()); assert_eq!(cfg.network.stun_use, default_true());
assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback());
@@ -2578,6 +2644,65 @@ mod tests {
); );
} }
#[test]
fn logging_config_is_loaded_from_strict_config() {
let cfg = load_config_from_temp_toml(
r#"
[general]
config_strict = true
[general.modes]
classic = false
secure = false
tls = true
[logging]
destination = "file"
path = "/tmp/telemt.log"
rotation = "daily"
max_size_bytes = 1024
max_files = 3
max_age_secs = 60
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
assert_eq!(cfg.logging.destination, LoggingDestination::File);
assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log"));
assert_eq!(cfg.logging.rotation, LogRotation::Daily);
assert_eq!(cfg.logging.max_size_bytes, 1024);
assert_eq!(cfg.logging.max_files, 3);
assert_eq!(cfg.logging.max_age_secs, 60);
}
#[test]
fn file_logging_requires_path() {
let error = load_config_error_from_temp_toml(
r#"
[general.modes]
classic = false
secure = false
tls = true
[logging]
destination = "file"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
assert!(error.contains("logging.path must be set"));
}
#[test] #[test]
fn impl_defaults_are_sourced_from_default_helpers() { fn impl_defaults_are_sourced_from_default_helpers() {
let network = NetworkConfig::default(); let network = NetworkConfig::default();
@@ -95,6 +95,44 @@ max_client_frame = 16777217
remove_temp_config(&path); remove_temp_config(&path);
} }
#[test]
fn load_rejects_listen_backlog_above_i32_upper_bound() {
let path = write_temp_config(
r#"
[server]
listen_backlog = 2147483648
"#,
);
let err = ProxyConfig::load(&path).expect_err("listen_backlog above socket cap must fail");
let msg = err.to_string();
assert!(
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
"error must explain listen_backlog hard cap, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_zero_listen_backlog() {
let path = write_temp_config(
r#"
[server]
listen_backlog = 0
"#,
);
let err = ProxyConfig::load(&path).expect_err("zero listen_backlog must fail");
let msg = err.to_string();
assert!(
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
"error must explain listen_backlog lower bound, got: {msg}"
);
remove_temp_config(&path);
}
#[test] #[test]
fn load_accepts_memory_limits_at_hard_upper_bounds() { fn load_accepts_memory_limits_at_hard_upper_bounds() {
let path = write_temp_config( let path = write_temp_config(
+96 -1
View File
@@ -63,6 +63,86 @@ impl std::fmt::Display for LogLevel {
} }
} }
/// Logging output destination.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LoggingDestination {
/// Write logs to stderr.
#[default]
Stderr,
/// Write logs to syslog on Unix platforms.
Syslog,
/// Write logs to a file.
File,
}
/// Time-based log rotation interval for file logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogRotation {
/// Do not rotate logs by time.
#[default]
Never,
/// Rotate once per minute.
Minutely,
/// Rotate once per hour.
Hourly,
/// Rotate once per day.
Daily,
/// Rotate once per week.
Weekly,
}
impl LogRotation {
/// Parse a CLI rotation value.
pub fn from_cli_arg(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"never" | "none" | "off" => Some(Self::Never),
"minutely" | "minute" => Some(Self::Minutely),
"hourly" | "hour" => Some(Self::Hourly),
"daily" | "day" => Some(Self::Daily),
"weekly" | "week" => Some(Self::Weekly),
_ => None,
}
}
}
/// File logging and retention settings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggingConfig {
/// Effective logging destination.
#[serde(default)]
pub destination: LoggingDestination,
/// File path used when `destination = "file"`.
#[serde(default)]
pub path: Option<String>,
/// Time rotation interval for file logs.
#[serde(default)]
pub rotation: LogRotation,
/// Maximum active log file size before rotating. `0` disables size rotation.
#[serde(default)]
pub max_size_bytes: u64,
/// Maximum number of matching log files to keep. `0` disables count retention.
#[serde(default)]
pub max_files: usize,
/// Maximum age for rotated log files in seconds. `0` disables age retention.
#[serde(default)]
pub max_age_secs: u64,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
destination: LoggingDestination::Stderr,
path: None,
rotation: LogRotation::Never,
max_size_bytes: 0,
max_files: 0,
max_age_secs: 0,
}
}
}
/// Middle-End telemetry verbosity level. /// Middle-End telemetry verbosity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -429,7 +509,7 @@ pub struct GeneralConfig {
pub ad_tag: Option<String>, pub ad_tag: Option<String>,
/// Public IP override for middle-proxy NAT environments. /// Public IP override for middle-proxy NAT environments.
/// When set, this IP is used in ME key derivation and RPC_PROXY_REQ "our_addr". /// When set, this IP is used in ME key derivation and local address translation.
#[serde(default)] #[serde(default)]
pub middle_proxy_nat_ip: Option<IpAddr>, pub middle_proxy_nat_ip: Option<IpAddr>,
@@ -1527,6 +1607,15 @@ pub struct ServerConfig {
#[serde(default)] #[serde(default)]
pub client_mss: Option<String>, pub client_mss: Option<String>,
/// Client-facing TCP MSS to switch to AFTER the TLS handshake (ServerHello)
/// is sent. Lets `client_mss` fragment ONLY the handshake (the DPI-inspected
/// part) while the bulk transfer uses normal-size packets — avoids the ~10x
/// packets-per-second blowup that triggers anti-DDoS abuse blocks on
/// pps-policing hosts. Empty/omitted = keep the handshake MSS for the whole
/// connection (previous behavior). Same preset/int grammar as `client_mss`.
#[serde(default)]
pub client_mss_bulk: Option<String>,
/// Accept HAProxy PROXY protocol headers on incoming connections. /// Accept HAProxy PROXY protocol headers on incoming connections.
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers. /// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
#[serde(default)] #[serde(default)]
@@ -1594,6 +1683,7 @@ impl Default for ServerConfig {
listen_unix_sock_perm: None, listen_unix_sock_perm: None,
listen_tcp: None, listen_tcp: None,
client_mss: None, client_mss: None,
client_mss_bulk: None,
proxy_protocol: false, proxy_protocol: false,
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(), proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(), proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
@@ -2218,6 +2308,11 @@ impl ServerConfig {
pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> { pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> {
parse_client_mss(self.client_mss.as_deref()) parse_client_mss(self.client_mss.as_deref())
} }
/// Resolves the post-handshake (bulk transfer) client MSS, if configured.
pub fn client_mss_bulk_value(&self) -> std::result::Result<Option<u16>, String> {
parse_client_mss(self.client_mss_bulk.as_deref())
}
} }
impl ListenerConfig { impl ListenerConfig {
+221 -100
View File
@@ -5,16 +5,41 @@
//! - syslog (Unix only, for traditional init systems) //! - syslog (Unix only, for traditional init systems)
//! - file (with optional rotation) //! - file (with optional rotation)
#![allow(dead_code)] // Infrastructure module - used via CLI flags // Infrastructure module used via CLI flags.
#![allow(dead_code)]
use std::path::Path; use std::path::Path;
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt, reload}; use tracing_subscriber::{EnvFilter, fmt, reload};
// Submodules:
// - file: bounded file appender for size and retention controls.
mod file;
#[cfg(test)]
mod tests;
/// File logging and retention options resolved from config and CLI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileLogOptions {
/// Log file path or rolling filename prefix path.
pub path: String,
/// Time rotation interval.
pub rotation: LogRotation,
/// Maximum active file size before size rotation. `0` disables it.
pub max_size_bytes: u64,
/// Maximum number of matching log files to keep. `0` disables it.
pub max_files: usize,
/// Maximum rotated file age in seconds. `0` disables it.
pub max_age_secs: u64,
}
/// Log destination configuration. /// Log destination configuration.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LogDestination { pub enum LogDestination {
/// Log to stderr (default, captured by systemd journald). /// Log to stderr (default, captured by systemd journald).
#[default] #[default]
@@ -24,12 +49,29 @@ pub enum LogDestination {
Syslog, Syslog,
/// Log to a file with optional rotation. /// Log to a file with optional rotation.
File { File {
path: String, /// Resolved file logging options.
/// Rotate daily if true. options: FileLogOptions,
rotate_daily: bool,
}, },
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LogCliDestination {
Stderr,
Syslog,
File,
}
/// Logging-related CLI overrides.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LogCliOptions {
destination: Option<LogCliDestination>,
path: Option<String>,
rotation: Option<LogRotation>,
max_size_bytes: Option<u64>,
max_files: Option<usize>,
max_age_secs: Option<u64>,
}
/// Logging options parsed from CLI/config. /// Logging options parsed from CLI/config.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct LoggingOptions { pub struct LoggingOptions {
@@ -101,23 +143,29 @@ pub fn init_logging(
(filter_handle, LoggingGuard::noop()) (filter_handle, LoggingGuard::noop())
} }
LogDestination::File { path, rotate_daily } => { LogDestination::File { options } => {
let (non_blocking, guard) = if *rotate_daily { let (non_blocking, guard) = if options.max_size_bytes > 0
// Extract directory and filename prefix || options.max_files > 0
let path = Path::new(path); || options.max_age_secs > 0
let dir = path.parent().unwrap_or(Path::new("/var/log")); {
let prefix = path let file_appender = file::BoundedFileAppender::new(options.clone())
.file_name() .expect("Failed to open log file");
.and_then(|s| s.to_str()) tracing_appender::non_blocking(file_appender)
.unwrap_or("telemt"); } else if !matches!(options.rotation, LogRotation::Never) {
let path = Path::new(&options.path);
let file_appender = tracing_appender::rolling::daily(dir, prefix); let dir = log_file_dir(path);
let prefix = log_file_name(path);
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(to_tracing_rotation(options.rotation))
.filename_prefix(prefix)
.build(dir)
.expect("Failed to open log file");
tracing_appender::non_blocking(file_appender) tracing_appender::non_blocking(file_appender)
} else { } else {
let file = std::fs::OpenOptions::new() let file = std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(path) .open(&options.path)
.expect("Failed to open log file"); .expect("Failed to open log file");
tracing_appender::non_blocking(file) tracing_appender::non_blocking(file)
}; };
@@ -137,6 +185,28 @@ pub fn init_logging(
} }
} }
fn log_file_dir(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn log_file_name(path: &Path) -> &str {
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("telemt")
}
fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation {
match rotation {
LogRotation::Never => tracing_appender::rolling::Rotation::NEVER,
LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY,
LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY,
}
}
/// Syslog writer for tracing. /// Syslog writer for tracing.
#[cfg(unix)] #[cfg(unix)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter {
} }
} }
/// Parse log destination from CLI arguments. /// Parse logging overrides from CLI arguments.
pub fn parse_log_destination(args: &[String]) -> LogDestination { pub fn parse_log_cli_options(args: &[String]) -> Result<LogCliOptions, String> {
let mut options = LogCliOptions::default();
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
match args[i].as_str() { match args[i].as_str() {
#[cfg(unix)] #[cfg(unix)]
"--syslog" => { "--syslog" => {
return LogDestination::Syslog; options.destination = Some(LogCliDestination::Syslog);
}
#[cfg(not(unix))]
"--syslog" => {
options.destination = Some(LogCliDestination::Syslog);
} }
"--log-file" => { "--log-file" => {
i += 1; i += 1;
if i < args.len() { if i < args.len() {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: args[i].clone(), options.path = Some(args[i].clone());
rotate_daily: false, } else {
}; return Err("Missing value for --log-file".to_string());
} }
} }
s if s.starts_with("--log-file=") => { s if s.starts_with("--log-file=") => {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: s.trim_start_matches("--log-file=").to_string(), options.path = Some(s.trim_start_matches("--log-file=").to_string());
rotate_daily: false,
};
} }
"--log-file-daily" => { "--log-file-daily" => {
i += 1; i += 1;
if i < args.len() { if i < args.len() {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: args[i].clone(), options.path = Some(args[i].clone());
rotate_daily: true, options.rotation = Some(LogRotation::Daily);
}; } else {
return Err("Missing value for --log-file-daily".to_string());
} }
} }
s if s.starts_with("--log-file-daily=") => { s if s.starts_with("--log-file-daily=") => {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: s.trim_start_matches("--log-file-daily=").to_string(), options.path = Some(s.trim_start_matches("--log-file-daily=").to_string());
rotate_daily: true, options.rotation = Some(LogRotation::Daily);
}; }
"--log-rotation" => {
i += 1;
if i < args.len() {
options.rotation = Some(parse_rotation_cli_value(&args[i])?);
} else {
return Err("Missing value for --log-rotation".to_string());
}
}
s if s.starts_with("--log-rotation=") => {
options.rotation = Some(parse_rotation_cli_value(
s.trim_start_matches("--log-rotation="),
)?);
}
"--log-max-size-bytes" => {
i += 1;
if i < args.len() {
options.max_size_bytes =
Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?);
} else {
return Err("Missing value for --log-max-size-bytes".to_string());
}
}
s if s.starts_with("--log-max-size-bytes=") => {
options.max_size_bytes = Some(parse_u64_cli_value(
"--log-max-size-bytes",
s.trim_start_matches("--log-max-size-bytes="),
)?);
}
"--log-max-files" => {
i += 1;
if i < args.len() {
options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?);
} else {
return Err("Missing value for --log-max-files".to_string());
}
}
s if s.starts_with("--log-max-files=") => {
options.max_files = Some(parse_usize_cli_value(
"--log-max-files",
s.trim_start_matches("--log-max-files="),
)?);
}
"--log-max-age-secs" => {
i += 1;
if i < args.len() {
options.max_age_secs =
Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?);
} else {
return Err("Missing value for --log-max-age-secs".to_string());
}
}
s if s.starts_with("--log-max-age-secs=") => {
options.max_age_secs = Some(parse_u64_cli_value(
"--log-max-age-secs",
s.trim_start_matches("--log-max-age-secs="),
)?);
} }
_ => {} _ => {}
} }
i += 1; i += 1;
} }
LogDestination::Stderr Ok(options)
} }
#[cfg(test)] fn parse_rotation_cli_value(value: &str) -> Result<LogRotation, String> {
mod tests { LogRotation::from_cli_arg(value).ok_or_else(|| {
use super::*; format!(
"Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly"
)
})
}
#[test] fn parse_u64_cli_value(flag: &str, value: &str) -> Result<u64, String> {
fn test_parse_log_destination_default() { value
let args: Vec<String> = vec![]; .parse::<u64>()
assert!(matches!( .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
parse_log_destination(&args), }
LogDestination::Stderr
));
}
#[test] fn parse_usize_cli_value(flag: &str, value: &str) -> Result<usize, String> {
fn test_parse_log_destination_file() { value
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()]; .parse::<usize>()
match parse_log_destination(&args) { .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
LogDestination::File { path, rotate_daily } => { }
assert_eq!(path, "/var/log/telemt.log");
assert!(!rotate_daily);
}
_ => panic!("Expected File destination"),
}
}
#[test] /// Resolve effective logging destination from config and CLI overrides.
fn test_parse_log_destination_file_daily() { pub fn resolve_log_destination(
let args = vec!["--log-file-daily=/var/log/telemt".to_string()]; config: &LoggingConfig,
match parse_log_destination(&args) { cli: &LogCliOptions,
LogDestination::File { path, rotate_daily } => { ) -> Result<LogDestination, String> {
assert_eq!(path, "/var/log/telemt"); let destination = cli.destination.unwrap_or(match config.destination {
assert!(rotate_daily); LoggingDestination::Stderr => LogCliDestination::Stderr,
} LoggingDestination::Syslog => LogCliDestination::Syslog,
_ => panic!("Expected File destination"), LoggingDestination::File => LogCliDestination::File,
} });
}
match destination {
LogCliDestination::Stderr => Ok(LogDestination::Stderr),
LogCliDestination::Syslog => {
#[cfg(unix)] #[cfg(unix)]
#[test] {
fn test_parse_log_destination_syslog() { Ok(LogDestination::Syslog)
let args = vec!["--syslog".to_string()]; }
assert!(matches!( #[cfg(not(unix))]
parse_log_destination(&args), {
LogDestination::Syslog Err("Syslog logging is only supported on Unix platforms".to_string())
)); }
}
LogCliDestination::File => {
let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| {
"logging.path or --log-file must be set when file logging is enabled".to_string()
})?;
if path.trim().is_empty() {
return Err("Log file path cannot be empty".to_string());
} }
#[cfg(unix)] Ok(LogDestination::File {
#[test] options: FileLogOptions {
fn test_syslog_priority_for_level_mapping() { path: path.clone(),
assert_eq!( rotation: cli.rotation.unwrap_or(config.rotation),
syslog_priority_for_level(&tracing::Level::ERROR), max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes),
libc::LOG_ERR max_files: cli.max_files.unwrap_or(config.max_files),
); max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs),
assert_eq!( },
syslog_priority_for_level(&tracing::Level::WARN), })
libc::LOG_WARNING }
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::INFO),
libc::LOG_INFO
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::DEBUG),
libc::LOG_DEBUG
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::TRACE),
libc::LOG_DEBUG
);
} }
} }
+395
View File
@@ -0,0 +1,395 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc};
use crate::config::LogRotation;
use super::FileLogOptions;
const CLEANUP_INTERVAL_SECS: i64 = 60;
/// File appender with size rotation and local retention cleanup.
pub(crate) struct BoundedFileAppender {
options: FileLogOptions,
dir: PathBuf,
base_name: String,
current_path: PathBuf,
current_size: u64,
last_cleanup: DateTime<Utc>,
file: Option<File>,
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
}
impl BoundedFileAppender {
pub(crate) fn new(options: FileLogOptions) -> io::Result<Self> {
Self::with_now(options, Box::new(Utc::now))
}
fn with_now(
options: FileLogOptions,
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
) -> io::Result<Self> {
let path = Path::new(&options.path);
let dir = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let base_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("telemt")
.to_string();
let start = now();
let current_path = active_path_for(&dir, &base_name, options.rotation, &start);
let (file, current_size) = open_append_file(&current_path)?;
let mut appender = Self {
options,
dir,
base_name,
current_path,
current_size,
last_cleanup: start,
file: Some(file),
now,
};
appender.cleanup(&start);
Ok(appender)
}
fn now(&self) -> DateTime<Utc> {
(self.now)()
}
fn refresh_active_path(&mut self, now: &DateTime<Utc>) -> io::Result<bool> {
let next_path = active_path_for(&self.dir, &self.base_name, self.options.rotation, now);
if next_path == self.current_path {
return Ok(false);
}
self.close_current()?;
self.current_path = next_path;
self.open_current()?;
Ok(true)
}
fn rotate_for_size(&mut self, now: &DateTime<Utc>) -> io::Result<()> {
self.close_current()?;
if self.current_path.exists() {
let archive_path = self.archive_path(now);
fs::rename(&self.current_path, archive_path)?;
}
self.open_current()
}
fn archive_path(&self, now: &DateTime<Utc>) -> PathBuf {
let file_name = self
.current_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&self.base_name);
let stamp = now.format("%Y%m%d%H%M%S");
for seq in 0..1000 {
let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}"));
if !candidate.exists() {
return candidate;
}
}
self.dir.join(format!("{file_name}.{stamp}.overflow"))
}
fn open_current(&mut self) -> io::Result<()> {
let (file, current_size) = open_append_file(&self.current_path)?;
self.file = Some(file);
self.current_size = current_size;
Ok(())
}
fn close_current(&mut self) -> io::Result<()> {
if let Some(mut file) = self.file.take() {
file.flush()?;
}
Ok(())
}
fn should_rotate_for_size(&self, incoming_len: usize) -> bool {
self.options.max_size_bytes > 0
&& self.current_size > 0
&& self.current_size.saturating_add(incoming_len as u64) > self.options.max_size_bytes
}
fn cleanup_due(&self, now: &DateTime<Utc>) -> bool {
self.options.max_age_secs > 0
&& now.signed_duration_since(self.last_cleanup)
>= ChronoDuration::seconds(CLEANUP_INTERVAL_SECS)
}
fn cleanup(&mut self, now: &DateTime<Utc>) {
self.last_cleanup = now.clone();
let Ok(entries) = fs::read_dir(&self.dir) else {
return;
};
let mut candidates = Vec::new();
let prefix = format!("{}.", self.base_name);
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let is_current = path == self.current_path;
let Some(name) = entry.file_name().to_str().map(|name| name.to_string()) else {
continue;
};
if !is_current && !name.starts_with(&prefix) {
continue;
}
let Ok(metadata) = entry.metadata() else {
continue;
};
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
candidates.push(LogFileCandidate {
path,
modified,
is_current,
});
}
if self.options.max_age_secs > 0 {
let cutoff = system_time_from_utc(now)
.checked_sub(Duration::from_secs(self.options.max_age_secs))
.unwrap_or(UNIX_EPOCH);
candidates.retain(|candidate| {
if candidate.is_current || candidate.modified >= cutoff {
true
} else {
let _ = fs::remove_file(&candidate.path);
false
}
});
}
if self.options.max_files > 0 && candidates.len() > self.options.max_files {
let mut archives: Vec<_> = candidates
.into_iter()
.filter(|candidate| !candidate.is_current)
.collect();
archives.sort_by_key(|candidate| candidate.modified);
let mut total = archives.len() + 1;
for candidate in archives {
if total <= self.options.max_files {
break;
}
let _ = fs::remove_file(candidate.path);
total -= 1;
}
}
}
}
impl Write for BoundedFileAppender {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let now = self.now();
let rotated_by_time = self.refresh_active_path(&now)?;
if self.should_rotate_for_size(buf.len()) {
self.rotate_for_size(&now)?;
self.cleanup(&now);
} else if rotated_by_time || self.cleanup_due(&now) {
self.cleanup(&now);
}
let Some(file) = self.file.as_mut() else {
return Err(io::Error::new(
io::ErrorKind::Other,
"bounded log file is not open",
));
};
file.write_all(buf)?;
self.current_size = self.current_size.saturating_add(buf.len() as u64);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if let Some(file) = self.file.as_mut() {
file.flush()
} else {
Ok(())
}
}
}
struct LogFileCandidate {
path: PathBuf,
modified: SystemTime,
is_current: bool,
}
fn open_append_file(path: &Path) -> io::Result<(File, u64)> {
let mut options = OpenOptions::new();
options.create(true).append(true);
let file = match options.open(path) {
Ok(file) => file,
Err(error) => {
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return Err(error);
};
fs::create_dir_all(parent)?;
options.open(path)?
}
};
let current_size = file.metadata()?.len();
Ok((file, current_size))
}
fn active_path_for(
dir: &Path,
base_name: &str,
rotation: LogRotation,
now: &DateTime<Utc>,
) -> PathBuf {
match rotation {
LogRotation::Never => dir.join(base_name),
LogRotation::Minutely | LogRotation::Hourly | LogRotation::Daily | LogRotation::Weekly => {
dir.join(format!("{base_name}.{}", period_suffix_for(rotation, now)))
}
}
}
fn period_suffix_for(rotation: LogRotation, now: &DateTime<Utc>) -> String {
match rotation {
LogRotation::Never | LogRotation::Daily => now.format("%Y-%m-%d").to_string(),
LogRotation::Hourly => now.format("%Y-%m-%d-%H").to_string(),
LogRotation::Minutely => now.format("%Y-%m-%d-%H-%M").to_string(),
LogRotation::Weekly => {
let days_since_sunday = now.weekday().num_days_from_sunday() as i64;
let week_start = now.date_naive() - ChronoDuration::days(days_since_sunday);
week_start.format("%Y-%m-%d").to_string()
}
}
}
fn system_time_from_utc(now: &DateTime<Utc>) -> SystemTime {
let duration = Duration::new(now.timestamp().unsigned_abs(), now.timestamp_subsec_nanos());
if now.timestamp() >= 0 {
UNIX_EPOCH + duration
} else {
UNIX_EPOCH - duration
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::tempdir;
use super::*;
fn fixed_now() -> DateTime<Utc> {
DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(10))
}
fn options(path: PathBuf) -> FileLogOptions {
FileLogOptions {
path: path.to_string_lossy().to_string(),
rotation: LogRotation::Never,
max_size_bytes: 0,
max_files: 0,
max_age_secs: 0,
}
}
fn matching_logs(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<_> = fs::read_dir(dir)
.unwrap()
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("telemt.log"))
.unwrap_or(false)
})
.collect();
files.sort();
files
}
#[test]
fn size_rotation_keeps_latest_write_in_active_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("telemt.log");
let mut options = options(path.clone());
options.max_size_bytes = 6;
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
appender.write_all(b"abc\n").unwrap();
appender.write_all(b"def\n").unwrap();
appender.flush().unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "def\n");
assert_eq!(matching_logs(dir.path()).len(), 2);
}
#[test]
fn max_files_retention_removes_oldest_archives() {
let dir = tempdir().unwrap();
let path = dir.path().join("telemt.log");
let mut options = options(path);
options.max_size_bytes = 4;
options.max_files = 2;
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] {
appender.write_all(line).unwrap();
}
appender.flush().unwrap();
assert!(matching_logs(dir.path()).len() <= 2);
}
#[cfg(unix)]
#[test]
fn max_age_retention_removes_old_archives() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let dir = tempdir().unwrap();
let path = dir.path().join("telemt.log");
let old_archive = dir.path().join("telemt.log.20000101000000.0");
fs::write(&old_archive, "old").unwrap();
let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap();
let times = [
libc::timespec {
tv_sec: 0,
tv_nsec: 0,
},
libc::timespec {
tv_sec: 0,
tv_nsec: 0,
},
];
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
assert_eq!(rc, 0);
let mut options = options(path);
options.max_age_secs = 1;
let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
assert!(!old_archive.exists());
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::*;
#[test]
fn test_parse_log_cli_options_default() {
let args: Vec<String> = vec![];
let options = parse_log_cli_options(&args).unwrap();
assert_eq!(
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
LogDestination::Stderr
);
}
#[test]
fn test_parse_log_cli_options_file() {
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
let options = parse_log_cli_options(&args).unwrap();
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
LogDestination::File { options } => {
assert_eq!(options.path, "/var/log/telemt.log");
assert_eq!(options.rotation, LogRotation::Never);
}
_ => panic!("Expected File destination"),
}
}
#[test]
fn test_parse_log_cli_options_file_daily() {
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
let options = parse_log_cli_options(&args).unwrap();
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
LogDestination::File { options } => {
assert_eq!(options.path, "/var/log/telemt");
assert_eq!(options.rotation, LogRotation::Daily);
}
_ => panic!("Expected File destination"),
}
}
#[test]
fn test_parse_log_cli_options_bounds() {
let args = vec![
"--log-file=/var/log/telemt.log".to_string(),
"--log-rotation=hourly".to_string(),
"--log-max-size-bytes=1024".to_string(),
"--log-max-files=3".to_string(),
"--log-max-age-secs=60".to_string(),
];
let options = parse_log_cli_options(&args).unwrap();
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
LogDestination::File { options } => {
assert_eq!(options.rotation, LogRotation::Hourly);
assert_eq!(options.max_size_bytes, 1024);
assert_eq!(options.max_files, 3);
assert_eq!(options.max_age_secs, 60);
}
_ => panic!("Expected File destination"),
}
}
#[test]
fn test_parse_log_cli_options_rejects_bad_rotation() {
let args = vec!["--log-rotation=yearly".to_string()];
assert!(parse_log_cli_options(&args).is_err());
}
#[cfg(unix)]
#[test]
fn test_parse_log_cli_options_syslog() {
let args = vec!["--syslog".to_string()];
let options = parse_log_cli_options(&args).unwrap();
assert_eq!(
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
LogDestination::Syslog
);
}
#[cfg(unix)]
#[test]
fn test_syslog_priority_for_level_mapping() {
assert_eq!(
syslog_priority_for_level(&tracing::Level::ERROR),
libc::LOG_ERR
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::WARN),
libc::LOG_WARNING
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::INFO),
libc::LOG_INFO
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::DEBUG),
libc::LOG_DEBUG
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::TRACE),
libc::LOG_DEBUG
);
}
+31 -6
View File
@@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn};
use crate::cli; use crate::cli;
use crate::config::ProxyConfig; use crate::config::ProxyConfig;
use crate::logging::LogDestination; use crate::logging::LogCliOptions;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::{ use crate::transport::middle_proxy::{
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache, ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
@@ -113,7 +113,7 @@ pub(crate) struct CliArgs {
pub data_path: Option<PathBuf>, pub data_path: Option<PathBuf>,
pub silent: bool, pub silent: bool,
pub log_level: Option<String>, pub log_level: Option<String>,
pub log_destination: LogDestination, pub log_cli_options: LogCliOptions,
} }
pub(crate) fn parse_cli() -> CliArgs { pub(crate) fn parse_cli() -> CliArgs {
@@ -125,8 +125,13 @@ pub(crate) fn parse_cli() -> CliArgs {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
// Parse log destination let log_cli_options = match crate::logging::parse_log_cli_options(&args) {
let log_destination = crate::logging::parse_log_destination(&args); Ok(options) => options,
Err(error) => {
eprintln!("[telemt] {error}");
std::process::exit(2);
}
};
// Check for --init first (handled before tokio) // Check for --init first (handled before tokio)
if let Some(init_opts) = cli::parse_init_args(&args) { if let Some(init_opts) = cli::parse_init_args(&args) {
@@ -180,6 +185,21 @@ pub(crate) fn parse_cli() -> CliArgs {
s if s.starts_with("--log-level=") => { s if s.starts_with("--log-level=") => {
log_level = Some(s.trim_start_matches("--log-level=").to_string()); log_level = Some(s.trim_start_matches("--log-level=").to_string());
} }
"--log-file" | "--log-file-daily" => {
i += 1;
}
s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {}
"--log-rotation"
| "--log-max-size-bytes"
| "--log-max-files"
| "--log-max-age-secs" => {
i += 1;
}
s if s.starts_with("--log-rotation=")
|| s.starts_with("--log-max-size-bytes=")
|| s.starts_with("--log-max-files=")
|| s.starts_with("--log-max-age-secs=") => {}
"--syslog" => {}
"--help" | "-h" => { "--help" | "-h" => {
print_help(); print_help();
std::process::exit(0); std::process::exit(0);
@@ -192,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs {
"--daemon" | "-d" | "--foreground" | "-f" => {} "--daemon" | "-d" | "--foreground" | "-f" => {}
s if s.starts_with("--pid-file") => { s if s.starts_with("--pid-file") => {
if !s.contains('=') { if !s.contains('=') {
i += 1; // skip value // Skip the pid-file value consumed by daemon argument parsing.
i += 1;
} }
} }
s if s.starts_with("--run-as-user") => { s if s.starts_with("--run-as-user") => {
@@ -224,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs {
data_path, data_path,
silent, silent,
log_level, log_level,
log_destination, log_cli_options,
} }
} }
@@ -254,6 +275,10 @@ fn print_help() {
eprintln!("Logging options:"); eprintln!("Logging options:");
eprintln!(" --log-file <PATH> Log to file (default: stderr)"); eprintln!(" --log-file <PATH> Log to file (default: stderr)");
eprintln!(" --log-file-daily <PATH> Log to file with daily rotation"); eprintln!(" --log-file-daily <PATH> Log to file with daily rotation");
eprintln!(" --log-rotation <MODE> never|minutely|hourly|daily|weekly");
eprintln!(" --log-max-size-bytes N Rotate file logs when active file exceeds N bytes");
eprintln!(" --log-max-files N Keep at most N matching file logs (0 disables)");
eprintln!(" --log-max-age-secs N Remove rotated file logs older than N seconds");
#[cfg(unix)] #[cfg(unix)]
eprintln!(" --syslog Log to syslog (Unix only)"); eprintln!(" --syslog Log to syslog (Unix only)");
eprintln!(); eprintln!();
+2
View File
@@ -208,6 +208,8 @@ pub(crate) async fn initialize_me_pool(
me_nat_probe, me_nat_probe,
None, None,
config.network.stun_servers.clone(), config.network.stun_servers.clone(),
config.network.stun_tcp_fallback,
config.network.http_ip_detect_urls.clone(),
config.general.stun_nat_probe_concurrency, config.general.stun_nat_probe_concurrency,
probe.detected_ipv6, probe.detected_ipv6,
config.timeouts.me_one_retry, config.timeouts.me_one_retry,
+9 -1
View File
@@ -108,7 +108,7 @@ async fn run_telemt_core(
let data_path = cli_args.data_path; let data_path = cli_args.data_path;
let cli_silent = cli_args.silent; let cli_silent = cli_args.silent;
let cli_log_level = cli_args.log_level; let cli_log_level = cli_args.log_level;
let log_destination = cli_args.log_destination; let log_cli_options = cli_args.log_cli_options;
let startup_cwd = match std::env::current_dir() { let startup_cwd = match std::env::current_dir() {
Ok(cwd) => cwd, Ok(cwd) => cwd,
Err(e) => { Err(e) => {
@@ -331,6 +331,14 @@ async fn run_telemt_core(
}; };
let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level); let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level);
let log_destination =
match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) {
Ok(destination) => destination,
Err(error) => {
eprintln!("[telemt] {error}");
std::process::exit(1);
}
};
let (filter_layer, filter_handle) = let (filter_layer, filter_handle) =
reload::Layer::new(EnvFilter::new(initial_filter_spec.clone())); reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
startup_tracker startup_tracker
+32 -6
View File
@@ -12,7 +12,7 @@ use tracing::{debug, info, warn};
use crate::config::{NetworkConfig, UpstreamConfig, UpstreamType}; use crate::config::{NetworkConfig, UpstreamConfig, UpstreamType};
use crate::error::Result; use crate::error::Result;
use crate::network::stun::{ use crate::network::stun::{
DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind, DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind_and_tcp_fallback,
}; };
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
@@ -58,6 +58,7 @@ impl NetworkDecision {
} }
const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5); const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5);
const STUN_BATCH_TCP_FALLBACK_TIMEOUT: Duration = Duration::from_secs(12);
pub async fn run_probe( pub async fn run_probe(
config: &NetworkConfig, config: &NetworkConfig,
@@ -81,7 +82,13 @@ pub async fn run_probe(
warn!("STUN probe is enabled but network.stun_servers is empty"); warn!("STUN probe is enabled but network.stun_servers is empty");
DualStunResult::default() DualStunResult::default()
} else { } else {
probe_stun_servers_parallel(&servers, stun_nat_probe_concurrency.max(1), None, None) probe_stun_servers_parallel(
&servers,
stun_nat_probe_concurrency.max(1),
None,
None,
config.stun_tcp_fallback,
)
.await .await
} }
} else if nat_probe { } else if nat_probe {
@@ -163,6 +170,7 @@ pub async fn run_probe(
stun_nat_probe_concurrency.max(1), stun_nat_probe_concurrency.max(1),
bind_v4, bind_v4,
bind_v6, bind_v6,
config.stun_tcp_fallback,
) )
.await; .await;
if let Some(reflected) = direct_stun_res.v4.map(|r| r.reflected_addr) { if let Some(reflected) = direct_stun_res.v4.map(|r| r.reflected_addr) {
@@ -234,7 +242,7 @@ pub async fn run_probe(
Ok(probe) Ok(probe)
} }
async fn detect_public_ipv4_http(urls: &[String]) -> Option<Ipv4Addr> { pub(crate) async fn detect_public_ipv4_http(urls: &[String]) -> Option<Ipv4Addr> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3)) .timeout(Duration::from_secs(3))
.build() .build()
@@ -277,6 +285,7 @@ async fn probe_stun_servers_parallel(
concurrency: usize, concurrency: usize,
bind_v4: Option<IpAddr>, bind_v4: Option<IpAddr>,
bind_v6: Option<IpAddr>, bind_v6: Option<IpAddr>,
tcp_fallback: bool,
) -> DualStunResult { ) -> DualStunResult {
let mut join_set = JoinSet::new(); let mut join_set = JoinSet::new();
let mut next_idx = 0usize; let mut next_idx = 0usize;
@@ -288,9 +297,26 @@ async fn probe_stun_servers_parallel(
let stun_addr = servers[next_idx].clone(); let stun_addr = servers[next_idx].clone();
next_idx += 1; next_idx += 1;
join_set.spawn(async move { join_set.spawn(async move {
let res = timeout(STUN_BATCH_TIMEOUT, async { let batch_timeout = if tcp_fallback {
let v4 = stun_probe_family_with_bind(&stun_addr, IpFamily::V4, bind_v4).await?; STUN_BATCH_TCP_FALLBACK_TIMEOUT
let v6 = stun_probe_family_with_bind(&stun_addr, IpFamily::V6, bind_v6).await?; } else {
STUN_BATCH_TIMEOUT
};
let res = timeout(batch_timeout, async {
let v4 = stun_probe_family_with_bind_and_tcp_fallback(
&stun_addr,
IpFamily::V4,
bind_v4,
tcp_fallback,
)
.await?;
let v6 = stun_probe_family_with_bind_and_tcp_fallback(
&stun_addr,
IpFamily::V6,
bind_v6,
tcp_fallback,
)
.await?;
Ok::<DualStunResult, crate::error::ProxyError>(DualStunResult { v4, v6 }) Ok::<DualStunResult, crate::error::ProxyError>(DualStunResult { v4, v6 })
}) })
.await; .await;
+235 -35
View File
@@ -4,7 +4,8 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::OnceLock; use std::sync::OnceLock;
use tokio::net::{UdpSocket, lookup_host}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpSocket, UdpSocket, lookup_host};
use tokio::time::{Duration, sleep, timeout}; use tokio::time::{Duration, sleep, timeout};
use crate::crypto::SecureRandom; use crate::crypto::SecureRandom;
@@ -36,9 +37,16 @@ pub struct DualStunResult {
} }
pub async fn stun_probe_dual(stun_addr: &str) -> Result<DualStunResult> { pub async fn stun_probe_dual(stun_addr: &str) -> Result<DualStunResult> {
stun_probe_dual_with_tcp_fallback(stun_addr, false).await
}
pub async fn stun_probe_dual_with_tcp_fallback(
stun_addr: &str,
tcp_fallback: bool,
) -> Result<DualStunResult> {
let (v4, v6) = tokio::join!( let (v4, v6) = tokio::join!(
stun_probe_family(stun_addr, IpFamily::V4), stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V4, tcp_fallback),
stun_probe_family(stun_addr, IpFamily::V6), stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V6, tcp_fallback),
); );
Ok(DualStunResult { v4: v4?, v6: v6? }) Ok(DualStunResult { v4: v4?, v6: v6? })
@@ -48,13 +56,44 @@ pub async fn stun_probe_family(
stun_addr: &str, stun_addr: &str,
family: IpFamily, family: IpFamily,
) -> Result<Option<StunProbeResult>> { ) -> Result<Option<StunProbeResult>> {
stun_probe_family_with_bind(stun_addr, family, None).await stun_probe_family_with_tcp_fallback(stun_addr, family, false).await
}
pub async fn stun_probe_family_with_tcp_fallback(
stun_addr: &str,
family: IpFamily,
tcp_fallback: bool,
) -> Result<Option<StunProbeResult>> {
stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, None, tcp_fallback).await
} }
pub async fn stun_probe_family_with_bind( pub async fn stun_probe_family_with_bind(
stun_addr: &str, stun_addr: &str,
family: IpFamily, family: IpFamily,
bind_ip: Option<IpAddr>, bind_ip: Option<IpAddr>,
) -> Result<Option<StunProbeResult>> {
stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, bind_ip, false).await
}
pub async fn stun_probe_family_with_bind_and_tcp_fallback(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
tcp_fallback: bool,
) -> Result<Option<StunProbeResult>> {
let udp_attempts = if tcp_fallback { 1 } else { 3 };
let udp_result = stun_probe_family_udp(stun_addr, family, bind_ip, udp_attempts).await?;
if udp_result.is_some() || !tcp_fallback {
return Ok(udp_result);
}
stun_probe_family_tcp(stun_addr, family, bind_ip).await
}
async fn stun_probe_family_udp(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
max_attempts: u8,
) -> Result<Option<StunProbeResult>> { ) -> Result<Option<StunProbeResult>> {
let bind_addr = match (family, bind_ip) { let bind_addr = match (family, bind_ip) {
(IpFamily::V4, Some(IpAddr::V4(ip))) => SocketAddr::new(IpAddr::V4(ip), 0), (IpFamily::V4, Some(IpAddr::V4(ip))) => SocketAddr::new(IpAddr::V4(ip), 0),
@@ -94,12 +133,7 @@ pub async fn stun_probe_family_with_bind(
return Ok(None); return Ok(None);
} }
let mut req = [0u8; 20]; let req = build_binding_request();
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes()); // Binding Request
req[2..4].copy_from_slice(&0u16.to_be_bytes()); // length
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes()); // magic cookie
stun_rng().fill(&mut req[8..20]); // transaction ID
let mut buf = [0u8; 256]; let mut buf = [0u8; 256];
let mut attempt = 0; let mut attempt = 0;
let mut backoff = Duration::from_secs(1); let mut backoff = Duration::from_secs(1);
@@ -115,7 +149,7 @@ pub async fn stun_probe_family_with_bind(
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN recv failed: {e}"))), Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN recv failed: {e}"))),
Err(_) => { Err(_) => {
attempt += 1; attempt += 1;
if attempt >= 3 { if attempt >= max_attempts {
return Ok(None); return Ok(None);
} }
sleep(backoff).await; sleep(backoff).await;
@@ -128,19 +162,139 @@ pub async fn stun_probe_family_with_bind(
return Ok(None); return Ok(None);
} }
let magic = 0x2112A442u32.to_be_bytes();
let txid = &req[8..20]; let txid = &req[8..20];
if let Some(reflected_addr) = parse_reflected_addr(&buf[..n], txid) {
let local_addr = socket
.local_addr()
.map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
return Ok(Some(StunProbeResult {
local_addr,
reflected_addr,
family,
}));
}
}
Ok(None)
}
async fn stun_probe_family_tcp(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
) -> Result<Option<StunProbeResult>> {
let target_addr = match resolve_stun_addr(stun_addr, family).await? {
Some(addr) => addr,
None => return Ok(None),
};
let socket = match family {
IpFamily::V4 => TcpSocket::new_v4(),
IpFamily::V6 => TcpSocket::new_v6(),
}
.map_err(|e| ProxyError::Proxy(format!("STUN TCP socket failed: {e}")))?;
match (family, bind_ip) {
(IpFamily::V4, Some(IpAddr::V4(ip))) => {
if socket.bind(SocketAddr::new(IpAddr::V4(ip), 0)).is_err() {
return Ok(None);
}
}
(IpFamily::V6, Some(IpAddr::V6(ip))) => {
if socket.bind(SocketAddr::new(IpAddr::V6(ip), 0)).is_err() {
return Ok(None);
}
}
(IpFamily::V4, Some(IpAddr::V6(_))) | (IpFamily::V6, Some(IpAddr::V4(_))) => {
return Ok(None);
}
(_, None) => {}
}
let connect_res = timeout(Duration::from_secs(3), socket.connect(target_addr)).await;
let mut stream = match connect_res {
Ok(Ok(stream)) => stream,
Ok(Err(e))
if family == IpFamily::V6
&& matches!(
e.kind(),
std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::HostUnreachable
| std::io::ErrorKind::Unsupported
| std::io::ErrorKind::NetworkDown
) =>
{
return Ok(None);
}
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN TCP connect failed: {e}"))),
Err(_) => return Ok(None),
};
let req = build_binding_request();
timeout(Duration::from_secs(3), stream.write_all(&req))
.await
.map_err(|_| ProxyError::Proxy("STUN TCP send timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP send failed: {e}")))?;
let mut header = [0u8; 20];
timeout(Duration::from_secs(3), stream.read_exact(&mut header))
.await
.map_err(|_| ProxyError::Proxy("STUN TCP header timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP header read failed: {e}")))?;
let body_len = u16::from_be_bytes([header[2], header[3]]) as usize;
if body_len > 236 {
return Ok(None);
}
let mut buf = [0u8; 256];
buf[..20].copy_from_slice(&header);
if body_len > 0 {
timeout(
Duration::from_secs(3),
stream.read_exact(&mut buf[20..20 + body_len]),
)
.await
.map_err(|_| ProxyError::Proxy("STUN TCP body timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP body read failed: {e}")))?;
}
let txid = &req[8..20];
let Some(reflected_addr) = parse_reflected_addr(&buf[..20 + body_len], txid) else {
return Ok(None);
};
let local_addr = stream
.local_addr()
.map_err(|e| ProxyError::Proxy(format!("STUN TCP local_addr failed: {e}")))?;
Ok(Some(StunProbeResult {
local_addr,
reflected_addr,
family,
}))
}
fn build_binding_request() -> [u8; 20] {
let mut req = [0u8; 20];
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes());
req[2..4].copy_from_slice(&0u16.to_be_bytes());
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
stun_rng().fill(&mut req[8..20]);
req
}
fn parse_reflected_addr(buf: &[u8], txid: &[u8]) -> Option<SocketAddr> {
if buf.len() < 20 {
return None;
}
let magic = 0x2112A442u32.to_be_bytes();
let mut idx = 20; let mut idx = 20;
while idx + 4 <= n { while idx + 4 <= buf.len() {
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().unwrap()); let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().ok()?);
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().unwrap()) as usize; let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().ok()?) as usize;
idx += 4; idx += 4;
if idx + alen > n { if idx + alen > buf.len() {
break; break;
} }
match atype { match atype {
0x0020 /* XOR-MAPPED-ADDRESS */ | 0x0001 /* MAPPED-ADDRESS */ => { 0x0020 | 0x0001 => {
if alen < 8 { if alen < 8 {
break; break;
} }
@@ -157,7 +311,6 @@ pub async fn stun_probe_family_with_bind(
let raw_ip = &buf[idx + 4..idx + 4 + len_check]; let raw_ip = &buf[idx + 4..idx + 4 + len_check];
let mut port = u16::from_be_bytes(port_bytes); let mut port = u16::from_be_bytes(port_bytes);
let reflected_ip = if atype == 0x0020 { let reflected_ip = if atype == 0x0020 {
port ^= ((magic[0] as u16) << 8) | magic[1] as u16; port ^= ((magic[0] as u16) << 8) | magic[1] as u16;
match family_byte { match family_byte {
@@ -172,7 +325,9 @@ pub async fn stun_probe_family_with_bind(
} }
0x02 => { 0x02 => {
let mut ip = [0u8; 16]; let mut ip = [0u8; 16];
let xor_key = [magic.as_slice(), txid].concat(); let mut xor_key = [0u8; 16];
xor_key[..4].copy_from_slice(&magic);
xor_key[4..].copy_from_slice(txid.get(..12)?);
for (i, b) in raw_ip.iter().enumerate().take(16) { for (i, b) in raw_ip.iter().enumerate().take(16) {
ip[i] = *b ^ xor_key[i]; ip[i] = *b ^ xor_key[i];
} }
@@ -185,34 +340,24 @@ pub async fn stun_probe_family_with_bind(
} }
} else { } else {
match family_byte { match family_byte {
0x01 => IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3])), 0x01 => {
0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).unwrap())), IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3]))
}
0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).ok()?)),
_ => { _ => {
idx += (alen + 3) & !3; idx += (alen + 3) & !3;
continue; continue;
} }
} }
}; };
return Some(SocketAddr::new(reflected_ip, port));
let reflected_addr = SocketAddr::new(reflected_ip, port);
let local_addr = socket
.local_addr()
.map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
return Ok(Some(StunProbeResult {
local_addr,
reflected_addr,
family,
}));
} }
_ => {} _ => {}
} }
idx += (alen + 3) & !3; idx += (alen + 3) & !3;
} }
} None
Ok(None)
} }
async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<SocketAddr>> { async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<SocketAddr>> {
@@ -245,3 +390,58 @@ async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<S
}); });
Ok(target) Ok(target)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_reflected_addr_reads_mapped_ipv4() {
let txid = [0u8; 12];
let mut response = [0u8; 32];
response[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
response[2..4].copy_from_slice(&12u16.to_be_bytes());
response[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
response[20..22].copy_from_slice(&0x0001u16.to_be_bytes());
response[22..24].copy_from_slice(&8u16.to_be_bytes());
response[25] = 0x01;
response[26..28].copy_from_slice(&443u16.to_be_bytes());
response[28..32].copy_from_slice(&[203, 0, 113, 9]);
let reflected = parse_reflected_addr(&response, &txid).unwrap();
assert_eq!(
reflected,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), 443)
);
}
#[test]
fn parse_reflected_addr_reads_xor_mapped_ipv4() {
let txid = [0u8; 12];
let magic = 0x2112A442u32.to_be_bytes();
let port = 443u16;
let ip = [203u8, 0, 113, 9];
let xport = port ^ (((magic[0] as u16) << 8) | magic[1] as u16);
let xip = [
ip[0] ^ magic[0],
ip[1] ^ magic[1],
ip[2] ^ magic[2],
ip[3] ^ magic[3],
];
let mut response = [0u8; 32];
response[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
response[2..4].copy_from_slice(&12u16.to_be_bytes());
response[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
response[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
response[22..24].copy_from_slice(&8u16.to_be_bytes());
response[25] = 0x01;
response[26..28].copy_from_slice(&xport.to_be_bytes());
response[28..32].copy_from_slice(&xip);
let reflected = parse_reflected_addr(&response, &txid).unwrap();
assert_eq!(
reflected,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), 443)
);
}
}
+20 -15
View File
@@ -5,6 +5,9 @@
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use crate::crypto::SecureRandom; use crate::crypto::SecureRandom;
use crate::protocol::framing::{
secure_version_d_body_len_from_wire_len, secure_version_d_padding_len,
};
use std::sync::LazyLock; use std::sync::LazyLock;
// ============= Telegram Datacenters ============= // ============= Telegram Datacenters =============
@@ -236,22 +239,20 @@ pub fn is_valid_secure_payload_len(data_len: usize) -> bool {
} }
/// Compute Secure Intermediate payload length from wire length. /// Compute Secure Intermediate payload length from wire length.
/// Secure mode strips up to 3 random tail bytes by truncating to 4-byte boundary. /// Secure mode cannot distinguish full-word padding from payload, so only the
/// non-aligned tail bytes are stripped.
pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option<usize> { pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option<usize> {
if wire_len < 4 { secure_version_d_body_len_from_wire_len(wire_len)
return None;
}
Some(wire_len - (wire_len % 4))
} }
/// Generate padding length for Secure Intermediate protocol. /// Generate padding length for Secure Intermediate protocol.
/// Data must be 4-byte aligned; padding is 1..=3 so total is never divisible by 4. /// Telegram Desktop uses a 4-bit random padding length for VersionD packets.
pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize { pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize {
debug_assert!( debug_assert!(
is_valid_secure_payload_len(data_len), is_valid_secure_payload_len(data_len),
"Secure payload must be 4-byte aligned, got {data_len}" "Secure payload must be 4-byte aligned, got {data_len}"
); );
rng.range(3) + 1 secure_version_d_padding_len(rng)
} }
// ============= Timeouts ============= // ============= Timeouts =============
@@ -424,21 +425,15 @@ mod tests {
} }
#[test] #[test]
fn secure_padding_never_produces_aligned_total() { fn secure_padding_matches_tdesktop_range() {
let rng = SecureRandom::new(); let rng = SecureRandom::new();
for data_len in (0..1000).step_by(4) { for data_len in (0..1000).step_by(4) {
for _ in 0..100 { for _ in 0..100 {
let padding = secure_padding_len(data_len, &rng); let padding = secure_padding_len(data_len, &rng);
assert!( assert!(
padding <= 3, padding <= 15,
"padding out of range: data_len={data_len}, padding={padding}" "padding out of range: data_len={data_len}, padding={padding}"
); );
assert_ne!(
(data_len + padding) % 4,
0,
"invariant violated: data_len={data_len}, padding={padding}, total={}",
data_len + padding
);
} }
} }
} }
@@ -454,6 +449,16 @@ mod tests {
} }
} }
#[test]
fn secure_wire_len_preserves_full_word_tail() {
let payload_len = 64;
for padding in [4usize, 8, 12] {
let wire_len = payload_len + padding;
let recovered = secure_payload_len_from_wire_len(wire_len);
assert_eq!(recovered, Some(wire_len));
}
}
#[test] #[test]
fn secure_wire_len_rejects_too_short_frames() { fn secure_wire_len_rejects_too_short_frames() {
assert_eq!(secure_payload_len_from_wire_len(0), None); assert_eq!(secure_payload_len_from_wire_len(0), None);
+92
View File
@@ -0,0 +1,92 @@
//! Shared MTProto transport framing helpers.
use crate::crypto::SecureRandom;
/// QuickACK marker bit used by Intermediate and Secure Intermediate headers.
pub(crate) const INTERMEDIATE_QUICKACK_FLAG: u32 = 0x8000_0000;
/// Payload length mask used by Intermediate and Secure Intermediate headers.
pub(crate) const INTERMEDIATE_WIRE_LEN_MASK: u32 = 0x7fff_ffff;
/// Maximum random tail length used by Telegram Desktop VersionD packets.
pub(crate) const SECURE_VERSION_D_PADDING_MAX: usize = 15;
/// Parsed Intermediate/Secure Intermediate length header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct IntermediateHeader {
/// Payload length on the wire, excluding the four-byte header.
pub(crate) wire_len: usize,
/// Whether the QuickACK marker bit was set in the length header.
pub(crate) quickack: bool,
}
/// Parse an Intermediate/Secure Intermediate length header.
pub(crate) fn parse_intermediate_header(header: [u8; 4]) -> IntermediateHeader {
let raw = u32::from_le_bytes(header);
IntermediateHeader {
wire_len: (raw & INTERMEDIATE_WIRE_LEN_MASK) as usize,
quickack: (raw & INTERMEDIATE_QUICKACK_FLAG) != 0,
}
}
/// Encode an Intermediate/Secure Intermediate length header.
pub(crate) fn encode_intermediate_header(wire_len: usize, quickack: bool) -> Option<u32> {
if wire_len > INTERMEDIATE_WIRE_LEN_MASK as usize {
return None;
}
let mut raw = u32::try_from(wire_len).ok()?;
if quickack {
raw |= INTERMEDIATE_QUICKACK_FLAG;
}
Some(raw)
}
/// Recover the VersionD body length visible to MTProto from the encrypted wire length.
pub(crate) fn secure_version_d_body_len_from_wire_len(wire_len: usize) -> Option<usize> {
if wire_len < 4 {
return None;
}
Some(wire_len - (wire_len % 4))
}
/// Generate Telegram Desktop-compatible VersionD random tail length.
pub(crate) fn secure_version_d_padding_len(rng: &SecureRandom) -> usize {
rng.range(SECURE_VERSION_D_PADDING_MAX + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intermediate_header_roundtrip_preserves_quickack_zero_length() {
let encoded = encode_intermediate_header(0, true).unwrap();
assert_eq!(encoded, INTERMEDIATE_QUICKACK_FLAG);
let parsed = parse_intermediate_header(encoded.to_le_bytes());
assert_eq!(parsed.wire_len, 0);
assert!(parsed.quickack);
}
#[test]
fn intermediate_header_rejects_lengths_above_31_bits() {
assert_eq!(
encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize, false),
Some(INTERMEDIATE_WIRE_LEN_MASK)
);
assert_eq!(
encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize + 1, false),
None
);
}
#[test]
fn secure_version_d_body_len_strips_only_non_word_tail() {
assert_eq!(secure_version_d_body_len_from_wire_len(3), None);
assert_eq!(secure_version_d_body_len_from_wire_len(8), Some(8));
assert_eq!(secure_version_d_body_len_from_wire_len(11), Some(8));
assert_eq!(secure_version_d_body_len_from_wire_len(12), Some(12));
}
}
+1
View File
@@ -2,6 +2,7 @@
pub mod constants; pub mod constants;
pub mod frame; pub mod frame;
pub(crate) mod framing;
pub mod obfuscation; pub mod obfuscation;
pub mod tls; pub mod tls;
pub mod tls_fingerprint; pub mod tls_fingerprint;
+32 -2
View File
@@ -113,7 +113,7 @@ use crate::proxy::handshake::{
}; };
#[cfg(test)] #[cfg(test)]
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake}; use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
use crate::proxy::masking::handle_bad_client; use crate::proxy::masking::handle_bad_client_with_shared;
use crate::proxy::middle_relay::handle_via_middle_proxy; use crate::proxy::middle_relay::handle_via_middle_proxy;
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
use crate::proxy::shared_state::ProxySharedState; use crate::proxy::shared_state::ProxySharedState;
@@ -310,6 +310,7 @@ fn masking_outcome<R, W>(
local_addr: SocketAddr, local_addr: SocketAddr,
config: Arc<ProxyConfig>, config: Arc<ProxyConfig>,
beobachten: Arc<BeobachtenStore>, beobachten: Arc<BeobachtenStore>,
shared: Arc<ProxySharedState>,
) -> HandshakeOutcome ) -> HandshakeOutcome
where where
R: AsyncRead + Unpin + Send + 'static, R: AsyncRead + Unpin + Send + 'static,
@@ -325,7 +326,7 @@ where
) )
.await; .await;
handle_bad_client( handle_bad_client_with_shared(
reader, reader,
writer, writer,
&initial_data, &initial_data,
@@ -333,6 +334,7 @@ where
local_addr, local_addr,
&config, &config,
&beobachten, &beobachten,
shared.as_ref(),
) )
.await; .await;
Ok(()) Ok(())
@@ -718,6 +720,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
@@ -739,6 +742,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
}; };
@@ -757,6 +761,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
@@ -787,6 +792,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => { HandshakeResult::Error(e) => {
@@ -844,6 +850,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -873,6 +880,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
@@ -898,6 +906,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -1096,6 +1105,12 @@ impl RunningClientHandler {
#[cfg(unix)] #[cfg(unix)]
let raw_fd = self.raw_fd; let raw_fd = self.raw_fd;
let rst_on_close = self.rst_on_close; let rst_on_close = self.rst_on_close;
// MSS for the bulk data phase: once the handshake (incl. ServerHello) is
// sent, restore a normal MSS so only the handshake stays fragmented by the
// low listener `client_mss`. Cuts pps ~10x (anti-DDoS abuse on pps-policing
// hosts like FastVPS). None = keep handshake MSS for the whole connection.
#[cfg(unix)]
let bulk_mss: Option<u16> = self.config.server.client_mss_bulk_value().ok().flatten();
let outcome = match self.do_handshake().await? { let outcome = match self.do_handshake().await? {
Some(outcome) => outcome, Some(outcome) => outcome,
@@ -1109,6 +1124,14 @@ impl RunningClientHandler {
if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) { if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) {
let _ = crate::transport::socket::clear_linger_fd(raw_fd); let _ = crate::transport::socket::clear_linger_fd(raw_fd);
} }
// Handshake (ServerHello) done — raise MSS for bulk transfer.
#[cfg(unix)]
if let Some(mss) = bulk_mss {
if let Err(e) = crate::transport::socket::set_tcp_mss_fd(raw_fd, u32::from(mss))
{
debug!(error = %e, "Failed to raise bulk MSS; keeping handshake MSS");
}
}
fut.await fut.await
} }
HandshakeOutcome::NeedsMasking(fut) => fut.await, HandshakeOutcome::NeedsMasking(fut) => fut.await,
@@ -1329,6 +1352,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
@@ -1350,6 +1374,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
}; };
@@ -1369,6 +1394,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
@@ -1416,6 +1442,7 @@ impl RunningClientHandler {
local_addr, local_addr,
config.clone(), config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => { HandshakeResult::Error(e) => {
@@ -1483,6 +1510,7 @@ impl RunningClientHandler {
local_addr, local_addr,
config.clone(), config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -1530,6 +1558,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
@@ -1568,6 +1597,7 @@ impl RunningClientHandler {
local_addr, local_addr,
config.clone(), config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
+14 -121
View File
@@ -4,7 +4,6 @@
use dashmap::DashMap; use dashmap::DashMap;
use dashmap::mapref::entry::Entry; use dashmap::mapref::entry::Entry;
use hmac::{Hmac, Mac};
#[cfg(test)] #[cfg(test)]
use std::collections::HashSet; use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -33,8 +32,10 @@ use crate::stream::{CryptoReader, CryptoWriter, FakeTlsReader, FakeTlsWriter};
use crate::tls_front::{TlsFrontCache, emulator}; use crate::tls_front::{TlsFrontCache, emulator};
#[cfg(test)] #[cfg(test)]
use rand::RngExt; use rand::RngExt;
use sha2::Sha256;
use subtle::ConstantTimeEq; mod tls_auth;
use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate};
const ACCESS_SECRET_BYTES: usize = 16; const ACCESS_SECRET_BYTES: usize = 16;
const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5; const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5;
@@ -58,8 +59,6 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8;
const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64; const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64;
const RECENT_USER_RING_SCAN_LIMIT: usize = 32; const RECENT_USER_RING_SCAN_LIMIT: usize = 32;
type HmacSha256 = Hmac<Sha256>;
#[cfg(test)] #[cfg(test)]
const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1;
#[cfg(not(test))] #[cfg(not(test))]
@@ -104,23 +103,6 @@ fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> b
true true
} }
#[derive(Clone, Copy)]
struct ParsedTlsAuthMaterial {
digest: [u8; tls::TLS_DIGEST_LEN],
session_id: [u8; 32],
session_id_len: usize,
now: i64,
ignore_time_skew: bool,
boot_time_cap_secs: u32,
}
#[derive(Clone, Copy)]
struct TlsCandidateValidation {
digest: [u8; tls::TLS_DIGEST_LEN],
session_id: [u8; 32],
session_id_len: usize,
}
struct MtprotoCandidateValidation { struct MtprotoCandidateValidation {
proto_tag: ProtoTag, proto_tag: ProtoTag,
dc_idx: i16, dc_idx: i16,
@@ -251,104 +233,6 @@ fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) ->
total_users.min(cap.max(1)) total_users.min(cap.max(1))
} }
fn parse_tls_auth_material(
handshake: &[u8],
ignore_time_skew: bool,
replay_window_secs: u64,
) -> Option<ParsedTlsAuthMaterial> {
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
return None;
}
let digest: [u8; tls::TLS_DIGEST_LEN] = handshake
[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.try_into()
.ok()?;
let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN;
let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?);
if session_id_len > 32 {
return None;
}
let session_id_start = session_id_len_pos + 1;
if handshake.len() < session_id_start + session_id_len {
return None;
}
let mut session_id = [0u8; 32];
session_id[..session_id_len]
.copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]);
let now = if !ignore_time_skew {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
i64::try_from(d.as_secs()).ok()?
} else {
0_i64
};
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
let boot_time_cap_secs = if ignore_time_skew {
0
} else {
tls::BOOT_TIME_MAX_SECS
.min(replay_window_u32)
.min(tls::BOOT_TIME_COMPAT_MAX_SECS)
};
Some(ParsedTlsAuthMaterial {
digest,
session_id,
session_id_len,
now,
ignore_time_skew,
boot_time_cap_secs,
})
}
fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(&handshake[..tls::TLS_DIGEST_POS]);
mac.update(&[0u8; tls::TLS_DIGEST_LEN]);
mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]);
mac.finalize().into_bytes().into()
}
fn validate_tls_secret_candidate(
parsed: &ParsedTlsAuthMaterial,
handshake: &[u8],
secret: &[u8],
) -> Option<TlsCandidateValidation> {
let computed = compute_tls_hmac_zeroed_digest(secret, handshake);
if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) {
return None;
}
let timestamp = u32::from_le_bytes([
parsed.digest[28] ^ computed[28],
parsed.digest[29] ^ computed[29],
parsed.digest[30] ^ computed[30],
parsed.digest[31] ^ computed[31],
]);
if !parsed.ignore_time_skew {
let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs;
if !is_boot_time {
let time_diff = parsed.now - i64::from(timestamp);
if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) {
return None;
}
}
}
Some(TlsCandidateValidation {
digest: parsed.digest,
session_id: parsed.session_id,
session_id_len: parsed.session_id_len,
})
}
fn validate_mtproto_secret_candidate( fn validate_mtproto_secret_candidate(
handshake: &[u8; HANDSHAKE_LEN], handshake: &[u8; HANDSHAKE_LEN],
dec_prekey: &[u8; PREKEY_LEN], dec_prekey: &[u8; PREKEY_LEN],
@@ -1857,7 +1741,16 @@ where
return HandshakeResult::BadClient { reader, writer }; return HandshakeResult::BadClient { reader, writer };
} }
let validation = matched_validation.expect("validation must exist when matched"); let Some(validation) = matched_validation else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
warn!(
peer = %peer,
user = %matched_user,
"MTProto handshake matched user without validation material"
);
return HandshakeResult::BadClient { reader, writer };
};
if config if config
.access .access
+126
View File
@@ -0,0 +1,126 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::protocol::tls;
type HmacSha256 = Hmac<Sha256>;
/// Parsed TLS authentication material extracted from a ClientHello candidate.
#[derive(Clone, Copy)]
pub(super) struct ParsedTlsAuthMaterial {
digest: [u8; tls::TLS_DIGEST_LEN],
session_id: [u8; 32],
session_id_len: usize,
now: i64,
ignore_time_skew: bool,
boot_time_cap_secs: u32,
}
/// Successful TLS secret validation output used by the handshake state machine.
#[derive(Clone, Copy)]
pub(super) struct TlsCandidateValidation {
pub(super) digest: [u8; tls::TLS_DIGEST_LEN],
pub(super) session_id: [u8; 32],
pub(super) session_id_len: usize,
}
/// Parse TLS auth digest and session-id material from a candidate handshake.
pub(super) fn parse_tls_auth_material(
handshake: &[u8],
ignore_time_skew: bool,
replay_window_secs: u64,
) -> Option<ParsedTlsAuthMaterial> {
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
return None;
}
let digest: [u8; tls::TLS_DIGEST_LEN] = handshake
[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.try_into()
.ok()?;
let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN;
let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?);
if session_id_len > 32 {
return None;
}
let session_id_start = session_id_len_pos + 1;
if handshake.len() < session_id_start + session_id_len {
return None;
}
let mut session_id = [0u8; 32];
session_id[..session_id_len]
.copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]);
let now = if !ignore_time_skew {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
i64::try_from(d.as_secs()).ok()?
} else {
0_i64
};
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
let boot_time_cap_secs = if ignore_time_skew {
0
} else {
tls::BOOT_TIME_MAX_SECS
.min(replay_window_u32)
.min(tls::BOOT_TIME_COMPAT_MAX_SECS)
};
Some(ParsedTlsAuthMaterial {
digest,
session_id,
session_id_len,
now,
ignore_time_skew,
boot_time_cap_secs,
})
}
fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> Option<[u8; 32]> {
let mut mac = HmacSha256::new_from_slice(secret).ok()?;
mac.update(&handshake[..tls::TLS_DIGEST_POS]);
mac.update(&[0u8; tls::TLS_DIGEST_LEN]);
mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]);
Some(mac.finalize().into_bytes().into())
}
/// Validate a candidate secret against parsed TLS authentication material.
pub(super) fn validate_tls_secret_candidate(
parsed: &ParsedTlsAuthMaterial,
handshake: &[u8],
secret: &[u8],
) -> Option<TlsCandidateValidation> {
let computed = compute_tls_hmac_zeroed_digest(secret, handshake)?;
if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) {
return None;
}
let timestamp = u32::from_le_bytes([
parsed.digest[28] ^ computed[28],
parsed.digest[29] ^ computed[29],
parsed.digest[30] ^ computed[30],
parsed.digest[31] ^ computed[31],
]);
if !parsed.ignore_time_skew {
let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs;
if !is_boot_time {
let time_diff = parsed.now - i64::from(timestamp);
if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) {
return None;
}
}
}
Some(TlsCandidateValidation {
digest: parsed.digest,
session_id: parsed.session_id,
session_id_len: parsed.session_id_len,
})
}
+177 -69
View File
@@ -3,12 +3,15 @@
use crate::config::ProxyConfig; use crate::config::ProxyConfig;
use crate::network::dns_overrides::resolve_socket_addr; use crate::network::dns_overrides::resolve_socket_addr;
use crate::protocol::tls; use crate::protocol::tls;
use crate::proxy::shared_state::ProxySharedState;
use crate::stats::beobachten::BeobachtenStore; use crate::stats::beobachten::BeobachtenStore;
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder}; use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
use crate::transport::socket::configure_tcp_socket;
#[cfg(unix)] #[cfg(unix)]
use nix::ifaddrs::getifaddrs; use nix::ifaddrs::getifaddrs;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, RngExt, SeedableRng}; use rand::{Rng, RngExt, SeedableRng};
use std::io::{Error as IoError, ErrorKind};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::str; use std::str;
#[cfg(test)] #[cfg(test)]
@@ -17,9 +20,9 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant as StdInstant}; use std::time::{Duration, Instant as StdInstant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
#[cfg(unix)] #[cfg(unix)]
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::net::{TcpStream, lookup_host};
#[cfg(unix)] #[cfg(unix)]
use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Mutex as AsyncMutex;
use tokio::time::{Instant, timeout}; use tokio::time::{Instant, timeout};
@@ -36,6 +39,8 @@ const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200);
#[cfg(test)] #[cfg(test)]
const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100); const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
const MASK_BUFFER_SIZE: usize = 8192; const MASK_BUFFER_SIZE: usize = 8192;
const MASK_BUFFER_GROW_AFTER_BYTES: usize = 256 * 1024;
const MASK_BUFFER_MAX_SIZE: usize = 64 * 1024;
#[cfg(unix)] #[cfg(unix)]
#[cfg(not(test))] #[cfg(not(test))]
const LOCAL_INTERFACE_CACHE_TTL: Duration = Duration::from_secs(300); const LOCAL_INTERFACE_CACHE_TTL: Duration = Duration::from_secs(300);
@@ -53,6 +58,27 @@ struct MaskTcpTarget<'a> {
port: u16, port: u16,
} }
fn mask_copy_read_len(total: usize, byte_cap: usize) -> usize {
// Keep short scanner probes on the small baseline buffer and grow only
// after the session has proven to be sustained masking relay traffic.
let active_buffer_size = if total >= MASK_BUFFER_GROW_AFTER_BYTES {
MASK_BUFFER_MAX_SIZE
} else {
MASK_BUFFER_SIZE
};
if byte_cap == 0 {
return active_buffer_size;
}
let remaining_budget = byte_cap.saturating_sub(total);
if remaining_budget == 0 {
return 0;
}
remaining_budget.min(active_buffer_size)
}
async fn copy_with_idle_timeout<R, W>( async fn copy_with_idle_timeout<R, W>(
reader: &mut R, reader: &mut R,
writer: &mut W, writer: &mut W,
@@ -64,21 +90,18 @@ where
R: AsyncRead + Unpin, R: AsyncRead + Unpin,
W: AsyncWrite + Unpin, W: AsyncWrite + Unpin,
{ {
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]); let mut buf = vec![0u8; MASK_BUFFER_SIZE];
let mut total = 0usize; let mut total = 0usize;
let mut ended_by_eof = false; let mut ended_by_eof = false;
let unlimited = byte_cap == 0;
loop { loop {
let read_len = if unlimited { let read_len = mask_copy_read_len(total, byte_cap);
MASK_BUFFER_SIZE if read_len == 0 {
} else {
let remaining_budget = byte_cap.saturating_sub(total);
if remaining_budget == 0 {
break; break;
} }
remaining_budget.min(MASK_BUFFER_SIZE) if buf.len() < read_len {
}; buf.resize(read_len, 0);
}
let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await; let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await;
let n = match read_res { let n = match read_res {
Ok(Ok(n)) => n, Ok(Ok(n)) => n,
@@ -250,6 +273,32 @@ async fn consume_client_data_with_timeout_and_cap<R>(
} }
} }
fn mask_failure_drain_cap(config: &ProxyConfig) -> usize {
let configured_cap = config.censorship.mask_relay_max_bytes;
if configured_cap == 0 {
return MASK_BUFFER_SIZE;
}
configured_cap.min(MASK_BUFFER_SIZE)
}
async fn consume_mask_failure_path<R>(
reader: R,
config: &ProxyConfig,
relay_timeout: Duration,
idle_timeout: Duration,
) where
R: AsyncRead + Unpin,
{
consume_client_data_with_timeout_and_cap(
reader,
mask_failure_drain_cap(config),
relay_timeout,
idle_timeout,
)
.await;
}
async fn wait_mask_connect_budget(started: Instant) { async fn wait_mask_connect_budget(started: Instant) {
let elapsed = started.elapsed(); let elapsed = started.elapsed();
if elapsed < MASK_TIMEOUT { if elapsed < MASK_TIMEOUT {
@@ -480,6 +529,32 @@ fn parse_mask_host_ip_literal(host: &str) -> Option<IpAddr> {
host.parse::<IpAddr>().ok() host.parse::<IpAddr>().ok()
} }
async fn resolve_mask_target_addrs(
mask_host: &str,
mask_port: u16,
) -> std::io::Result<Vec<SocketAddr>> {
if let Some(addr) = resolve_socket_addr(mask_host, mask_port) {
return Ok(vec![addr]);
}
if let Some(ip) = parse_mask_host_ip_literal(mask_host) {
return Ok(vec![SocketAddr::new(ip, mask_port)]);
}
let addrs = timeout(MASK_TIMEOUT, lookup_host((mask_host, mask_port)))
.await
.map_err(|_| IoError::new(ErrorKind::TimedOut, "mask target DNS lookup timed out"))??;
let addrs = addrs.collect::<Vec<_>>();
if addrs.is_empty() {
return Err(IoError::new(
ErrorKind::NotFound,
"mask target DNS lookup returned no addresses",
));
}
Ok(addrs)
}
fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> { fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> {
if config.censorship.tls_domain.eq_ignore_ascii_case(sni) { if config.censorship.tls_domain.eq_ignore_ascii_case(sni) {
return Some(config.censorship.tls_domain.as_str()); return Some(config.censorship.tls_domain.as_str());
@@ -761,7 +836,7 @@ fn is_mask_target_local_listener_with_interfaces(
mask_host: &str, mask_host: &str,
mask_port: u16, mask_port: u16,
local_addr: SocketAddr, local_addr: SocketAddr,
resolved_override: Option<SocketAddr>, resolved_addrs: &[SocketAddr],
interface_ips: &[IpAddr], interface_ips: &[IpAddr],
) -> bool { ) -> bool {
if mask_port != local_addr.port() { if mask_port != local_addr.port() {
@@ -771,7 +846,7 @@ fn is_mask_target_local_listener_with_interfaces(
let local_ip = canonical_ip(local_addr.ip()); let local_ip = canonical_ip(local_addr.ip());
let literal_mask_ip = parse_mask_host_ip_literal(mask_host).map(canonical_ip); let literal_mask_ip = parse_mask_host_ip_literal(mask_host).map(canonical_ip);
if let Some(addr) = resolved_override { for addr in resolved_addrs {
let resolved_ip = canonical_ip(addr.ip()); let resolved_ip = canonical_ip(addr.ip());
if resolved_ip == local_ip { if resolved_ip == local_ip {
return true; return true;
@@ -808,7 +883,7 @@ fn is_mask_target_local_listener(
mask_host: &str, mask_host: &str,
mask_port: u16, mask_port: u16,
local_addr: SocketAddr, local_addr: SocketAddr,
resolved_override: Option<SocketAddr>, resolved_addrs: &[SocketAddr],
) -> bool { ) -> bool {
if mask_port != local_addr.port() { if mask_port != local_addr.port() {
return false; return false;
@@ -819,7 +894,7 @@ fn is_mask_target_local_listener(
mask_host, mask_host,
mask_port, mask_port,
local_addr, local_addr,
resolved_override, resolved_addrs,
&interfaces, &interfaces,
) )
} }
@@ -828,7 +903,7 @@ async fn is_mask_target_local_listener_async(
mask_host: &str, mask_host: &str,
mask_port: u16, mask_port: u16,
local_addr: SocketAddr, local_addr: SocketAddr,
resolved_override: Option<SocketAddr>, resolved_addrs: &[SocketAddr],
) -> bool { ) -> bool {
if mask_port != local_addr.port() { if mask_port != local_addr.port() {
return false; return false;
@@ -839,7 +914,7 @@ async fn is_mask_target_local_listener_async(
mask_host, mask_host,
mask_port, mask_port,
local_addr, local_addr,
resolved_override, resolved_addrs,
&interfaces, &interfaces,
) )
} }
@@ -877,7 +952,13 @@ fn build_mask_proxy_header(
} }
} }
/// Handle a bad client by forwarding to mask host fn configure_mask_backend_socket(stream: &TcpStream) {
if let Err(e) = configure_tcp_socket(stream, false, Duration::from_secs(0)) {
debug!(error = %e, "Failed to configure mask backend socket");
}
}
/// Handles a bad client by forwarding it to the configured mask target.
pub async fn handle_bad_client<R, W>( pub async fn handle_bad_client<R, W>(
reader: R, reader: R,
writer: W, writer: W,
@@ -889,6 +970,34 @@ pub async fn handle_bad_client<R, W>(
) where ) where
R: AsyncRead + Unpin + Send + 'static, R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static,
{
let shared = ProxySharedState::new();
handle_bad_client_with_shared(
reader,
writer,
initial_data,
peer,
local_addr,
config,
beobachten,
shared.as_ref(),
)
.await;
}
/// Handles a bad client with shared pre-auth fallback admission state.
pub(crate) async fn handle_bad_client_with_shared<R, W>(
reader: R,
writer: W,
initial_data: &[u8],
peer: SocketAddr,
local_addr: SocketAddr,
config: &ProxyConfig,
beobachten: &BeobachtenStore,
shared: &ProxySharedState,
) where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{ {
let client_type = detect_client_type(initial_data); let client_type = detect_client_type(initial_data);
if config.general.beobachten { if config.general.beobachten {
@@ -911,6 +1020,17 @@ pub async fn handle_bad_client<R, W>(
return; return;
} }
let Some(_masking_permit) = shared.try_acquire_masking_fallback_permit() else {
let outcome_started = Instant::now();
debug!(
client_type = client_type,
"Masking fallback concurrency limit reached"
);
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
wait_mask_outcome_budget(outcome_started, config).await;
return;
};
let client_sni = tls::extract_sni_from_client_hello(initial_data); let client_sni = tls::extract_sni_from_client_hello(initial_data);
let exclusive_tcp_target = client_sni let exclusive_tcp_target = client_sni
.as_deref() .as_deref()
@@ -973,24 +1093,12 @@ pub async fn handle_bad_client<R, W>(
Ok(Err(e)) => { Ok(Err(e)) => {
wait_mask_connect_budget_if_needed(connect_started, config).await; wait_mask_connect_budget_if_needed(connect_started, config).await;
debug!(error = %e, "Failed to connect to mask unix socket"); debug!(error = %e, "Failed to connect to mask unix socket");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
Err(_) => { Err(_) => {
debug!("Timeout connecting to mask unix socket"); debug!("Timeout connecting to mask unix socket");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
} }
@@ -1003,11 +1111,27 @@ pub async fn handle_bad_client<R, W>(
let mask_host = mask_target.host; let mask_host = mask_target.host;
let mask_port = mask_target.port; let mask_port = mask_target.port;
let resolved_mask_addrs = match resolve_mask_target_addrs(mask_host, mask_port).await {
Ok(addrs) => addrs,
Err(e) => {
let outcome_started = Instant::now();
debug!(
client_type = client_type,
host = %mask_host,
port = mask_port,
error = %e,
"Failed to resolve mask target"
);
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
wait_mask_outcome_budget(outcome_started, config).await;
return;
}
};
// Fail closed when fallback points at our own listener endpoint. // Fail closed when fallback points at our own listener endpoint.
// Self-referential masking can create recursive proxy loops under // Self-referential masking can create recursive proxy loops under
// misconfiguration and leak distinguishable load spikes to adversaries. // misconfiguration and leak distinguishable load spikes to adversaries.
let resolved_mask_addr = resolve_socket_addr(mask_host, mask_port); if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, &resolved_mask_addrs)
if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, resolved_mask_addr)
.await .await
{ {
let outcome_started = Instant::now(); let outcome_started = Instant::now();
@@ -1018,13 +1142,7 @@ pub async fn handle_bad_client<R, W>(
local = %local_addr, local = %local_addr,
"Mask target resolves to local listener; refusing self-referential masking fallback" "Mask target resolves to local listener; refusing self-referential masking fallback"
); );
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
return; return;
} }
@@ -1039,14 +1157,15 @@ pub async fn handle_bad_client<R, W>(
"Forwarding bad client to mask host" "Forwarding bad client to mask host"
); );
// Apply runtime DNS override for mask target when configured.
let mask_addr = resolved_mask_addr
.map(|addr| addr.to_string())
.unwrap_or_else(|| format!("{}:{}", mask_host, mask_port));
let connect_started = Instant::now(); let connect_started = Instant::now();
let connect_result = timeout(MASK_TIMEOUT, TcpStream::connect(&mask_addr)).await; let connect_result = timeout(
MASK_TIMEOUT,
TcpStream::connect(resolved_mask_addrs.as_slice()),
)
.await;
match connect_result { match connect_result {
Ok(Ok(stream)) => { Ok(Ok(stream)) => {
configure_mask_backend_socket(&stream);
let proxy_header = let proxy_header =
build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr); build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr);
@@ -1085,24 +1204,12 @@ pub async fn handle_bad_client<R, W>(
Ok(Err(e)) => { Ok(Err(e)) => {
wait_mask_connect_budget_if_needed(connect_started, config).await; wait_mask_connect_budget_if_needed(connect_started, config).await;
debug!(error = %e, "Failed to connect to mask host"); debug!(error = %e, "Failed to connect to mask host");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
Err(_) => { Err(_) => {
debug!("Timeout connecting to mask host"); debug!("Timeout connecting to mask host");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
} }
@@ -1190,20 +1297,17 @@ async fn consume_client_data<R: AsyncRead + Unpin>(
idle_timeout: Duration, idle_timeout: Duration,
) { ) {
// Keep drain path fail-closed under slow-loris stalls. // Keep drain path fail-closed under slow-loris stalls.
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]); let mut buf = vec![0u8; MASK_BUFFER_SIZE];
let mut total = 0usize; let mut total = 0usize;
let unlimited = byte_cap == 0;
loop { loop {
let read_len = if unlimited { let read_len = mask_copy_read_len(total, byte_cap);
MASK_BUFFER_SIZE if read_len == 0 {
} else {
let remaining_budget = byte_cap.saturating_sub(total);
if remaining_budget == 0 {
break; break;
} }
remaining_budget.min(MASK_BUFFER_SIZE) if buf.len() < read_len {
}; buf.resize(read_len, 0);
}
let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await { let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await {
Ok(Ok(n)) => n, Ok(Ok(n)) => n,
Ok(Err(_)) | Err(_) => break, Ok(Err(_)) | Err(_) => break,
@@ -1214,7 +1318,7 @@ async fn consume_client_data<R: AsyncRead + Unpin>(
} }
total = total.saturating_add(n); total = total.saturating_add(n);
if !unlimited && total >= byte_cap { if byte_cap != 0 && total >= byte_cap {
break; break;
} }
} }
@@ -1332,6 +1436,10 @@ mod masking_interface_cache_concurrency_security_tests;
#[path = "tests/masking_production_cap_regression_security_tests.rs"] #[path = "tests/masking_production_cap_regression_security_tests.rs"]
mod masking_production_cap_regression_security_tests; mod masking_production_cap_regression_security_tests;
#[cfg(test)]
#[path = "tests/masking_relay_manual_perf_tests.rs"]
mod masking_relay_manual_perf_tests;
#[cfg(test)] #[cfg(test)]
#[path = "tests/masking_extended_attack_surface_security_tests.rs"] #[path = "tests/masking_extended_attack_surface_security_tests.rs"]
mod masking_extended_attack_surface_security_tests; mod masking_extended_attack_surface_security_tests;
+1 -1
View File
@@ -52,7 +52,7 @@ use self::c2me::{
}; };
use self::d2c::{ use self::d2c::{
MeD2cFlushPolicy, MeWriterResponseOutcome, classify_me_d2c_flush_reason, MeD2cFlushPolicy, MeWriterResponseOutcome, classify_me_d2c_flush_reason,
flush_client_or_cancel, observe_me_d2c_flush_event, flush_client_or_cancel, me_d2c_flush_reason_requires_client_flush, observe_me_d2c_flush_event,
process_me_writer_response_with_traffic_lease, process_me_writer_response_with_traffic_lease,
}; };
use self::desync::{RelayForensicsState, hash_ip_in, report_desync_frame_too_large_in}; use self::desync::{RelayForensicsState, hash_ip_in, report_desync_frame_too_large_in};
+35 -11
View File
@@ -55,6 +55,37 @@ pub(super) fn classify_me_d2c_flush_reason(
MeD2cFlushReason::QueueDrain MeD2cFlushReason::QueueDrain
} }
pub(super) fn me_d2c_flush_reason_requires_client_flush(reason: MeD2cFlushReason) -> bool {
!matches!(reason, MeD2cFlushReason::QueueDrain)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn queue_drain_is_not_a_physical_flush_trigger() {
assert!(!me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::QueueDrain
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::AckImmediate
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::BatchFrames
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::BatchBytes
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::MaxDelay
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::Close
));
}
}
pub(super) fn observe_me_d2c_flush_event( pub(super) fn observe_me_d2c_flush_event(
stats: &Stats, stats: &Stats,
reason: MeD2cFlushReason, reason: MeD2cFlushReason,
@@ -276,20 +307,13 @@ pub(in crate::proxy::middle_relay) fn compute_intermediate_secure_wire_len(
let wire_len = data_len let wire_len = data_len
.checked_add(padding_len) .checked_add(padding_len)
.ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?; .ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?;
if wire_len > 0x7fff_ffffusize { let len_val = crate::protocol::framing::encode_intermediate_header(wire_len, quickack)
return Err(ProxyError::Proxy(format!( .ok_or_else(|| {
"Intermediate/Secure frame too large: {wire_len}" ProxyError::Proxy(format!("Intermediate/Secure frame too large: {wire_len}"))
))); })?;
}
let total = 4usize let total = 4usize
.checked_add(wire_len) .checked_add(wire_len)
.ok_or_else(|| ProxyError::Proxy("Frame buffer size overflow".into()))?; .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)) Ok((len_val, total))
} }
+4 -7
View File
@@ -236,12 +236,8 @@ where
} }
Err(e) => return Err(e), Err(e) => return Err(e),
} }
let quickack = (len_buf[3] & 0x80) != 0; let header = crate::protocol::framing::parse_intermediate_header(len_buf);
( (header.wire_len, header.quickack, Some(len_buf))
(u32::from_le_bytes(len_buf) & 0x7fff_ffff) as usize,
quickack,
Some(len_buf),
)
} }
}; };
@@ -331,7 +327,8 @@ where
) )
.await?; .await?;
// Secure Intermediate: strip validated trailing padding bytes. // Secure Intermediate strips only non-aligned tail padding; full-word
// padding is indistinguishable from payload in VersionD framing.
if proto_tag == ProtoTag::Secure { if proto_tag == ProtoTag::Secure {
payload.truncate(secure_payload_len); payload.truncate(secure_payload_len);
} }
+7 -1
View File
@@ -491,12 +491,18 @@ where
d2c_flush_policy.max_bytes, d2c_flush_policy.max_bytes,
max_delay_fired, max_delay_fired,
); );
let flush_started_at = if stats_clone.telemetry_policy().me_level.allows_debug() { let physical_flush =
me_d2c_flush_reason_requires_client_flush(flush_reason);
let flush_started_at = if physical_flush
&& stats_clone.telemetry_policy().me_level.allows_debug()
{
Some(Instant::now()) Some(Instant::now())
} else { } else {
None None
}; };
if physical_flush {
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?; flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?;
}
let flush_duration_us = flush_started_at.map(|started| { let flush_duration_us = flush_started_at.map(|started| {
started started
.elapsed() .elapsed()
+12 -1
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use dashmap::DashMap; use dashmap::DashMap;
use tokio::sync::mpsc; use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState}; use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState};
@@ -14,6 +14,7 @@ use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateReg
use crate::proxy::traffic_limiter::TrafficLimiter; use crate::proxy::traffic_limiter::TrafficLimiter;
const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64; const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64;
const MASKING_FALLBACK_MAX_CONCURRENT: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConntrackCloseReason { pub(crate) enum ConntrackCloseReason {
@@ -72,6 +73,7 @@ pub(crate) struct ProxySharedState {
active_user_sessions: DashMap<(String, u64), CancellationToken>, active_user_sessions: DashMap<(String, u64), CancellationToken>,
pub(crate) conntrack_pressure_active: AtomicBool, pub(crate) conntrack_pressure_active: AtomicBool,
pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>, pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>,
masking_fallback_permits: Arc<Semaphore>,
} }
#[must_use = "registered user sessions must be kept alive until relay completion"] #[must_use = "registered user sessions must be kept alive until relay completion"]
@@ -131,9 +133,18 @@ impl ProxySharedState {
active_user_sessions: DashMap::new(), active_user_sessions: DashMap::new(),
conntrack_pressure_active: AtomicBool::new(false), conntrack_pressure_active: AtomicBool::new(false),
conntrack_close_tx: Mutex::new(None), conntrack_close_tx: Mutex::new(None),
masking_fallback_permits: Arc::new(Semaphore::new(MASKING_FALLBACK_MAX_CONCURRENT)),
}) })
} }
/// Attempts to reserve one masking fallback slot for a pre-auth connection.
pub(crate) fn try_acquire_masking_fallback_permit(&self) -> Option<OwnedSemaphorePermit> {
self.masking_fallback_permits
.clone()
.try_acquire_owned()
.ok()
}
pub(crate) fn is_user_enabled(&self, user: &str) -> bool { pub(crate) fn is_user_enabled(&self, user: &str) -> bool {
!self.disabled_users.contains_key(user) !self.disabled_users.contains_key(user)
} }
@@ -34,7 +34,7 @@ fn loop_guard_unspecified_bind_uses_interface_inventory() {
"mask.example", "mask.example",
443, 443,
local, local,
Some(resolved), &[resolved],
&interfaces, &interfaces,
)); ));
} }
@@ -25,7 +25,7 @@ async fn adversarial_parallel_cold_miss_performs_single_interface_refresh() {
let barrier = std::sync::Arc::clone(&barrier); let barrier = std::sync::Arc::clone(&barrier);
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(async move {
barrier.wait().await; barrier.wait().await;
is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await
})); }));
} }
@@ -17,8 +17,8 @@ async fn tdd_repeated_local_listener_checks_do_not_repeat_interface_enumeration_
let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr"); let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr");
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await; let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await;
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await; let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await;
assert_eq!( assert_eq!(
local_interface_enumerations_for_tests(), local_interface_enumerations_for_tests(),
@@ -35,7 +35,7 @@ async fn tdd_non_local_port_short_circuit_does_not_enumerate_interfaces() {
reset_local_interface_enumerations_for_tests(); reset_local_interface_enumerations_for_tests();
let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr"); let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr");
let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, None).await; let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, &[]).await;
assert!( assert!(
!is_local, !is_local,
@@ -0,0 +1,111 @@
use super::*;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::time::{Duration, Instant};
const PERF_TOTAL_BYTES: usize = 64 * 1024 * 1024;
struct PatternReader {
remaining: usize,
chunk: usize,
read_calls: AtomicUsize,
}
impl PatternReader {
fn new(total: usize, chunk: usize) -> Self {
Self {
remaining: total,
chunk,
read_calls: AtomicUsize::new(0),
}
}
fn read_calls(&self) -> usize {
self.read_calls.load(Ordering::Relaxed)
}
}
impl AsyncRead for PatternReader {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
self.read_calls.fetch_add(1, Ordering::Relaxed);
if self.remaining == 0 {
return Poll::Ready(Ok(()));
}
let take = self.remaining.min(self.chunk).min(buf.remaining());
if take == 0 {
return Poll::Ready(Ok(()));
}
static PATTERN: [u8; MASK_BUFFER_MAX_SIZE] = [0xA5; MASK_BUFFER_MAX_SIZE];
buf.put_slice(&PATTERN[..take]);
self.remaining -= take;
Poll::Ready(Ok(()))
}
}
#[derive(Default)]
struct CountingWriter {
written: usize,
}
impl AsyncWrite for CountingWriter {
fn poll_write(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
self.written = self.written.saturating_add(buf.len());
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
#[ignore = "manual benchmark: throughput-sensitive and host-dependent"]
async fn masking_copy_with_idle_timeout_manual_throughput() {
let mut reader = PatternReader::new(PERF_TOTAL_BYTES, MASK_BUFFER_MAX_SIZE);
let mut writer = CountingWriter::default();
let started = Instant::now();
let outcome = copy_with_idle_timeout(
&mut reader,
&mut writer,
PERF_TOTAL_BYTES,
true,
Duration::from_secs(30),
)
.await;
let elapsed = started.elapsed();
let mb = PERF_TOTAL_BYTES as f64 / (1024.0 * 1024.0);
let mbps = mb / elapsed.as_secs_f64();
assert_eq!(outcome.total, PERF_TOTAL_BYTES);
assert_eq!(writer.written, PERF_TOTAL_BYTES);
assert!(
!outcome.ended_by_eof,
"manual throughput run should terminate at byte cap"
);
eprintln!(
"masking manual throughput: bytes={} elapsed_ms={} mib_per_sec={:.2} read_calls={}",
PERF_TOTAL_BYTES,
elapsed.as_millis(),
mbps,
reader.read_calls()
);
}
@@ -15,38 +15,49 @@ fn closed_local_port() -> u16 {
#[tokio::test] #[tokio::test]
async fn self_target_detection_matches_literal_ipv4_listener() { async fn self_target_detection_matches_literal_ipv4_listener() {
let local: SocketAddr = "198.51.100.40:443".parse().unwrap(); let local: SocketAddr = "198.51.100.40:443".parse().unwrap();
assert!(is_mask_target_local_listener_async("198.51.100.40", 443, local, None,).await); assert!(is_mask_target_local_listener_async("198.51.100.40", 443, local, &[],).await);
} }
#[tokio::test] #[tokio::test]
async fn self_target_detection_matches_bracketed_ipv6_listener() { async fn self_target_detection_matches_bracketed_ipv6_listener() {
let local: SocketAddr = "[2001:db8::44]:8443".parse().unwrap(); let local: SocketAddr = "[2001:db8::44]:8443".parse().unwrap();
assert!(is_mask_target_local_listener_async("[2001:db8::44]", 8443, local, None,).await); assert!(is_mask_target_local_listener_async("[2001:db8::44]", 8443, local, &[],).await);
} }
#[tokio::test] #[tokio::test]
async fn self_target_detection_keeps_same_ip_different_port_forwardable() { async fn self_target_detection_keeps_same_ip_different_port_forwardable() {
let local: SocketAddr = "203.0.113.44:443".parse().unwrap(); let local: SocketAddr = "203.0.113.44:443".parse().unwrap();
assert!(!is_mask_target_local_listener_async("203.0.113.44", 8443, local, None,).await); assert!(!is_mask_target_local_listener_async("203.0.113.44", 8443, local, &[],).await);
} }
#[tokio::test] #[tokio::test]
async fn self_target_detection_normalizes_ipv4_mapped_ipv6_literal() { async fn self_target_detection_normalizes_ipv4_mapped_ipv6_literal() {
let local: SocketAddr = "127.0.0.1:443".parse().unwrap(); let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
assert!(is_mask_target_local_listener_async("::ffff:127.0.0.1", 443, local, None,).await); assert!(is_mask_target_local_listener_async("::ffff:127.0.0.1", 443, local, &[],).await);
} }
#[tokio::test] #[tokio::test]
async fn self_target_detection_unspecified_bind_blocks_loopback_target() { async fn self_target_detection_unspecified_bind_blocks_loopback_target() {
let local: SocketAddr = "0.0.0.0:443".parse().unwrap(); let local: SocketAddr = "0.0.0.0:443".parse().unwrap();
assert!(is_mask_target_local_listener_async("127.0.0.1", 443, local, None,).await); assert!(is_mask_target_local_listener_async("127.0.0.1", 443, local, &[],).await);
} }
#[tokio::test] #[tokio::test]
async fn self_target_detection_unspecified_bind_keeps_remote_target_forwardable() { async fn self_target_detection_unspecified_bind_keeps_remote_target_forwardable() {
let local: SocketAddr = "0.0.0.0:443".parse().unwrap(); let local: SocketAddr = "0.0.0.0:443".parse().unwrap();
let remote: SocketAddr = "198.51.100.44:443".parse().unwrap(); let remote: SocketAddr = "198.51.100.44:443".parse().unwrap();
assert!(!is_mask_target_local_listener_async("mask.example", 443, local, Some(remote),).await); assert!(!is_mask_target_local_listener_async("mask.example", 443, local, &[remote],).await);
}
#[tokio::test]
async fn self_target_detection_checks_all_resolved_addresses() {
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
let remote: SocketAddr = "198.51.100.44:443".parse().unwrap();
let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap();
assert!(
is_mask_target_local_listener_async("mask.example", 443, local, &[remote, loopback],).await
);
} }
#[tokio::test] #[tokio::test]
+40 -30
View File
@@ -15,6 +15,7 @@ use crate::crypto::SecureRandom;
use crate::protocol::constants::{ use crate::protocol::constants::{
ProtoTag, is_valid_secure_payload_len, secure_padding_len, secure_payload_len_from_wire_len, ProtoTag, is_valid_secure_payload_len, secure_padding_len, secure_payload_len_from_wire_len,
}; };
use crate::protocol::framing::{encode_intermediate_header, parse_intermediate_header};
// ============= Unified Codec ============= // ============= Unified Codec =============
@@ -197,13 +198,9 @@ fn decode_intermediate(src: &mut BytesMut, max_size: usize) -> io::Result<Option
} }
let mut meta = FrameMeta::new(); let mut meta = FrameMeta::new();
let mut len = u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize; let header = parse_intermediate_header([src[0], src[1], src[2], src[3]]);
let len = header.wire_len;
// Check QuickACK flag meta.quickack = header.quickack;
if len >= 0x80000000 {
meta.quickack = true;
len -= 0x80000000;
}
// Validate size // Validate size
if len > max_size { if len > max_size {
@@ -239,10 +236,12 @@ fn encode_intermediate(frame: &Frame, dst: &mut BytesMut) -> io::Result<()> {
dst.reserve(4 + data.len()); dst.reserve(4 + data.len());
let mut len = data.len() as u32; let len = encode_intermediate_header(data.len(), frame.meta.quickack).ok_or_else(|| {
if frame.meta.quickack { Error::new(
len |= 0x80000000; ErrorKind::InvalidInput,
} format!("frame too large: {} bytes", data.len()),
)
})?;
dst.extend_from_slice(&len.to_le_bytes()); dst.extend_from_slice(&len.to_le_bytes());
dst.extend_from_slice(data); dst.extend_from_slice(data);
@@ -258,13 +257,9 @@ fn decode_secure(src: &mut BytesMut, max_size: usize) -> io::Result<Option<Frame
} }
let mut meta = FrameMeta::new(); let mut meta = FrameMeta::new();
let mut len = u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize; let header = parse_intermediate_header([src[0], src[1], src[2], src[3]]);
let len = header.wire_len;
// Check QuickACK flag meta.quickack = header.quickack;
if len >= 0x80000000 {
meta.quickack = true;
len -= 0x80000000;
}
// Validate size // Validate size
if len > max_size { if len > max_size {
@@ -317,16 +312,18 @@ fn encode_secure(frame: &Frame, dst: &mut BytesMut, rng: &SecureRandom) -> io::R
)); ));
} }
// Generate padding that keeps total length non-divisible by 4. // Telegram Desktop VersionD uses a 4-bit random padding length.
let padding_len = secure_padding_len(data.len(), rng); let padding_len = secure_padding_len(data.len(), rng);
let total_len = data.len() + padding_len; let total_len = data.len() + padding_len;
dst.reserve(4 + total_len); dst.reserve(4 + total_len);
let mut len = total_len as u32; let len = encode_intermediate_header(total_len, frame.meta.quickack).ok_or_else(|| {
if frame.meta.quickack { Error::new(
len |= 0x80000000; ErrorKind::InvalidInput,
} format!("frame too large: {} bytes", total_len),
)
})?;
dst.extend_from_slice(&len.to_le_bytes()); dst.extend_from_slice(&len.to_le_bytes());
dst.extend_from_slice(data); dst.extend_from_slice(data);
@@ -523,6 +520,16 @@ mod tests {
use tokio::io::duplex; use tokio::io::duplex;
use tokio_util::codec::{FramedRead, FramedWrite}; use tokio_util::codec::{FramedRead, FramedWrite};
fn assert_secure_decoded_payload(decoded: &[u8], original: &[u8]) {
assert!(decoded.starts_with(original));
assert!(
(original.len()..=original.len() + 12).contains(&decoded.len()),
"Secure decoded payload may retain up to 12 bytes of full-word padding, got {}",
decoded.len()
);
assert_eq!(decoded.len() % 4, 0);
}
#[tokio::test] #[tokio::test]
async fn test_framed_abridged() { async fn test_framed_abridged() {
let (client, server) = duplex(4096); let (client, server) = duplex(4096);
@@ -565,7 +572,7 @@ mod tests {
writer.send(frame).await.unwrap(); writer.send(frame).await.unwrap();
let received = reader.next().await.unwrap().unwrap(); let received = reader.next().await.unwrap().unwrap();
assert_eq!(&received.data[..], &original[..]); assert_secure_decoded_payload(&received.data, &original);
} }
#[tokio::test] #[tokio::test]
@@ -588,7 +595,11 @@ mod tests {
writer.send(frame).await.unwrap(); writer.send(frame).await.unwrap();
let received = reader.next().await.unwrap().unwrap(); let received = reader.next().await.unwrap().unwrap();
assert_eq!(received.data.len(), 8); if proto_tag == ProtoTag::Secure {
assert_secure_decoded_payload(&received.data, &original);
} else {
assert_eq!(received.data.len(), original.len());
}
} }
} }
@@ -642,7 +653,7 @@ mod tests {
} }
#[test] #[test]
fn secure_codec_always_adds_padding_and_jitters_wire_length() { fn secure_codec_uses_tdesktop_padding_range_and_jitters_wire_length() {
let codec = SecureCodec::new(Arc::new(SecureRandom::new())); let codec = SecureCodec::new(Arc::new(SecureRandom::new()));
let payload = Bytes::from_static(&[1, 2, 3, 4, 5, 6, 7, 8]); let payload = Bytes::from_static(&[1, 2, 3, 4, 5, 6, 7, 8]);
let mut wire_lens = HashSet::new(); let mut wire_lens = HashSet::new();
@@ -652,13 +663,12 @@ mod tests {
let mut out = BytesMut::new(); let mut out = BytesMut::new();
codec.encode(&frame, &mut out).unwrap(); codec.encode(&frame, &mut out).unwrap();
assert!(out.len() > 4 + payload.len());
let wire_len = u32::from_le_bytes([out[0], out[1], out[2], out[3]]) as usize; let wire_len = u32::from_le_bytes([out[0], out[1], out[2], out[3]]) as usize;
assert_eq!(out.len(), 4 + wire_len);
assert!( assert!(
(payload.len() + 1..=payload.len() + 3).contains(&wire_len), (payload.len()..=payload.len() + 15).contains(&wire_len),
"Secure wire length must be payload+1..3, got {wire_len}" "Secure wire length must be payload+0..15, got {wire_len}"
); );
assert_ne!(wire_len % 4, 0, "Secure wire length must be non-4-aligned");
wire_lens.insert(wire_len); wire_lens.insert(wire_len);
} }
+209 -40
View File
@@ -5,21 +5,47 @@
use super::traits::{FrameMeta, LayeredStream}; use super::traits::{FrameMeta, LayeredStream};
use crate::crypto::{SecureRandom, crc32}; use crate::crypto::{SecureRandom, crc32};
use crate::protocol::constants::*; use crate::protocol::constants::*;
use crate::protocol::framing::{encode_intermediate_header, parse_intermediate_header};
use bytes::Bytes; use bytes::Bytes;
use std::io::{Error, ErrorKind, Result}; use std::io::{Error, ErrorKind, Result};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
const DEFAULT_MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
fn reject_oversize_frame(len: usize, max_frame_size: usize, protocol: &str) -> Result<()> {
if len > max_frame_size {
return Err(Error::new(
ErrorKind::InvalidData,
format!("{protocol} frame too large: {len} bytes (max {max_frame_size})"),
));
}
Ok(())
}
// ============= Abridged (Compact) Frame ============= // ============= Abridged (Compact) Frame =============
/// Reader for abridged MTProto framing /// Reader for abridged MTProto framing
pub struct AbridgedFrameReader<R> { pub struct AbridgedFrameReader<R> {
upstream: R, upstream: R,
max_frame_size: usize,
} }
impl<R> AbridgedFrameReader<R> { impl<R> AbridgedFrameReader<R> {
/// Creates a reader with the default maximum frame size.
pub fn new(upstream: R) -> Self { pub fn new(upstream: R) -> Self {
Self { upstream } Self {
upstream,
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
}
}
fn with_max_frame_size(upstream: R, max_frame_size: usize) -> Self {
Self {
upstream,
max_frame_size,
}
} }
} }
@@ -47,10 +73,12 @@ impl<R: AsyncRead + Unpin> AbridgedFrameReader<R> {
len = u32::from_le_bytes([len_bytes[0], len_bytes[1], len_bytes[2], 0]) as usize; len = u32::from_le_bytes([len_bytes[0], len_bytes[1], len_bytes[2], 0]) as usize;
} }
// Length is in 4-byte words // Length is in 4-byte words.
let byte_len = len * 4; let byte_len = len
.checked_mul(4)
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "abridged frame length overflow"))?;
reject_oversize_frame(byte_len, self.max_frame_size, "abridged")?;
// Read data
let mut data = vec![0u8; byte_len]; let mut data = vec![0u8; byte_len];
self.upstream.read_exact(&mut data).await?; self.upstream.read_exact(&mut data).await?;
@@ -105,10 +133,17 @@ impl<W: AsyncWrite + Unpin> AbridgedFrameWriter<W> {
if len_div_4 < 0x7f { if len_div_4 < 0x7f {
// Short length (1 byte) // Short length (1 byte)
self.upstream.write_all(&[len_div_4 as u8]).await?; let mut first = len_div_4 as u8;
if meta.quickack {
first |= 0x80;
}
self.upstream.write_all(&[first]).await?;
} else if len_div_4 < (1 << 24) { } else if len_div_4 < (1 << 24) {
// Long length (4 bytes: 0x7f + 3 bytes) // Long length (4 bytes: 0x7f + 3 bytes)
let mut header = [0x7f, 0, 0, 0]; let mut header = [0x7f, 0, 0, 0];
if meta.quickack {
header[0] |= 0x80;
}
header[1..4].copy_from_slice(&(len_div_4 as u32).to_le_bytes()[..3]); header[1..4].copy_from_slice(&(len_div_4 as u32).to_le_bytes()[..3]);
self.upstream.write_all(&header).await?; self.upstream.write_all(&header).await?;
} else { } else {
@@ -144,11 +179,23 @@ impl<W> LayeredStream<W> for AbridgedFrameWriter<W> {
/// Reader for intermediate MTProto framing /// Reader for intermediate MTProto framing
pub struct IntermediateFrameReader<R> { pub struct IntermediateFrameReader<R> {
upstream: R, upstream: R,
max_frame_size: usize,
} }
impl<R> IntermediateFrameReader<R> { impl<R> IntermediateFrameReader<R> {
/// Creates a reader with the default maximum frame size.
pub fn new(upstream: R) -> Self { pub fn new(upstream: R) -> Self {
Self { upstream } Self {
upstream,
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
}
}
fn with_max_frame_size(upstream: R, max_frame_size: usize) -> Self {
Self {
upstream,
max_frame_size,
}
} }
} }
@@ -160,15 +207,11 @@ impl<R: AsyncRead + Unpin> IntermediateFrameReader<R> {
let mut len_bytes = [0u8; 4]; let mut len_bytes = [0u8; 4];
self.upstream.read_exact(&mut len_bytes).await?; self.upstream.read_exact(&mut len_bytes).await?;
let mut len = u32::from_le_bytes(len_bytes) as usize; let header = parse_intermediate_header(len_bytes);
let len = header.wire_len;
meta.quickack = header.quickack;
reject_oversize_frame(len, self.max_frame_size, "intermediate")?;
// Check QuickACK flag (high bit)
if len > 0x80000000 {
meta.quickack = true;
len -= 0x80000000;
}
// Read data
let mut data = vec![0u8; len]; let mut data = vec![0u8; len];
self.upstream.read_exact(&mut data).await?; self.upstream.read_exact(&mut data).await?;
@@ -204,7 +247,13 @@ impl<W: AsyncWrite + Unpin> IntermediateFrameWriter<W> {
if meta.simple_ack { if meta.simple_ack {
self.upstream.write_all(data).await?; self.upstream.write_all(data).await?;
} else { } else {
let len_bytes = (data.len() as u32).to_le_bytes(); let len = encode_intermediate_header(data.len(), meta.quickack).ok_or_else(|| {
Error::new(
ErrorKind::InvalidInput,
format!("Frame too large: {} bytes", data.len()),
)
})?;
let len_bytes = len.to_le_bytes();
self.upstream.write_all(&len_bytes).await?; self.upstream.write_all(&len_bytes).await?;
self.upstream.write_all(data).await?; self.upstream.write_all(data).await?;
} }
@@ -233,11 +282,23 @@ impl<W> LayeredStream<W> for IntermediateFrameWriter<W> {
/// Reader for secure intermediate MTProto framing (with padding) /// Reader for secure intermediate MTProto framing (with padding)
pub struct SecureIntermediateFrameReader<R> { pub struct SecureIntermediateFrameReader<R> {
upstream: R, upstream: R,
max_frame_size: usize,
} }
impl<R> SecureIntermediateFrameReader<R> { impl<R> SecureIntermediateFrameReader<R> {
/// Creates a reader with the default maximum frame size.
pub fn new(upstream: R) -> Self { pub fn new(upstream: R) -> Self {
Self { upstream } Self {
upstream,
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
}
}
fn with_max_frame_size(upstream: R, max_frame_size: usize) -> Self {
Self {
upstream,
max_frame_size,
}
} }
} }
@@ -249,24 +310,19 @@ impl<R: AsyncRead + Unpin> SecureIntermediateFrameReader<R> {
let mut len_bytes = [0u8; 4]; let mut len_bytes = [0u8; 4];
self.upstream.read_exact(&mut len_bytes).await?; self.upstream.read_exact(&mut len_bytes).await?;
let mut len = u32::from_le_bytes(len_bytes) as usize; let header = parse_intermediate_header(len_bytes);
let len = header.wire_len;
// Check QuickACK flag meta.quickack = header.quickack;
if len > 0x80000000 { reject_oversize_frame(len, self.max_frame_size, "secure intermediate")?;
meta.quickack = true;
len -= 0x80000000;
}
// Read data (including padding)
let mut data = vec![0u8; len];
self.upstream.read_exact(&mut data).await?;
let payload_len = secure_payload_len_from_wire_len(len).ok_or_else(|| { let payload_len = secure_payload_len_from_wire_len(len).ok_or_else(|| {
Error::new( Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
format!("Invalid secure frame length: {len}"), format!("Invalid secure frame length: {len}"),
) )
})?; })?;
let mut data = vec![0u8; len];
self.upstream.read_exact(&mut data).await?;
data.truncate(payload_len); data.truncate(payload_len);
Ok((Bytes::from(data), meta)) Ok((Bytes::from(data), meta))
@@ -311,12 +367,21 @@ impl<W: AsyncWrite + Unpin> SecureIntermediateFrameWriter<W> {
)); ));
} }
// Add padding so total length is never divisible by 4 (MTProto Secure) // Telegram Desktop VersionD uses a 4-bit random padding length.
let padding_len = secure_padding_len(data.len(), &self.rng); let padding_len = secure_padding_len(data.len(), &self.rng);
let padding = self.rng.bytes(padding_len); let padding = self.rng.bytes(padding_len);
let total_len = data.len() + padding_len; let total_len = data
let len_bytes = (total_len as u32).to_le_bytes(); .len()
.checked_add(padding_len)
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, "secure frame length overflow"))?;
let len = encode_intermediate_header(total_len, meta.quickack).ok_or_else(|| {
Error::new(
ErrorKind::InvalidInput,
format!("Frame too large: {total_len} bytes"),
)
})?;
let len_bytes = len.to_le_bytes();
self.upstream.write_all(&len_bytes).await?; self.upstream.write_all(&len_bytes).await?;
self.upstream.write_all(data).await?; self.upstream.write_all(data).await?;
@@ -495,15 +560,22 @@ pub enum FrameReaderKind<R> {
} }
impl<R: AsyncRead + Unpin> FrameReaderKind<R> { impl<R: AsyncRead + Unpin> FrameReaderKind<R> {
/// Creates a frame reader with the default maximum frame size.
pub fn new(upstream: R, proto_tag: ProtoTag) -> Self { pub fn new(upstream: R, proto_tag: ProtoTag) -> Self {
Self::with_max_frame_size(upstream, proto_tag, DEFAULT_MAX_FRAME_SIZE)
}
fn with_max_frame_size(upstream: R, proto_tag: ProtoTag, max_frame_size: usize) -> Self {
match proto_tag { match proto_tag {
ProtoTag::Abridged => FrameReaderKind::Abridged(AbridgedFrameReader::new(upstream)), ProtoTag::Abridged => FrameReaderKind::Abridged(
ProtoTag::Intermediate => { AbridgedFrameReader::with_max_frame_size(upstream, max_frame_size),
FrameReaderKind::Intermediate(IntermediateFrameReader::new(upstream)) ),
} ProtoTag::Intermediate => FrameReaderKind::Intermediate(
ProtoTag::Secure => { IntermediateFrameReader::with_max_frame_size(upstream, max_frame_size),
FrameReaderKind::SecureIntermediate(SecureIntermediateFrameReader::new(upstream)) ),
} ProtoTag::Secure => FrameReaderKind::SecureIntermediate(
SecureIntermediateFrameReader::with_max_frame_size(upstream, max_frame_size),
),
} }
} }
@@ -557,7 +629,18 @@ mod tests {
use super::*; use super::*;
use crate::crypto::SecureRandom; use crate::crypto::SecureRandom;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::duplex; use tokio::io::{AsyncWriteExt, duplex};
use tokio::time::{Duration, timeout};
fn assert_secure_decoded_payload(decoded: &[u8], original: &[u8]) {
assert!(decoded.starts_with(original));
assert!(
(original.len()..=original.len() + 12).contains(&decoded.len()),
"Secure decoded payload may retain up to 12 bytes of full-word padding, got {}",
decoded.len()
);
assert_eq!(decoded.len() % 4, 0);
}
#[tokio::test] #[tokio::test]
async fn test_abridged_roundtrip() { async fn test_abridged_roundtrip() {
@@ -613,6 +696,92 @@ mod tests {
assert_eq!(&received[..], &data[..]); assert_eq!(&received[..], &data[..]);
} }
#[tokio::test]
async fn test_intermediate_quickack_zero_length_roundtrip() {
let (client, server) = duplex(1024);
let mut writer = IntermediateFrameWriter::new(client);
let mut reader = IntermediateFrameReader::new(server);
writer
.write_frame(&[], &FrameMeta::new().with_quickack())
.await
.unwrap();
writer.flush().await.unwrap();
let (received, meta) = reader.read_frame().await.unwrap();
assert!(received.is_empty());
assert!(meta.quickack);
}
#[tokio::test]
async fn test_abridged_quickack_roundtrip() {
let (client, server) = duplex(1024);
let mut writer = AbridgedFrameWriter::new(client);
let mut reader = AbridgedFrameReader::new(server);
let data = vec![1u8, 2, 3, 4];
writer
.write_frame(&data, &FrameMeta::new().with_quickack())
.await
.unwrap();
writer.flush().await.unwrap();
let (received, meta) = reader.read_frame().await.unwrap();
assert_eq!(&received[..], &data[..]);
assert!(meta.quickack);
}
#[tokio::test]
async fn abridged_reader_rejects_oversize_frame_before_body_read() {
let (mut client, server) = duplex(1024);
let mut reader = AbridgedFrameReader::new(server);
let len_words = (DEFAULT_MAX_FRAME_SIZE / 4) + 1;
let encoded = (len_words as u32).to_le_bytes();
client
.write_all(&[0x7f, encoded[0], encoded[1], encoded[2]])
.await
.unwrap();
let err = timeout(Duration::from_millis(50), reader.read_frame())
.await
.unwrap()
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidData);
}
#[tokio::test]
async fn intermediate_reader_rejects_oversize_frame_before_body_read() {
let (mut client, server) = duplex(1024);
let mut reader = IntermediateFrameReader::new(server);
let len = encode_intermediate_header(DEFAULT_MAX_FRAME_SIZE + 1, false).unwrap();
client.write_all(&len.to_le_bytes()).await.unwrap();
let err = timeout(Duration::from_millis(50), reader.read_frame())
.await
.unwrap()
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidData);
}
#[tokio::test]
async fn secure_reader_rejects_oversize_frame_before_body_read() {
let (mut client, server) = duplex(1024);
let mut reader = SecureIntermediateFrameReader::new(server);
let len = encode_intermediate_header(DEFAULT_MAX_FRAME_SIZE + 4, false).unwrap();
client.write_all(&len.to_le_bytes()).await.unwrap();
let err = timeout(Duration::from_millis(50), reader.read_frame())
.await
.unwrap()
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidData);
}
#[tokio::test] #[tokio::test]
async fn test_secure_intermediate_padding() { async fn test_secure_intermediate_padding() {
let (client, server) = duplex(1024); let (client, server) = duplex(1024);
@@ -625,7 +794,7 @@ mod tests {
writer.flush().await.unwrap(); writer.flush().await.unwrap();
let (received, _meta) = reader.read_frame().await.unwrap(); let (received, _meta) = reader.read_frame().await.unwrap();
assert_eq!(received.len(), data.len()); assert_secure_decoded_payload(&received, &data);
} }
#[tokio::test] #[tokio::test]
+4
View File
@@ -124,6 +124,10 @@ pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
} }
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> { pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
if !has_cap_net_admin() {
return Ok(());
}
let mut errors = Vec::new(); let mut errors = Vec::new();
if let Err(error) = clear_nft_synlimit_rules_all_families().await { if let Err(error) = clear_nft_synlimit_rules_all_families().await {
errors.push(error); errors.push(error);
+17 -81
View File
@@ -18,7 +18,7 @@ use tokio::time::timeout;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::config::MeSocksKdfPolicy; use crate::config::MeSocksKdfPolicy;
use crate::crypto::{SecureRandom, build_middleproxy_prekey, derive_middleproxy_keys, sha256}; use crate::crypto::{SecureRandom, derive_middleproxy_keys};
use crate::error::{ProxyError, Result}; use crate::error::{ProxyError, Result};
use crate::network::IpFamily; use crate::network::IpFamily;
use crate::network::probe::is_bogon; use crate::network::probe::is_bogon;
@@ -292,14 +292,17 @@ impl MePool {
BndPortStatus::Error BndPortStatus::Error
}; };
record_bnd_status(bnd_addr_status, bnd_port_status, raw_socks_bound_addr); record_bnd_status(bnd_addr_status, bnd_port_status, raw_socks_bound_addr);
let reflected = if let Some(bound) = socks_bound_addr { let socks_bound_kdf_addr = socks_bound_addr.filter(|bound| bound.port() != 0);
// SOCKS BND is the only reflected source that can supply both KDF IP and
// port. Direct STUN reflection is IP-only and keeps the TCP local port.
let reflected = if let Some(bound) = socks_bound_kdf_addr {
Some(bound) Some(bound)
} else if is_socks_route { } else if is_socks_route {
match self.socks_kdf_policy() { match self.socks_kdf_policy() {
MeSocksKdfPolicy::Strict => { MeSocksKdfPolicy::Strict => {
self.stats.increment_me_socks_kdf_strict_reject(); self.stats.increment_me_socks_kdf_strict_reject();
return Err(ProxyError::InvalidHandshake( return Err(ProxyError::InvalidHandshake(
"SOCKS route returned no valid BND.ADDR for ME KDF (strict policy)" "SOCKS route returned no valid BND tuple for ME KDF (strict policy)"
.to_string(), .to_string(),
)); ));
} }
@@ -323,16 +326,14 @@ impl MePool {
let local_addr_nat = self.translate_our_addr_with_reflection(local_addr, reflected); let local_addr_nat = self.translate_our_addr_with_reflection(local_addr, reflected);
let peer_addr_nat = let peer_addr_nat =
SocketAddr::new(self.translate_ip_for_nat(peer_addr.ip()), peer_addr.port()); SocketAddr::new(self.translate_ip_for_nat(peer_addr.ip()), peer_addr.port());
let client_addr_for_kdf = socks_bound_kdf_addr.unwrap_or(local_addr_nat);
if let Some(upstream_info) = upstream_egress { if let Some(upstream_info) = upstream_egress {
let client_ip_for_kdf = socks_bound_addr
.map(|value| value.ip())
.unwrap_or(local_addr_nat.ip());
record_upstream_bnd_status( record_upstream_bnd_status(
upstream_info.upstream_id, upstream_info.upstream_id,
bnd_addr_status, bnd_addr_status,
bnd_port_status, bnd_port_status,
raw_socks_bound_addr, raw_socks_bound_addr,
Some(client_ip_for_kdf), Some(client_addr_for_kdf.ip()),
); );
} }
let (mut rd, mut wr) = tokio::io::split(stream); let (mut rd, mut wr) = tokio::io::split(stream);
@@ -409,6 +410,7 @@ impl MePool {
info!( info!(
%local_addr, %local_addr,
%local_addr_nat, %local_addr_nat,
%client_addr_for_kdf,
reflected_ip = reflected.map(|r| r.ip()).as_ref().map(ToString::to_string), reflected_ip = reflected.map(|r| r.ip()).as_ref().map(ToString::to_string),
%peer_addr, %peer_addr,
%transport_peer_addr, %transport_peer_addr,
@@ -417,21 +419,20 @@ impl MePool {
key_selector = format_args!("0x{ks:08x}"), key_selector = format_args!("0x{ks:08x}"),
crypto_schema = format_args!("0x{schema:08x}"), crypto_schema = format_args!("0x{schema:08x}"),
skew_secs = skew, skew_secs = skew,
socks_kdf_policy = ?self.socks_kdf_policy(),
"ME key derivation parameters" "ME key derivation parameters"
); );
let ts_bytes = crypto_ts.to_le_bytes(); let ts_bytes = crypto_ts.to_le_bytes();
let server_port_bytes = peer_addr_nat.port().to_le_bytes(); let server_port_bytes = peer_addr_nat.port().to_le_bytes();
let socks_bound_port = socks_bound_addr let socks_bound_port = socks_bound_kdf_addr.map(|bound| bound.port());
.map(|bound| bound.port()) let client_port_for_kdf = client_addr_for_kdf.port();
.filter(|port| *port != 0);
let client_port_for_kdf = socks_bound_port.unwrap_or(local_addr_nat.port());
let client_port_source = KdfClientPortSource::from_socks_bound_port(socks_bound_port); let client_port_source = KdfClientPortSource::from_socks_bound_port(socks_bound_port);
let kdf_fingerprint = Self::kdf_material_fingerprint( let kdf_fingerprint = Self::kdf_material_fingerprint(
local_addr_nat.ip(), client_addr_for_kdf.ip(),
peer_addr_nat, peer_addr_nat,
reflected.map(|value| value.ip()), reflected.map(|value| value.ip()),
socks_bound_addr.map(|value| value.ip()), socks_bound_kdf_addr.map(|value| value.ip()),
client_port_source, client_port_source,
); );
let previous_kdf_fingerprint = { let previous_kdf_fingerprint = {
@@ -473,7 +474,7 @@ impl MePool {
let client_port_bytes = client_port_for_kdf.to_le_bytes(); let client_port_bytes = client_port_for_kdf.to_le_bytes();
let server_ip = extract_ip_material(peer_addr_nat); let server_ip = extract_ip_material(peer_addr_nat);
let client_ip = extract_ip_material(local_addr_nat); let client_ip = extract_ip_material(client_addr_for_kdf);
let (srv_ip_opt, clt_ip_opt, clt_v6_opt, srv_v6_opt, hs_our_ip, hs_peer_ip) = let (srv_ip_opt, clt_ip_opt, clt_v6_opt, srv_v6_opt, hs_our_ip, hs_peer_ip) =
match (server_ip, client_ip) { match (server_ip, client_ip) {
@@ -494,38 +495,6 @@ impl MePool {
} }
}; };
let diag_level: u8 = std::env::var("ME_DIAG")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let prekey_client = build_middleproxy_prekey(
&srv_nonce,
&my_nonce,
&ts_bytes,
srv_ip_opt.as_ref().map(|x| &x[..]),
&client_port_bytes,
b"CLIENT",
clt_ip_opt.as_ref().map(|x| &x[..]),
&server_port_bytes,
&secret,
clt_v6_opt.as_ref(),
srv_v6_opt.as_ref(),
);
let prekey_server = build_middleproxy_prekey(
&srv_nonce,
&my_nonce,
&ts_bytes,
srv_ip_opt.as_ref().map(|x| &x[..]),
&client_port_bytes,
b"SERVER",
clt_ip_opt.as_ref().map(|x| &x[..]),
&server_port_bytes,
&secret,
clt_v6_opt.as_ref(),
srv_v6_opt.as_ref(),
);
let (wk, wi) = derive_middleproxy_keys( let (wk, wi) = derive_middleproxy_keys(
&srv_nonce, &srv_nonce,
&my_nonce, &my_nonce,
@@ -556,47 +525,14 @@ impl MePool {
let requested_crc_mode = RpcChecksumMode::Crc32c; let requested_crc_mode = RpcChecksumMode::Crc32c;
let hs_payload = build_handshake_payload( let hs_payload = build_handshake_payload(
hs_our_ip, hs_our_ip,
local_addr.port(), client_port_for_kdf,
hs_peer_ip, hs_peer_ip,
peer_addr.port(), peer_addr_nat.port(),
requested_crc_mode.advertised_flags(), requested_crc_mode.advertised_flags(),
); );
let hs_frame = build_rpc_frame(-1, &hs_payload, RpcChecksumMode::Crc32); let hs_frame = build_rpc_frame(-1, &hs_payload, RpcChecksumMode::Crc32);
if diag_level >= 1 {
info!(
write_key = %hex_dump(&wk),
write_iv = %hex_dump(&wi),
read_key = %hex_dump(&rk),
read_iv = %hex_dump(&ri),
srv_ip = %srv_ip_opt.map(|ip| hex_dump(&ip)).unwrap_or_default(),
clt_ip = %clt_ip_opt.map(|ip| hex_dump(&ip)).unwrap_or_default(),
srv_port = %hex_dump(&server_port_bytes),
clt_port = %hex_dump(&client_port_bytes),
crypto_ts = %hex_dump(&ts_bytes),
nonce_srv = %hex_dump(&srv_nonce),
nonce_clt = %hex_dump(&my_nonce),
prekey_sha256_client = %hex_dump(&sha256(&prekey_client)),
prekey_sha256_server = %hex_dump(&sha256(&prekey_server)),
hs_plain = %hex_dump(&hs_frame),
proxy_secret_sha256 = %hex_dump(&sha256(&secret)),
"ME diag: derived keys and handshake plaintext"
);
}
if diag_level >= 2 {
info!(
prekey_client = %hex_dump(&prekey_client),
prekey_server = %hex_dump(&prekey_server),
"ME diag: full prekey buffers"
);
}
let (encrypted_hs, write_iv) = cbc_encrypt_padded(&wk, &wi, &hs_frame)?; let (encrypted_hs, write_iv) = cbc_encrypt_padded(&wk, &wi, &hs_frame)?;
if diag_level >= 1 {
info!(
hs_cipher = %hex_dump(&encrypted_hs),
"ME diag: handshake ciphertext"
);
}
wr.write_all(&encrypted_hs).await.map_err(ProxyError::Io)?; wr.write_all(&encrypted_hs).await.map_err(ProxyError::Io)?;
wr.flush().await.map_err(ProxyError::Io)?; wr.flush().await.map_err(ProxyError::Io)?;
+2
View File
@@ -1728,6 +1728,8 @@ mod tests {
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
+6
View File
@@ -336,6 +336,8 @@ pub(super) struct NatRuntimeCore {
pub(super) nat_probe: bool, pub(super) nat_probe: bool,
pub(super) nat_stun: Option<String>, pub(super) nat_stun: Option<String>,
pub(super) nat_stun_servers: Vec<String>, pub(super) nat_stun_servers: Vec<String>,
pub(super) stun_tcp_fallback: bool,
pub(super) http_ip_detect_urls: Vec<String>,
pub(super) nat_stun_live_servers: Arc<RwLock<Vec<String>>>, pub(super) nat_stun_live_servers: Arc<RwLock<Vec<String>>>,
pub(super) nat_probe_concurrency: usize, pub(super) nat_probe_concurrency: usize,
pub(super) detected_ipv6: Option<Ipv6Addr>, pub(super) detected_ipv6: Option<Ipv6Addr>,
@@ -484,6 +486,8 @@ impl MePool {
nat_probe: bool, nat_probe: bool,
nat_stun: Option<String>, nat_stun: Option<String>,
nat_stun_servers: Vec<String>, nat_stun_servers: Vec<String>,
stun_tcp_fallback: bool,
http_ip_detect_urls: Vec<String>,
nat_probe_concurrency: usize, nat_probe_concurrency: usize,
detected_ipv6: Option<Ipv6Addr>, detected_ipv6: Option<Ipv6Addr>,
me_one_retry: u8, me_one_retry: u8,
@@ -706,6 +710,8 @@ impl MePool {
nat_probe, nat_probe,
nat_stun, nat_stun,
nat_stun_servers, nat_stun_servers,
stun_tcp_fallback,
http_ip_detect_urls,
nat_stun_live_servers: Arc::new(RwLock::new(Vec::new())), nat_stun_live_servers: Arc::new(RwLock::new(Vec::new())),
nat_probe_concurrency: nat_probe_concurrency.max(1), nat_probe_concurrency: nat_probe_concurrency.max(1),
detected_ipv6, detected_ipv6,
+30 -49
View File
@@ -1,19 +1,22 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr}; use std::net::IpAddr;
use std::time::Duration; use std::time::Duration;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio::time::timeout; use tokio::time::timeout;
use tracing::{debug, info, warn}; use tracing::{debug, info};
use crate::error::{ProxyError, Result}; use crate::error::{ProxyError, Result};
use crate::network::probe::is_bogon; use crate::network::probe::{detect_public_ipv4_http, is_bogon};
use crate::network::stun::{IpFamily, stun_probe_dual, stun_probe_family_with_bind}; use crate::network::stun::{
IpFamily, stun_probe_dual_with_tcp_fallback, stun_probe_family_with_bind_and_tcp_fallback,
};
use super::MePool; use super::MePool;
use std::time::Instant; use std::time::Instant;
const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5); const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5);
const STUN_BATCH_TCP_FALLBACK_TIMEOUT: Duration = Duration::from_secs(12);
#[allow(dead_code)] #[allow(dead_code)]
pub async fn stun_probe(stun_addr: Option<String>) -> Result<crate::network::stun::DualStunResult> { pub async fn stun_probe(stun_addr: Option<String>) -> Result<crate::network::stun::DualStunResult> {
@@ -28,16 +31,13 @@ pub async fn stun_probe(stun_addr: Option<String>) -> Result<crate::network::stu
"STUN server is not configured".to_string(), "STUN server is not configured".to_string(),
)); ));
} }
stun_probe_dual(&stun_addr).await stun_probe_dual_with_tcp_fallback(&stun_addr, false).await
} }
#[allow(dead_code)] #[allow(dead_code)]
pub async fn detect_public_ip() -> Option<IpAddr> { pub async fn detect_public_ip() -> Option<IpAddr> {
fetch_public_ipv4_with_retry() let urls = crate::config::defaults::default_http_ip_detect_urls();
.await detect_public_ipv4_http(&urls).await.map(IpAddr::V4)
.ok()
.flatten()
.map(IpAddr::V4)
} }
impl MePool { impl MePool {
@@ -65,15 +65,26 @@ impl MePool {
let mut live_servers = Vec::new(); let mut live_servers = Vec::new();
let mut best_by_ip: HashMap<IpAddr, (usize, std::net::SocketAddr)> = HashMap::new(); let mut best_by_ip: HashMap<IpAddr, (usize, std::net::SocketAddr)> = HashMap::new();
let concurrency = self.nat_runtime.nat_probe_concurrency.max(1); let concurrency = self.nat_runtime.nat_probe_concurrency.max(1);
let tcp_fallback = self.nat_runtime.stun_tcp_fallback;
while next_idx < servers.len() || !join_set.is_empty() { while next_idx < servers.len() || !join_set.is_empty() {
while next_idx < servers.len() && join_set.len() < concurrency { while next_idx < servers.len() && join_set.len() < concurrency {
let stun_addr = servers[next_idx].clone(); let stun_addr = servers[next_idx].clone();
next_idx += 1; next_idx += 1;
join_set.spawn(async move { join_set.spawn(async move {
let batch_timeout = if tcp_fallback {
STUN_BATCH_TCP_FALLBACK_TIMEOUT
} else {
STUN_BATCH_TIMEOUT
};
let res = timeout( let res = timeout(
STUN_BATCH_TIMEOUT, batch_timeout,
stun_probe_family_with_bind(&stun_addr, family, bind_ip), stun_probe_family_with_bind_and_tcp_fallback(
&stun_addr,
family,
bind_ip,
tcp_fallback,
),
) )
.await; .await;
(stun_addr, res) (stun_addr, res)
@@ -193,6 +204,10 @@ impl MePool {
return self.nat_runtime.nat_ip_cfg; return self.nat_runtime.nat_ip_cfg;
} }
if !self.nat_runtime.nat_probe {
return None;
}
if !(is_bogon(local_ip) || local_ip.is_loopback() || local_ip.is_unspecified()) { if !(is_bogon(local_ip) || local_ip.is_loopback() || local_ip.is_unspecified()) {
return None; return None;
} }
@@ -201,8 +216,9 @@ impl MePool {
return Some(ip); return Some(ip);
} }
match fetch_public_ipv4_with_retry().await { let Some(ip) = detect_public_ipv4_http(&self.nat_runtime.http_ip_detect_urls).await else {
Ok(Some(ip)) => { return None;
};
{ {
let mut guard = self.nat_runtime.nat_ip_detected.write().await; let mut guard = self.nat_runtime.nat_ip_detected.write().await;
*guard = Some(IpAddr::V4(ip)); *guard = Some(IpAddr::V4(ip));
@@ -210,13 +226,6 @@ impl MePool {
info!(public_ip = %ip, "Auto-detected public IP for NAT translation"); info!(public_ip = %ip, "Auto-detected public IP for NAT translation");
Some(IpAddr::V4(ip)) Some(IpAddr::V4(ip))
} }
Ok(None) => None,
Err(e) => {
warn!(error = %e, "Failed to auto-detect public IP");
None
}
}
}
pub(super) async fn maybe_reflect_public_addr( pub(super) async fn maybe_reflect_public_addr(
&self, &self,
@@ -365,31 +374,3 @@ impl MePool {
None None
} }
} }
async fn fetch_public_ipv4_with_retry() -> Result<Option<Ipv4Addr>> {
let providers = [
"https://checkip.amazonaws.com",
"http://v4.ident.me",
"http://ipv4.icanhazip.com",
];
for url in providers {
if let Ok(Some(ip)) = fetch_public_ipv4_once(url).await {
return Ok(Some(ip));
}
}
Ok(None)
}
async fn fetch_public_ipv4_once(url: &str) -> Result<Option<Ipv4Addr>> {
let res = reqwest::get(url)
.await
.map_err(|e| ProxyError::Proxy(format!("public IP detection request failed: {e}")))?;
let text = res
.text()
.await
.map_err(|e| ProxyError::Proxy(format!("public IP detection read failed: {e}")))?;
let ip = text.trim().parse().ok();
Ok(ip)
}
+2 -4
View File
@@ -464,8 +464,7 @@ impl MePool {
if !self.writer_accepts_new_binding(w) { if !self.writer_accepts_new_binding(w) {
continue; continue;
} }
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port()); let (payload, meta) = build_routed_payload(our_addr);
let (payload, meta) = build_routed_payload(effective_our_addr);
match w.tx.clone().try_reserve_owned() { match w.tx.clone().try_reserve_owned() {
Ok(permit) => { Ok(permit) => {
if !self.registry.bind_writer(conn_id, w.id, meta).await { if !self.registry.bind_writer(conn_id, w.id, meta).await {
@@ -520,8 +519,7 @@ impl MePool {
} }
self.stats self.stats
.increment_me_writer_pick_blocking_fallback_total(); .increment_me_writer_pick_blocking_fallback_total();
let effective_our_addr = SocketAddr::new(w.source_ip, our_addr.port()); let (payload, meta) = build_routed_payload(our_addr);
let (payload, meta) = build_routed_payload(effective_our_addr);
let reserve_result = let reserve_result =
if let Some(timeout) = self.route_runtime.me_route_blocking_send_timeout { if let Some(timeout) = self.route_runtime.me_route_blocking_send_timeout {
match tokio::time::timeout(timeout, w.tx.clone().reserve_owned()).await { match tokio::time::timeout(timeout, w.tx.clone().reserve_owned()).await {
@@ -38,6 +38,8 @@ async fn make_pool(
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
@@ -36,6 +36,8 @@ async fn make_pool(
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
@@ -31,6 +31,8 @@ async fn make_pool(me_pool_drain_threshold: u64) -> Arc<MePool> {
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
@@ -20,6 +20,8 @@ async fn make_pool() -> Arc<MePool> {
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
@@ -25,6 +25,8 @@ async fn make_pool() -> Arc<MePool> {
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
@@ -31,6 +31,8 @@ async fn make_pool() -> (Arc<MePool>, Arc<SecureRandom>) {
false, false,
None, None,
Vec::new(), Vec::new(),
false,
Vec::new(),
1, 1,
None, None,
12, 12,
@@ -175,6 +177,37 @@ async fn recv_data_count(rx: &mut mpsc::Receiver<WriterCommand>, budget: Duratio
data_count data_count
} }
async fn recv_first_data_payload(
rx: &mut mpsc::Receiver<WriterCommand>,
budget: Duration,
) -> Option<Vec<u8>> {
let start = Instant::now();
while Instant::now().duration_since(start) < budget {
let remaining = budget.saturating_sub(Instant::now().duration_since(start));
match tokio::time::timeout(remaining.min(Duration::from_millis(10)), rx.recv()).await {
Ok(Some(WriterCommand::Data(payload))) => return Some(payload.to_vec()),
Ok(Some(WriterCommand::DataAndFlush(payload))) => return Some(payload.to_vec()),
Ok(Some(_)) => {}
Ok(None) => break,
Err(_) => break,
}
}
None
}
fn proxy_req_our_addr_from_payload(payload: &[u8]) -> SocketAddr {
const CLIENT_ADDR_WIRE_LEN: usize = 20;
const OUR_ADDR_OFFSET: usize = 4 + 4 + 8 + CLIENT_ADDR_WIRE_LEN;
let our_addr = &payload[OUR_ADDR_OFFSET..OUR_ADDR_OFFSET + CLIENT_ADDR_WIRE_LEN];
let ip = Ipv4Addr::new(our_addr[12], our_addr[13], our_addr[14], our_addr[15]);
let port = u32::from_le_bytes([our_addr[16], our_addr[17], our_addr[18], our_addr[19]]);
SocketAddr::new(
IpAddr::V4(ip),
u16::try_from(port).expect("test port must fit u16"),
)
}
#[tokio::test] #[tokio::test]
async fn send_proxy_req_does_not_replay_when_first_bind_commit_fails() { async fn send_proxy_req_does_not_replay_when_first_bind_commit_fails() {
let (pool, _rng) = make_pool().await; let (pool, _rng) = make_pool().await;
@@ -288,3 +321,47 @@ async fn send_proxy_req_prunes_iterative_stale_bind_failures_without_data_replay
drop(writers); drop(writers);
assert_eq!(writer_ids, vec![23]); assert_eq!(writer_ids, vec![23]);
} }
#[tokio::test]
async fn send_proxy_req_preserves_client_facing_our_addr_when_writer_source_ip_differs() {
let (pool, _rng) = make_pool().await;
pool.rr.store(0, Ordering::Relaxed);
let (conn_id, _rx) = pool.registry.register().await;
let mut live_rx = insert_writer(
&pool,
31,
2,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 2, 31)), 443),
true,
)
.await;
{
let mut writers = pool.writers.write().await;
let writer = writers
.iter_mut()
.find(|writer| writer.id == 31)
.expect("test writer must exist");
writer.source_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 31));
}
let our_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)), 8443);
let result = pool
.send_proxy_req(
conn_id,
2,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)), 30002),
our_addr,
b"route",
0,
None,
)
.await;
assert!(result.is_ok());
let payload = recv_first_data_payload(&mut live_rx, Duration::from_millis(50))
.await
.expect("writer must receive routed payload");
assert_eq!(proxy_req_our_addr_from_payload(&payload), our_addr);
}
+33
View File
@@ -125,6 +125,39 @@ pub fn clear_linger_fd(fd: std::os::unix::io::RawFd) -> Result<()> {
Ok(()) Ok(())
} }
/// Raise the TCP MSS on an already-accepted connection's fd. Used to fragment
/// ONLY the TLS handshake (via a low listener MSS) and then restore a normal MSS
/// for the bulk (post-handshake) data phase — cuts packets-per-second ~10x without losing the
/// DPI evasion that the fragmented ServerHello provides. No-op safe: errors are
/// returned to the caller, which logs and continues with the handshake MSS.
#[cfg(target_os = "linux")]
pub fn set_tcp_mss_fd(fd: std::os::unix::io::RawFd, mss: u32) -> Result<()> {
use std::io::Error;
let mss = i32::try_from(mss)
.map_err(|_| Error::new(std::io::ErrorKind::InvalidInput, "bulk MSS out of range"))?;
// Direct setsockopt(TCP_MAXSEG) — same pattern as the TCP_USER_TIMEOUT call
// above; avoids socket2 method-name drift across versions.
let rc = unsafe {
libc::setsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_MAXSEG,
&mss as *const libc::c_int as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
)
};
if rc != 0 {
return Err(Error::last_os_error());
}
Ok(())
}
/// Non-Linux stub: MSS shaping only on Linux (TCP_MAXSEG).
#[cfg(all(unix, not(target_os = "linux")))]
pub fn set_tcp_mss_fd(_fd: std::os::unix::io::RawFd, _mss: u32) -> Result<()> {
Ok(())
}
/// Create a new TCP socket for outgoing connections /// Create a new TCP socket for outgoing connections
#[allow(dead_code)] #[allow(dead_code)]
pub fn create_outgoing_socket(addr: SocketAddr) -> Result<Socket> { pub fn create_outgoing_socket(addr: SocketAddr) -> Result<Socket> {