Compare commits

..

31 Commits

Author SHA1 Message Date
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 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
Alexey 9dc67727b0 Merge pull request #840 from telemt/flow
Restore single-record TLS-F primary application flight + Fix SYN limiter lifecycle and default burst
2026-06-12 15:23:23 +03:00
Alexey 2d02fbe548 Bump 2026-06-12 15:06:14 +03:00
Alexey 2675779915 Fix SYN limiter lifecycle and default burst
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 14:40:26 +03:00
Alexey c4954f745f Restore single-record TLS-F primary application flight
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 12:44:22 +03:00
Alexey f33abfb09e Merge pull request #838 from telemt/flow
SYN limiter for Netfilter control + Syntactic key shares for TLS-F
2026-06-12 10:08:25 +03:00
Alexey 9904da737a Rustfmt 2026-06-12 01:28:41 +03:00
Alexey 9a3ff726b2 Use token-bucket SYN limiter backends
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 01:27:03 +03:00
Alexey 942882f9de SYN Limiter interval and hitcount in Config
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 00:29:23 +03:00
Alexey eeff16c3fd Rustfmt 2026-06-12 00:01:01 +03:00
Alexey c86dc2f65e Docs for SYN Limiter
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:59:47 +03:00
Alexey 1cbde70a14 Add per-listener SYN limiter for Netfilter control
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:58:48 +03:00
Alexey 26cd4734de Update tls.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:29:10 +03:00
Alexey 52a1b66ad7 Syntactic key shares for TLS-F
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:13:21 +03:00
57 changed files with 3900 additions and 1019 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.16" 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.16" 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]]
+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)
+147
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.
@@ -2219,6 +2310,10 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
| [`ip`](#ip) | `IpAddr` | — | `` | | [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `` |
| [`announce`](#announce) | `String` | — | `` | | [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2254,6 +2349,58 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
port = 443 port = 443
client_mss = "256" client_mss = "256"
``` ```
## synlimit (server.listeners)
- **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener.
- **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
[[server.listeners]]
ip = "::"
port = 443
synlimit = "nftables"
```
## synlimit_seconds (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
- **Description**: Token-bucket interval for both SYN limiter backends. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`).
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_seconds = 1
```
## synlimit_hitcount (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
- **Description**: Token-bucket rate amount for both SYN limiter backends. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets are dropped.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hitcount = 1
```
## synlimit_burst (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `2`.
- **Description**: Token-bucket burst size for both SYN limiter backends. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_burst = 2
```
## announce ## announce
- **Constraints / validation**: `String` (optional). Must not be empty when set. - **Constraints / validation**: `String` (optional). Must not be empty when set.
- **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`. - **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`.
+67
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-заголовка.
@@ -2225,6 +2236,10 @@
| [`ip`](#ip) | `IpAddr` | — | `` | | [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `` |
| [`announce`](#announce) | `String` | — | `` | | [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2260,6 +2275,58 @@
port = 443 port = 443
client_mss = "256" client_mss = "256"
``` ```
## synlimit (server.listeners)
- **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен.
- **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
[[server.listeners]]
ip = "::"
port = 443
synlimit = "nftables"
```
## synlimit_seconds (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
- **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`).
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_seconds = 1
```
## synlimit_hitcount (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
- **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hitcount = 1
```
## synlimit_burst (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`.
- **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_burst = 2
```
## announce ## announce
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
- **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`. - **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`.
+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");
+204 -19
View File
@@ -102,9 +102,14 @@ pub(super) async fn save_config_to_disk(
/// Intentionally excluded (defense-in-depth, enforces the spec's per-node /// Intentionally excluded (defense-in-depth, enforces the spec's per-node
/// identity invariant at the Telemt layer too): /// identity invariant at the Telemt layer too):
/// ///
/// - `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();
+15
View File
@@ -54,6 +54,9 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true;
const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85; const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85;
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70; const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096; const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1;
const DEFAULT_SYNLIMIT_BURST: u32 = 2;
const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2; const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
@@ -243,6 +246,18 @@ pub(crate) fn default_conntrack_delete_budget_per_sec() -> u64 {
DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC
} }
pub(crate) fn default_synlimit_seconds() -> u32 {
DEFAULT_SYNLIMIT_SECONDS
}
pub(crate) fn default_synlimit_hitcount() -> u32 {
DEFAULT_SYNLIMIT_HITCOUNT
}
pub(crate) fn default_synlimit_burst() -> u32 {
DEFAULT_SYNLIMIT_BURST
}
pub(crate) fn default_prefer_4() -> u8 { pub(crate) fn default_prefer_4() -> u8 {
4 4
} }
+58 -2
View File
@@ -16,10 +16,12 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately | //! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately | //! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately | //! | `access` | All user/quota fields | Effective immediately |
//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately |
//! //!
//! Fields that require re-binding sockets (`server.listeners`, legacy //! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. //! applied, except for SYN limiter fields on unchanged listener endpoints; a
//! warning is emitted.
//! Non-hot changes are never mixed into the runtime config snapshot. //! Non-hot changes are never mixed into the runtime config snapshot.
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -34,7 +36,8 @@ use tracing::{error, info, warn};
use super::load::{LoadedConfig, ProxyConfig}; use super::load::{LoadedConfig, ProxyConfig};
use crate::config::{ use crate::config::{
LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
MeWriterPickMode, SynLimitMode,
}; };
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50); const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
@@ -131,6 +134,17 @@ pub struct HotFields {
pub user_max_unique_ips_global_each: usize, pub user_max_unique_ips_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode, pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64, pub user_max_unique_ips_window_secs: u64,
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerSynLimitHotFields {
pub ip: IpAddr,
pub port: Option<u16>,
pub synlimit: SynLimitMode,
pub synlimit_seconds: u32,
pub synlimit_hitcount: u32,
pub synlimit_burst: u32,
} }
impl HotFields { impl HotFields {
@@ -260,6 +274,25 @@ impl HotFields {
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each, user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode, user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs, user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
listener_synlimit: cfg
.server
.listeners
.iter()
.map(ListenerSynLimitHotFields::from_listener)
.collect(),
}
}
}
impl ListenerSynLimitHotFields {
fn from_listener(listener: &ListenerConfig) -> Self {
Self {
ip: listener.ip,
port: listener.port,
synlimit: listener.synlimit,
synlimit_seconds: listener.synlimit_seconds,
synlimit_hitcount: listener.synlimit_hitcount,
synlimit_burst: listener.synlimit_burst,
} }
} }
} }
@@ -566,6 +599,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners);
if cfg.rebuild_runtime_user_auth().is_err() { if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None; cfg.runtime_user_auth = None;
@@ -574,6 +608,21 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg cfg
} }
fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) {
if old.len() != new.len() {
return;
}
for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) {
if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port {
continue;
}
old_listener.synlimit = new_listener.synlimit;
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
old_listener.synlimit_burst = new_listener.synlimit_burst;
}
}
/// Warn if any non-hot fields changed (require restart). /// Warn if any non-hot fields changed (require restart).
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
let mut warned = false; let mut warned = false;
@@ -850,6 +899,13 @@ fn log_changes(
); );
} }
if old_hot.listener_synlimit != new_hot.listener_synlimit {
info!(
"config reload: server.listeners SYN limiter updated ({} listeners)",
new_hot.listener_synlimit.len()
);
}
if old_hot.desync_all_full != new_hot.desync_all_full { if old_hot.desync_all_full != new_hot.desync_all_full {
info!( info!(
"config reload: desync_all_full: {} → {}", "config reload: desync_all_full: {} → {}",
+153 -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",
@@ -346,6 +348,10 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip", "ip",
"port", "port",
"client_mss", "client_mss",
"synlimit",
"synlimit_seconds",
"synlimit_hitcount",
"synlimit_burst",
"announce", "announce",
"announce_ip", "announce_ip",
"proxy_protocol", "proxy_protocol",
@@ -454,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 {
@@ -495,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);
} }
@@ -628,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,
@@ -993,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 { .. })
@@ -1053,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,
@@ -1936,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()
@@ -1948,6 +1999,21 @@ impl ProxyConfig {
ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}")) ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}"))
})?; })?;
} }
if listener.synlimit_seconds == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_seconds must be > 0"
)));
}
if listener.synlimit_hitcount == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_hitcount must be > 0"
)));
}
if listener.synlimit_burst == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_burst must be > 0"
)));
}
} }
if config.server.accept_permit_timeout_ms > 60_000 { if config.server.accept_permit_timeout_ms > 60_000 {
@@ -2186,6 +2252,10 @@ impl ProxyConfig {
ip: ipv4, ip: ipv4,
port: Some(config.server.port), port: Some(config.server.port),
client_mss: None, client_mss: None,
synlimit: SynLimitMode::default(),
synlimit_seconds: default_synlimit_seconds(),
synlimit_hitcount: default_synlimit_hitcount(),
synlimit_burst: default_synlimit_burst(),
announce: None, announce: None,
announce_ip: None, announce_ip: None,
proxy_protocol: None, proxy_protocol: None,
@@ -2199,6 +2269,10 @@ impl ProxyConfig {
ip: ipv6, ip: ipv6,
port: Some(config.server.port), port: Some(config.server.port),
client_mss: None, client_mss: None,
synlimit: SynLimitMode::default(),
synlimit_seconds: default_synlimit_seconds(),
synlimit_hitcount: default_synlimit_hitcount(),
synlimit_burst: default_synlimit_burst(),
announce: None, announce: None,
announce_ip: None, announce_ip: None,
proxy_protocol: None, proxy_protocol: None,
@@ -2253,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()?;
@@ -2278,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()));
} }
@@ -2374,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#"
@@ -2384,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());
@@ -2551,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(
+179 -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>,
@@ -1369,6 +1449,77 @@ impl ConntrackPressureProfile {
} }
} }
/// Per-listener SYN limiter mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SynLimitMode {
/// Disable SYN limiting for this listener.
#[default]
Off,
/// Use iptables/ip6tables filter rules with the hashlimit match.
Iptables,
/// Use nftables rules with per-source token-bucket meters.
Nftables,
}
impl Serialize for SynLimitMode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Off => serializer.serialize_bool(false),
Self::Iptables => serializer.serialize_str("iptables"),
Self::Nftables => serializer.serialize_str("nftables"),
}
}
}
impl<'de> Deserialize<'de> for SynLimitMode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SynLimitModeVisitor;
impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor {
type Value = SynLimitMode;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("false, iptables, or nftables")
}
fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
if value {
Err(E::custom(
"synlimit=true is ambiguous; use \"iptables\" or \"nftables\"",
))
} else {
Ok(SynLimitMode::Off)
}
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
match value.trim().to_ascii_lowercase().as_str() {
"false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off),
"iptables" => Ok(SynLimitMode::Iptables),
"nftables" => Ok(SynLimitMode::Nftables),
_ => Err(E::custom(
"synlimit must be false, \"iptables\", or \"nftables\"",
)),
}
}
}
deserializer.deserialize_any(SynLimitModeVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConntrackControlConfig { pub struct ConntrackControlConfig {
/// Enables runtime conntrack-control worker for pressure mitigation. /// Enables runtime conntrack-control worker for pressure mitigation.
@@ -1456,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)]
@@ -1523,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(),
@@ -2102,6 +2263,18 @@ pub struct ListenerConfig {
/// Empty string disables MSS shaping for this listener. /// Empty string disables MSS shaping for this listener.
#[serde(default)] #[serde(default)]
pub client_mss: Option<String>, pub client_mss: Option<String>,
/// Per-listener SYN limiter mode.
#[serde(default)]
pub synlimit: SynLimitMode,
/// Token-bucket rate interval for the per-listener SYN limiter.
#[serde(default = "default_synlimit_seconds")]
pub synlimit_seconds: u32,
/// Token-bucket rate amount for the per-listener SYN limiter.
#[serde(default = "default_synlimit_hitcount")]
pub synlimit_hitcount: u32,
/// Token-bucket burst size for the per-listener SYN limiter.
#[serde(default = "default_synlimit_burst")]
pub synlimit_burst: u32,
/// IP address or hostname to announce in proxy links. /// IP address or hostname to announce in proxy links.
/// Takes precedence over `announce_ip` if both are set. /// Takes precedence over `announce_ip` if both are set.
#[serde(default)] #[serde(default)]
@@ -2135,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 {
+222 -101
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); /// Resolve effective logging destination from config and CLI overrides.
pub fn resolve_log_destination(
config: &LoggingConfig,
cli: &LogCliOptions,
) -> Result<LogDestination, String> {
let destination = cli.destination.unwrap_or(match config.destination {
LoggingDestination::Stderr => LogCliDestination::Stderr,
LoggingDestination::Syslog => LogCliDestination::Syslog,
LoggingDestination::File => LogCliDestination::File,
});
match destination {
LogCliDestination::Stderr => Ok(LogDestination::Stderr),
LogCliDestination::Syslog => {
#[cfg(unix)]
{
Ok(LogDestination::Syslog)
} }
_ => panic!("Expected File destination"), #[cfg(not(unix))]
} {
} Err("Syslog logging is only supported on Unix platforms".to_string())
#[test]
fn test_parse_log_destination_file_daily() {
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
match parse_log_destination(&args) {
LogDestination::File { path, rotate_daily } => {
assert_eq!(path, "/var/log/telemt");
assert!(rotate_daily);
} }
_ => panic!("Expected File destination"),
} }
} LogCliDestination::File => {
let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| {
"logging.path or --log-file must be set when file logging is enabled".to_string()
})?;
if path.trim().is_empty() {
return Err("Log file path cannot be empty".to_string());
}
#[cfg(unix)] Ok(LogDestination::File {
#[test] options: FileLogOptions {
fn test_parse_log_destination_syslog() { path: path.clone(),
let args = vec!["--syslog".to_string()]; rotation: cli.rotation.unwrap_or(config.rotation),
assert!(matches!( max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes),
parse_log_destination(&args), max_files: cli.max_files.unwrap_or(config.max_files),
LogDestination::Syslog max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs),
)); },
} })
}
#[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
);
} }
} }
+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,
+13 -1
View File
@@ -45,6 +45,7 @@ use crate::stats::beobachten::BeobachtenStore;
use crate::stats::telemetry::TelemetryPolicy; use crate::stats::telemetry::TelemetryPolicy;
use crate::stats::{ReplayChecker, Stats}; use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool; use crate::stream::BufferPool;
use crate::synlimit_control;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool; use crate::transport::middle_proxy::MePool;
use helpers::{ use helpers::{
@@ -107,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) => {
@@ -330,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
@@ -909,6 +918,9 @@ async fn run_telemt_core(
// On Unix, caller supplies privilege drop after bind (may require root for port < 1024). // On Unix, caller supplies privilege drop after bind (may require root for port < 1024).
drop_after_bind(); drop_after_bind();
synlimit_control::reconcile_synlimit_rules(&config).await;
synlimit_control::spawn_synlimit_controller(config_rx.clone());
runtime_tasks::apply_runtime_log_filter( runtime_tasks::apply_runtime_log_filter(
has_rust_log, has_rust_log,
&effective_log_level, &effective_log_level,
+5
View File
@@ -19,6 +19,7 @@ use tokio::signal::unix::{SignalKind, signal};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::stats::Stats; use crate::stats::Stats;
use crate::synlimit_control;
use crate::transport::middle_proxy::MePool; use crate::transport::middle_proxy::MePool;
use super::helpers::{format_uptime, unit_label}; use super::helpers::{format_uptime, unit_label};
@@ -102,6 +103,10 @@ async fn perform_shutdown(
let uptime_secs = process_started_at.elapsed().as_secs(); let uptime_secs = process_started_at.elapsed().as_secs();
info!("Uptime: {}", format_uptime(uptime_secs)); info!("Uptime: {}", format_uptime(uptime_secs));
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
}
// Graceful ME pool shutdown // Graceful ME pool shutdown
if let Some(pool) = &me_pool { if let Some(pool) = &me_pool {
match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all()) match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all())
+1
View File
@@ -30,6 +30,7 @@ mod service;
mod startup; mod startup;
mod stats; mod stats;
mod stream; mod stream;
mod synlimit_control;
mod tls_front; mod tls_front;
mod transport; mod transport;
mod util; mod util;
+33 -7
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,8 +82,14 @@ 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(
.await &servers,
stun_nat_probe_concurrency.max(1),
None,
None,
config.stun_tcp_fallback,
)
.await
} }
} else if nat_probe { } else if nat_probe {
info!("STUN probe is disabled by network.stun_use=false"); info!("STUN probe is disabled by network.stun_use=false");
@@ -163,6 +170,7 @@ pub async fn run_probe(
stun_nat_probe_concurrency.max(1), 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;
+241 -41
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];
let mut idx = 20; if let Some(reflected_addr) = parse_reflected_addr(&buf[..n], txid) {
while idx + 4 <= n { let local_addr = socket
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().unwrap()); .local_addr()
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().unwrap()) as usize; .map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
idx += 4; return Ok(Some(StunProbeResult {
if idx + alen > n { local_addr,
break; reflected_addr,
} family,
}));
}
}
match atype { Ok(None)
0x0020 /* XOR-MAPPED-ADDRESS */ | 0x0001 /* MAPPED-ADDRESS */ => { }
async fn stun_probe_family_tcp(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
) -> Result<Option<StunProbeResult>> {
let target_addr = match resolve_stun_addr(stun_addr, family).await? {
Some(addr) => addr,
None => return Ok(None),
};
let socket = match family {
IpFamily::V4 => TcpSocket::new_v4(),
IpFamily::V6 => TcpSocket::new_v6(),
}
.map_err(|e| ProxyError::Proxy(format!("STUN TCP socket failed: {e}")))?;
match (family, bind_ip) {
(IpFamily::V4, Some(IpAddr::V4(ip))) => {
if socket.bind(SocketAddr::new(IpAddr::V4(ip), 0)).is_err() {
return Ok(None);
}
}
(IpFamily::V6, Some(IpAddr::V6(ip))) => {
if socket.bind(SocketAddr::new(IpAddr::V6(ip), 0)).is_err() {
return Ok(None);
}
}
(IpFamily::V4, Some(IpAddr::V6(_))) | (IpFamily::V6, Some(IpAddr::V4(_))) => {
return Ok(None);
}
(_, None) => {}
}
let connect_res = timeout(Duration::from_secs(3), socket.connect(target_addr)).await;
let mut stream = match connect_res {
Ok(Ok(stream)) => stream,
Ok(Err(e))
if family == IpFamily::V6
&& matches!(
e.kind(),
std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::HostUnreachable
| std::io::ErrorKind::Unsupported
| std::io::ErrorKind::NetworkDown
) =>
{
return Ok(None);
}
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN TCP connect failed: {e}"))),
Err(_) => return Ok(None),
};
let req = build_binding_request();
timeout(Duration::from_secs(3), stream.write_all(&req))
.await
.map_err(|_| ProxyError::Proxy("STUN TCP send timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP send failed: {e}")))?;
let mut header = [0u8; 20];
timeout(Duration::from_secs(3), stream.read_exact(&mut header))
.await
.map_err(|_| ProxyError::Proxy("STUN TCP header timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP header read failed: {e}")))?;
let body_len = u16::from_be_bytes([header[2], header[3]]) as usize;
if body_len > 236 {
return Ok(None);
}
let mut buf = [0u8; 256];
buf[..20].copy_from_slice(&header);
if body_len > 0 {
timeout(
Duration::from_secs(3),
stream.read_exact(&mut buf[20..20 + body_len]),
)
.await
.map_err(|_| ProxyError::Proxy("STUN TCP body timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP body read failed: {e}")))?;
}
let txid = &req[8..20];
let Some(reflected_addr) = parse_reflected_addr(&buf[..20 + body_len], txid) else {
return Ok(None);
};
let local_addr = stream
.local_addr()
.map_err(|e| ProxyError::Proxy(format!("STUN TCP local_addr failed: {e}")))?;
Ok(Some(StunProbeResult {
local_addr,
reflected_addr,
family,
}))
}
fn build_binding_request() -> [u8; 20] {
let mut req = [0u8; 20];
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes());
req[2..4].copy_from_slice(&0u16.to_be_bytes());
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
stun_rng().fill(&mut req[8..20]);
req
}
fn parse_reflected_addr(buf: &[u8], txid: &[u8]) -> Option<SocketAddr> {
if buf.len() < 20 {
return None;
}
let magic = 0x2112A442u32.to_be_bytes();
let mut idx = 20;
while idx + 4 <= buf.len() {
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().ok()?);
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().ok()?) as usize;
idx += 4;
if idx + alen > buf.len() {
break;
}
match atype {
0x0020 | 0x0001 => {
if alen < 8 { 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;
+14 -8
View File
@@ -638,15 +638,21 @@ fn build_server_hello_key_share_for_group(
group: u16, group: u16,
rng: &SecureRandom, rng: &SecureRandom,
) -> Option<ServerHelloKeyShare> { ) -> Option<ServerHelloKeyShare> {
let expected_key_exchange_len = client_hello_key_share_group_len(group)?;
client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len)?;
// FakeTLS clients validate ServerHello shape and digest, not TLS traffic
// secrets, so the response must mirror the offered group without binding to
// the camouflage key bytes embedded in ClientHello.
match group { match group {
TLS_NAMED_GROUP_X25519MLKEM768 => { TLS_NAMED_GROUP_X25519MLKEM768 => Some(ServerHelloKeyShare::new(
let key_exchange = build_x25519mlkem768_server_key_share(handshake, rng)?; group,
Some(ServerHelloKeyShare::new(group, key_exchange)) gen_fake_x25519mlkem768_server_key_share(rng),
} )),
TLS_NAMED_GROUP_X25519 => { TLS_NAMED_GROUP_X25519 => Some(ServerHelloKeyShare::new(
let key_exchange = build_x25519_server_key_share(handshake, rng)?; group,
Some(ServerHelloKeyShare::new(group, key_exchange)) gen_fake_x25519_key(rng).to_vec(),
} )),
_ => None, _ => None,
} }
} }
+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,
})
}
+181 -73
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 { break;
let remaining_budget = byte_cap.saturating_sub(total); }
if remaining_budget == 0 { if buf.len() < read_len {
break; buf.resize(read_len, 0);
} }
remaining_budget.min(MASK_BUFFER_SIZE)
};
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 { break;
let remaining_budget = byte_cap.saturating_sub(total); }
if remaining_budget == 0 { if buf.len() < read_len {
break; buf.resize(read_len, 0);
} }
remaining_budget.min(MASK_BUFFER_SIZE)
};
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);
} }
+8 -2
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
}; };
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?; if physical_flush {
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?;
}
let flush_duration_us = flush_started_at.map(|started| { 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]
+605
View File
@@ -0,0 +1,605 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::watch;
use tracing::warn;
use crate::config::{ProxyConfig, SynLimitMode};
const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT";
const IPTABLES_HASHLIMIT_NAME: &str = "TELEMT-BUMPER";
const NFT_TABLE: &str = "telemt_synlimit";
const NFT_CHAIN: &str = "input";
type SynLimitTarget = (Option<IpAddr>, u16, u32, u32, u32);
#[derive(Default)]
struct SynLimitTargets {
iptables_v4: Vec<SynLimitTarget>,
iptables_v6: Vec<SynLimitTarget>,
nft_v4: Vec<SynLimitTarget>,
nft_v6: Vec<SynLimitTarget>,
}
#[derive(Clone, Copy)]
struct NftTableFamilies {
inet: bool,
ip: bool,
ip6: bool,
}
#[derive(Clone, Copy)]
enum NftFamily {
Inet,
Ip,
Ip6,
}
struct NftApplyPlan<'a> {
family: NftFamily,
v4_targets: &'a [SynLimitTarget],
v6_targets: &'a [SynLimitTarget],
}
impl SynLimitTargets {
fn is_empty(&self) -> bool {
self.iptables_v4.is_empty()
&& self.iptables_v6.is_empty()
&& self.nft_v4.is_empty()
&& self.nft_v6.is_empty()
}
fn has_iptables_targets(&self) -> bool {
!self.iptables_v4.is_empty() || !self.iptables_v6.is_empty()
}
fn has_nft_targets(&self) -> bool {
!self.nft_v4.is_empty() || !self.nft_v6.is_empty()
}
}
impl NftFamily {
fn as_str(self) -> &'static str {
match self {
Self::Inet => "inet",
Self::Ip => "ip",
Self::Ip6 => "ip6",
}
}
}
pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<Arc<ProxyConfig>>) {
if !cfg!(target_os = "linux") {
if has_synlimit_config(&config_rx.borrow()) {
warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules");
}
return;
}
tokio::spawn(async move {
wait_for_config_channel_close_and_reconcile(config_rx).await;
if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules after config channel close");
}
});
}
async fn wait_for_config_channel_close_and_reconcile(
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
) {
while config_rx.changed().await.is_ok() {
let cfg = config_rx.borrow_and_update().clone();
reconcile_synlimit_rules(&cfg).await;
}
}
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile");
}
let targets = synlimit_targets(cfg);
if targets.is_empty() {
return;
}
if !has_cap_net_admin() {
warn!(
"SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"
);
return;
}
if targets.has_iptables_targets()
&& let Err(error) = apply_iptables_synlimit_rules(&targets).await
{
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
}
if targets.has_nft_targets()
&& let Err(error) = apply_nft_synlimit_rules(&targets).await
{
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
}
}
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
if !has_cap_net_admin() {
return Ok(());
}
let mut errors = Vec::new();
if let Err(error) = clear_nft_synlimit_rules_all_families().await {
errors.push(error);
}
if let Err(error) = clear_iptables_synlimit_rules_for_binary("iptables").await {
errors.push(error);
}
if let Err(error) = clear_iptables_synlimit_rules_for_binary("ip6tables").await {
errors.push(error);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
cfg.server
.listeners
.iter()
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
}
fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets {
let mut iptables_v4 = BTreeSet::new();
let mut iptables_v6 = BTreeSet::new();
let mut nft_v4 = BTreeSet::new();
let mut nft_v6 = BTreeSet::new();
for listener in &cfg.server.listeners {
let backend = listener.synlimit;
if matches!(backend, SynLimitMode::Off) {
continue;
}
let port = listener.port.unwrap_or(cfg.server.port);
let ip = (!listener.ip.is_unspecified()).then_some(listener.ip);
let seconds = listener.synlimit_seconds;
let hitcount = listener.synlimit_hitcount;
let burst = listener.synlimit_burst;
match (backend, listener.ip.is_ipv4()) {
(SynLimitMode::Iptables, true) => {
iptables_v4.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Iptables, false) => {
iptables_v6.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Nftables, true) => {
nft_v4.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Nftables, false) => {
nft_v6.insert((ip, port, seconds, hitcount, burst));
}
(SynLimitMode::Off, _) => {}
}
}
SynLimitTargets {
iptables_v4: iptables_v4.into_iter().collect(),
iptables_v6: iptables_v6.into_iter().collect(),
nft_v4: nft_v4.into_iter().collect(),
nft_v6: nft_v6.into_iter().collect(),
}
}
async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
apply_iptables_synlimit_rules_for_binary("iptables", &targets.iptables_v4).await?;
apply_iptables_synlimit_rules_for_binary("ip6tables", &targets.iptables_v6).await
}
async fn apply_iptables_synlimit_rules_for_binary(
binary: &str,
targets: &[SynLimitTarget],
) -> Result<(), String> {
if targets.is_empty() {
return Ok(());
}
let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await;
run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?;
if run_command(
binary,
&["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await
.is_err()
{
run_command(
binary,
&["-t", "filter", "-A", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await?;
}
for (idx, (ip, port, seconds, hitcount, burst)) in targets.iter().enumerate() {
let hashlimit_name = format!("{IPTABLES_HASHLIMIT_NAME}-{idx}");
let accept_args = iptables_hashlimit_accept_rule_args(
ip,
*port,
*seconds,
*hitcount,
*burst,
&hashlimit_name,
);
let drop_args = iptables_synlimit_drop_rule_args(ip, *port);
let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect();
let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect();
run_command(binary, &accept_refs, None).await?;
run_command(binary, &drop_refs, None).await?;
}
run_command(
binary,
&["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"],
None,
)
.await?;
Ok(())
}
fn iptables_hashlimit_accept_rule_args(
ip: &Option<IpAddr>,
port: u16,
seconds: u32,
hitcount: u32,
burst: u32,
hashlimit_name: &str,
) -> Vec<String> {
let mut args = vec![
"-t".to_string(),
"filter".to_string(),
"-A".to_string(),
IPTABLES_CHAIN.to_string(),
"-p".to_string(),
"tcp".to_string(),
"--syn".to_string(),
];
if let Some(ip) = ip {
args.push("-d".to_string());
args.push(ip.to_string());
}
let rate = synlimit_rate_arg(seconds, hitcount);
args.extend([
"--dport".to_string(),
port.to_string(),
"-m".to_string(),
"hashlimit".to_string(),
"--hashlimit-name".to_string(),
hashlimit_name.to_string(),
"--hashlimit-mode".to_string(),
"srcip".to_string(),
"--hashlimit-upto".to_string(),
rate,
"--hashlimit-burst".to_string(),
burst.to_string(),
"--hashlimit-htable-expire".to_string(),
"15000".to_string(),
"-j".to_string(),
"ACCEPT".to_string(),
]);
args
}
fn iptables_synlimit_drop_rule_args(ip: &Option<IpAddr>, port: u16) -> Vec<String> {
let mut args = vec![
"-t".to_string(),
"filter".to_string(),
"-A".to_string(),
IPTABLES_CHAIN.to_string(),
"-p".to_string(),
"tcp".to_string(),
"--syn".to_string(),
];
if let Some(ip) = ip {
args.push("-d".to_string());
args.push(ip.to_string());
}
args.extend([
"--dport".to_string(),
port.to_string(),
"-j".to_string(),
"DROP".to_string(),
]);
args
}
fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
let seconds = u64::from(seconds.max(1));
let hitcount = u64::from(hitcount.max(1));
for (unit_seconds, unit_name) in [
(1_u64, "second"),
(60_u64, "minute"),
(3_600_u64, "hour"),
(86_400_u64, "day"),
] {
let amount = hitcount.saturating_mul(unit_seconds);
if amount >= seconds && amount % seconds == 0 {
return format!("{}/{}", amount / seconds, unit_name);
}
}
let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds;
format!("{}/day", amount.max(1))
}
async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> {
let mut errors = Vec::new();
for _ in 0..8 {
match run_command(
binary,
&["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await
{
Ok(()) => {}
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
Err(error) => {
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
break;
}
}
}
if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await
&& !is_missing_command_or_iptables_rule(&error)
{
errors.push(format!("{binary} flush chain failed: {error}"));
}
if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await
&& !is_missing_command_or_iptables_rule(&error)
{
errors.push(format!("{binary} delete chain failed: {error}"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(", "))
}
}
async fn apply_nft_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
let families = detect_nft_table_families().await;
for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) {
let script = nft_synlimit_script(plan);
run_command("nft", &["-f", "-"], Some(script)).await?;
}
Ok(())
}
async fn detect_nft_table_families() -> NftTableFamilies {
let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else {
return NftTableFamilies {
inet: false,
ip: false,
ip6: false,
};
};
let mut families = NftTableFamilies {
inet: false,
ip: false,
ip6: false,
};
for line in output.lines() {
let mut fields = line.split_whitespace();
if fields.next() != Some("table") {
continue;
}
match fields.next() {
Some("inet") => families.inet = true,
Some("ip") => families.ip = true,
Some("ip6") => families.ip6 = true,
_ => {}
}
}
families
}
fn nft_apply_plan<'a>(
families: NftTableFamilies,
v4_targets: &'a [SynLimitTarget],
v6_targets: &'a [SynLimitTarget],
) -> Vec<NftApplyPlan<'a>> {
if !v4_targets.is_empty() && !v6_targets.is_empty() {
return vec![NftApplyPlan {
family: NftFamily::Inet,
v4_targets,
v6_targets,
}];
}
if !v4_targets.is_empty() {
return vec![NftApplyPlan {
family: if families.inet || !families.ip {
NftFamily::Inet
} else {
NftFamily::Ip
},
v4_targets,
v6_targets: &[],
}];
}
if !v6_targets.is_empty() {
return vec![NftApplyPlan {
family: if families.inet || !families.ip6 {
NftFamily::Inet
} else {
NftFamily::Ip6
},
v4_targets: &[],
v6_targets,
}];
}
Vec::new()
}
fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String {
let mut script = String::new();
script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str()));
script.push_str(&format!(" chain {NFT_CHAIN} {{\n"));
script.push_str(" type filter hook input priority filter; policy accept;\n");
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v4_targets.iter().enumerate() {
let daddr = ip
.map(|ip| format!(" ip daddr {ip}"))
.unwrap_or_else(String::new);
let rate = synlimit_rate_arg(*seconds, *hitcount);
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v4_{idx} {{ ip saddr limit rate over {rate} burst {burst} packets }} drop\n"
));
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
));
}
for (idx, (ip, port, seconds, hitcount, burst)) in plan.v6_targets.iter().enumerate() {
let daddr = ip
.map(|ip| format!(" ip6 daddr {ip}"))
.unwrap_or_else(String::new);
let rate = synlimit_rate_arg(*seconds, *hitcount);
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v6_{idx} {{ ip6 saddr limit rate over {rate} burst {burst} packets }} drop\n"
));
script.push_str(&format!(
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n"
));
}
script.push_str(" }\n");
script.push_str("}\n");
script
}
async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> {
let mut errors = Vec::new();
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
if let Err(error) = run_command(
"nft",
&["delete", "table", family.as_str(), NFT_TABLE],
None,
)
.await
&& !is_missing_command_or_nft_table(&error)
{
errors.push(format!(
"nft delete table {} {NFT_TABLE} failed: {error}",
family.as_str()
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(", "))
}
}
fn is_missing_command_or_iptables_rule(error: &str) -> bool {
error.contains("is not available")
|| error.contains("No chain/target/match by that name")
|| error.contains("does not exist")
}
fn is_missing_command_or_nft_table(error: &str) -> bool {
error.contains("is not available") || error.contains("No such file or directory")
}
async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Result<(), String> {
let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available"));
};
let mut command = Command::new(command_path);
command.args(args);
if stdin.is_some() {
command.stdin(std::process::Stdio::piped());
}
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("spawn {binary} failed: {e}"))?;
if let Some(blob) = stdin
&& let Some(mut writer) = child.stdin.take()
{
writer
.write_all(blob.as_bytes())
.await
.map_err(|e| format!("stdin write {binary} failed: {e}"))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("wait {binary} failed: {e}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> {
let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available"));
};
let output = Command::new(command_path)
.args(args)
.output()
.await
.map_err(|e| format!("wait {binary} failed: {e}"))?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).to_string());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
fn resolve_command(binary: &str) -> Option<PathBuf> {
let mut dirs = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.unwrap_or_default();
dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from));
dirs.into_iter()
.map(|dir| dir.join(binary))
.find(|candidate| candidate.exists() && candidate.is_file())
}
fn has_cap_net_admin() -> bool {
#[cfg(target_os = "linux")]
{
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
for line in status.lines() {
if let Some(raw) = line.strip_prefix("CapEff:") {
let caps = raw.trim();
if let Ok(bits) = u64::from_str_radix(caps, 16) {
const CAP_NET_ADMIN_BIT: u64 = 12;
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
}
}
}
false
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
+27 -49
View File
@@ -155,57 +155,35 @@ fn push_fallback_size(sizes: &mut Vec<usize>, size: usize) {
} }
fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> { fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) let mut sizes = Vec::with_capacity(1);
&& !cached.app_data_records_sizes.is_empty() let size = if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) {
{ cached
return cached.app_data_records_sizes.clone(); .app_data_records_sizes
} .first()
.copied()
let family = fallback_shape_family(cached); .unwrap_or_else(|| fallback_total_app_data_len(cached))
let mut remaining = fallback_total_app_data_len(cached); } else {
let preferred_chunk = match family { fallback_total_app_data_len(cached)
FallbackShapeFamily::NginxLike => 2896,
FallbackShapeFamily::BoringSslLike => 1369,
FallbackShapeFamily::RustlsLike => 2048,
}; };
let split_threshold = match family { push_fallback_size(&mut sizes, size);
FallbackShapeFamily::NginxLike => 4096,
FallbackShapeFamily::BoringSslLike => 1536,
FallbackShapeFamily::RustlsLike => 3072,
};
if remaining <= split_threshold {
return vec![remaining.clamp(MIN_APP_DATA, MAX_APP_DATA)];
}
let mut sizes: Vec<usize> = Vec::new();
while remaining > 0 {
let chunk = remaining.min(preferred_chunk).min(MAX_APP_DATA);
if chunk < MIN_APP_DATA {
if let Some(last) = sizes.last_mut() {
*last = (*last).saturating_add(chunk).min(MAX_APP_DATA);
} else {
push_fallback_size(&mut sizes, chunk);
}
break;
}
push_fallback_size(&mut sizes, chunk);
remaining = remaining.saturating_sub(chunk);
}
sizes sizes
} }
fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> { fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
match cached.behavior_profile.source { match cached.behavior_profile.source {
TlsProfileSource::Raw | TlsProfileSource::Merged => { TlsProfileSource::Raw | TlsProfileSource::Merged => {
if !cached.behavior_profile.app_data_record_sizes.is_empty() { if let Some(size) = cached.behavior_profile.app_data_record_sizes.first() {
return cached.behavior_profile.app_data_record_sizes.clone(); return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
} }
if !cached.app_data_records_sizes.is_empty() { if let Some(size) = cached.app_data_records_sizes.first() {
return cached.app_data_records_sizes.clone(); return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
} }
return vec![cached.total_app_data_len.max(1024)]; return vec![
cached
.total_app_data_len
.max(1024)
.clamp(MIN_APP_DATA, MAX_APP_DATA),
];
} }
TlsProfileSource::Default | TlsProfileSource::Rustls => { TlsProfileSource::Default | TlsProfileSource::Rustls => {
return fallback_family_app_data_sizes(cached); return fallback_family_app_data_sizes(cached);
@@ -417,7 +395,7 @@ pub fn build_emulated_server_hello(
alpn: Option<Vec<u8>>, alpn: Option<Vec<u8>>,
new_session_tickets: u8, new_session_tickets: u8,
) -> Vec<u8> { ) -> Vec<u8> {
// --- ServerHello --- // ServerHello carries the authenticated digest bytes that the client verifies.
let extensions = build_profiled_server_hello_extensions(cached, server_key_share); let extensions = build_profiled_server_hello_extensions(cached, server_key_share);
let extensions_len = extensions.len() as u16; let extensions_len = extensions.len() as u16;
@@ -449,7 +427,7 @@ pub fn build_emulated_server_hello(
server_hello.extend_from_slice(&(message.len() as u16).to_be_bytes()); server_hello.extend_from_slice(&(message.len() as u16).to_be_bytes());
server_hello.extend_from_slice(&message); server_hello.extend_from_slice(&message);
// --- ChangeCipherSpec --- // ChangeCipherSpec is part of the client-visible TLS shim prefix.
let change_cipher_spec_count = emulated_change_cipher_spec_count(cached); let change_cipher_spec_count = emulated_change_cipher_spec_count(cached);
let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6); let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6);
for _ in 0..change_cipher_spec_count { for _ in 0..change_cipher_spec_count {
@@ -463,7 +441,8 @@ pub fn build_emulated_server_hello(
]); ]);
} }
// --- ApplicationData (fake encrypted records) --- // Telegram clients authenticate the hello prefix and then expose any later
// ApplicationData bytes to the MTProto packet parser.
let mut sizes = { let mut sizes = {
let base_sizes = emulated_app_data_sizes(cached); let base_sizes = emulated_app_data_sizes(cached);
match cached.behavior_profile.source { match cached.behavior_profile.source {
@@ -550,8 +529,7 @@ pub fn build_emulated_server_hello(
app_data.extend_from_slice(&rec); app_data.extend_from_slice(&rec);
} }
// --- Combine --- // Optional NewSessionTicket mimic records are an explicit fingerprint opt-in.
// Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint).
let mut tickets = Vec::new(); let mut tickets = Vec::new();
for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) { for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) {
let mut rec = Vec::with_capacity(5 + ticket_len); let mut rec = Vec::with_capacity(5 + ticket_len);
@@ -570,7 +548,7 @@ pub fn build_emulated_server_hello(
response.extend_from_slice(&app_data); response.extend_from_slice(&app_data);
response.extend_from_slice(&tickets); response.extend_from_slice(&tickets);
// --- HMAC --- // The digest authenticates the server response bytes emitted by this builder.
let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + response.len()); let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + response.len());
hmac_input.extend_from_slice(client_digest); hmac_input.extend_from_slice(client_digest);
hmac_input.extend_from_slice(&response); hmac_input.extend_from_slice(&response);
@@ -1062,7 +1040,7 @@ mod tests {
app_lens.push(record_len); app_lens.push(record_len);
pos += 5 + record_len; pos += 5 + record_len;
} }
assert_eq!(app_lens, vec![64, 3905, 537]); assert_eq!(app_lens, vec![64]);
assert_eq!(pos, response.len()); assert_eq!(pos, response.len());
} }
} }
@@ -106,7 +106,37 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() {
); );
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
assert_eq!(app_records, vec![1200, 900]); assert_eq!(app_records, vec![1200]);
}
#[test]
fn emulated_server_hello_keeps_default_profile_primary_app_data_single() {
let mut cached = make_cached();
cached.behavior_profile.source = TlsProfileSource::Default;
cached.behavior_profile.app_data_record_sizes.clear();
cached.behavior_profile.ticket_record_sizes.clear();
cached.app_data_records_sizes = vec![2048, 1024];
cached.total_app_data_len = 5000;
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x85; 32],
&[0x86; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
);
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
assert_eq!(app_records.len(), 1);
assert!(app_records[0] >= 64);
} }
#[test] #[test]
@@ -130,5 +160,5 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() {
); );
let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION);
assert_eq!(app_records, vec![1200, 900, 220, 180]); assert_eq!(app_records, vec![1200, 220, 180]);
} }
+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,
+35 -54
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,21 +216,15 @@ 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; {
*guard = Some(IpAddr::V4(ip)); let mut guard = self.nat_runtime.nat_ip_detected.write().await;
} *guard = Some(IpAddr::V4(ip));
info!(public_ip = %ip, "Auto-detected public IP for NAT translation");
Some(IpAddr::V4(ip))
}
Ok(None) => None,
Err(e) => {
warn!(error = %e, "Failed to auto-detect public IP");
None
}
} }
info!(public_ip = %ip, "Auto-detected public IP for NAT translation");
Some(IpAddr::V4(ip))
} }
pub(super) async fn maybe_reflect_public_addr( pub(super) async fn maybe_reflect_public_addr(
@@ -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> {