diff --git a/Cargo.lock b/Cargo.lock index 9d451b8..befabab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,9 +105,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "asn1-rs" @@ -222,9 +222,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake3" @@ -251,9 +251,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -281,9 +281,9 @@ checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cast" @@ -302,9 +302,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -361,9 +361,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -745,9 +745,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "digest" @@ -766,7 +763,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -875,12 +872,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1038,16 +1029,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -1062,9 +1051,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1101,9 +1090,6 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] [[package]] name = "hashbrown" @@ -1113,7 +1099,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1200,9 +1186,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1256,9 +1242,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1420,12 +1406,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -1455,15 +1435,13 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ "bitflags", "inotify-sys", @@ -1593,13 +1571,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1625,9 +1602,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -1649,12 +1626,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1684,9 +1655,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -1730,9 +1701,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -2101,16 +2072,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2147,9 +2108,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -2167,9 +2128,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", @@ -2203,9 +2164,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2239,7 +2200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -2317,9 +2278,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2340,9 +2301,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2451,9 +2412,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -2466,9 +2427,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2832,9 +2793,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -2901,9 +2862,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2938,7 +2899,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.18" +version = "3.4.19" dependencies = [ "aes", "anyhow", @@ -3007,7 +2968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3044,12 +3005,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3059,15 +3019,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -3380,9 +3340,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -3396,12 +3356,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -3438,11 +3392,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -3495,27 +3449,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -3526,9 +3471,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -3536,9 +3481,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3546,9 +3491,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -3559,52 +3504,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3622,18 +3533,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -3907,100 +3818,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -4038,9 +3861,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4061,18 +3884,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -4102,18 +3925,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8b223ee..7435630 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.18" +version = "3.4.19" edition = "2024" [features] @@ -8,89 +8,89 @@ redteam_offline_expected_fail = [] [dependencies] # C -libc = "0.2" +libc = "0.2.186" # Async runtime -tokio = { version = "1.42", features = ["full", "tracing"] } -tokio-util = { version = "0.7", features = ["full"] } +tokio = { version = "1.52.3", features = ["full", "tracing"] } +tokio-util = { version = "0.7.18", features = ["full"] } # Crypto -aes = "0.8" -ctr = "0.9" -cbc = "0.1" -sha2 = "0.10" -sha1 = "0.10" -md-5 = "0.10" -hmac = "0.12" -crc32fast = "1.4" -crc32c = "0.6" -zeroize = { version = "1.8", features = ["derive"] } -subtle = "2.6" -static_assertions = "1.1" +aes = "0.8.4" +ctr = "0.9.2" +cbc = "0.1.2" +sha2 = "0.10.9" +sha1 = "0.10.6" +md-5 = "0.10.6" +hmac = "0.12.1" +crc32fast = "1.5.0" +crc32c = "0.6.8" +zeroize = { version = "1.9.0", features = ["derive"] } +subtle = "2.6.1" +static_assertions = "1.1.0" ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] } # Network -socket2 = { version = "0.6", features = ["all"] } -nix = { version = "0.31", default-features = false, features = [ +socket2 = { version = "0.6.4", features = ["all"] } +nix = { version = "0.31.3", default-features = false, features = [ "net", "user", "process", "fs", "signal", ] } -shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] } +shadowsocks = { version = "1.24.0", features = ["aead-cipher-2022"] } # Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -toml = "1.0" -x509-parser = "0.18" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +toml = "1.1" +x509-parser = "0.18.1" # Utils -bytes = "1.9" -thiserror = "2.0" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "0.2" -parking_lot = "0.12" -dashmap = "6.1" -arc-swap = "1.7" -lru = "0.16" -rand = "0.10" -chrono = { version = "0.4", features = ["serde"] } -hex = "0.4" -base64 = "0.22" -url = "2.5" -regex = "1.11" -crossbeam-queue = "0.3" -num-bigint = "0.4" -num-traits = "0.2" -x25519-dalek = "2" -anyhow = "1.0" +bytes = "1.12.0" +thiserror = "2.0.18" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tracing-appender = "0.2.5" +parking_lot = "0.12.5" +dashmap = "6.2.1" +arc-swap = "1.9.1" +lru = "0.16.4" +rand = "0.10.1" +chrono = { version = "0.4.45", features = ["serde"] } +hex = "0.4.3" +base64 = "0.22.1" +url = "2.5.8" +regex = "1.12.4" +crossbeam-queue = "0.3.12" +num-bigint = "0.4.6" +num-traits = "0.2.19" +x25519-dalek = "2.0.1" +anyhow = "1.0.102" # HTTP -reqwest = { version = "0.13", features = ["rustls"], default-features = false } -notify = "8.2" -ipnetwork = { version = "0.21", features = ["serde"] } -hyper = { version = "1", features = ["server", "http1"] } -hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } -http-body-util = "0.1" -httpdate = "1.0" -tokio-rustls = { version = "0.26", default-features = false, features = [ +reqwest = { version = "0.13.4", features = ["rustls"], default-features = false } +notify = "8.2.0" +ipnetwork = { version = "0.21.1", features = ["serde"] } +hyper = { version = "1.10.1", features = ["server", "http1"] } +hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] } +http-body-util = "0.1.3" +httpdate = "1.0.3" +tokio-rustls = { version = "0.26.4", default-features = false, features = [ "tls12", ] } -rustls = { version = "0.23", default-features = false, features = [ +rustls = { version = "0.23.41", default-features = false, features = [ "std", "tls12", "ring", ] } -webpki-roots = "1.0" +webpki-roots = "1.0.8" [dev-dependencies] -tokio-test = "0.4" -criterion = "0.8" -proptest = "1.4" -futures = "0.3" +tokio-test = "0.4.5" +criterion = "0.8.2" +proptest = "1.11.0" +futures = "0.3.32" tempfile = "3.27.0" [[bench]] diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index c508c87..d1fe111 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`. # Table of contents - [Top-level keys](#top-level-keys) + - [logging](#logging) - [general](#general) - [general.modes](#generalmodes) - [general.links](#generallinks) @@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`. | --- | ---- | ------- | ---------- | | [`include`](#include) | `String` (special directive) | — | `✔` | | [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` | +| [`logging`](#logging) | Table | default values | `✘` | | [`dc_overrides`](#dc_overrides) | `Map` | `{}` | `✘` | | [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` | | [`beobachten`](#beobachten) | `bool` | `true` | `✘` | @@ -83,6 +85,84 @@ This document lists all configuration keys accepted by `config.toml`. default_dc = 2 ``` +# [logging] + +| Key | Type | Default | Hot-Reload | +| --- | ---- | ------- | ---------- | +| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `✘` | +| [`path`](#loggingpath) | `String` | — | `✘` | +| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `✘` | +| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `✘` | +| [`max_files`](#loggingmax_files) | `usize` | `0` | `✘` | +| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `✘` | + +## logging.destination + - **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`. + - **Description**: Selects the runtime log destination. CLI flags override this value. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + ``` +## logging.path + - **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty. + - **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + ``` +## logging.rotation + - **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`. + - **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + rotation = "daily" + ``` +## logging.max_size_bytes + - **Constraints / validation**: `0` disables size rotation. + - **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + max_size_bytes = 104857600 + ``` +## logging.max_files + - **Constraints / validation**: `0` disables count-based retention. + - **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + rotation = "daily" + max_files = 14 + ``` +## logging.max_age_secs + - **Constraints / validation**: `0` disables age-based retention. + - **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + rotation = "daily" + max_age_secs = 1209600 + ``` + # [general] @@ -1806,6 +1886,7 @@ This document lists all configuration keys accepted by `config.toml`. | [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` | | [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` | | [`client_mss`](#client_mss) | `String` | `""` | `✘` | +| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `✘` | | [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `✘` | | [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `✘` | @@ -1898,6 +1979,16 @@ This document lists all configuration keys accepted by `config.toml`. [server] client_mss = "tspu" ``` +## client_mss_bulk + - **Constraints / validation**: `String`. Same grammar as [`client_mss`](#client_mss) (empty/omitted, presets `"extreme-low"`/`"tspu"`/`"2in8"`, or a decimal in `88..=4096`). + - **Description**: Optional bulk-phase MSS. When set, the low `client_mss` is applied only while the TLS handshake (including the DPI-inspected ServerHello) is sent; once the connection transitions to relaying, the client socket MSS is raised to `client_mss_bulk` for the bulk data phase. This keeps the anti-DPI handshake fragmentation but restores normal-size packets for payload, cutting outgoing packets-per-second by roughly the `client_mss` segment multiplier (e.g. ~10x with `"tspu"`). Useful on hosts whose abuse detection counts packets-per-second rather than bandwidth. When empty/omitted, the handshake MSS is kept for the whole connection (previous behavior). Linux only; a no-op elsewhere. + - **Example**: + + ```toml + [server] + client_mss = "tspu" + client_mss_bulk = "1400" + ``` ## proxy_protocol - **Constraints / validation**: `bool`. - **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header. diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index 0be80fe..755e964 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -1808,6 +1808,7 @@ | [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` | | [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` | | [`client_mss`](#client_mss) | `String` | `""` | `✘` | +| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `✘` | | [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `✘` | | [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `✘` | @@ -1900,6 +1901,16 @@ [server] client_mss = "tspu" ``` +## client_mss_bulk + - **Ограничения / валидация**: `String`. Грамматика та же, что у [`client_mss`](#client_mss) (пусто/не задано, пресеты `"extreme-low"`/`"tspu"`/`"2in8"` либо десятичное число в диапазоне `88..=4096`). + - **Описание**: Необязательный MSS для bulk-фазы. Если задан, низкий `client_mss` применяется только на время TLS-handshake (включая инспектируемый DPI ServerHello); как только соединение переходит в фазу relay, MSS клиентского сокета поднимается до `client_mss_bulk` для передачи полезной нагрузки. Так сохраняется anti-DPI фрагментация handshake, но для данных возвращаются пакеты нормального размера — это снижает исходящий packets-per-second примерно во столько раз, каков segment multiplier у `client_mss` (например, ~10x для `"tspu"`). Полезно на хостингах, где abuse-детекция считает packets-per-second, а не полосу. Если пусто/не задано — MSS handshake сохраняется на всё соединение (прежнее поведение). Только Linux; на прочих платформах — no-op. + - **Пример**: + + ```toml + [server] + client_mss = "tspu" + client_mss_bulk = "1400" + ``` ## proxy_protocol - **Ограничения / валидация**: `bool`. - **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка. diff --git a/src/api/config_edit.rs b/src/api/config_edit.rs index 1de100b..8f25cfc 100644 --- a/src/api/config_edit.rs +++ b/src/api/config_edit.rs @@ -313,6 +313,83 @@ mod tests { assert_eq!(err.code, "section_not_editable"); } + #[tokio::test] + async fn patch_rejects_show_link_section() { + // show_link is a legacy top-level scalar/array (not a [table]); it cannot + // be upserted safely and is superseded by the editable general.links.show. + let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n"); + let patch: Json = serde_json::json!({"show_link": "*"}); + let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err(); + assert_eq!(err.code, "section_not_editable"); + } + + #[tokio::test] + async fn patch_general_links_show_is_editable() { + // The supported replacement path: edit show via the general.links sub-table. + let (path, _d) = temp_config( + "[general]\nprefer_ipv6 = false\n[general.links]\nshow = \"*\"\n\ + [censorship]\ntls_domain = \"a\"\n", + ); + let patch: Json = serde_json::json!({"general": {"links": {"show": ["alice"]}}}); + let resp = apply_patch_to_path(&path, &patch, None).await.unwrap(); + assert!(resp.changed.iter().any(|c| c == "general")); + let written = tokio::fs::read_to_string(&path).await.unwrap(); + let parsed: toml::Value = toml::from_str(&written).unwrap(); + assert_eq!( + parsed["general"]["links"]["show"][0].as_str(), + Some("alice"), + "{written}" + ); + // No leaked top-level [links]/[modes] and no duplicate sub-tables. + assert_eq!(written.matches("[general.links]").count(), 1, "{written}"); + } + + #[tokio::test] + async fn patch_links_public_port_written_as_integer_not_float_or_string() { + // A JSON integer must land on disk as a bare TOML integer (443), never + // 443.0 nor "443". The write re-renders from the typed config, so the + // u16 field dictates the output format regardless of JSON quirks. + let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n"); + let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443}}}); + apply_patch_to_path(&path, &patch, None).await.unwrap(); + + let written = tokio::fs::read_to_string(&path).await.unwrap(); + assert!(written.contains("public_port = 443"), "{written}"); + assert!( + !written.contains("443.0"), + "must not be a float:\n{written}" + ); + assert!( + !written.contains("\"443\""), + "must not be a string:\n{written}" + ); + + let parsed: toml::Value = toml::from_str(&written).unwrap(); + assert_eq!( + parsed["general"]["links"]["public_port"].as_integer(), + Some(443), + "{written}" + ); + } + + #[tokio::test] + async fn patch_links_public_port_rejects_float() { + // 443.0 cannot deserialize into u16 -> rejected, not silently coerced. + let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n"); + let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443.0}}}); + let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err(); + assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err); + } + + #[tokio::test] + async fn patch_links_public_port_rejects_string() { + // "443" is a string, not a u16 -> rejected. + let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n"); + let patch: Json = serde_json::json!({"general": {"links": {"public_port": "443"}}}); + let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err(); + assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err); + } + #[tokio::test] async fn patch_empty_is_rejected() { let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n"); diff --git a/src/api/config_store.rs b/src/api/config_store.rs index e0ca68d..cc5dc25 100644 --- a/src/api/config_store.rs +++ b/src/api/config_store.rs @@ -102,9 +102,14 @@ pub(super) async fn save_config_to_disk( /// Intentionally excluded (defense-in-depth, enforces the spec's per-node /// identity invariant at the Telemt layer too): /// -/// - `access` : owned by the users API. -/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners). -/// - `network` : carries per-node identity (`ipv4`/`ipv6`). +/// - `access` : owned by the users API. +/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners). +/// - `network` : carries per-node identity (`ipv4`/`ipv6`). +/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded +/// by the editable `general.links.show` sub-table. The +/// section-upsert machinery here only handles `[table]` / +/// `[[array-of-tables]]` blocks; a bare top-level key cannot be +/// located or replaced safely, so it is edited via `general`. /// /// A future field-level allowlist can re-admit specific safe fields /// (e.g. `network.dns_overrides`) without opening the whole section. @@ -113,7 +118,6 @@ pub(super) const EDITABLE_SECTIONS: &[&str] = &[ "timeouts", "censorship", "upstreams", - "show_link", "dc_overrides", ]; @@ -162,10 +166,15 @@ fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result Result { } fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String { - if let Some((start, end)) = find_toml_table_bounds(source, table_name) { + let blocks = find_all_table_blocks(source, table_name); + if let Some(&(first_start, first_end)) = blocks.first() { + // Replace the first block in place and delete any further blocks that + // also belong to this table. Telemt writes a section's sub-tables + // contiguously, but a hand-edited config may scatter them; dropping the + // extras here prevents the duplicate-table corruption that would + // otherwise break config load. let mut out = String::with_capacity(source.len() + replacement.len()); - out.push_str(&source[..start]); + out.push_str(&source[..first_start]); out.push_str(replacement); - out.push_str(&source[end..]); + let mut cursor = first_end; + for &(start, end) in &blocks[1..] { + out.push_str(&source[cursor..start]); + cursor = end; + } + out.push_str(&source[cursor..]); return out; } @@ -347,29 +367,62 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin out } +/// Whether a (comment-stripped, trimmed) TOML header line belongs to +/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested +/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling +/// prefixes — `access.users` must not match `access.user_enabled`. +fn header_belongs_to(header: &str, table_name: &str) -> bool { + let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) { + Some(body) => body, + None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) { + Some(body) => body, + None => return false, + }, + }; + let body = body.trim(); + body == table_name + || body + .strip_prefix(table_name) + .is_some_and(|rest| rest.starts_with('.')) +} + +/// Locate the first contiguous byte range covering `table_name` and the nested +/// sub-tables immediately following it. Used for existence checks; see +/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks. fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> { - let single = format!("[{}]", table_name); - let array = format!("[[{}]]", table_name); + find_all_table_blocks(source, table_name).into_iter().next() +} + +/// Locate every byte range that belongs to `table_name`: the table header and +/// its nested sub-tables. Returns one range per contiguous run, so a config +/// where a section's sub-tables are scattered (e.g. hand-edited) yields several +/// ranges — letting the caller collapse them into a single rendered block. +fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> { + let mut blocks = Vec::new(); let mut offset = 0usize; - let mut start = None; + let mut start: Option = None; for line in source.split_inclusive('\n') { // Drop any inline comment so a hand-edited header like // `[censorship] # note` still matches. Section names never contain `#`. let header = line.trim().split('#').next().unwrap_or("").trim(); + let is_header = header.starts_with('['); if let Some(start_offset) = start { - let is_same_array = header == array; - let is_new_header = header.starts_with('['); - if is_new_header && !is_same_array { - return Some((start_offset, offset)); + if is_header && !header_belongs_to(header, table_name) { + blocks.push((start_offset, offset)); + start = None; } - } else if header == single || header == array { + } + if start.is_none() && header_belongs_to(header, table_name) { start = Some(offset); } offset = offset.saturating_add(line.len()); } - start.map(|start_offset| (start_offset, source.len())) + if let Some(start_offset) = start { + blocks.push((start_offset, source.len())); + } + blocks } async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> { @@ -467,6 +520,138 @@ mod tests { assert!(!slice.contains("[server]")); // terminates at the next header } + #[tokio::test] + async fn save_general_section_keeps_subtables_dotted_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + tokio::fs::write( + &path, + "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\ + [general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n", + ) + .await + .unwrap(); + + let mut cfg = ProxyConfig::default(); + cfg.general.prefer_ipv6 = true; + + save_sections_to_disk(&path, &cfg, &["general"]) + .await + .unwrap(); + + let written = tokio::fs::read_to_string(&path).await.unwrap(); + + // No bare top-level [modes] / [links] headers leaked. + for line in written.lines() { + let header = line.trim(); + assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}"); + assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}"); + } + + // Sub-tables kept their dotted prefix exactly once each. + assert_eq!( + written.matches("[general.modes]").count(), + 1, + "[general.modes] must appear exactly once:\n{written}" + ); + assert_eq!( + written.matches("[general.links]").count(), + 1, + "[general.links] must appear exactly once:\n{written}" + ); + + // Result parses (duplicate tables would error here). + toml::from_str::(&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::(&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::(&written) + .unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}")); + assert!(written.contains("[server]")); // unrelated section preserved + } + #[test] fn render_user_rate_limits_section() { let mut cfg = ProxyConfig::default(); diff --git a/src/config/load.rs b/src/config/load.rs index 7b976c8..016acda 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -114,6 +114,7 @@ fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "general", + "logging", "network", "server", "timeouts", @@ -300,6 +301,7 @@ const SERVER_CONFIG_KEYS: &[&str] = &[ "listen_unix_sock_perm", "listen_tcp", "client_mss", + "client_mss_bulk", "proxy_protocol", "proxy_protocol_header_timeout_ms", "proxy_protocol_trusted_cidrs", @@ -458,6 +460,14 @@ const UPSTREAM_CONFIG_KEYS: &[&str] = &[ const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"]; const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"]; const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"]; +const LOGGING_CONFIG_KEYS: &[&str] = &[ + "destination", + "path", + "rotation", + "max_size_bytes", + "max_files", + "max_age_secs", +]; #[derive(Debug)] struct UnknownConfigKey { @@ -499,6 +509,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> { PROXY_MODES_CONFIG_KEYS, TELEMETRY_CONFIG_KEYS, LINKS_CONFIG_KEYS, + LOGGING_CONFIG_KEYS, ] { keys.extend_from_slice(group); } @@ -632,6 +643,13 @@ fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec) { } } +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<()> { let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| { upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. }) @@ -1057,13 +1093,17 @@ fn normalize_upstream_family_policy(config: &mut ProxyConfig) { } } -// ============= Main Config ============= +// Main runtime configuration loaded from TOML. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProxyConfig { #[serde(default)] pub general: GeneralConfig, + /// Runtime logging destination, rotation, and retention configuration. + #[serde(default)] + pub logging: LoggingConfig, + #[serde(default)] pub network: NetworkConfig, @@ -1940,6 +1980,13 @@ impl ProxyConfig { )); } + if config.server.listen_backlog == 0 || config.server.listen_backlog > i32::MAX as u32 { + return Err(ProxyError::Config(format!( + "server.listen_backlog must be within [1, {}]", + i32::MAX + ))); + } + config .server .client_mss_value() @@ -2280,6 +2327,7 @@ impl ProxyConfig { .entry("203".to_string()) .or_insert_with(|| vec!["91.105.192.100:443".to_string()]); + validate_logging_config(&config.logging)?; validate_upstreams(&config)?; config.rebuild_runtime_user_auth()?; @@ -2305,6 +2353,8 @@ impl ProxyConfig { 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 { return Err(ProxyError::Config("No modes enabled".to_string())); } @@ -2401,6 +2451,21 @@ mod tests { 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] fn serde_defaults_remain_unchanged_for_present_sections() { let toml = r#" @@ -2411,6 +2476,7 @@ mod tests { "#; 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.stun_use, default_true()); assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); @@ -2578,6 +2644,65 @@ mod tests { ); } + #[test] + fn logging_config_is_loaded_from_strict_config() { + let cfg = load_config_from_temp_toml( + r#" + [general] + config_strict = true + + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + path = "/tmp/telemt.log" + rotation = "daily" + max_size_bytes = 1024 + max_files = 3 + max_age_secs = 60 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert_eq!(cfg.logging.destination, LoggingDestination::File); + assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log")); + assert_eq!(cfg.logging.rotation, LogRotation::Daily); + assert_eq!(cfg.logging.max_size_bytes, 1024); + assert_eq!(cfg.logging.max_files, 3); + assert_eq!(cfg.logging.max_age_secs, 60); + } + + #[test] + fn file_logging_requires_path() { + let error = load_config_error_from_temp_toml( + r#" + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert!(error.contains("logging.path must be set")); + } + #[test] fn impl_defaults_are_sourced_from_default_helpers() { let network = NetworkConfig::default(); diff --git a/src/config/tests/load_memory_envelope_tests.rs b/src/config/tests/load_memory_envelope_tests.rs index ea78498..06681d3 100644 --- a/src/config/tests/load_memory_envelope_tests.rs +++ b/src/config/tests/load_memory_envelope_tests.rs @@ -95,6 +95,44 @@ max_client_frame = 16777217 remove_temp_config(&path); } +#[test] +fn load_rejects_listen_backlog_above_i32_upper_bound() { + let path = write_temp_config( + r#" +[server] +listen_backlog = 2147483648 +"#, + ); + + let err = ProxyConfig::load(&path).expect_err("listen_backlog above socket cap must fail"); + let msg = err.to_string(); + assert!( + msg.contains("server.listen_backlog must be within [1, 2147483647]"), + "error must explain listen_backlog hard cap, got: {msg}" + ); + + remove_temp_config(&path); +} + +#[test] +fn load_rejects_zero_listen_backlog() { + let path = write_temp_config( + r#" +[server] +listen_backlog = 0 +"#, + ); + + let err = ProxyConfig::load(&path).expect_err("zero listen_backlog must fail"); + let msg = err.to_string(); + assert!( + msg.contains("server.listen_backlog must be within [1, 2147483647]"), + "error must explain listen_backlog lower bound, got: {msg}" + ); + + remove_temp_config(&path); +} + #[test] fn load_accepts_memory_limits_at_hard_upper_bounds() { let path = write_temp_config( diff --git a/src/config/types.rs b/src/config/types.rs index e0f7b04..6d38882 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -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 { + 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, + /// Time rotation interval for file logs. + #[serde(default)] + pub rotation: LogRotation, + /// Maximum active log file size before rotating. `0` disables size rotation. + #[serde(default)] + pub max_size_bytes: u64, + /// Maximum number of matching log files to keep. `0` disables count retention. + #[serde(default)] + pub max_files: usize, + /// Maximum age for rotated log files in seconds. `0` disables age retention. + #[serde(default)] + pub max_age_secs: u64, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + destination: LoggingDestination::Stderr, + path: None, + rotation: LogRotation::Never, + max_size_bytes: 0, + max_files: 0, + max_age_secs: 0, + } + } +} + /// Middle-End telemetry verbosity level. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -429,7 +509,7 @@ pub struct GeneralConfig { pub ad_tag: Option, /// Public IP override for middle-proxy NAT environments. - /// When set, this IP is used in ME key derivation and RPC_PROXY_REQ "our_addr". + /// When set, this IP is used in ME key derivation and local address translation. #[serde(default)] pub middle_proxy_nat_ip: Option, @@ -1527,6 +1607,15 @@ pub struct ServerConfig { #[serde(default)] pub client_mss: Option, + /// 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, + /// Accept HAProxy PROXY protocol headers on incoming connections. /// When enabled, real client IPs are extracted from PROXY v1/v2 headers. #[serde(default)] @@ -1594,6 +1683,7 @@ impl Default for ServerConfig { listen_unix_sock_perm: None, listen_tcp: None, client_mss: None, + client_mss_bulk: None, proxy_protocol: false, proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(), proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(), @@ -2218,6 +2308,11 @@ impl ServerConfig { pub fn client_mss_value(&self) -> std::result::Result, String> { parse_client_mss(self.client_mss.as_deref()) } + + /// Resolves the post-handshake (bulk transfer) client MSS, if configured. + pub fn client_mss_bulk_value(&self) -> std::result::Result, String> { + parse_client_mss(self.client_mss_bulk.as_deref()) + } } impl ListenerConfig { diff --git a/src/logging.rs b/src/logging.rs index af9e2f7..08f1131 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -5,16 +5,41 @@ //! - syslog (Unix only, for traditional init systems) //! - file (with optional rotation) -#![allow(dead_code)] // Infrastructure module - used via CLI flags +// Infrastructure module used via CLI flags. +#![allow(dead_code)] use std::path::Path; +use crate::config::{LogRotation, LoggingConfig, LoggingDestination}; + use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt, reload}; +// Submodules: +// - file: bounded file appender for size and retention controls. +mod file; + +#[cfg(test)] +mod tests; + +/// File logging and retention options resolved from config and CLI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileLogOptions { + /// Log file path or rolling filename prefix path. + pub path: String, + /// Time rotation interval. + pub rotation: LogRotation, + /// Maximum active file size before size rotation. `0` disables it. + pub max_size_bytes: u64, + /// Maximum number of matching log files to keep. `0` disables it. + pub max_files: usize, + /// Maximum rotated file age in seconds. `0` disables it. + pub max_age_secs: u64, +} + /// Log destination configuration. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum LogDestination { /// Log to stderr (default, captured by systemd journald). #[default] @@ -24,12 +49,29 @@ pub enum LogDestination { Syslog, /// Log to a file with optional rotation. File { - path: String, - /// Rotate daily if true. - rotate_daily: bool, + /// Resolved file logging options. + options: FileLogOptions, }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogCliDestination { + Stderr, + Syslog, + File, +} + +/// Logging-related CLI overrides. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LogCliOptions { + destination: Option, + path: Option, + rotation: Option, + max_size_bytes: Option, + max_files: Option, + max_age_secs: Option, +} + /// Logging options parsed from CLI/config. #[derive(Debug, Clone, Default)] pub struct LoggingOptions { @@ -101,23 +143,29 @@ pub fn init_logging( (filter_handle, LoggingGuard::noop()) } - LogDestination::File { path, rotate_daily } => { - let (non_blocking, guard) = if *rotate_daily { - // Extract directory and filename prefix - let path = Path::new(path); - let dir = path.parent().unwrap_or(Path::new("/var/log")); - let prefix = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("telemt"); - - let file_appender = tracing_appender::rolling::daily(dir, prefix); + LogDestination::File { options } => { + let (non_blocking, guard) = if options.max_size_bytes > 0 + || options.max_files > 0 + || options.max_age_secs > 0 + { + let file_appender = file::BoundedFileAppender::new(options.clone()) + .expect("Failed to open log file"); + tracing_appender::non_blocking(file_appender) + } else if !matches!(options.rotation, LogRotation::Never) { + let path = Path::new(&options.path); + let dir = log_file_dir(path); + let prefix = log_file_name(path); + let file_appender = tracing_appender::rolling::RollingFileAppender::builder() + .rotation(to_tracing_rotation(options.rotation)) + .filename_prefix(prefix) + .build(dir) + .expect("Failed to open log file"); tracing_appender::non_blocking(file_appender) } else { let file = std::fs::OpenOptions::new() .create(true) .append(true) - .open(path) + .open(&options.path) .expect("Failed to open log file"); tracing_appender::non_blocking(file) }; @@ -137,6 +185,28 @@ pub fn init_logging( } } +fn log_file_dir(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn log_file_name(path: &Path) -> &str { + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("telemt") +} + +fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation { + match rotation { + LogRotation::Never => tracing_appender::rolling::Rotation::NEVER, + LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY, + LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY, + LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY, + LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY, + } +} + /// Syslog writer for tracing. #[cfg(unix)] #[derive(Clone, Copy)] @@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter { } } -/// Parse log destination from CLI arguments. -pub fn parse_log_destination(args: &[String]) -> LogDestination { +/// Parse logging overrides from CLI arguments. +pub fn parse_log_cli_options(args: &[String]) -> Result { + let mut options = LogCliOptions::default(); let mut i = 0; while i < args.len() { match args[i].as_str() { #[cfg(unix)] "--syslog" => { - return LogDestination::Syslog; + options.destination = Some(LogCliDestination::Syslog); + } + #[cfg(not(unix))] + "--syslog" => { + options.destination = Some(LogCliDestination::Syslog); } "--log-file" => { i += 1; if i < args.len() { - return LogDestination::File { - path: args[i].clone(), - rotate_daily: false, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(args[i].clone()); + } else { + return Err("Missing value for --log-file".to_string()); } } s if s.starts_with("--log-file=") => { - return LogDestination::File { - path: s.trim_start_matches("--log-file=").to_string(), - rotate_daily: false, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(s.trim_start_matches("--log-file=").to_string()); } "--log-file-daily" => { i += 1; if i < args.len() { - return LogDestination::File { - path: args[i].clone(), - rotate_daily: true, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(args[i].clone()); + options.rotation = Some(LogRotation::Daily); + } else { + return Err("Missing value for --log-file-daily".to_string()); } } s if s.starts_with("--log-file-daily=") => { - return LogDestination::File { - path: s.trim_start_matches("--log-file-daily=").to_string(), - rotate_daily: true, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(s.trim_start_matches("--log-file-daily=").to_string()); + options.rotation = Some(LogRotation::Daily); + } + "--log-rotation" => { + i += 1; + if i < args.len() { + options.rotation = Some(parse_rotation_cli_value(&args[i])?); + } else { + return Err("Missing value for --log-rotation".to_string()); + } + } + s if s.starts_with("--log-rotation=") => { + options.rotation = Some(parse_rotation_cli_value( + s.trim_start_matches("--log-rotation="), + )?); + } + "--log-max-size-bytes" => { + i += 1; + if i < args.len() { + options.max_size_bytes = + Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?); + } else { + return Err("Missing value for --log-max-size-bytes".to_string()); + } + } + s if s.starts_with("--log-max-size-bytes=") => { + options.max_size_bytes = Some(parse_u64_cli_value( + "--log-max-size-bytes", + s.trim_start_matches("--log-max-size-bytes="), + )?); + } + "--log-max-files" => { + i += 1; + if i < args.len() { + options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?); + } else { + return Err("Missing value for --log-max-files".to_string()); + } + } + s if s.starts_with("--log-max-files=") => { + options.max_files = Some(parse_usize_cli_value( + "--log-max-files", + s.trim_start_matches("--log-max-files="), + )?); + } + "--log-max-age-secs" => { + i += 1; + if i < args.len() { + options.max_age_secs = + Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?); + } else { + return Err("Missing value for --log-max-age-secs".to_string()); + } + } + s if s.starts_with("--log-max-age-secs=") => { + options.max_age_secs = Some(parse_u64_cli_value( + "--log-max-age-secs", + s.trim_start_matches("--log-max-age-secs="), + )?); } _ => {} } i += 1; } - LogDestination::Stderr + Ok(options) } -#[cfg(test)] -mod tests { - use super::*; +fn parse_rotation_cli_value(value: &str) -> Result { + LogRotation::from_cli_arg(value).ok_or_else(|| { + format!( + "Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly" + ) + }) +} - #[test] - fn test_parse_log_destination_default() { - let args: Vec = vec![]; - assert!(matches!( - parse_log_destination(&args), - LogDestination::Stderr - )); - } +fn parse_u64_cli_value(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer")) +} - #[test] - fn test_parse_log_destination_file() { - let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()]; - match parse_log_destination(&args) { - LogDestination::File { path, rotate_daily } => { - assert_eq!(path, "/var/log/telemt.log"); - assert!(!rotate_daily); +fn parse_usize_cli_value(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer")) +} + +/// Resolve effective logging destination from config and CLI overrides. +pub fn resolve_log_destination( + config: &LoggingConfig, + cli: &LogCliOptions, +) -> Result { + let destination = cli.destination.unwrap_or(match config.destination { + LoggingDestination::Stderr => LogCliDestination::Stderr, + LoggingDestination::Syslog => LogCliDestination::Syslog, + LoggingDestination::File => LogCliDestination::File, + }); + + match destination { + LogCliDestination::Stderr => Ok(LogDestination::Stderr), + LogCliDestination::Syslog => { + #[cfg(unix)] + { + Ok(LogDestination::Syslog) } - _ => panic!("Expected File destination"), - } - } - - #[test] - fn test_parse_log_destination_file_daily() { - let args = vec!["--log-file-daily=/var/log/telemt".to_string()]; - match parse_log_destination(&args) { - LogDestination::File { path, rotate_daily } => { - assert_eq!(path, "/var/log/telemt"); - assert!(rotate_daily); + #[cfg(not(unix))] + { + Err("Syslog logging is only supported on Unix platforms".to_string()) } - _ => panic!("Expected File destination"), } - } + LogCliDestination::File => { + let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| { + "logging.path or --log-file must be set when file logging is enabled".to_string() + })?; + if path.trim().is_empty() { + return Err("Log file path cannot be empty".to_string()); + } - #[cfg(unix)] - #[test] - fn test_parse_log_destination_syslog() { - let args = vec!["--syslog".to_string()]; - assert!(matches!( - parse_log_destination(&args), - LogDestination::Syslog - )); - } - - #[cfg(unix)] - #[test] - fn test_syslog_priority_for_level_mapping() { - assert_eq!( - syslog_priority_for_level(&tracing::Level::ERROR), - libc::LOG_ERR - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::WARN), - libc::LOG_WARNING - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::INFO), - libc::LOG_INFO - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::DEBUG), - libc::LOG_DEBUG - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::TRACE), - libc::LOG_DEBUG - ); + Ok(LogDestination::File { + options: FileLogOptions { + path: path.clone(), + rotation: cli.rotation.unwrap_or(config.rotation), + max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes), + max_files: cli.max_files.unwrap_or(config.max_files), + max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs), + }, + }) + } } } diff --git a/src/logging/file.rs b/src/logging/file.rs new file mode 100644 index 0000000..3b96903 --- /dev/null +++ b/src/logging/file.rs @@ -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, + file: Option, + now: Box DateTime + Send + Sync>, +} + +impl BoundedFileAppender { + pub(crate) fn new(options: FileLogOptions) -> io::Result { + Self::with_now(options, Box::new(Utc::now)) + } + + fn with_now( + options: FileLogOptions, + now: Box DateTime + Send + Sync>, + ) -> io::Result { + let path = Path::new(&options.path); + let dir = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let base_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("telemt") + .to_string(); + + let start = now(); + let current_path = active_path_for(&dir, &base_name, options.rotation, &start); + let (file, current_size) = open_append_file(¤t_path)?; + let mut appender = Self { + options, + dir, + base_name, + current_path, + current_size, + last_cleanup: start, + file: Some(file), + now, + }; + appender.cleanup(&start); + Ok(appender) + } + + fn now(&self) -> DateTime { + (self.now)() + } + + fn refresh_active_path(&mut self, now: &DateTime) -> io::Result { + 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) -> 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) -> 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) -> 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) { + 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 { + 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, +) -> 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) -> 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) -> 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 { + DateTime::::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 { + 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()); + } +} diff --git a/src/logging/tests.rs b/src/logging/tests.rs new file mode 100644 index 0000000..ae57470 --- /dev/null +++ b/src/logging/tests.rs @@ -0,0 +1,100 @@ +use super::*; + +#[test] +fn test_parse_log_cli_options_default() { + let args: Vec = 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 + ); +} diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index fa23e8f..ba93792 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn}; use crate::cli; use crate::config::ProxyConfig; -use crate::logging::LogDestination; +use crate::logging::LogCliOptions; use crate::transport::UpstreamManager; use crate::transport::middle_proxy::{ ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache, @@ -113,7 +113,7 @@ pub(crate) struct CliArgs { pub data_path: Option, pub silent: bool, pub log_level: Option, - pub log_destination: LogDestination, + pub log_cli_options: LogCliOptions, } pub(crate) fn parse_cli() -> CliArgs { @@ -125,8 +125,13 @@ pub(crate) fn parse_cli() -> CliArgs { let args: Vec = std::env::args().skip(1).collect(); - // Parse log destination - let log_destination = crate::logging::parse_log_destination(&args); + let log_cli_options = match crate::logging::parse_log_cli_options(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("[telemt] {error}"); + std::process::exit(2); + } + }; // Check for --init first (handled before tokio) if let Some(init_opts) = cli::parse_init_args(&args) { @@ -180,6 +185,21 @@ pub(crate) fn parse_cli() -> CliArgs { s if s.starts_with("--log-level=") => { log_level = Some(s.trim_start_matches("--log-level=").to_string()); } + "--log-file" | "--log-file-daily" => { + i += 1; + } + s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {} + "--log-rotation" + | "--log-max-size-bytes" + | "--log-max-files" + | "--log-max-age-secs" => { + i += 1; + } + s if s.starts_with("--log-rotation=") + || s.starts_with("--log-max-size-bytes=") + || s.starts_with("--log-max-files=") + || s.starts_with("--log-max-age-secs=") => {} + "--syslog" => {} "--help" | "-h" => { print_help(); std::process::exit(0); @@ -192,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs { "--daemon" | "-d" | "--foreground" | "-f" => {} s if s.starts_with("--pid-file") => { if !s.contains('=') { - i += 1; // skip value + // Skip the pid-file value consumed by daemon argument parsing. + i += 1; } } s if s.starts_with("--run-as-user") => { @@ -224,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs { data_path, silent, log_level, - log_destination, + log_cli_options, } } @@ -254,6 +275,10 @@ fn print_help() { eprintln!("Logging options:"); eprintln!(" --log-file Log to file (default: stderr)"); eprintln!(" --log-file-daily Log to file with daily rotation"); + eprintln!(" --log-rotation never|minutely|hourly|daily|weekly"); + eprintln!(" --log-max-size-bytes N Rotate file logs when active file exceeds N bytes"); + eprintln!(" --log-max-files N Keep at most N matching file logs (0 disables)"); + eprintln!(" --log-max-age-secs N Remove rotated file logs older than N seconds"); #[cfg(unix)] eprintln!(" --syslog Log to syslog (Unix only)"); eprintln!(); diff --git a/src/maestro/me_startup.rs b/src/maestro/me_startup.rs index 9dde7fa..21e1ccf 100644 --- a/src/maestro/me_startup.rs +++ b/src/maestro/me_startup.rs @@ -208,6 +208,8 @@ pub(crate) async fn initialize_me_pool( me_nat_probe, None, config.network.stun_servers.clone(), + config.network.stun_tcp_fallback, + config.network.http_ip_detect_urls.clone(), config.general.stun_nat_probe_concurrency, probe.detected_ipv6, config.timeouts.me_one_retry, diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index e5bb2f7..e7f349c 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -108,7 +108,7 @@ async fn run_telemt_core( let data_path = cli_args.data_path; let cli_silent = cli_args.silent; let cli_log_level = cli_args.log_level; - let log_destination = cli_args.log_destination; + let log_cli_options = cli_args.log_cli_options; let startup_cwd = match std::env::current_dir() { Ok(cwd) => cwd, Err(e) => { @@ -331,6 +331,14 @@ async fn run_telemt_core( }; let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level); + let log_destination = + match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) { + Ok(destination) => destination, + Err(error) => { + eprintln!("[telemt] {error}"); + std::process::exit(1); + } + }; let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new(initial_filter_spec.clone())); startup_tracker diff --git a/src/network/probe.rs b/src/network/probe.rs index 90484b3..5c3cbb8 100644 --- a/src/network/probe.rs +++ b/src/network/probe.rs @@ -12,7 +12,7 @@ use tracing::{debug, info, warn}; use crate::config::{NetworkConfig, UpstreamConfig, UpstreamType}; use crate::error::Result; use crate::network::stun::{ - DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind, + DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind_and_tcp_fallback, }; use crate::transport::UpstreamManager; @@ -58,6 +58,7 @@ impl NetworkDecision { } const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5); +const STUN_BATCH_TCP_FALLBACK_TIMEOUT: Duration = Duration::from_secs(12); pub async fn run_probe( config: &NetworkConfig, @@ -81,8 +82,14 @@ pub async fn run_probe( warn!("STUN probe is enabled but network.stun_servers is empty"); DualStunResult::default() } else { - probe_stun_servers_parallel(&servers, stun_nat_probe_concurrency.max(1), None, None) - .await + probe_stun_servers_parallel( + &servers, + stun_nat_probe_concurrency.max(1), + None, + None, + config.stun_tcp_fallback, + ) + .await } } else if nat_probe { info!("STUN probe is disabled by network.stun_use=false"); @@ -163,6 +170,7 @@ pub async fn run_probe( stun_nat_probe_concurrency.max(1), bind_v4, bind_v6, + config.stun_tcp_fallback, ) .await; if let Some(reflected) = direct_stun_res.v4.map(|r| r.reflected_addr) { @@ -234,7 +242,7 @@ pub async fn run_probe( Ok(probe) } -async fn detect_public_ipv4_http(urls: &[String]) -> Option { +pub(crate) async fn detect_public_ipv4_http(urls: &[String]) -> Option { let client = reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build() @@ -277,6 +285,7 @@ async fn probe_stun_servers_parallel( concurrency: usize, bind_v4: Option, bind_v6: Option, + tcp_fallback: bool, ) -> DualStunResult { let mut join_set = JoinSet::new(); let mut next_idx = 0usize; @@ -288,9 +297,26 @@ async fn probe_stun_servers_parallel( let stun_addr = servers[next_idx].clone(); next_idx += 1; join_set.spawn(async move { - let res = timeout(STUN_BATCH_TIMEOUT, async { - let v4 = stun_probe_family_with_bind(&stun_addr, IpFamily::V4, bind_v4).await?; - let v6 = stun_probe_family_with_bind(&stun_addr, IpFamily::V6, bind_v6).await?; + let batch_timeout = if tcp_fallback { + STUN_BATCH_TCP_FALLBACK_TIMEOUT + } else { + STUN_BATCH_TIMEOUT + }; + let res = timeout(batch_timeout, async { + let v4 = stun_probe_family_with_bind_and_tcp_fallback( + &stun_addr, + IpFamily::V4, + bind_v4, + tcp_fallback, + ) + .await?; + let v6 = stun_probe_family_with_bind_and_tcp_fallback( + &stun_addr, + IpFamily::V6, + bind_v6, + tcp_fallback, + ) + .await?; Ok::(DualStunResult { v4, v6 }) }) .await; diff --git a/src/network/stun.rs b/src/network/stun.rs index d1e088c..ca4a8cb 100644 --- a/src/network/stun.rs +++ b/src/network/stun.rs @@ -4,7 +4,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::OnceLock; -use tokio::net::{UdpSocket, lookup_host}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpSocket, UdpSocket, lookup_host}; use tokio::time::{Duration, sleep, timeout}; use crate::crypto::SecureRandom; @@ -36,9 +37,16 @@ pub struct DualStunResult { } pub async fn stun_probe_dual(stun_addr: &str) -> Result { + 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 { let (v4, v6) = tokio::join!( - stun_probe_family(stun_addr, IpFamily::V4), - stun_probe_family(stun_addr, IpFamily::V6), + stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V4, tcp_fallback), + stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V6, tcp_fallback), ); Ok(DualStunResult { v4: v4?, v6: v6? }) @@ -48,13 +56,44 @@ pub async fn stun_probe_family( stun_addr: &str, family: IpFamily, ) -> Result> { - 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> { + stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, None, tcp_fallback).await } pub async fn stun_probe_family_with_bind( stun_addr: &str, family: IpFamily, bind_ip: Option, +) -> Result> { + 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, + tcp_fallback: bool, +) -> Result> { + 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, + max_attempts: u8, ) -> Result> { let bind_addr = match (family, bind_ip) { (IpFamily::V4, Some(IpAddr::V4(ip))) => SocketAddr::new(IpAddr::V4(ip), 0), @@ -94,12 +133,7 @@ pub async fn stun_probe_family_with_bind( return Ok(None); } - let mut req = [0u8; 20]; - req[0..2].copy_from_slice(&0x0001u16.to_be_bytes()); // Binding Request - req[2..4].copy_from_slice(&0u16.to_be_bytes()); // length - req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes()); // magic cookie - stun_rng().fill(&mut req[8..20]); // transaction ID - + let req = build_binding_request(); let mut buf = [0u8; 256]; let mut attempt = 0; let mut backoff = Duration::from_secs(1); @@ -115,7 +149,7 @@ pub async fn stun_probe_family_with_bind( Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN recv failed: {e}"))), Err(_) => { attempt += 1; - if attempt >= 3 { + if attempt >= max_attempts { return Ok(None); } sleep(backoff).await; @@ -128,19 +162,139 @@ pub async fn stun_probe_family_with_bind( return Ok(None); } - let magic = 0x2112A442u32.to_be_bytes(); let txid = &req[8..20]; - let mut idx = 20; - while idx + 4 <= n { - let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().unwrap()); - let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().unwrap()) as usize; - idx += 4; - if idx + alen > n { - break; - } + if let Some(reflected_addr) = parse_reflected_addr(&buf[..n], txid) { + let local_addr = socket + .local_addr() + .map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?; + return Ok(Some(StunProbeResult { + local_addr, + reflected_addr, + family, + })); + } + } - match atype { - 0x0020 /* XOR-MAPPED-ADDRESS */ | 0x0001 /* MAPPED-ADDRESS */ => { + Ok(None) +} + +async fn stun_probe_family_tcp( + stun_addr: &str, + family: IpFamily, + bind_ip: Option, +) -> Result> { + 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 { + if buf.len() < 20 { + return None; + } + + let magic = 0x2112A442u32.to_be_bytes(); + let mut idx = 20; + while idx + 4 <= buf.len() { + let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().ok()?); + let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().ok()?) as usize; + idx += 4; + if idx + alen > buf.len() { + break; + } + + match atype { + 0x0020 | 0x0001 => { if alen < 8 { break; } @@ -157,7 +311,6 @@ pub async fn stun_probe_family_with_bind( let raw_ip = &buf[idx + 4..idx + 4 + len_check]; let mut port = u16::from_be_bytes(port_bytes); - let reflected_ip = if atype == 0x0020 { port ^= ((magic[0] as u16) << 8) | magic[1] as u16; match family_byte { @@ -172,7 +325,9 @@ pub async fn stun_probe_family_with_bind( } 0x02 => { let mut ip = [0u8; 16]; - let xor_key = [magic.as_slice(), txid].concat(); + let mut xor_key = [0u8; 16]; + xor_key[..4].copy_from_slice(&magic); + xor_key[4..].copy_from_slice(txid.get(..12)?); for (i, b) in raw_ip.iter().enumerate().take(16) { ip[i] = *b ^ xor_key[i]; } @@ -185,34 +340,24 @@ pub async fn stun_probe_family_with_bind( } } else { match family_byte { - 0x01 => IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3])), - 0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).unwrap())), + 0x01 => { + IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3])) + } + 0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).ok()?)), _ => { idx += (alen + 3) & !3; continue; } } }; - - let reflected_addr = SocketAddr::new(reflected_ip, port); - let local_addr = socket - .local_addr() - .map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?; - - return Ok(Some(StunProbeResult { - local_addr, - reflected_addr, - family, - })); + return Some(SocketAddr::new(reflected_ip, port)); } _ => {} } - idx += (alen + 3) & !3; - } + idx += (alen + 3) & !3; } - - Ok(None) + None } async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result> { @@ -245,3 +390,58 @@ async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result bool { } /// Compute Secure Intermediate payload length from wire length. -/// Secure mode strips up to 3 random tail bytes by truncating to 4-byte boundary. +/// Secure mode cannot distinguish full-word padding from payload, so only the +/// non-aligned tail bytes are stripped. pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option { - if wire_len < 4 { - return None; - } - Some(wire_len - (wire_len % 4)) + secure_version_d_body_len_from_wire_len(wire_len) } /// Generate padding length for Secure Intermediate protocol. -/// Data must be 4-byte aligned; padding is 1..=3 so total is never divisible by 4. +/// Telegram Desktop uses a 4-bit random padding length for VersionD packets. pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize { debug_assert!( is_valid_secure_payload_len(data_len), "Secure payload must be 4-byte aligned, got {data_len}" ); - rng.range(3) + 1 + secure_version_d_padding_len(rng) } // ============= Timeouts ============= @@ -424,21 +425,15 @@ mod tests { } #[test] - fn secure_padding_never_produces_aligned_total() { + fn secure_padding_matches_tdesktop_range() { let rng = SecureRandom::new(); for data_len in (0..1000).step_by(4) { for _ in 0..100 { let padding = secure_padding_len(data_len, &rng); assert!( - padding <= 3, + padding <= 15, "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] fn secure_wire_len_rejects_too_short_frames() { assert_eq!(secure_payload_len_from_wire_len(0), None); diff --git a/src/protocol/framing.rs b/src/protocol/framing.rs new file mode 100644 index 0000000..dd63e89 --- /dev/null +++ b/src/protocol/framing.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 9ffff7c..63e75b7 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -2,6 +2,7 @@ pub mod constants; pub mod frame; +pub(crate) mod framing; pub mod obfuscation; pub mod tls; pub mod tls_fingerprint; diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 34b540b..bd30e5b 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -113,7 +113,7 @@ use crate::proxy::handshake::{ }; #[cfg(test)] use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake}; -use crate::proxy::masking::handle_bad_client; +use crate::proxy::masking::handle_bad_client_with_shared; use crate::proxy::middle_relay::handle_via_middle_proxy; use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; use crate::proxy::shared_state::ProxySharedState; @@ -310,6 +310,7 @@ fn masking_outcome( local_addr: SocketAddr, config: Arc, beobachten: Arc, + shared: Arc, ) -> HandshakeOutcome where R: AsyncRead + Unpin + Send + 'static, @@ -325,7 +326,7 @@ where ) .await; - handle_bad_client( + handle_bad_client_with_shared( reader, writer, &initial_data, @@ -333,6 +334,7 @@ where local_addr, &config, &beobachten, + shared.as_ref(), ) .await; Ok(()) @@ -718,6 +720,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } @@ -739,6 +742,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } }; @@ -757,6 +761,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } @@ -787,6 +792,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } HandshakeResult::Error(e) => { @@ -844,6 +850,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } HandshakeResult::Error(e) => return Err(e), @@ -873,6 +880,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } @@ -898,6 +906,7 @@ where local_addr, config.clone(), beobachten.clone(), + shared.clone(), )); } HandshakeResult::Error(e) => return Err(e), @@ -1096,6 +1105,12 @@ impl RunningClientHandler { #[cfg(unix)] let raw_fd = self.raw_fd; let rst_on_close = self.rst_on_close; + // MSS for the bulk data phase: once the handshake (incl. ServerHello) is + // sent, restore a normal MSS so only the handshake stays fragmented by the + // low listener `client_mss`. Cuts pps ~10x (anti-DDoS abuse on pps-policing + // hosts like FastVPS). None = keep handshake MSS for the whole connection. + #[cfg(unix)] + let bulk_mss: Option = self.config.server.client_mss_bulk_value().ok().flatten(); let outcome = match self.do_handshake().await? { Some(outcome) => outcome, @@ -1109,6 +1124,14 @@ impl RunningClientHandler { if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) { let _ = crate::transport::socket::clear_linger_fd(raw_fd); } + // Handshake (ServerHello) done — raise MSS for bulk transfer. + #[cfg(unix)] + if let Some(mss) = bulk_mss { + if let Err(e) = crate::transport::socket::set_tcp_mss_fd(raw_fd, u32::from(mss)) + { + debug!(error = %e, "Failed to raise bulk MSS; keeping handshake MSS"); + } + } fut.await } HandshakeOutcome::NeedsMasking(fut) => fut.await, @@ -1329,6 +1352,7 @@ impl RunningClientHandler { local_addr, self.config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } @@ -1350,6 +1374,7 @@ impl RunningClientHandler { local_addr, self.config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } }; @@ -1369,6 +1394,7 @@ impl RunningClientHandler { local_addr, self.config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } @@ -1416,6 +1442,7 @@ impl RunningClientHandler { local_addr, config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } HandshakeResult::Error(e) => { @@ -1483,6 +1510,7 @@ impl RunningClientHandler { local_addr, config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } HandshakeResult::Error(e) => return Err(e), @@ -1530,6 +1558,7 @@ impl RunningClientHandler { local_addr, self.config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } @@ -1568,6 +1597,7 @@ impl RunningClientHandler { local_addr, config.clone(), self.beobachten.clone(), + self.shared.clone(), )); } HandshakeResult::Error(e) => return Err(e), diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 084fadc..f9f55de 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -4,7 +4,6 @@ use dashmap::DashMap; use dashmap::mapref::entry::Entry; -use hmac::{Hmac, Mac}; #[cfg(test)] use std::collections::HashSet; use std::collections::hash_map::DefaultHasher; @@ -33,8 +32,10 @@ use crate::stream::{CryptoReader, CryptoWriter, FakeTlsReader, FakeTlsWriter}; use crate::tls_front::{TlsFrontCache, emulator}; #[cfg(test)] use rand::RngExt; -use sha2::Sha256; -use subtle::ConstantTimeEq; + +mod tls_auth; + +use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate}; const ACCESS_SECRET_BYTES: usize = 16; const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5; @@ -58,8 +59,6 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8; const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64; const RECENT_USER_RING_SCAN_LIMIT: usize = 32; -type HmacSha256 = Hmac; - #[cfg(test)] const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; #[cfg(not(test))] @@ -104,23 +103,6 @@ fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> b true } -#[derive(Clone, Copy)] -struct ParsedTlsAuthMaterial { - digest: [u8; tls::TLS_DIGEST_LEN], - session_id: [u8; 32], - session_id_len: usize, - now: i64, - ignore_time_skew: bool, - boot_time_cap_secs: u32, -} - -#[derive(Clone, Copy)] -struct TlsCandidateValidation { - digest: [u8; tls::TLS_DIGEST_LEN], - session_id: [u8; 32], - session_id_len: usize, -} - struct MtprotoCandidateValidation { proto_tag: ProtoTag, dc_idx: i16, @@ -251,104 +233,6 @@ fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> total_users.min(cap.max(1)) } -fn parse_tls_auth_material( - handshake: &[u8], - ignore_time_skew: bool, - replay_window_secs: u64, -) -> Option { - 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 { - let computed = compute_tls_hmac_zeroed_digest(secret, handshake); - if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) { - return None; - } - - let timestamp = u32::from_le_bytes([ - parsed.digest[28] ^ computed[28], - parsed.digest[29] ^ computed[29], - parsed.digest[30] ^ computed[30], - parsed.digest[31] ^ computed[31], - ]); - - if !parsed.ignore_time_skew { - let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs; - if !is_boot_time { - let time_diff = parsed.now - i64::from(timestamp); - if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) { - return None; - } - } - } - - Some(TlsCandidateValidation { - digest: parsed.digest, - session_id: parsed.session_id, - session_id_len: parsed.session_id_len, - }) -} - fn validate_mtproto_secret_candidate( handshake: &[u8; HANDSHAKE_LEN], dec_prekey: &[u8; PREKEY_LEN], @@ -1857,7 +1741,16 @@ where return HandshakeResult::BadClient { reader, writer }; } - let validation = matched_validation.expect("validation must exist when matched"); + let Some(validation) = matched_validation else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %matched_user, + "MTProto handshake matched user without validation material" + ); + return HandshakeResult::BadClient { reader, writer }; + }; if config .access diff --git a/src/proxy/handshake/tls_auth.rs b/src/proxy/handshake/tls_auth.rs new file mode 100644 index 0000000..2feb666 --- /dev/null +++ b/src/proxy/handshake/tls_auth.rs @@ -0,0 +1,126 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +use crate::protocol::tls; + +type HmacSha256 = Hmac; + +/// 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 { + 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 { + 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, + }) +} diff --git a/src/proxy/masking.rs b/src/proxy/masking.rs index 7e73eb8..66bc0f8 100644 --- a/src/proxy/masking.rs +++ b/src/proxy/masking.rs @@ -3,12 +3,15 @@ use crate::config::ProxyConfig; use crate::network::dns_overrides::resolve_socket_addr; use crate::protocol::tls; +use crate::proxy::shared_state::ProxySharedState; use crate::stats::beobachten::BeobachtenStore; use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder}; +use crate::transport::socket::configure_tcp_socket; #[cfg(unix)] use nix::ifaddrs::getifaddrs; use rand::rngs::StdRng; use rand::{Rng, RngExt, SeedableRng}; +use std::io::{Error as IoError, ErrorKind}; use std::net::{IpAddr, SocketAddr}; use std::str; #[cfg(test)] @@ -17,9 +20,9 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant as StdInstant}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::net::TcpStream; #[cfg(unix)] use tokio::net::UnixStream; +use tokio::net::{TcpStream, lookup_host}; #[cfg(unix)] use tokio::sync::Mutex as AsyncMutex; use tokio::time::{Instant, timeout}; @@ -36,6 +39,8 @@ const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200); #[cfg(test)] const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100); const MASK_BUFFER_SIZE: usize = 8192; +const MASK_BUFFER_GROW_AFTER_BYTES: usize = 256 * 1024; +const MASK_BUFFER_MAX_SIZE: usize = 64 * 1024; #[cfg(unix)] #[cfg(not(test))] const LOCAL_INTERFACE_CACHE_TTL: Duration = Duration::from_secs(300); @@ -53,6 +58,27 @@ struct MaskTcpTarget<'a> { port: u16, } +fn mask_copy_read_len(total: usize, byte_cap: usize) -> usize { + // Keep short scanner probes on the small baseline buffer and grow only + // after the session has proven to be sustained masking relay traffic. + let active_buffer_size = if total >= MASK_BUFFER_GROW_AFTER_BYTES { + MASK_BUFFER_MAX_SIZE + } else { + MASK_BUFFER_SIZE + }; + + if byte_cap == 0 { + return active_buffer_size; + } + + let remaining_budget = byte_cap.saturating_sub(total); + if remaining_budget == 0 { + return 0; + } + + remaining_budget.min(active_buffer_size) +} + async fn copy_with_idle_timeout( reader: &mut R, writer: &mut W, @@ -64,21 +90,18 @@ where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { - let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]); + let mut buf = vec![0u8; MASK_BUFFER_SIZE]; let mut total = 0usize; let mut ended_by_eof = false; - let unlimited = byte_cap == 0; loop { - let read_len = if unlimited { - MASK_BUFFER_SIZE - } else { - let remaining_budget = byte_cap.saturating_sub(total); - if remaining_budget == 0 { - break; - } - remaining_budget.min(MASK_BUFFER_SIZE) - }; + let read_len = mask_copy_read_len(total, byte_cap); + if read_len == 0 { + break; + } + if buf.len() < read_len { + buf.resize(read_len, 0); + } let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await; let n = match read_res { Ok(Ok(n)) => n, @@ -250,6 +273,32 @@ async fn consume_client_data_with_timeout_and_cap( } } +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( + reader: R, + config: &ProxyConfig, + relay_timeout: Duration, + idle_timeout: Duration, +) where + R: AsyncRead + Unpin, +{ + consume_client_data_with_timeout_and_cap( + reader, + mask_failure_drain_cap(config), + relay_timeout, + idle_timeout, + ) + .await; +} + async fn wait_mask_connect_budget(started: Instant) { let elapsed = started.elapsed(); if elapsed < MASK_TIMEOUT { @@ -480,6 +529,32 @@ fn parse_mask_host_ip_literal(host: &str) -> Option { host.parse::().ok() } +async fn resolve_mask_target_addrs( + mask_host: &str, + mask_port: u16, +) -> std::io::Result> { + 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::>(); + if addrs.is_empty() { + return Err(IoError::new( + ErrorKind::NotFound, + "mask target DNS lookup returned no addresses", + )); + } + + Ok(addrs) +} + fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> { if config.censorship.tls_domain.eq_ignore_ascii_case(sni) { return Some(config.censorship.tls_domain.as_str()); @@ -761,7 +836,7 @@ fn is_mask_target_local_listener_with_interfaces( mask_host: &str, mask_port: u16, local_addr: SocketAddr, - resolved_override: Option, + resolved_addrs: &[SocketAddr], interface_ips: &[IpAddr], ) -> bool { if mask_port != local_addr.port() { @@ -771,7 +846,7 @@ fn is_mask_target_local_listener_with_interfaces( let local_ip = canonical_ip(local_addr.ip()); let literal_mask_ip = parse_mask_host_ip_literal(mask_host).map(canonical_ip); - if let Some(addr) = resolved_override { + for addr in resolved_addrs { let resolved_ip = canonical_ip(addr.ip()); if resolved_ip == local_ip { return true; @@ -808,7 +883,7 @@ fn is_mask_target_local_listener( mask_host: &str, mask_port: u16, local_addr: SocketAddr, - resolved_override: Option, + resolved_addrs: &[SocketAddr], ) -> bool { if mask_port != local_addr.port() { return false; @@ -819,7 +894,7 @@ fn is_mask_target_local_listener( mask_host, mask_port, local_addr, - resolved_override, + resolved_addrs, &interfaces, ) } @@ -828,7 +903,7 @@ async fn is_mask_target_local_listener_async( mask_host: &str, mask_port: u16, local_addr: SocketAddr, - resolved_override: Option, + resolved_addrs: &[SocketAddr], ) -> bool { if mask_port != local_addr.port() { return false; @@ -839,7 +914,7 @@ async fn is_mask_target_local_listener_async( mask_host, mask_port, local_addr, - resolved_override, + resolved_addrs, &interfaces, ) } @@ -877,7 +952,13 @@ fn build_mask_proxy_header( } } -/// Handle a bad client by forwarding to mask host +fn configure_mask_backend_socket(stream: &TcpStream) { + if let Err(e) = configure_tcp_socket(stream, false, Duration::from_secs(0)) { + debug!(error = %e, "Failed to configure mask backend socket"); + } +} + +/// Handles a bad client by forwarding it to the configured mask target. pub async fn handle_bad_client( reader: R, writer: W, @@ -889,6 +970,34 @@ pub async fn handle_bad_client( ) where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, +{ + let shared = ProxySharedState::new(); + handle_bad_client_with_shared( + reader, + writer, + initial_data, + peer, + local_addr, + config, + beobachten, + shared.as_ref(), + ) + .await; +} + +/// Handles a bad client with shared pre-auth fallback admission state. +pub(crate) async fn handle_bad_client_with_shared( + reader: R, + writer: W, + initial_data: &[u8], + peer: SocketAddr, + local_addr: SocketAddr, + config: &ProxyConfig, + beobachten: &BeobachtenStore, + shared: &ProxySharedState, +) where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { let client_type = detect_client_type(initial_data); if config.general.beobachten { @@ -911,6 +1020,17 @@ pub async fn handle_bad_client( return; } + let Some(_masking_permit) = shared.try_acquire_masking_fallback_permit() else { + let outcome_started = Instant::now(); + debug!( + client_type = client_type, + "Masking fallback concurrency limit reached" + ); + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; + wait_mask_outcome_budget(outcome_started, config).await; + return; + }; + let client_sni = tls::extract_sni_from_client_hello(initial_data); let exclusive_tcp_target = client_sni .as_deref() @@ -973,24 +1093,12 @@ pub async fn handle_bad_client( Ok(Err(e)) => { wait_mask_connect_budget_if_needed(connect_started, config).await; debug!(error = %e, "Failed to connect to mask unix socket"); - consume_client_data_with_timeout_and_cap( - reader, - config.censorship.mask_relay_max_bytes, - relay_timeout, - idle_timeout, - ) - .await; + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; wait_mask_outcome_budget(outcome_started, config).await; } Err(_) => { debug!("Timeout connecting to mask unix socket"); - consume_client_data_with_timeout_and_cap( - reader, - config.censorship.mask_relay_max_bytes, - relay_timeout, - idle_timeout, - ) - .await; + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; wait_mask_outcome_budget(outcome_started, config).await; } } @@ -1003,11 +1111,27 @@ pub async fn handle_bad_client( let mask_host = mask_target.host; let mask_port = mask_target.port; + let resolved_mask_addrs = match resolve_mask_target_addrs(mask_host, mask_port).await { + Ok(addrs) => addrs, + Err(e) => { + let outcome_started = Instant::now(); + debug!( + client_type = client_type, + host = %mask_host, + port = mask_port, + error = %e, + "Failed to resolve mask target" + ); + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; + wait_mask_outcome_budget(outcome_started, config).await; + return; + } + }; + // Fail closed when fallback points at our own listener endpoint. // Self-referential masking can create recursive proxy loops under // misconfiguration and leak distinguishable load spikes to adversaries. - let resolved_mask_addr = resolve_socket_addr(mask_host, mask_port); - if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, resolved_mask_addr) + if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, &resolved_mask_addrs) .await { let outcome_started = Instant::now(); @@ -1018,13 +1142,7 @@ pub async fn handle_bad_client( local = %local_addr, "Mask target resolves to local listener; refusing self-referential masking fallback" ); - consume_client_data_with_timeout_and_cap( - reader, - config.censorship.mask_relay_max_bytes, - relay_timeout, - idle_timeout, - ) - .await; + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; wait_mask_outcome_budget(outcome_started, config).await; return; } @@ -1039,14 +1157,15 @@ pub async fn handle_bad_client( "Forwarding bad client to mask host" ); - // Apply runtime DNS override for mask target when configured. - let mask_addr = resolved_mask_addr - .map(|addr| addr.to_string()) - .unwrap_or_else(|| format!("{}:{}", mask_host, mask_port)); let connect_started = Instant::now(); - let connect_result = timeout(MASK_TIMEOUT, TcpStream::connect(&mask_addr)).await; + let connect_result = timeout( + MASK_TIMEOUT, + TcpStream::connect(resolved_mask_addrs.as_slice()), + ) + .await; match connect_result { Ok(Ok(stream)) => { + configure_mask_backend_socket(&stream); let proxy_header = build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr); @@ -1085,24 +1204,12 @@ pub async fn handle_bad_client( Ok(Err(e)) => { wait_mask_connect_budget_if_needed(connect_started, config).await; debug!(error = %e, "Failed to connect to mask host"); - consume_client_data_with_timeout_and_cap( - reader, - config.censorship.mask_relay_max_bytes, - relay_timeout, - idle_timeout, - ) - .await; + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; wait_mask_outcome_budget(outcome_started, config).await; } Err(_) => { debug!("Timeout connecting to mask host"); - consume_client_data_with_timeout_and_cap( - reader, - config.censorship.mask_relay_max_bytes, - relay_timeout, - idle_timeout, - ) - .await; + consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await; wait_mask_outcome_budget(outcome_started, config).await; } } @@ -1190,20 +1297,17 @@ async fn consume_client_data( idle_timeout: Duration, ) { // Keep drain path fail-closed under slow-loris stalls. - let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]); + let mut buf = vec![0u8; MASK_BUFFER_SIZE]; let mut total = 0usize; - let unlimited = byte_cap == 0; loop { - let read_len = if unlimited { - MASK_BUFFER_SIZE - } else { - let remaining_budget = byte_cap.saturating_sub(total); - if remaining_budget == 0 { - break; - } - remaining_budget.min(MASK_BUFFER_SIZE) - }; + let read_len = mask_copy_read_len(total, byte_cap); + if read_len == 0 { + break; + } + if buf.len() < read_len { + buf.resize(read_len, 0); + } let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await { Ok(Ok(n)) => n, Ok(Err(_)) | Err(_) => break, @@ -1214,7 +1318,7 @@ async fn consume_client_data( } total = total.saturating_add(n); - if !unlimited && total >= byte_cap { + if byte_cap != 0 && total >= byte_cap { break; } } @@ -1332,6 +1436,10 @@ mod masking_interface_cache_concurrency_security_tests; #[path = "tests/masking_production_cap_regression_security_tests.rs"] mod masking_production_cap_regression_security_tests; +#[cfg(test)] +#[path = "tests/masking_relay_manual_perf_tests.rs"] +mod masking_relay_manual_perf_tests; + #[cfg(test)] #[path = "tests/masking_extended_attack_surface_security_tests.rs"] mod masking_extended_attack_surface_security_tests; diff --git a/src/proxy/middle_relay.rs b/src/proxy/middle_relay.rs index 2c61c86..060d21e 100644 --- a/src/proxy/middle_relay.rs +++ b/src/proxy/middle_relay.rs @@ -52,7 +52,7 @@ use self::c2me::{ }; use self::d2c::{ MeD2cFlushPolicy, MeWriterResponseOutcome, classify_me_d2c_flush_reason, - flush_client_or_cancel, observe_me_d2c_flush_event, + flush_client_or_cancel, me_d2c_flush_reason_requires_client_flush, observe_me_d2c_flush_event, process_me_writer_response_with_traffic_lease, }; use self::desync::{RelayForensicsState, hash_ip_in, report_desync_frame_too_large_in}; diff --git a/src/proxy/middle_relay/d2c.rs b/src/proxy/middle_relay/d2c.rs index 92fe3c1..7faa702 100644 --- a/src/proxy/middle_relay/d2c.rs +++ b/src/proxy/middle_relay/d2c.rs @@ -55,6 +55,37 @@ pub(super) fn classify_me_d2c_flush_reason( MeD2cFlushReason::QueueDrain } +pub(super) fn me_d2c_flush_reason_requires_client_flush(reason: MeD2cFlushReason) -> bool { + !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( stats: &Stats, reason: MeD2cFlushReason, @@ -276,20 +307,13 @@ pub(in crate::proxy::middle_relay) fn compute_intermediate_secure_wire_len( let wire_len = data_len .checked_add(padding_len) .ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?; - if wire_len > 0x7fff_ffffusize { - return Err(ProxyError::Proxy(format!( - "Intermediate/Secure frame too large: {wire_len}" - ))); - } - + let len_val = crate::protocol::framing::encode_intermediate_header(wire_len, quickack) + .ok_or_else(|| { + ProxyError::Proxy(format!("Intermediate/Secure frame too large: {wire_len}")) + })?; let total = 4usize .checked_add(wire_len) .ok_or_else(|| ProxyError::Proxy("Frame buffer size overflow".into()))?; - let mut len_val = u32::try_from(wire_len) - .map_err(|_| ProxyError::Proxy("Frame length conversion overflow".into()))?; - if quickack { - len_val |= 0x8000_0000; - } Ok((len_val, total)) } diff --git a/src/proxy/middle_relay/idle/read.rs b/src/proxy/middle_relay/idle/read.rs index 270f104..483ee4c 100644 --- a/src/proxy/middle_relay/idle/read.rs +++ b/src/proxy/middle_relay/idle/read.rs @@ -236,12 +236,8 @@ where } Err(e) => return Err(e), } - let quickack = (len_buf[3] & 0x80) != 0; - ( - (u32::from_le_bytes(len_buf) & 0x7fff_ffff) as usize, - quickack, - Some(len_buf), - ) + let header = crate::protocol::framing::parse_intermediate_header(len_buf); + (header.wire_len, header.quickack, Some(len_buf)) } }; @@ -331,7 +327,8 @@ where ) .await?; - // Secure Intermediate: strip validated trailing padding bytes. + // Secure Intermediate strips only non-aligned tail padding; full-word + // padding is indistinguishable from payload in VersionD framing. if proto_tag == ProtoTag::Secure { payload.truncate(secure_payload_len); } diff --git a/src/proxy/middle_relay/session.rs b/src/proxy/middle_relay/session.rs index 4865993..acded2b 100644 --- a/src/proxy/middle_relay/session.rs +++ b/src/proxy/middle_relay/session.rs @@ -491,12 +491,18 @@ where d2c_flush_policy.max_bytes, max_delay_fired, ); - let flush_started_at = if stats_clone.telemetry_policy().me_level.allows_debug() { + let physical_flush = + me_d2c_flush_reason_requires_client_flush(flush_reason); + let flush_started_at = if physical_flush + && stats_clone.telemetry_policy().me_level.allows_debug() + { Some(Instant::now()) } else { None }; - flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?; + if physical_flush { + flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?; + } let flush_duration_us = flush_started_at.map(|started| { started .elapsed() diff --git a/src/proxy/shared_state.rs b/src/proxy/shared_state.rs index 9ed319b..6a47761 100644 --- a/src/proxy/shared_state.rs +++ b/src/proxy/shared_state.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use dashmap::DashMap; -use tokio::sync::mpsc; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc}; use tokio_util::sync::CancellationToken; use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState}; @@ -14,6 +14,7 @@ use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateReg use crate::proxy::traffic_limiter::TrafficLimiter; const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64; +const MASKING_FALLBACK_MAX_CONCURRENT: usize = 512; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConntrackCloseReason { @@ -72,6 +73,7 @@ pub(crate) struct ProxySharedState { active_user_sessions: DashMap<(String, u64), CancellationToken>, pub(crate) conntrack_pressure_active: AtomicBool, pub(crate) conntrack_close_tx: Mutex>>, + masking_fallback_permits: Arc, } #[must_use = "registered user sessions must be kept alive until relay completion"] @@ -131,9 +133,18 @@ impl ProxySharedState { active_user_sessions: DashMap::new(), conntrack_pressure_active: AtomicBool::new(false), conntrack_close_tx: Mutex::new(None), + masking_fallback_permits: Arc::new(Semaphore::new(MASKING_FALLBACK_MAX_CONCURRENT)), }) } + /// Attempts to reserve one masking fallback slot for a pre-auth connection. + pub(crate) fn try_acquire_masking_fallback_permit(&self) -> Option { + self.masking_fallback_permits + .clone() + .try_acquire_owned() + .ok() + } + pub(crate) fn is_user_enabled(&self, user: &str) -> bool { !self.disabled_users.contains_key(user) } diff --git a/src/proxy/tests/masking_additional_hardening_security_tests.rs b/src/proxy/tests/masking_additional_hardening_security_tests.rs index 1b8ca2e..22ee8e3 100644 --- a/src/proxy/tests/masking_additional_hardening_security_tests.rs +++ b/src/proxy/tests/masking_additional_hardening_security_tests.rs @@ -34,7 +34,7 @@ fn loop_guard_unspecified_bind_uses_interface_inventory() { "mask.example", 443, local, - Some(resolved), + &[resolved], &interfaces, )); } diff --git a/src/proxy/tests/masking_interface_cache_concurrency_security_tests.rs b/src/proxy/tests/masking_interface_cache_concurrency_security_tests.rs index ed6d1ab..a1584fc 100644 --- a/src/proxy/tests/masking_interface_cache_concurrency_security_tests.rs +++ b/src/proxy/tests/masking_interface_cache_concurrency_security_tests.rs @@ -25,7 +25,7 @@ async fn adversarial_parallel_cold_miss_performs_single_interface_refresh() { let barrier = std::sync::Arc::clone(&barrier); tasks.push(tokio::spawn(async move { barrier.wait().await; - is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await + is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await })); } diff --git a/src/proxy/tests/masking_interface_cache_security_tests.rs b/src/proxy/tests/masking_interface_cache_security_tests.rs index 17debb0..4be2857 100644 --- a/src/proxy/tests/masking_interface_cache_security_tests.rs +++ b/src/proxy/tests/masking_interface_cache_security_tests.rs @@ -17,8 +17,8 @@ async fn tdd_repeated_local_listener_checks_do_not_repeat_interface_enumeration_ let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr"); - let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await; - let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await; + let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await; + let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await; assert_eq!( local_interface_enumerations_for_tests(), @@ -35,7 +35,7 @@ async fn tdd_non_local_port_short_circuit_does_not_enumerate_interfaces() { reset_local_interface_enumerations_for_tests(); let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr"); - let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, None).await; + let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, &[]).await; assert!( !is_local, diff --git a/src/proxy/tests/masking_relay_manual_perf_tests.rs b/src/proxy/tests/masking_relay_manual_perf_tests.rs new file mode 100644 index 0000000..f10bd8a --- /dev/null +++ b/src/proxy/tests/masking_relay_manual_perf_tests.rs @@ -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> { + 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> { + self.written = self.written.saturating_add(buf.len()); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + 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() + ); +} diff --git a/src/proxy/tests/masking_self_target_loop_security_tests.rs b/src/proxy/tests/masking_self_target_loop_security_tests.rs index 975b4fc..0510d44 100644 --- a/src/proxy/tests/masking_self_target_loop_security_tests.rs +++ b/src/proxy/tests/masking_self_target_loop_security_tests.rs @@ -15,38 +15,49 @@ fn closed_local_port() -> u16 { #[tokio::test] async fn self_target_detection_matches_literal_ipv4_listener() { let local: SocketAddr = "198.51.100.40:443".parse().unwrap(); - assert!(is_mask_target_local_listener_async("198.51.100.40", 443, local, None,).await); + assert!(is_mask_target_local_listener_async("198.51.100.40", 443, local, &[],).await); } #[tokio::test] async fn self_target_detection_matches_bracketed_ipv6_listener() { let local: SocketAddr = "[2001:db8::44]:8443".parse().unwrap(); - assert!(is_mask_target_local_listener_async("[2001:db8::44]", 8443, local, None,).await); + assert!(is_mask_target_local_listener_async("[2001:db8::44]", 8443, local, &[],).await); } #[tokio::test] async fn self_target_detection_keeps_same_ip_different_port_forwardable() { let local: SocketAddr = "203.0.113.44:443".parse().unwrap(); - assert!(!is_mask_target_local_listener_async("203.0.113.44", 8443, local, None,).await); + assert!(!is_mask_target_local_listener_async("203.0.113.44", 8443, local, &[],).await); } #[tokio::test] async fn self_target_detection_normalizes_ipv4_mapped_ipv6_literal() { let local: SocketAddr = "127.0.0.1:443".parse().unwrap(); - assert!(is_mask_target_local_listener_async("::ffff:127.0.0.1", 443, local, None,).await); + assert!(is_mask_target_local_listener_async("::ffff:127.0.0.1", 443, local, &[],).await); } #[tokio::test] async fn self_target_detection_unspecified_bind_blocks_loopback_target() { let local: SocketAddr = "0.0.0.0:443".parse().unwrap(); - assert!(is_mask_target_local_listener_async("127.0.0.1", 443, local, None,).await); + assert!(is_mask_target_local_listener_async("127.0.0.1", 443, local, &[],).await); } #[tokio::test] async fn self_target_detection_unspecified_bind_keeps_remote_target_forwardable() { let local: SocketAddr = "0.0.0.0:443".parse().unwrap(); let remote: SocketAddr = "198.51.100.44:443".parse().unwrap(); - assert!(!is_mask_target_local_listener_async("mask.example", 443, local, Some(remote),).await); + assert!(!is_mask_target_local_listener_async("mask.example", 443, local, &[remote],).await); +} + +#[tokio::test] +async fn self_target_detection_checks_all_resolved_addresses() { + let local: SocketAddr = "127.0.0.1:443".parse().unwrap(); + let remote: SocketAddr = "198.51.100.44:443".parse().unwrap(); + let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap(); + + assert!( + is_mask_target_local_listener_async("mask.example", 443, local, &[remote, loopback],).await + ); } #[tokio::test] diff --git a/src/stream/frame_codec.rs b/src/stream/frame_codec.rs index 2542e37..ddf4bde 100644 --- a/src/stream/frame_codec.rs +++ b/src/stream/frame_codec.rs @@ -15,6 +15,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ ProtoTag, is_valid_secure_payload_len, secure_padding_len, secure_payload_len_from_wire_len, }; +use crate::protocol::framing::{encode_intermediate_header, parse_intermediate_header}; // ============= Unified Codec ============= @@ -197,13 +198,9 @@ fn decode_intermediate(src: &mut BytesMut, max_size: usize) -> io::Result