Compare commits

..

118 Commits

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

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

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

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

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

Add tests for dotted sub-tables, idempotent saves, non-contiguous layouts, show_link rejection, and integer/float/string coercion of public_port.
2026-06-15 09:49:47 +03:00
Alexey 37d0184a0b Implement shared MTProto framing and ME address role separation
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-15 08:50:08 +03:00
Alexey d81d7dba62 Rustfmt 2026-06-14 19:59:06 +03:00
Alexey 04b8d8365c Account for full-word paddings in roundtrip tests 2026-06-14 19:38:54 +03:00
Alexey 2e26bfb86e Updated secure padding expectations for VersionD
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-14 16:33:41 +03:00
Alexey d414c73c9b Hardened KDF-Tuple + NAT Probing + Paddings
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-14 16:15:41 +03:00
Alexey d1a97fe10f Update README.md 2026-06-14 12:03:55 +03:00
Alexey b153782597 More efficient Relay Mode
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-13 23:22:50 +03:00
Alexey 9dc67727b0 Merge pull request #840 from telemt/flow
Restore single-record TLS-F primary application flight + Fix SYN limiter lifecycle and default burst
2026-06-12 15:23:23 +03:00
Alexey 2d02fbe548 Bump 2026-06-12 15:06:14 +03:00
Alexey 2675779915 Fix SYN limiter lifecycle and default burst
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 14:40:26 +03:00
Alexey c4954f745f Restore single-record TLS-F primary application flight
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 12:44:22 +03:00
Alexey f33abfb09e Merge pull request #838 from telemt/flow
SYN limiter for Netfilter control + Syntactic key shares for TLS-F
2026-06-12 10:08:25 +03:00
Alexey 9904da737a Rustfmt 2026-06-12 01:28:41 +03:00
Alexey 9a3ff726b2 Use token-bucket SYN limiter backends
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 01:27:03 +03:00
Alexey 942882f9de SYN Limiter interval and hitcount in Config
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 00:29:23 +03:00
Alexey eeff16c3fd Rustfmt 2026-06-12 00:01:01 +03:00
Alexey c86dc2f65e Docs for SYN Limiter
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:59:47 +03:00
Alexey 1cbde70a14 Add per-listener SYN limiter for Netfilter control
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:58:48 +03:00
Urbaev Maxim b95956d141 Update Dockerfile (issue#821) 2026-06-11 23:37:09 +03:00
Alexey 26cd4734de Update tls.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:29:10 +03:00
Alexey 52a1b66ad7 Syntactic key shares for TLS-F
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 23:13:21 +03:00
Alexey 9ff48c2028 Merge pull request #836 from telemt/flow
API + TLS-F Advanced tuning
2026-06-11 21:08:11 +03:00
Alexey b43c683615 Rustfmt 2026-06-11 19:59:48 +03:00
Alexey e41470fb4c Update fetcher.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 19:52:23 +03:00
Alexey 09dc0cb76c Update handshake_security_tests.rs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 19:44:39 +03:00
Alexey c36eb81808 Fix for TLS-F, ALPN и SNI/ALPN helpers
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 19:17:06 +03:00
Alexey 0f8aca56d9 Fix fallback test record iterator lifetime
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 17:56:21 +03:00
Alexey 4e66933a35 Fix TLS masking test ClientHello fixtures and tail write ordering 2026-06-11 17:51:05 +03:00
Alexey 7cf00db242 Update client_masking_budget_security_tests.rs 2026-06-11 17:32:26 +03:00
Alexey 8bc1ac06d6 Update client_masking_budget_security_tests.rs 2026-06-11 17:31:23 +03:00
Alexey 59cfcf05d3 Update client_masking_blackhat_campaign_tests.rs 2026-06-11 17:23:35 +03:00
Alexey fcbedf66ea Update client_masking_blackhat_campaign_tests.rs 2026-06-11 17:21:54 +03:00
Alexey f5c402d9fc Update metrics.rs 2026-06-11 16:43:24 +03:00
Alexey 118d53239a Merge pull request #835 from telemt/flow-ey
TLS Fixes escalating
2026-06-11 16:38:10 +03:00
Alexey 607f5442ad Merge pull request #834 from telemt/flow-11ec
TLS Fixes
2026-06-11 16:37:15 +03:00
Alexey 1edd63bfb1 Rustfmt + Bump 2026-06-11 16:36:33 +03:00
Alexey a808dc2815 Fix TLS fetch test constants scope
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 16:34:58 +03:00
Alexey 6dc9f8c27a Replay-safe TLS-F ServerHello profile consistency
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 16:11:41 +03:00
Alexey 409b0ef5ee Expose TLS Fetcher Profile Quality for ServerHello fidelity
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 14:53:21 +03:00
Alexey 3d0560d583 Select ServerHello key share from TLS Fetcher Profile
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 14:43:03 +03:00
Alexey 62af515504 Generate Valid X25519MLKEM768 ServerHello key shares
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 14:14:09 +03:00
Alexey eba55e755d Preserve TLS-F Origin Record Choreography
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 13:51:58 +03:00
Alexey c4b58ad374 Hardened TLS-F ServerHello selection
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 13:07:40 +03:00
Alexey db7ff8737c Add dynamic SNI mask target mode
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 10:36:37 +03:00
Alexey cd2bb9c8cd Alles muss man selber machen
Co-Authored-By: Mikhail I. Izmestev <355023+izmmisha@users.noreply.github.com>
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
Co-Authored-By: Dietmar Schreiber <376736+dginorg@users.noreply.github.com>
2026-06-11 10:13:17 +03:00
Alexey 8d3f8a8215 Merge pull request #828 from amirotin/feat/config-edit-api
Add config-edit HTTP API: PATCH/GET /v1/config
2026-06-10 10:30:52 +03:00
Mirotin Artem ff7a12d5f8 fix(api): GET /v1/config returns only editable sections; tolerate commented TOML headers; doc fixes 2026-06-09 12:13:32 +03:00
Mirotin Artem 27ee634f4a docs(api): document PATCH/GET /v1/config 2026-06-09 12:03:35 +03:00
Mirotin Artem d7e16f5b26 feat(api): config-edit endpoints PATCH/GET /v1/config 2026-06-09 12:03:28 +03:00
Mirotin Artem e39aaeb5c5 feat(config): classify_config_changes (hot vs restart) via overlay_hot_fields 2026-06-09 12:03:10 +03:00
Mirotin Artem 1628a7d822 feat(api): generic config section writer + array-table bounds 2026-06-09 12:03:01 +03:00
Alexey e9c62b6d8d Merge pull request #827 from Rightarion/fix-rate-limits-document-bits-per-second
Document rate limits as bits per second
2026-06-08 20:04:10 +03:00
Alexey 36cf3b035c Merge pull request #825 from groozchique/main
[docs] change fingerprint for xray double hop instruction
2026-06-08 20:01:20 +03:00
Samat Gilmanov 8491f5183c Document rate limits as bits per second 2026-06-08 12:39:32 -04:00
Nick Parfyonov 357852cc59 [docs] change fingerprint for xray double hop 2026-06-08 11:14:15 +03:00
Alexey 504cafb129 Merge pull request #824 from telemt/flow
MSS Tuning
2026-06-06 12:25:33 +03:00
Alexey 1096e38854 Docs for MSS Tuning
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-06 12:24:27 +03:00
Alexey 9bbdf796d8 Rustfmt 2026-06-06 12:17:19 +03:00
Alexey 27a5f5a4ec MSS Tuning with config
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-06 12:11:05 +03:00
Alexey a8adc9fe54 API hardening + Dual-stack fixes + JA3/JA4 observability + Test Stabilization: merge pull request #822 from telemt/flow
API hardening + Dual-stack fixes + JA3/JA4 observability + Test Stabilization
2026-06-05 14:36:00 +03:00
Alexey 44be585ee3 Update Cargo.toml 2026-06-05 14:24:27 +03:00
Alexey cb89d3f4fe Merge branch 'flow' of https://github.com/telemt/telemt into flow 2026-06-05 14:21:34 +03:00
Alexey c4e522a16d Bump -> 3.4.14
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 14:21:29 +03:00
Alexey 8e5f73a86b Merge branch 'main' into flow 2026-06-05 13:01:05 +03:00
Alexey 7d543aeb67 Fixes for Adversarial Timing Profile Latency-flake by #761
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 12:59:50 +03:00
Alexey 89a885c25f Reset Interface Cache in Masking timing test
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 12:51:54 +03:00
Alexey 54e40fd073 Fixes for Load mask shape security test
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-05 12:43:30 +03:00
Alexey 1934c1279c Update README.md 2026-06-05 06:54:53 +03:00
Alexey 0bc99b9f74 Merge pull request #820 from groozchique/main
[docs] README updates
2026-06-04 18:45:01 +03:00
Alexey 1d8e8890a4 Update README.md 2026-06-04 18:43:04 +03:00
Alexey d1680a7a80 Update README.md 2026-06-04 18:42:27 +03:00
Alexey b027608282 JA3 + JA4 Docs
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-03 15:32:32 +03:00
Nick Parfyonov 2f2c9b336c [docs] make dashes great again 2026-06-03 15:11:52 +03:00
Nick Parfyonov b9ebfdcd7b [docs] update RU README to match EN README 2026-06-03 15:10:17 +03:00
Alexey 34b48325fd JA3+JA4 Pitfall in API + Beobachten
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-02 08:17:56 +03:00
Alexey 5c573a926b Update Docs after Dualstack + Disable User adding
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-01 20:03:56 +03:00
Alexey 462215b53c Dual-stack fixes for Upstreams by #798
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-01 19:50:26 +03:00
Alexey 2264980926 User Disabler in API by #814 + Consistent Listeners in API by #800 2026-05-31 11:17:18 +03:00
Alexey 3d0d575b94 Normalize rlimit type on 32-bit targets in Conntrack Control #815 2026-05-30 18:13:54 +03:00
Alexey b720906fbc Merge pull request #813 from telemt/flow
Flow
2026-05-29 16:50:37 +03:00
Alexey ac244962ed Merge branch 'main' into flow 2026-05-29 16:07:29 +03:00
Aleksei K 752a2f5012 Bump -> 3.4.13 2026-05-29 14:05:19 +03:00
Aleksei K a77aedfd7a Atomically claim pressure eviction budget in MR 2026-05-29 13:17:47 +03:00
Alexey 8575d0ee5d Merge pull request #809 from Dimasssss/install.sh
Add interactive prompt for server port during installation
2026-05-29 10:38:00 +03:00
Alexey 213aba5dc9 Update README.md 2026-05-29 08:54:03 +03:00
Aleksei K a79aaee166 Merge branch 'flow' of https://github.com/telemt/telemt into flow 2026-05-28 16:11:27 +03:00
Aleksei K 2a0fcd6e35 Align ServerHello cipher and opaque ALPN behavior in TLS-F 2026-05-28 16:11:25 +03:00
Dimasssss 54a53e9ff0 Update install.sh 2026-05-28 12:26:46 +03:00
Alexey 63bcd7b3d0 Merge pull request #780 from temandroid/main
fix(docker): mount config as directory to allow atomic API writes
2026-05-27 08:33:43 +03:00
Alexey b68b10790c Merge pull request #799 from groozchique/main
[docs] more CONFIG_PARAMS.md fixes
2026-05-27 08:30:33 +03:00
Alexey 383d4318fe Add logging configuration to docker-compose: merge pull request #804 from Iv/main
Add logging configuration to docker-compose
2026-05-27 08:30:00 +03:00
Kravchenko Ivan d293861351 Add logging configuration to docker-compose
There was a disk full problem.
2026-05-26 20:43:26 +03:00
Alexey 31da0a1356 Fixes for Disable Colors 2026-05-26 12:20:28 +03:00
Nick Parfyonov 34bc1d943a [docs] add Hot-Reload column back to [general]
Re-adding Hot-Reload column to the [general] which was removed by mistake in my previous changes
2026-05-25 10:19:01 +03:00
Nick Parfyonov 50dee40dd2 [docs] remove duplicated parameters in [censorship] 2026-05-25 10:16:11 +03:00
Alexey d4adf0ef9a ME: Bound writer queue waits under backpressure 2026-05-25 00:28:29 +03:00
Alexey dc8951eae8 Reduce MR + ME Routing hot-path contention 2026-05-22 20:19:09 +03:00
Alexey 77a7f89075 Reuse ME reader scratch buffer across read loop iterations 2026-05-22 19:56:38 +03:00
Alexey 31b9504464 Merge pull request #797 from groozchique/main
Remove duplicated config params in [general] + some clarifications in FAQ + fix grammar mistake in RU FAQ
2026-05-22 19:23:40 +03:00
Nick Parfyonov 54cb4d0f29 [docs] some clarification in client-to-DC section in FAQ
Made some clarification about MTProxy requiremnt to reach all DCs to work properly for every client
2026-05-22 18:20:37 +03:00
Nick Parfyonov d449fc080c [docs] fix grammar mistake in FAQ.ru.md 2026-05-22 18:15:49 +03:00
Nick Parfyonov 3b8d16bee5 [docs] remove duplicated parameters in CONFIG_PARAMS.md 2026-05-22 18:14:10 +03:00
Alexey 9abaf9006c Prioritize Cancellation in MP select paths 2026-05-22 16:47:54 +03:00
TEMAndroid 855c5eef8b Merge branch 'main' into main 2026-05-18 10:41:24 +03:00
TEMAndroid b175927324 fix(docker): mount config as directory to allow atomic API writes 2026-05-11 20:50:31 +00:00
135 changed files with 11877 additions and 2558 deletions
Generated
+369 -483
View File
File diff suppressed because it is too large Load Diff
+59 -57
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telemt" name = "telemt"
version = "3.4.12" version = "3.4.19"
edition = "2024" edition = "2024"
[features] [features]
@@ -8,88 +8,90 @@ redteam_offline_expected_fail = []
[dependencies] [dependencies]
# C # C
libc = "0.2" libc = "0.2.186"
# Async runtime # Async runtime
tokio = { version = "1.42", features = ["full", "tracing"] } tokio = { version = "1.52.3", features = ["full", "tracing"] }
tokio-util = { version = "0.7", features = ["full"] } tokio-util = { version = "0.7.18", features = ["full"] }
# Crypto # Crypto
aes = "0.8" aes = "0.8.4"
ctr = "0.9" ctr = "0.9.2"
cbc = "0.1" cbc = "0.1.2"
sha2 = "0.10" sha2 = "0.10.9"
sha1 = "0.10" sha1 = "0.10.6"
md-5 = "0.10" md-5 = "0.10.6"
hmac = "0.12" hmac = "0.12.1"
crc32fast = "1.4" crc32fast = "1.5.0"
crc32c = "0.6" crc32c = "0.6.8"
zeroize = { version = "1.8", features = ["derive"] } zeroize = { version = "1.9.0", features = ["derive"] }
subtle = "2.6" subtle = "2.6.1"
static_assertions = "1.1" static_assertions = "1.1.0"
ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] }
# Network # Network
socket2 = { version = "0.6", features = ["all"] } socket2 = { version = "0.6.4", features = ["all"] }
nix = { version = "0.31", default-features = false, features = [ nix = { version = "0.31.3", default-features = false, features = [
"net", "net",
"user", "user",
"process", "process",
"fs", "fs",
"signal", "signal",
] } ] }
shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] } shadowsocks = { version = "1.24.0", features = ["aead-cipher-2022"] }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0.150"
toml = "1.0" toml = "1.1"
x509-parser = "0.18" x509-parser = "0.18.1"
# Utils # Utils
bytes = "1.9" bytes = "1.12.0"
thiserror = "2.0" thiserror = "2.0.18"
tracing = "0.1" tracing = "0.1.44"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-appender = "0.2" tracing-appender = "0.2.5"
parking_lot = "0.12" parking_lot = "0.12.5"
dashmap = "6.1" dashmap = "6.2.1"
arc-swap = "1.7" arc-swap = "1.9.1"
lru = "0.16" lru = "0.16.4"
rand = "0.10" rand = "0.10.1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
hex = "0.4" hex = "0.4.3"
base64 = "0.22" base64 = "0.22.1"
url = "2.5" url = "2.5.8"
regex = "1.11" regex = "1.12.4"
crossbeam-queue = "0.3" crossbeam-queue = "0.3.12"
num-bigint = "0.4" num-bigint = "0.4.6"
num-traits = "0.2" num-traits = "0.2.19"
x25519-dalek = "2" x25519-dalek = "2.0.1"
anyhow = "1.0" anyhow = "1.0.102"
# HTTP # HTTP
reqwest = { version = "0.13", features = ["rustls"], default-features = false } reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
notify = "8.2" notify = "8.2.0"
ipnetwork = { version = "0.21", features = ["serde"] } ipnetwork = { version = "0.21.1", features = ["serde"] }
hyper = { version = "1", features = ["server", "http1"] } hyper = { version = "1.10.1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
http-body-util = "0.1" http-body-util = "0.1.3"
httpdate = "1.0" httpdate = "1.0.3"
tokio-rustls = { version = "0.26", default-features = false, features = [ tokio-rustls = { version = "0.26.4", default-features = false, features = [
"tls12", "tls12",
] } ] }
rustls = { version = "0.23", default-features = false, features = [ rustls = { version = "0.23.41", default-features = false, features = [
"std", "std",
"tls12", "tls12",
"ring", "ring",
] } ] }
webpki-roots = "1.0" webpki-roots = "1.0.8"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4.5"
criterion = "0.8" criterion = "0.8.2"
proptest = "1.4" proptest = "1.11.0"
futures = "0.3" futures = "0.3.32"
tempfile = "3.27.0"
[[bench]] [[bench]]
name = "crypto_bench" name = "crypto_bench"
+3 -3
View File
@@ -73,7 +73,7 @@ RUN set -eux; \
WORKDIR /app WORKDIR /app
COPY --from=minimal /telemt /app/telemt COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml COPY ./config/config.toml /app/config.toml
EXPOSE 443 9090 9091 EXPOSE 443 9090 9091
@@ -99,7 +99,7 @@ RUN set -eux; \
WORKDIR /app WORKDIR /app
COPY --from=minimal /telemt /app/telemt COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml COPY ./config/config.toml /app/config.toml
EXPOSE 443 9090 9091 EXPOSE 443 9090 9091
@@ -116,7 +116,7 @@ FROM gcr.io/distroless/static-debian12 AS prod
WORKDIR /app WORKDIR /app
COPY --from=minimal /telemt /app/telemt COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml COPY ./config/config.toml /app/config.toml
USER nonroot:nonroot USER nonroot:nonroot
+4 -4
View File
@@ -4,13 +4,13 @@
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md) [🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
***Löst Probleme, bevor andere überhaupt wissen, dass sie existieren*** / ***It solves problems before others even realize they exist***
> [!NOTE] > [!NOTE]
> >
> Fixed TLS ClientHello is now available in official clients for Desktop / Android / iOS > From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions"
>
> Telegram Clients TLS ClientHello has been banned by JA4/JA4+ Fingerprint: we are already looking for ways to solve this problem
> >
> To work with EE-MTProxy, please update your client! > You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf)
<p align="center"> <p align="center">
<a href="https://t.me/telemtrs"> <a href="https://t.me/telemtrs">
+28 -38
View File
@@ -1,57 +1,52 @@
# Telemt — MTProxy на Rust + Tokio # Telemt — MTProxy на Rust + Tokio
[![Latest Release](https://img.shields.io/github/v/release/telemt/telemt?color=neon)](https://github.com/telemt/telemt/releases/latest) [![Stars](https://img.shields.io/github/stars/telemt/telemt?style=social)](https://github.com/telemt/telemt/stargazers) [![Forks](https://img.shields.io/github/forks/telemt/telemt?style=social)](https://github.com/telemt/telemt/network/members) [![Telegram](https://img.shields.io/badge/Telegram-Chat-24a1de?logo=telegram&logoColor=24a1de)](https://t.me/telemtrs) [![Latest Release](https://img.shields.io/github/v/release/telemt/telemt?color=neon)](https://github.com/telemt/telemt/releases/latest) [![Stars](https://img.shields.io/github/stars/telemt/telemt?style=social)](https://github.com/telemt/telemt/stargazers) [![Forks](https://img.shields.io/github/forks/telemt/telemt?style=social)](https://github.com/telemt/telemt/network/members)
***Решает проблемы раньше, чем другие узнают об их существовании***
> [!NOTE] > [!NOTE]
> >
> Исправленный TLS ClientHello доступен в Telegram для настольных ПК, Android и iOS. > Клиенты Telegram подвергаются блокировке по JA3-отпечатку; мы ищем варианты решения этой проблемы
> >
> Пожалуйста, обновите клиентское приложение для работы с EE-MTProxy. > Вы можете попробовать собрать свой клиент с нашей Telegram Devlibrary — [tdlib-obf](https://github.com/telemt/tdlib-obf)
<p align="center"> <p align="center">
<a href="https://t.me/telemtrs"> <a href="https://t.me/telemtrs">
<img src="/docs/assets/telegram_button.svg" width="150"/> <img src="https://github.com/user-attachments/assets/30b7e7b9-974a-4e3d-aab6-b58a85de4507" width="240"/>
</a> </a>
</p> </p>
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust. Он полностью реализует официальный алгоритм прокси Telegram и добавляет множество улучшений для продакшена: **Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust: он полностью реализует официальный алгоритм Telegram прокси и добавляет множество различных улучшений
## Установка и обновление одной командой ## Установка и обновление одной командой
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
``` ```
- [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md) - [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md)
- [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md)
Реализация **TLS-fronting** максимально приближена к поведению реального HTTPS-трафика (подробнее - [FAQ](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров)). ## Функционал
Наша реализация **TLS-fronting** одна из наиболее глубоко отлаженных, продвинутых и почти поведенчески неотличима от настоящего: мы уверены, что сделали это правильно - [см. доказательства в нашей проверке](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров).
***Middle-End Pool*** оптимизирован для высокой производительности. Наша архитектура ***Middle-End Pool*** в стандартных сценариях самая производительная, по сравнению с другими реализациями подключения к Middle-End прокси: не кардинально, но достаточно
- Поддержка всех режимов MTProto proxy: - Полная поддержа всех официальных режимов MTProto proxy:
- Classic; - Classic;
- Secure (префикс `dd`); - Secure с префиксом `dd`;
- Fake TLS (префикс `ee` + SNI fronting); - Fake TLS с префиксом `ee` + SNI fronting;
- Защита от replay-атак; - Защита от replay-атак;
- Маскировка трафика (перенаправление неизвестных подключений на реальные сайты); - Опциональная маскировка трафика: перенаправление неизвестных подключений на реальные сайты;
- Настраиваемые keepalive, таймауты, IPv6 и «быстрый режим»; - Настраиваемые keepalive, таймауты, IPv6 и "быстрый режим";
- Корректное завершение работы (Ctrl+C); - Корректное завершение работы (Ctrl+C);
- Подробное логирование через `trace` и `debug`. - Подробное логирование через `trace` и `debug` с помощью `RUST_LOG`.
# Подробнее о Telemt ## ЧаВо
- [FAQ](#faq) - [Часто задаваемые вопросы](docs/FAQ.ru.md)
- [Архитектура](docs/Architecture)
- [Параметры конфигурационного файла](docs/Config_params)
- [Сборка](#build)
- [Установка на BSD](#%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D0%BD%D0%B0-bsd)
- [Почему Rust?](#why-rust)
## FAQ # Узнайте больше о Telemt
- [FAQ RU](docs/FAQ.ru.md) - [Наша архитектура](docs/Architecture)
- [FAQ EN](docs/FAQ.en.md) - [Все конфигурационные параметры](docs/Config_params)
- [Как собрать Telemt самостоятельно?](#сборка)
- [Установка на BSD](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md)
- [Почему Rust?](#почему-rust)
## Сборка ## Сборка
```bash ```bash
@@ -63,7 +58,7 @@ cd telemt
cargo build --release cargo build --release
# В текущем release-профиле используется lto = "fat" для максимальной оптимизации (см. Cargo.toml). # В текущем release-профиле используется lto = "fat" для максимальной оптимизации (см. Cargo.toml).
# На системах с малым объёмом RAM (~1 ГБ) можно переопределить это значение на "thin". # На системах с малым объёмом ОЗУ (~1 ГБ) можно переопределить это значение на "thin".
# Перейдите в каталог /bin # Перейдите в каталог /bin
mv ./target/release/telemt /bin mv ./target/release/telemt /bin
@@ -73,24 +68,19 @@ chmod +x /bin/telemt
telemt config.toml telemt config.toml
``` ```
## Установка на BSD
- Руководство по сборке и настройке на английском языке [OpenBSD Guide (EN)](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md);
- Пример rc.d скрипта: [contrib/openbsd/telemt.rcd](contrib/openbsd/telemt.rcd);
- Поддержка sandbox с `pledge(2)` и `unveil(2)` пока не реализована.
## Почему Rust? ## Почему Rust?
- Надёжность для долгоживущих процессов; - Надёжность при длительной работе и идемпотентное поведение;
- Детерминированное управление ресурсами (RAII); - Детерминированное управление ресурсами RAII;
- Отсутствие сборщика мусора; - Отсутствие сборщика мусора;
- Безопасность памяти; - Безопасность памяти и меньше поверхность атаки;
- Асинхронная архитектура Tokio. - Асинхронная архитектура Tokio.
## Поддержать Telemt ## Поддержать Telemt
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разработанное в свободное время. Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разрабатываемое в свободное время.
Если оно оказалось вам полезным, вы можете поддержать дальнейшую разработку. Если оно оказалось вам полезным, вы можете поддержать дальнейшую разработку.
Принимаемые криптовалюты (BTC, ETH, USDT, 350+ и другие): Любая криптовалюта (BTC, ETH, USDT и 350+ других):
<p align="center"> <p align="center">
<a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener"> <a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener">
+12 -6
View File
@@ -10,12 +10,15 @@ services:
- "443:443" - "443:443"
- "127.0.0.1:9090:9090" - "127.0.0.1:9090:9090"
- "127.0.0.1:9091:9091" - "127.0.0.1:9091:9091"
# Allow caching 'proxy-secret' in read-only container # Working dir uses tmpfs for caching 'proxy-secret' at runtime.
working_dir: /etc/telemt # Config is mounted as a directory (not a single file) so the API can
# atomically update config.toml via write-temp → rename within the same FS.
working_dir: /run/telemt
command: ["/etc/telemt/config.toml"]
volumes: volumes:
- ./config.toml:/etc/telemt/config.toml:ro - ./config:/etc/telemt:rw
tmpfs: tmpfs:
- /etc/telemt:rw,mode=1777,size=4m - /run/telemt:rw,mode=1777,size=4m
environment: environment:
- RUST_LOG=info - RUST_LOG=info
healthcheck: healthcheck:
@@ -24,8 +27,6 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 20s start_period: 20s
# Uncomment this line if you want to use host network for IPv6, but bridge is default and usually better
# network_mode: host
cap_drop: cap_drop:
- ALL - ALL
cap_add: cap_add:
@@ -37,3 +38,8 @@ services:
nofile: nofile:
soft: 65536 soft: 65536
hard: 262144 hard: 262144
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
+3
View File
@@ -86,6 +86,9 @@ Die unten angegebenen `Default`-Werte sind Code-Defaults (bei fehlendem Schlüss
| `[[upstreams]].weight` | alle Upstreams | `u16` | nein | `1` | Basisgewicht für weighted-random Auswahl. | | `[[upstreams]].weight` | alle Upstreams | `u16` | nein | `1` | Basisgewicht für weighted-random Auswahl. |
| `[[upstreams]].enabled` | alle Upstreams | `bool` | nein | `true` | Deaktivierte Einträge werden beim Start ignoriert. | | `[[upstreams]].enabled` | alle Upstreams | `bool` | nein | `true` | Deaktivierte Einträge werden beim Start ignoriert. |
| `[[upstreams]].scopes` | alle Upstreams | `String` | nein | `""` | Komma-separierte Scope-Tags für Request-Routing. | | `[[upstreams]].scopes` | alle Upstreams | `String` | nein | `""` | Komma-separierte Scope-Tags für Request-Routing. |
| `[[upstreams]].ipv4` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv4-DC-Ziele für diesen Upstream. |
| `[[upstreams]].ipv6` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv6-DC-Ziele für diesen Upstream, inklusive Proxy-Egress unabhängig vom Host-IPv6. |
| `[[upstreams]].prefer` | alle Upstreams | `Option<4 \| 6>` | nein | effective `[network].prefer` | Pro-Upstream-Präferenz für die DC-Ziel-Adressfamilie. |
| `interface` | `direct` | `Option<String>` | nein | `null` | Interface-Name (z. B. `eth0`) oder lokale Literal-IP. | | `interface` | `direct` | `Option<String>` | nein | `null` | Interface-Name (z. B. `eth0`) oder lokale Literal-IP. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | nein | `null` | Explizite Source-IP-Kandidaten (strikter Vorrang vor `interface`). | | `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | nein | `null` | Explizite Source-IP-Kandidaten (strikter Vorrang vor `interface`). |
| `address` | `socks4` | `String` | ja | n/a | SOCKS4-Server (`ip:port` oder `host:port`). | | `address` | `socks4` | `String` | ja | n/a | SOCKS4-Server (`ip:port` oder `host:port`). |
+3
View File
@@ -86,6 +86,9 @@ Defaults below are code defaults (used when a key is omitted), not necessarily v
| `[[upstreams]].weight` | all upstreams | `u16` | no | `1` | Base weight for weighted-random selection. | | `[[upstreams]].weight` | all upstreams | `u16` | no | `1` | Base weight for weighted-random selection. |
| `[[upstreams]].enabled` | all upstreams | `bool` | no | `true` | Disabled entries are ignored at startup. | | `[[upstreams]].enabled` | all upstreams | `bool` | no | `true` | Disabled entries are ignored at startup. |
| `[[upstreams]].scopes` | all upstreams | `String` | no | `""` | Comma-separated scope tags for request-level routing. | | `[[upstreams]].scopes` | all upstreams | `String` | no | `""` | Comma-separated scope tags for request-level routing. |
| `[[upstreams]].ipv4` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv4 DC targets for this upstream. |
| `[[upstreams]].ipv6` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv6 DC targets for this upstream, including proxy egress independent of host IPv6. |
| `[[upstreams]].prefer` | all upstreams | `Option<4 \| 6>` | no | effective `[network].prefer` | Per-upstream DC target family preference. |
| `interface` | `direct` | `Option<String>` | no | `null` | Interface name (e.g. `eth0`) or literal local IP for bind selection. | | `interface` | `direct` | `Option<String>` | no | `null` | Interface name (e.g. `eth0`) or literal local IP for bind selection. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | no | `null` | Explicit local source IP candidates (strict priority over `interface`). | | `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | no | `null` | Explicit local source IP candidates (strict priority over `interface`). |
| `address` | `socks4` | `String` | yes | n/a | SOCKS4 server endpoint (`ip:port` or `host:port`). | | `address` | `socks4` | `String` | yes | n/a | SOCKS4 server endpoint (`ip:port` or `host:port`). |
+3
View File
@@ -86,6 +86,9 @@
| `[[upstreams]].weight` | все upstream | `u16` | нет | `1` | Базовый вес в weighted-random выборе. | | `[[upstreams]].weight` | все upstream | `u16` | нет | `1` | Базовый вес в weighted-random выборе. |
| `[[upstreams]].enabled` | все upstream | `bool` | нет | `true` | Выключенные записи игнорируются на старте. | | `[[upstreams]].enabled` | все upstream | `bool` | нет | `true` | Выключенные записи игнорируются на старте. |
| `[[upstreams]].scopes` | все upstream | `String` | нет | `""` | Список scope-токенов через запятую для маршрутизации. | | `[[upstreams]].scopes` | все upstream | `String` | нет | `""` | Список scope-токенов через запятую для маршрутизации. |
| `[[upstreams]].ipv4` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv4 DC-targets для этого upstream. |
| `[[upstreams]].ipv6` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv6 DC-targets для этого upstream, включая proxy egress независимо от IPv6 на хосте. |
| `[[upstreams]].prefer` | все upstream | `Option<4 \| 6>` | нет | эффективный `[network].prefer` | Предпочтительное семейство DC-target для конкретного upstream. |
| `interface` | `direct` | `Option<String>` | нет | `null` | Имя интерфейса (например `eth0`) или literal локальный IP. | | `interface` | `direct` | `Option<String>` | нет | `null` | Имя интерфейса (например `eth0`) или literal локальный IP. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | нет | `null` | Явные кандидаты source IP (имеют приоритет над `interface`). | | `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | нет | `null` | Явные кандидаты source IP (имеют приоритет над `interface`). |
| `address` | `socks4` | `String` | да | n/a | Адрес SOCKS4 сервера (`ip:port` или `host:port`). | | `address` | `socks4` | `String` | да | n/a | Адрес SOCKS4 сервера (`ip:port` или `host:port`). |
+193 -6
View File
@@ -103,14 +103,19 @@ Notes:
| `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` | | `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` |
| `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` | | `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` |
| `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` | | `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` |
| `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` |
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` | | `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` | | `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
| `GET` | `/v1/config` | none | `200` | `ConfigData` |
| `PATCH` | `/v1/config` | sparse JSON object | `200` | `PatchConfigResponse` |
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` | | `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` | | `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` | | `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` or `202` | `UserInfo` | | `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` or `202` | `UserInfo` |
| `DELETE` | `/v1/users/{username}` | none | `200` or `202` | `DeleteUserResponse` | | `DELETE` | `/v1/users/{username}` | none | `200` or `202` | `DeleteUserResponse` |
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `200` or `202` | `CreateUserResponse` | | `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `200` or `202` | `CreateUserResponse` |
| `POST` | `/v1/users/{username}/enable` | empty body | `200` or `202` | `UserInfo` |
| `POST` | `/v1/users/{username}/disable` | empty body | `200` or `202` | `UserInfo` |
| `POST` | `/v1/users/{username}/reset-quota` | empty body | `200` | `ResetUserQuotaResponse` | | `POST` | `/v1/users/{username}/reset-quota` | empty body | `200` | `ResetUserQuotaResponse` |
## Endpoint Behavior ## Endpoint Behavior
@@ -140,12 +145,16 @@ Notes:
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. | | `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. | | `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. | | `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
| `GET /v1/config` | Returns the current editable config sections as JSON (no `access.*`) plus the revision. |
| `PATCH /v1/config` | Applies a sparse patch to editable config sections; validates, writes, and reports restart impact. |
| `GET /v1/users` | Returns disk-first user views sorted by username. | | `GET /v1/users` | Returns disk-first user views sorted by username. |
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. | | `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. | | `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
| `PATCH /v1/users/{username}` | Updates selected per-user fields with JSON Merge Patch semantics. | | `PATCH /v1/users/{username}` | Updates selected per-user fields with JSON Merge Patch semantics. |
| `DELETE /v1/users/{username}` | Deletes one user and related per-user access-map entries. | | `DELETE /v1/users/{username}` | Deletes one user and related per-user access-map entries. |
| `POST /v1/users/{username}/rotate-secret` | Rotates one user's secret and returns the effective secret. | | `POST /v1/users/{username}/rotate-secret` | Rotates one user's secret and returns the effective secret. |
| `POST /v1/users/{username}/enable` | Enables one user, removing any disabled override from config. |
| `POST /v1/users/{username}/disable` | Disables one user and closes active runtime sessions for that user. |
| `POST /v1/users/{username}/reset-quota` | Resets one user's runtime quota counter and persists quota state. | | `POST /v1/users/{username}/reset-quota` | Resets one user's runtime quota counter and persists quota state. |
## Common Error Codes ## Common Error Codes
@@ -153,6 +162,8 @@ Notes:
| HTTP | `error.code` | Trigger | | HTTP | `error.code` | Trigger |
| --- | --- | --- | | --- | --- | --- |
| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. | | `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. |
| `400` | `access_not_editable` | `PATCH /v1/config` body contains an `access` key (managed via users API). |
| `400` | `section_not_editable` | `PATCH /v1/config` body contains `server`, `network`, or an unknown top-level key. |
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. | | `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
| `403` | `forbidden` | Source IP is not allowed by whitelist. | | `403` | `forbidden` | Source IP is not allowed by whitelist. |
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. | | `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
@@ -172,9 +183,12 @@ Notes:
| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. | | Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. |
| Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. | | Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. |
| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. | | Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. |
| `DELETE /v1/config` (or any method not in `GET`, `PATCH`) | `405 method_not_allowed` with `Allow: GET, PATCH`. |
| `PUT /v1/users/{username}` | `405 method_not_allowed`. | | `PUT /v1/users/{username}` | `405 method_not_allowed`. |
| `POST /v1/users/{username}` | `404 not_found`. | | `POST /v1/users/{username}` | `404 not_found`. |
| `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. | | `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. |
| `POST /v1/users/{username}/enable/` | Trailing slash is trimmed and the route matches `enable`. |
| `POST /v1/users/{username}/disable/` | Trailing slash is trimmed and the route matches `disable`. |
| `POST /v1/users/{username}/reset-quota/` | Trailing slash is trimmed and the route matches `reset-quota`. | | `POST /v1/users/{username}/reset-quota/` | Trailing slash is trimmed and the route matches `reset-quota`. |
## Body and JSON Semantics ## Body and JSON Semantics
@@ -205,9 +219,10 @@ Notes:
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. | | `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. | | `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. | | `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
| `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bytes per second. | | `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bits per second. |
| `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bytes per second. | | `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bits per second. |
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. | | `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
| `enabled` | `bool` | no | User enable flag. Missing means enabled. `false` persists a disabled override. |
### `PatchUserRequest` ### `PatchUserRequest`
| Field | Type | Required | Description | | Field | Type | Required | Description |
@@ -217,9 +232,10 @@ Notes:
| `max_tcp_conns` | `usize|null` | no | Per-user concurrent TCP limit; `null` removes the per-user override. | | `max_tcp_conns` | `usize|null` | no | Per-user concurrent TCP limit; `null` removes the per-user override. |
| `expiration_rfc3339` | `string|null` | no | RFC3339 expiration timestamp; `null` removes the expiration. | | `expiration_rfc3339` | `string|null` | no | RFC3339 expiration timestamp; `null` removes the expiration. |
| `data_quota_bytes` | `u64|null` | no | Per-user traffic quota; `null` removes the per-user quota. | | `data_quota_bytes` | `u64|null` | no | Per-user traffic quota; `null` removes the per-user quota. |
| `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bytes per second; `null` removes the upload direction limit. | | `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bits per second; `null` removes the upload direction limit. |
| `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bytes per second; `null` removes the download direction limit. | | `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bits per second; `null` removes the download direction limit. |
| `max_unique_ips` | `usize|null` | no | Per-user unique source IP limit; `null` removes the per-user override. | | `max_unique_ips` | `usize|null` | no | Per-user unique source IP limit; `null` removes the per-user override. |
| `enabled` | `bool|null` | no | `false` disables the user. `true` or `null` removes the disabled override, so the user is enabled. |
### `access.user_source_deny` via API ### `access.user_source_deny` via API
- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`. - In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`.
@@ -236,6 +252,20 @@ alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
bob = ["198.51.100.42/32"] bob = ["198.51.100.42/32"]
``` ```
### `PatchConfigRequest`
A sparse JSON object containing only the top-level config sections to modify. Each key must be one of the editable sections (`general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`). Tables within a section are deep-merged field-by-field into the existing config; arrays and scalar values replace the existing value wholesale. Untouched sections and file comments are preserved.
**Rejected keys:**
- `access``400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`).
- `server`, `network`, or any unknown top-level key → `400 section_not_editable`.
- An object with no editable keys → `400 bad_request` (empty patch).
Example — patch only the SNI domain:
```json
{"censorship": {"tls_domain": "front.example.com"}}
```
### `RotateSecretRequest` ### `RotateSecretRequest`
| Field | Type | Required | Description | | Field | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -245,6 +275,31 @@ An empty request body is accepted and generates a new secret automatically.
## Response Data Contracts ## Response Data Contracts
### `ConfigData`
Returned by `GET /v1/config` as the envelope `data`. The fields are exactly the editable TOML sections. The current revision is returned in the envelope `revision` field (same value as `config_hash` in `SystemInfoData`), **not** inside `data`.
| Field | Type | Description |
| --- | --- | --- |
| `general` | `object?` | `[general]` section, if present in config. |
| `timeouts` | `object?` | `[timeouts]` section, if present. |
| `censorship` | `object?` | `[censorship]` section, if present. |
| `upstreams` | `object?` | `[upstreams]` section, if present. |
| `show_link` | `object?` | `[show_link]` section, if present. |
| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. |
Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets), `server` (carries the API `auth_header` and per-node identity), and `network` (per-node addresses) are always excluded.
### `PatchConfigResponse`
Returned by `PATCH /v1/config` on success (`200`).
| Field | Type | Description |
| --- | --- | --- |
| `revision` | `string` | SHA-256 hex of the config file after the patch was written. |
| `restart_required` | `bool` | `true` when one or more changed fields require a process restart to take effect. Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher; restart-required fields (e.g. any `censorship.*`, `timeouts.*`, `upstreams`, or `general.modes` change) are written to disk but only take effect after the Telemt process is restarted. The caller is responsible for triggering a restart when this flag is `true`. |
| `changed` | `string[]` | Top-level section names that differed between the old and new config (e.g. `["censorship"]`). |
### `HealthData` ### `HealthData`
| Field | Type | Description | | Field | Type | Description |
| --- | --- | --- | | --- | --- | --- |
@@ -807,6 +862,43 @@ An empty request body is accepted and generates a new secret automatically.
| `event_type` | `string` | Event kind identifier. | | `event_type` | `string` | Event kind identifier. |
| `context` | `string` | Context text (truncated to implementation-defined max length). | | `context` | `string` | Context text (truncated to implementation-defined max length). |
### `RuntimeEdgeTlsFingerprintsData`
| Field | Type | Description |
| --- | --- | --- |
| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. |
| `reason` | `string?` | `feature_disabled` when endpoint is disabled. |
| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. |
| `data` | `RuntimeEdgeTlsFingerprintsPayload?` | Null when unavailable. |
#### `RuntimeEdgeTlsFingerprintsPayload`
| Field | Type | Description |
| --- | --- | --- |
| `limit` | `usize` | Effective Top-N row count. |
| `retention_secs` | `u64` | In-memory retention window, derived from `general.beobachten_minutes`. |
| `capacity` | `usize` | Maximum retained fingerprint buckets. |
| `dropped_total` | `u64` | Buckets dropped because the collector was full. |
| `parse_error_total` | `u64` | Complete ClientHello records that could not be fingerprinted. |
| `by_fingerprint` | `RuntimeEdgeTlsFingerprintRow[]` | Global JA3/JA4 leaderboard. |
| `by_ip` | `RuntimeEdgeTlsFingerprintRow[]` | Source-IP scoped leaderboard. |
| `by_cidr` | `RuntimeEdgeTlsFingerprintRow[]` | Source CIDR scoped leaderboard (`/24` for IPv4, `/56` for IPv6). |
| `by_user` | `RuntimeEdgeTlsFingerprintRow[]` | Authenticated user scoped leaderboard. |
#### `RuntimeEdgeTlsFingerprintRow`
| Field | Type | Description |
| --- | --- | --- |
| `scope` | `string?` | IP, CIDR, or username; absent in `by_fingerprint`. |
| `ja3` | `string` | JA3 MD5 hash. |
| `ja3_raw` | `string` | Raw JA3 field string. |
| `ja4` | `string` | JA4 TLS client fingerprint. |
| `ja4_raw` | `string` | Raw JA4 material used for the hashed parts. |
| `total` | `u64` | Complete ClientHello observations for this bucket. |
| `auth_success` | `u64` | TLS-authenticated observations for this bucket. |
| `bad_or_probe` | `u64` | Complete ClientHello observations later classified as bad/probe. |
| `first_seen_epoch_secs` | `u64` | First observation timestamp. |
| `last_seen_epoch_secs` | `u64` | Last observation timestamp. |
JA3 follows the Salesforce ClientHello field order. JA4 follows the FoxIO TLS-client `a_b_c` format; GREASE values are excluded and no high-cardinality Prometheus labels are emitted for fingerprints.
### `ZeroAllData` ### `ZeroAllData`
| Field | Type | Description | | Field | Type | Description |
| --- | --- | --- | | --- | --- | --- |
@@ -1165,13 +1257,14 @@ An empty request body is accepted and generates a new secret automatically.
| Field | Type | Description | | Field | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `username` | `string` | Username. | | `username` | `string` | Username. |
| `enabled` | `bool` | Effective user enable flag. Missing config entry is reported as `true`. |
| `in_runtime` | `bool` | Whether current runtime config already contains this user. | | `in_runtime` | `bool` | Whether current runtime config already contains this user. |
| `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). | | `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). |
| `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. | | `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. |
| `expiration_rfc3339` | `string?` | Optional expiration timestamp. | | `expiration_rfc3339` | `string?` | Optional expiration timestamp. |
| `data_quota_bytes` | `u64?` | Optional data quota. | | `data_quota_bytes` | `u64?` | Optional data quota. |
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bytes per second. | | `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bits per second. |
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bytes per second. | | `rate_limit_down_bps` | `u64?` | Optional download rate limit in bits per second. |
| `max_unique_ips` | `usize?` | Optional unique IP limit. | | `max_unique_ips` | `usize?` | Optional unique IP limit. |
| `current_connections` | `u64` | Current live connections. | | `current_connections` | `u64` | Current live connections. |
| `active_unique_ips` | `usize` | Current active unique source IPs. | | `active_unique_ips` | `usize` | Current active unique source IPs. |
@@ -1232,13 +1325,106 @@ Link generation uses active config and enabled modes:
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. | | `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
| `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. | | `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. |
## Config Endpoints
### `GET /v1/config`
Returns the current editable config sections as TOML-shaped JSON, plus the current revision. The `access` section (users and secrets) is always stripped and never appears in the response.
**Auth:** requires `Authorization` header when `auth_header` is configured (same as all other endpoints).
**Success `200` response body** (`data` field of the standard envelope):
```json
{
"revision": "<sha256-hex>",
"censorship": {"tls_domain": "front.example.com"},
"general": {"log_level": "normal"}
}
```
Top-level sections absent from the config file are absent from the response. Only `GET` and `PATCH` are accepted; any other method returns `405 Method Not Allowed` with `Allow: GET, PATCH`.
---
### `PATCH /v1/config`
Applies a sparse patch to the editable config sections. The merged config is fully validated before writing; if validation fails the file is not modified.
**Auth:** requires `Authorization` header when `auth_header` is configured.
**Headers:**
| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | when configured | Same token as all other endpoints. |
| `Content-Type: application/json` | recommended | Not enforced, but body must be valid JSON. |
| `If-Match: <revision>` | no | Optimistic concurrency. `<revision>` is the `revision` value from `GET /v1/config` or `config_hash` from `GET /v1/system/info`. If supplied and it does not match the current on-disk revision, returns `409 revision_conflict`. If omitted, the patch applies unconditionally. |
**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`.
**Rejected keys and their error codes:**
| Key | HTTP | `error.code` |
| --- | --- | --- |
| `access` | `400` | `access_not_editable` |
| `server`, `network`, or any unknown key | `400` | `section_not_editable` |
| Object with no editable key | `400` | `bad_request` |
**Merge semantics:** tables are deep-merged field-by-field; arrays and scalar values replace the existing value wholesale. File comments and untouched sections are preserved.
**Validation:** the merged config is deserialized into the full `ProxyConfig` type and validated before writing. Failures return `400` with a descriptive message; the file is not modified.
**Read-only mode:** returns `403 read_only` when the API runs with `read_only = true`.
**Success `200` response body** (`data` field of the standard envelope):
```json
{
"revision": "<new-sha256-hex>",
"restart_required": true,
"changed": ["censorship"]
}
```
- `revision` — SHA-256 hex of the config file after the write.
- `restart_required``true` when the change affects a field that Telemt cannot hot-reload (e.g. `censorship.*`, `timeouts.*`, `upstreams`, `general.modes`). Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher. Restart-required fields are written to disk but only take effect after the Telemt process is restarted; the caller is responsible for triggering the restart.
- `changed` — list of top-level section names that differed.
**Status codes:**
| HTTP | `error.code` | Condition |
| --- | --- | --- |
| `200` | — | Patch applied successfully. |
| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. |
| `400` | `access_not_editable` | Patch contains an `access` key. |
| `400` | `section_not_editable` | Patch contains `server`, `network`, or an unknown top-level key. |
| `401` | `unauthorized` | Missing or invalid `Authorization` header. |
| `403` | `read_only` | API is in read-only mode. |
| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. |
| `409` | `revision_conflict` | `If-Match` header supplied but does not match current revision. |
| `500` | `internal_error` | I/O or serialization failure. |
**curl example:**
```bash
# get current revision
curl -s -H "Authorization: <token>" http://127.0.0.1:<api>/v1/system/info | jq -r .config_hash
# patch the SNI domain with optimistic concurrency
curl -s -X PATCH -H "Authorization: <token>" -H "If-Match: <revision>" \
-H "Content-Type: application/json" \
-d '{"censorship":{"tls_domain":"front.example.com"}}' \
http://127.0.0.1:<api>/v1/config
```
## Mutation Semantics ## Mutation Semantics
| Endpoint | Notes | | Endpoint | Notes |
| --- | --- | | --- | --- |
| `PATCH /v1/config` | Deep-merges the patch into editable config sections (tables merged per-field; arrays/scalars replaced wholesale). Validates the merged result before writing. Writes only the touched sections via atomic `tmp + rename`. Returns the new revision and which sections changed. |
| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). | | `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). |
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. | | `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. |
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. | | `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |
| `POST /v1/users/{username}/enable` | Enables the user idempotently by removing the `access.user_enabled[username]` override and updating the runtime admission state immediately. |
| `POST /v1/users/{username}/disable` | Disables the user idempotently by writing `access.user_enabled[username] = false`, updating runtime admission immediately, and cancelling active sessions for that username. |
| `POST /v1/users/{username}/reset-quota` | Resets the runtime quota counter for the route username, persists quota state to `general.quota_state_path`, and does not modify user config. | | `POST /v1/users/{username}/reset-quota` | Resets the runtime quota counter for the route username, persists quota state to `general.quota_state_path`, and does not modify user config. |
| `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. | | `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. |
@@ -1282,6 +1468,7 @@ Additional runtime endpoint behavior:
| `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload | | `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload |
| `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload | | `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload |
| `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload | | `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
| `/v1/runtime/tls-fingerprints` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
## ME Fallback Behavior Exposed Via API ## ME Fallback Behavior Exposed Via API
@@ -0,0 +1,507 @@
# JA3 и JA4 анализ в Telemt
Этот документ описывает, как использовать JA3/JA4 telemetry в Telemt для диагностики блокировок, которые происходят на основе TLS ClientHello, особенно JA4 TLS client fingerprint.
Цель документа практическая: помочь оператору понять, какой клиентский TLS-отпечаток реально доходит до Telemt, как он распределён по IP/CIDR/пользователям, и как отделить JA4-based фильтрацию от блокировки по IP, SNI, домену, server flight или активному сканированию.
## Коротко
JA3 и JA4 описывают форму TLS ClientHello. ClientHello отправляет клиент, поэтому JA3/JA4 в этом контексте являются fingerprint'ами клиентской TLS-реализации, а не Telemt как сервера.
Telemt собирает JA3/JA4 только из уже прочитанного полного ClientHello:
- без packet capture;
- без MITM;
- без расшифровки TLS;
- без дополнительных сетевых чтений;
- без Prometheus labels с высокой кардинальностью;
- с ограниченным in-memory TTL/cap collector.
Собранные данные доступны:
- через API: `GET /v1/runtime/tls-fingerprints`;
- через `/beobachten`, если `general.beobachten=true`.
Основная польза:
- увидеть, какие JA4 реально используют клиенты;
- понять, один ли fingerprint страдает у всех пользователей;
- отделить проблему клиента от проблемы IP/ASN/домена;
- увидеть, доходят ли проблемные соединения до Telemt вообще;
- сравнить successful TLS-auth и bad/probe поток для одного fingerprint;
- собрать evidence для последующего изменения клиента, маршрута или deployment-профиля.
## Что такое JA3
JA3 - старый и широко совместимый способ получить hash от TLS ClientHello.
JA3 строится из ClientHello fields:
```text
SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat
```
Значения внутри полей записываются в порядке, в котором они пришли в ClientHello. GREASE values исключаются. Итоговая строка хэшируется MD5, поэтому в API есть два поля:
- `ja3` - MD5 hash;
- `ja3_raw` - исходная строка, из которой получен hash.
Практическое значение JA3 в 2026 году ограничено тем, что современные TLS-клиенты и браузерные стеки могут менять порядок extensions. Поэтому JA3 полезен как совместимый исторический сигнал, но для диагностики современных блокировок обычно важнее JA4.
## Что такое JA4
JA4 TLS client fingerprint - более структурированный fingerprint ClientHello.
JA4 в Telemt считается для TLS-over-TCP ClientHello и имеет форму:
```text
t<version><sni_marker><cipher_count><extension_count><alpn_marker>_<cipher_hash>_<extension_hash>
```
Пример:
```text
t13d1516h2_8daaf6152771_e5627efa2ab1
```
Части JA4:
| Часть | Смысл |
| --- | --- |
| `t` | TLS over TCP. Telemt сейчас не считает JA4 для QUIC/DTLS. |
| `13`, `12`, `11`, `10` | TLS version, предпочтительно из `supported_versions`. |
| `d` / `i` | Есть SNI domain (`d`) или SNI отсутствует (`i`). |
| `15` | Количество cipher suites без GREASE, capped до `99`. |
| `16` | Количество extensions без GREASE, capped до `99`. |
| `h2`, `h1`, `00` | ALPN marker: первый и последний символ первого ALPN value или `00`. |
| `cipher_hash` | SHA256 от отсортированного списка ciphers, первые 12 hex chars. |
| `extension_hash` | SHA256 от отсортированных extensions плюс signature algorithms, первые 12 hex chars. |
Важное отличие JA4 от JA3: JA4 нормализует часть полей, поэтому он устойчивее к простому изменению порядка extensions. Это делает JA4 удобным для фильтров и одновременно полезным для диагностики таких фильтров.
## Где Telemt видит ClientHello
В TLS/FakeTLS режиме Telemt получает первые bytes соединения и определяет, похоже ли оно на TLS handshake. Если record является полным ClientHello и проходит bounds checks, Telemt один раз парсит его для JA3/JA4.
Дальше возможны три исхода:
1. **Успешный MTProxy/FakeTLS клиент**
- Telemt принимает TLS-auth;
- fingerprint записывается в global/IP/CIDR scopes;
- после успешной TLS-auth Telemt добавляет user scope.
2. **Bad client или probe**
- ClientHello полный, но auth не проходит;
- fingerprint записывается в global/IP/CIDR scopes;
- user scope не записывается;
- `bad_or_probe` увеличивается.
3. **Неполный или обрезанный ClientHello**
- fingerprint не считается;
- такие случаи остаются в существующих bad-class counters.
Если фильтр режет трафик до того, как TCP connection или ClientHello дошли до процесса Telemt, Telemt не увидит этот fingerprint. Это важнейшее диагностическое отличие: отсутствие fingerprint'а во время жалобы пользователя часто означает блокировку до приложения, а не проблему внутри Telemt.
## Включение сбора
Collector включается, когда включён хотя бы один потребитель:
```toml
[general]
beobachten = true
beobachten_minutes = 10
```
или:
```toml
[server.api]
runtime_edge_enabled = true
runtime_edge_top_n = 50
```
Практически:
- для файлового/metrics endpoint анализа достаточно `general.beobachten=true`;
- для API snapshot нужен `server.api.runtime_edge_enabled=true`;
- `general.beobachten_minutes` задаёт retention window для fingerprint buckets;
- `server.api.runtime_edge_top_n` задаёт default Top-N размер API snapshot.
## API snapshot
Endpoint:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints
```
С явным лимитом:
```bash
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100'
```
Если API защищён header'ом:
```bash
curl -s \
-H 'Authorization: Bearer YOUR_TOKEN' \
'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100'
```
Если `runtime_edge_enabled=false`, endpoint возвращает payload с:
```json
{
"enabled": false,
"reason": "feature_disabled"
}
```
### Структура payload
Основные поля:
| Поле | Смысл |
| --- | --- |
| `retention_secs` | Текущее TTL окно collector'а. |
| `capacity` | Максимум retained buckets. |
| `dropped_total` | Сколько новых buckets отброшено из-за cap. |
| `parse_error_total` | Сколько полных ClientHello не удалось распарсить. |
| `by_fingerprint` | Top fingerprints глобально. |
| `by_ip` | Top fingerprints по exact source IP. |
| `by_cidr` | Top fingerprints по source prefix: IPv4 `/24`, IPv6 `/56`. |
| `by_user` | Top fingerprints по authenticated user. |
Строка snapshot:
| Поле | Смысл |
| --- | --- |
| `scope` | IP, CIDR или username. В `by_fingerprint` отсутствует. |
| `ja3` | JA3 hash. |
| `ja3_raw` | Raw JA3 string. |
| `ja4` | JA4 TLS client fingerprint. |
| `ja4_raw` | Raw JA4 material. |
| `total` | Сколько полных ClientHello попало в этот bucket. |
| `auth_success` | Сколько из них успешно прошли TLS-auth. |
| `bad_or_probe` | Сколько были bad/probe после полного ClientHello. |
| `first_seen_epoch_secs` | Первый timestamp bucket'а. |
| `last_seen_epoch_secs` | Последний timestamp bucket'а. |
### Быстрый просмотр через jq
Top JA4 глобально:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints \
| jq -r '.data.data.by_fingerprint[] | [.ja4, .total, .auth_success, .bad_or_probe] | @tsv'
```
Top JA4 по пользователям:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100 \
| jq -r '.data.data.by_user[] | [.scope, .ja4, .total, .auth_success] | @tsv'
```
Top JA4 по CIDR:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=100 \
| jq -r '.data.data.by_cidr[] | [.scope, .ja4, .total, .auth_success, .bad_or_probe] | @tsv'
```
Ошибки парсинга и drops:
```bash
curl -s http://127.0.0.1:9091/v1/runtime/tls-fingerprints \
| jq '.data.data | {retention_secs, capacity, dropped_total, parse_error_total}'
```
## Beobachten output
Если включён endpoint metrics, `/beobachten` содержит обычные forensic buckets и, когда есть данные, append-only секцию TLS fingerprints:
```bash
curl -s http://127.0.0.1:9090/beobachten
```
Фрагмент:
```text
[tls_fingerprints]
retention_secs=600 capacity=65536 dropped_total=0 parse_error_total=0
[tls_fingerprints.by_fingerprint]
ja4=t13d1516h2_8daaf6152771_e5627efa2ab1 ja3=... total=42 auth_success=41 bad_or_probe=1 first_seen=... last_seen=...
[tls_fingerprints.by_cidr]
scope=203.0.113.0/24 ja4=t13d1516h2_8daaf6152771_e5627efa2ab1 ja3=... total=10 auth_success=10 bad_or_probe=0 first_seen=... last_seen=...
```
`/beobachten` удобен для быстрой операторской диагностики без API client. API удобнее для автоматической корреляции.
## Как анализировать JA4-based блокировку
### 1. Зафиксировать симптом
Перед анализом нужно записать:
- какие пользователи жалуются;
- какая версия Telegram client используется;
- какая платформа: Desktop, Android, iOS;
- какой источник сети: mobile ISP, home ISP, corporate network, country/region;
- работает ли тот же пользователь через другой network path;
- работает ли другой пользователь с того же IP/CIDR;
- видит ли Telemt новые ClientHello от проблемного пользователя в момент попытки.
JA4 без контекста почти всегда недостаточен. Фильтры часто используют сочетание:
- JA4;
- destination IP;
- SNI;
- порт;
- ASN/source network;
- rate или connection pattern;
- reputation домена/IP;
- active probing result.
### 2. Проверить, доходит ли ClientHello до Telemt
Во время попытки подключения проблемного пользователя смотрите:
```bash
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=200' \
| jq '.data.data.by_user, .data.data.by_ip, .data.data.by_cidr'
```
Интерпретация:
| Наблюдение | Вероятный вывод |
| --- | --- |
| Нет новых rows для IP/CIDR пользователя | Блокировка до Telemt: routing, firewall, ISP/DPI drop, IP block, SYN/TCP reset, UDP/TCP path issue. |
| Есть `by_ip`/`by_cidr`, но нет `by_user` | ClientHello дошёл, но TLS-auth/MTProxy layer не дошёл до успешного пользователя. Возможны bad key, probe, wrong client, active scanner, обрыв после ClientHello. |
| Есть `by_user.auth_success` | Клиентский JA4 дошёл и был принят Telemt. Если пользователь всё равно видит проблему, искать нужно дальше: relay path, Telegram upstream, quota, route mode, session cancellation, ME/direct routing. |
| Резко растёт `bad_or_probe` для одного JA4 | Вероятны сканеры или неправильные клиенты с тем же fingerprint family. |
### 3. Сравнить working и blocked случаи
Снимите snapshot во время working case и blocked case:
```bash
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=500' > tls-fp-working.json
curl -s 'http://127.0.0.1:9091/v1/runtime/tls-fingerprints?limit=500' > tls-fp-blocked.json
```
Сравните:
- появился ли тот же `ja4` в blocked сети;
- меняется ли `ja4` между версиями клиента;
- меняется ли только IP/CIDR при том же `ja4`;
- есть ли `auth_success` для того же `ja4` из других сетей;
- отличается ли `bad_or_probe` между сетями.
Ключевая матрица:
| Working JA4 | Blocked JA4 | Вывод |
| --- | --- | --- |
| Same | Same, но blocked network не доходит до Telemt | Вероятна фильтрация по JA4 + destination/IP/SNI/network до приложения. |
| Same | Same, доходит и `auth_success>0` | JA4 ClientHello не является точкой отказа; искать post-auth проблему. |
| Different | Blocked только один JA4 | Вероятен client-version/platform-specific fingerprint block. |
| Same | `bad_or_probe` растёт, `auth_success=0` | Возможно, доходит не тот клиент/secret или фильтр/прокси ломает поток после ClientHello. |
### 4. Разделить client JA4 и server fingerprint
JA4 ClientHello - это клиентская сторона. Настройки Telemt вроде TLS-front server flight, `mask_host`, ticket-tail или CCS replay не меняют ClientHello, который отправляет Telegram client.
Если фильтр принимает решение строго после ClientHello, то серверные улучшения могут не помочь. В этом случае полезные действия:
- проверить обновление Telegram client;
- сравнить платформы и версии клиента;
- проверить, меняется ли JA4 на другой версии;
- проверить, блокируется ли тот же JA4 к другому destination;
- проверить, блокируется ли другой JA4 к тому же Telemt IP/SNI;
- собрать evidence для client-side fingerprint fix.
Если ClientHello проходит, а блокировка возникает после server response, тогда уже важны:
- форма FakeTLS server flight;
- TLS front profile fidelity;
- `mask_host` поведение для non-auth clients;
- certificate/provenance fallback для сканеров;
- TCP relay behavior;
- upstream route к Telegram.
### 5. Коррелировать с packet capture
Telemt collector показывает только то, что процесс увидел. Для подтверждения фильтрации до Telemt нужен внешний capture.
На сервере:
```bash
sudo tcpdump -i any -w telemt-clienthello.pcap host CLIENT_IP and port 443
```
Быстрый tshark вывод ClientHello fields:
```bash
tshark -r telemt-clienthello.pcap -Y "tls.handshake.type == 1" -T fields \
-e frame.time_epoch \
-e ip.src \
-e ip.dst \
-e tcp.srcport \
-e tcp.dstport \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extensions_alpn_str
```
Если на клиентской стороне capture видит ClientHello, а серверный capture не видит, проблема в сети между клиентом и сервером. Если серверный capture видит ClientHello, но Telemt API не видит fingerprint, проверьте порт, listener, PROXY protocol, TLS record fragmentation и bounds/errors.
## Практические сценарии
### Сценарий A: один JA4 перестал работать у многих пользователей
Признаки:
- один `ja4` доминирует в жалобах;
- у разных source CIDR нет `auth_success`;
- working пользователи используют другой JA4;
- обновление клиента меняет поведение.
Вероятный вывод: фильтр на стороне сети научился распознавать конкретный ClientHello family.
Действия:
- сравнить Telegram client versions;
- проверить, не используют ли пользователи старые клиенты;
- собрать `ja4`, `ja4_raw`, platform/version, source network;
- проверить тот же client через другую сеть;
- проверить другой client version через ту же сеть.
### Сценарий B: один CIDR не работает, JA4 обычный
Признаки:
- тот же `ja4` успешно работает из других сетей;
- проблемный `/24` или `/56` не доходит до Telemt или не получает `auth_success`;
- нет общей корреляции по версии клиента.
Вероятный вывод: проблема не в JA4 alone, а в source network policy или destination reputation.
Действия:
- сменить route/VPS/IP;
- проверить port;
- проверить SNI/domain reputation;
- сравнить с другим Telemt endpoint;
- смотреть server-side packet capture.
### Сценарий C: много `bad_or_probe` на одном JA4
Признаки:
- `bad_or_probe` высокий;
- `by_user` пустой или слабый;
- source IP/CIDR разнообразные;
- попытки не соответствуют реальным пользователям.
Вероятный вывод: активное сканирование или нерелевантный TLS traffic с похожим ClientHello.
Действия:
- смотреть `/beobachten` по IP classes;
- проверить `unknown_tls_sni` и bad-client counters;
- убедиться, что fallback `mask_host` отвечает правдоподобно;
- не делать вывод о блокировке пользователей только по global `bad_or_probe`.
### Сценарий D: `auth_success` есть, но пользователь жалуется
Признаки:
- fingerprint присутствует в `by_user`;
- `auth_success` растёт;
- соединение проходит TLS-auth.
Вероятный вывод: JA4 ClientHello не является причиной отказа в этом случае.
Действия:
- проверить user enabled/disabled status;
- проверить quota;
- проверить direct/ME route;
- проверить upstream health;
- проверить runtime events;
- смотреть relay/session logs.
## Что нельзя вывести из JA3/JA4
JA3/JA4 не говорят:
- почему сеть приняла решение о блокировке;
- какой именно vendor DPI используется;
- был ли block только по JA4 или по связке JA4+IP+SNI;
- что произошло с соединением после TLS-auth;
- как выглядит server-side TLS fingerprint;
- как ведёт себя HTTP layer после TLS.
JA3/JA4 также не являются уникальной идентичностью человека. Это fingerprint клиентской TLS-реализации и её настроек. Один fingerprint может быть у большого числа пользователей.
## Ограничения collector'а Telemt
- Считается только TLS ClientHello, который полностью дошёл до Telemt.
- QUIC/DTLS/HTTP JA4 variants не собираются.
- Truncated ClientHello не fingerprint'ится.
- User scope появляется только после успешной TLS-auth.
- `by_ip` и `by_cidr` отражают source address после нормализации/PROXY protocol path, если он используется.
- Collector bounded: при большом количестве уникальных buckets возможен рост `dropped_total`.
- Retention зависит от `general.beobachten_minutes`.
- Данные runtime in-memory; это snapshot для диагностики, а не долговременное хранилище.
## Рекомендованный workflow расследования
1. Включить `runtime_edge_enabled=true` и разумный `runtime_edge_top_n`, например `100`.
2. Зафиксировать baseline в период нормальной работы.
3. Во время жалобы снять API snapshot и `/beobachten`.
4. Сравнить `by_user`, `by_ip`, `by_cidr`, `by_fingerprint`.
5. Проверить, появляется ли problematic source в Telemt вообще.
6. Если не появляется, снять packet capture на сервере и клиенте.
7. Если появляется без `auth_success`, проверить secret/client/proxy link и bad/probe counters.
8. Если появляется с `auth_success`, исключить JA4 ClientHello как primary cause и перейти к relay/upstream/runtime диагностике.
9. Если один JA4 стабильно коррелирует с block, собрать client version/platform evidence.
10. Проверить, меняет ли обновление клиента JA4 и результат подключения.
## Минимальный incident report
Для полезного отчёта по JA4-based блокировке соберите:
```text
time_window:
telemt_version:
server_ip:
server_port:
tls_domain:
mask_host:
client_platform:
client_version:
source_network:
source_ip_or_cidr:
ja4:
ja4_raw:
ja3:
total:
auth_success:
bad_or_probe:
seen_in_by_user: yes/no
seen_in_by_ip: yes/no
seen_in_by_cidr: yes/no
server_tcpdump_seen_clienthello: yes/no
client_tcpdump_sent_clienthello: yes/no
works_from_other_network: yes/no
works_with_other_client_version: yes/no
```
Этот набор обычно достаточен, чтобы отличить client fingerprint block от IP/SNI/reputation block и от post-auth проблем Telemt.
## Источники форматов
- JA3 reference: https://github.com/salesforce/ja3
- JA4 technical details: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
+200 -179
View File
@@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`.
# Table of contents # Table of contents
- [Top-level keys](#top-level-keys) - [Top-level keys](#top-level-keys)
- [logging](#logging)
- [general](#general) - [general](#general)
- [general.modes](#generalmodes) - [general.modes](#generalmodes)
- [general.links](#generallinks) - [general.links](#generallinks)
@@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`.
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`include`](#include) | `String` (special directive) | — | `✔` | | [`include`](#include) | `String` (special directive) | — | `✔` |
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` | | [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
| [`logging`](#logging) | Table | default values | `✘` |
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` | | [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` | | [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` |
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` | | [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
@@ -83,147 +85,87 @@ This document lists all configuration keys accepted by `config.toml`.
default_dc = 2 default_dc = 2
``` ```
# [logging]
| Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- |
| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `` |
| [`path`](#loggingpath) | `String` | — | `` |
| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `` |
| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `` |
| [`max_files`](#loggingmax_files) | `usize` | `0` | `` |
| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `` |
## logging.destination
- **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`.
- **Description**: Selects the runtime log destination. CLI flags override this value.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
```
## logging.path
- **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty.
- **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
```
## logging.rotation
- **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`.
- **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
```
## logging.max_size_bytes
- **Constraints / validation**: `0` disables size rotation.
- **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
max_size_bytes = 104857600
```
## logging.max_files
- **Constraints / validation**: `0` disables count-based retention.
- **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
max_files = 14
```
## logging.max_age_secs
- **Constraints / validation**: `0` disables age-based retention.
- **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
max_age_secs = 1209600
```
# [general] # [general]
| Key | Type | Default |
| --- | ---- | ------- |
| [`data_path`](#data_path) | `String` | — |
| [`prefer_ipv6`](#prefer_ipv6) | `bool` | `false` |
| [`fast_mode`](#fast_mode) | `bool` | `true` |
| [`use_middle_proxy`](#use_middle_proxy) | `bool` | `true` |
| [`proxy_secret_path`](#proxy_secret_path) | `String` | `"proxy-secret"` |
| [`proxy_config_v4_cache_path`](#proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` |
| [`proxy_config_v6_cache_path`](#proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` |
| [`ad_tag`](#ad_tag) | `String` | — |
| [`middle_proxy_nat_ip`](#middle_proxy_nat_ip) | `IpAddr` | — |
| [`middle_proxy_nat_probe`](#middle_proxy_nat_probe) | `bool` | `true` |
| [`middle_proxy_nat_stun`](#middle_proxy_nat_stun) | `String` | — |
| [`middle_proxy_nat_stun_servers`](#middle_proxy_nat_stun_servers) | `String[]` | `[]` |
| [`stun_nat_probe_concurrency`](#stun_nat_probe_concurrency) | `usize` | `8` |
| [`middle_proxy_pool_size`](#middle_proxy_pool_size) | `usize` | `8` |
| [`middle_proxy_warm_standby`](#middle_proxy_warm_standby) | `usize` | `16` |
| [`me_init_retry_attempts`](#me_init_retry_attempts) | `u32` | `0` |
| [`me2dc_fallback`](#me2dc_fallback) | `bool` | `true` |
| [`me2dc_fast`](#me2dc_fast) | `bool` | `true` |
| [`me_keepalive_enabled`](#me_keepalive_enabled) | `bool` | `true` |
| [`me_keepalive_interval_secs`](#me_keepalive_interval_secs) | `u64` | `8` |
| [`me_keepalive_jitter_secs`](#me_keepalive_jitter_secs) | `u64` | `2` |
| [`me_keepalive_payload_random`](#me_keepalive_payload_random) | `bool` | `true` |
| [`rpc_proxy_req_every`](#rpc_proxy_req_every) | `u64` | `0` |
| [`me_writer_cmd_channel_capacity`](#me_writer_cmd_channel_capacity) | `usize` | `4096` |
| [`me_route_channel_capacity`](#me_route_channel_capacity) | `usize` | `768` |
| [`me_c2me_channel_capacity`](#me_c2me_channel_capacity) | `usize` | `1024` |
| [`me_c2me_send_timeout_ms`](#me_c2me_send_timeout_ms) | `u64` | `4000` |
| [`me_reader_route_data_wait_ms`](#me_reader_route_data_wait_ms) | `u64` | `2` |
| [`me_d2c_flush_batch_max_frames`](#me_d2c_flush_batch_max_frames) | `usize` | `32` |
| [`me_d2c_flush_batch_max_bytes`](#me_d2c_flush_batch_max_bytes) | `usize` | `131072` |
| [`me_d2c_flush_batch_max_delay_us`](#me_d2c_flush_batch_max_delay_us) | `u64` | `500` |
| [`me_d2c_ack_flush_immediate`](#me_d2c_ack_flush_immediate) | `bool` | `true` |
| [`me_quota_soft_overshoot_bytes`](#me_quota_soft_overshoot_bytes) | `u64` | `65536` |
| [`me_d2c_frame_buf_shrink_threshold_bytes`](#me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` |
| [`direct_relay_copy_buf_c2s_bytes`](#direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` |
| [`direct_relay_copy_buf_s2c_bytes`](#direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` |
| [`crypto_pending_buffer`](#crypto_pending_buffer) | `usize` | `262144` |
| [`max_client_frame`](#max_client_frame) | `usize` | `16777216` |
| [`desync_all_full`](#desync_all_full) | `bool` | `false` |
| [`beobachten`](#beobachten) | `bool` | `true` |
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` |
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` |
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` |
| [`hardswap`](#hardswap) | `bool` | `true` |
| [`me_warmup_stagger_enabled`](#me_warmup_stagger_enabled) | `bool` | `true` |
| [`me_warmup_step_delay_ms`](#me_warmup_step_delay_ms) | `u64` | `500` |
| [`me_warmup_step_jitter_ms`](#me_warmup_step_jitter_ms) | `u64` | `300` |
| [`me_reconnect_max_concurrent_per_dc`](#me_reconnect_max_concurrent_per_dc) | `u32` | `8` |
| [`me_reconnect_backoff_base_ms`](#me_reconnect_backoff_base_ms) | `u64` | `500` |
| [`me_reconnect_backoff_cap_ms`](#me_reconnect_backoff_cap_ms) | `u64` | `30000` |
| [`me_reconnect_fast_retry_count`](#me_reconnect_fast_retry_count) | `u32` | `16` |
| [`me_single_endpoint_shadow_writers`](#me_single_endpoint_shadow_writers) | `u8` | `2` |
| [`me_single_endpoint_outage_mode_enabled`](#me_single_endpoint_outage_mode_enabled) | `bool` | `true` |
| [`me_single_endpoint_outage_disable_quarantine`](#me_single_endpoint_outage_disable_quarantine) | `bool` | `true` |
| [`me_single_endpoint_outage_backoff_min_ms`](#me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` |
| [`me_single_endpoint_outage_backoff_max_ms`](#me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` |
| [`me_single_endpoint_shadow_rotate_every_secs`](#me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` |
| [`me_floor_mode`](#me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` |
| [`me_adaptive_floor_idle_secs`](#me_adaptive_floor_idle_secs) | `u64` | `90` |
| [`me_adaptive_floor_min_writers_single_endpoint`](#me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` |
| [`me_adaptive_floor_min_writers_multi_endpoint`](#me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` |
| [`me_adaptive_floor_recover_grace_secs`](#me_adaptive_floor_recover_grace_secs) | `u64` | `180` |
| [`me_adaptive_floor_writers_per_core_total`](#me_adaptive_floor_writers_per_core_total) | `u16` | `48` |
| [`me_adaptive_floor_cpu_cores_override`](#me_adaptive_floor_cpu_cores_override) | `u16` | `0` |
| [`me_adaptive_floor_max_extra_writers_single_per_core`](#me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` |
| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` |
| [`me_adaptive_floor_max_active_writers_per_core`](#me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` |
| [`me_adaptive_floor_max_warm_writers_per_core`](#me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` |
| [`me_adaptive_floor_max_active_writers_global`](#me_adaptive_floor_max_active_writers_global) | `u32` | `256` |
| [`me_adaptive_floor_max_warm_writers_global`](#me_adaptive_floor_max_warm_writers_global) | `u32` | `256` |
| [`upstream_connect_retry_attempts`](#upstream_connect_retry_attempts) | `u32` | `2` |
| [`upstream_connect_retry_backoff_ms`](#upstream_connect_retry_backoff_ms) | `u64` | `100` |
| [`upstream_connect_budget_ms`](#upstream_connect_budget_ms) | `u64` | `3000` |
| [`upstream_unhealthy_fail_threshold`](#upstream_unhealthy_fail_threshold) | `u32` | `5` |
| [`upstream_connect_failfast_hard_errors`](#upstream_connect_failfast_hard_errors) | `bool` | `false` |
| [`stun_iface_mismatch_ignore`](#stun_iface_mismatch_ignore) | `bool` | `false` |
| [`unknown_dc_log_path`](#unknown_dc_log_path) | `String` | `"unknown-dc.txt"` |
| [`unknown_dc_file_log_enabled`](#unknown_dc_file_log_enabled) | `bool` | `false` |
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
| [`disable_colors`](#disable_colors) | `bool` | `false` |
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` |
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` |
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
| [`me_health_interval_ms_unhealthy`](#me_health_interval_ms_unhealthy) | `u64` | `1000` |
| [`me_health_interval_ms_healthy`](#me_health_interval_ms_healthy) | `u64` | `3000` |
| [`me_admission_poll_ms`](#me_admission_poll_ms) | `u64` | `1000` |
| [`me_warn_rate_limit_ms`](#me_warn_rate_limit_ms) | `u64` | `5000` |
| [`me_route_no_writer_mode`](#me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` |
| [`me_route_no_writer_wait_ms`](#me_route_no_writer_wait_ms) | `u64` | `250` |
| [`me_route_hybrid_max_wait_ms`](#me_route_hybrid_max_wait_ms) | `u64` | `3000` |
| [`me_route_blocking_send_timeout_ms`](#me_route_blocking_send_timeout_ms) | `u64` | `250` |
| [`me_route_inline_recovery_attempts`](#me_route_inline_recovery_attempts) | `u32` | `3` |
| [`me_route_inline_recovery_wait_ms`](#me_route_inline_recovery_wait_ms) | `u64` | `3000` |
| [`fast_mode_min_tls_record`](#fast_mode_min_tls_record) | `usize` | `0` |
| [`update_every`](#update_every) | `u64` | `300` |
| [`me_reinit_every_secs`](#me_reinit_every_secs) | `u64` | `900` |
| [`me_hardswap_warmup_delay_min_ms`](#me_hardswap_warmup_delay_min_ms) | `u64` | `1000` |
| [`me_hardswap_warmup_delay_max_ms`](#me_hardswap_warmup_delay_max_ms) | `u64` | `2000` |
| [`me_hardswap_warmup_extra_passes`](#me_hardswap_warmup_extra_passes) | `u8` | `3` |
| [`me_hardswap_warmup_pass_backoff_base_ms`](#me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` |
| [`me_config_stable_snapshots`](#me_config_stable_snapshots) | `u8` | `2` |
| [`me_config_apply_cooldown_secs`](#me_config_apply_cooldown_secs) | `u64` | `300` |
| [`me_snapshot_require_http_2xx`](#me_snapshot_require_http_2xx) | `bool` | `true` |
| [`me_snapshot_reject_empty_map`](#me_snapshot_reject_empty_map) | `bool` | `true` |
| [`me_snapshot_min_proxy_for_lines`](#me_snapshot_min_proxy_for_lines) | `u32` | `1` |
| [`proxy_secret_stable_snapshots`](#proxy_secret_stable_snapshots) | `u8` | `2` |
| [`proxy_secret_rotate_runtime`](#proxy_secret_rotate_runtime) | `bool` | `true` |
| [`me_secret_atomic_snapshot`](#me_secret_atomic_snapshot) | `bool` | `true` |
| [`proxy_secret_len_max`](#proxy_secret_len_max) | `usize` | `256` |
| [`me_pool_drain_ttl_secs`](#me_pool_drain_ttl_secs) | `u64` | `90` |
| [`me_instadrain`](#me_instadrain) | `bool` | `false` |
| [`me_pool_drain_threshold`](#me_pool_drain_threshold) | `u64` | `32` |
| [`me_pool_drain_soft_evict_enabled`](#me_pool_drain_soft_evict_enabled) | `bool` | `true` |
| [`me_pool_drain_soft_evict_grace_secs`](#me_pool_drain_soft_evict_grace_secs) | `u64` | `10` |
| [`me_pool_drain_soft_evict_per_writer`](#me_pool_drain_soft_evict_per_writer) | `u8` | `2` |
| [`me_pool_drain_soft_evict_budget_per_core`](#me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` |
| [`me_pool_drain_soft_evict_cooldown_ms`](#me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` |
| [`me_bind_stale_mode`](#me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` |
| [`me_bind_stale_ttl_secs`](#me_bind_stale_ttl_secs) | `u64` | `90` |
| [`me_pool_min_fresh_ratio`](#me_pool_min_fresh_ratio) | `f32` | `0.8` |
| [`me_reinit_drain_timeout_secs`](#me_reinit_drain_timeout_secs) | `u64` | `90` |
| [`proxy_secret_auto_reload_secs`](#proxy_secret_auto_reload_secs) | `u64` | `3600` |
| [`proxy_config_auto_reload_secs`](#proxy_config_auto_reload_secs) | `u64` | `3600` |
| [`me_reinit_singleflight`](#me_reinit_singleflight) | `bool` | `true` |
| [`me_reinit_trigger_channel`](#me_reinit_trigger_channel) | `usize` | `64` |
| [`me_reinit_coalesce_window_ms`](#me_reinit_coalesce_window_ms) | `u64` | `200` |
| [`me_deterministic_writer_sort`](#me_deterministic_writer_sort) | `bool` | `true` |
| [`me_writer_pick_mode`](#me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` |
| [`me_writer_pick_sample_size`](#me_writer_pick_sample_size) | `u8` | `3` |
| [`ntp_check`](#ntp_check) | `bool` | `true` |
| [`ntp_servers`](#ntp_servers) | `String[]` | `["pool.ntp.org"]` |
| [`auto_degradation_enabled`](#auto_degradation_enabled) | `bool` | `true` |
| [`degradation_min_unavailable_dc_groups`](#degradation_min_unavailable_dc_groups) | `u8` | `2` |
| [`rst_on_close`](#rst_on_close) | `"off"`, `"errors"`, or `"always"` | `"off"` |
| Key | Type | Default | Hot-Reload | | Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`data_path`](#data_path) | `String` | — | `` | | [`data_path`](#data_path) | `String` | — | `` |
@@ -770,7 +712,7 @@ This document lists all configuration keys accepted by `config.toml`.
``` ```
## beobachten ## beobachten
- **Constraints / validation**: `bool`. - **Constraints / validation**: `bool`.
- **Description**: Enables per-IP forensic observation buckets. - **Description**: Enables per-IP forensic observation buckets and appends TLS JA3/JA4 fingerprint snapshots to Beobachten output when available.
- **Example**: - **Example**:
```toml ```toml
@@ -779,7 +721,7 @@ This document lists all configuration keys accepted by `config.toml`.
``` ```
## beobachten_minutes ## beobachten_minutes
- **Constraints / validation**: Must be `> 0` (minutes). - **Constraints / validation**: Must be `> 0` (minutes).
- **Description**: Retention window (minutes) for per-IP observation buckets. - **Description**: Retention window (minutes) for per-IP observation buckets and in-memory TLS fingerprint buckets.
- **Example**: - **Example**:
```toml ```toml
@@ -1943,6 +1885,8 @@ This document lists all configuration keys accepted by `config.toml`.
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `` | | [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `` |
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` | | [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` | | [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` |
| [`client_mss`](#client_mss) | `String` | `""` | `` |
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` | | [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` | | [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` |
@@ -2025,6 +1969,26 @@ This document lists all configuration keys accepted by `config.toml`.
listen_unix_sock = "/run/telemt.sock" listen_unix_sock = "/run/telemt.sock"
listen_tcp = true listen_tcp = true
``` ```
## client_mss
- **Constraints / validation**: `String`. Empty or omitted means do not change kernel MSS. Presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Custom decimal strings must be within `88..=4096`.
- **Description**: Client-facing TCP MSS applied to TCP listener sockets before `listen(2)`, so Linux can announce it in SYN/ACK. This affects only proxy client TCP listeners, not API, metrics, Unix sockets, Telegram upstreams, ME sockets, or mask backend connections. Changes require listener restart/rebind.
- **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`.
- **Example**:
```toml
[server]
client_mss = "tspu"
```
## client_mss_bulk
- **Constraints / validation**: `String`. Same grammar as [`client_mss`](#client_mss) (empty/omitted, presets `"extreme-low"`/`"tspu"`/`"2in8"`, or a decimal in `88..=4096`).
- **Description**: Optional bulk-phase MSS. When set, the low `client_mss` is applied only while the TLS handshake (including the DPI-inspected ServerHello) is sent; once the connection transitions to relaying, the client socket MSS is raised to `client_mss_bulk` for the bulk data phase. This keeps the anti-DPI handshake fragmentation but restores normal-size packets for payload, cutting outgoing packets-per-second by roughly the `client_mss` segment multiplier (e.g. ~10x with `"tspu"`). Useful on hosts whose abuse detection counts packets-per-second rather than bandwidth. When empty/omitted, the handshake MSS is kept for the whole connection (previous behavior). Linux only; a no-op elsewhere.
- **Example**:
```toml
[server]
client_mss = "tspu"
client_mss_bulk = "1400"
```
## proxy_protocol ## proxy_protocol
- **Constraints / validation**: `bool`. - **Constraints / validation**: `bool`.
- **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header. - **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header.
@@ -2311,7 +2275,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
``` ```
## runtime_edge_top_n ## runtime_edge_top_n
- **Constraints / validation**: `1..=1000`. - **Constraints / validation**: `1..=1000`.
- **Description**: Top-N size for edge connection leaderboard. - **Description**: Top-N size for edge connection and TLS fingerprint leaderboard snapshots.
- **Example**: - **Example**:
```toml ```toml
@@ -2345,6 +2309,11 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`ip`](#ip) | `IpAddr` | — | `` | | [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `` |
| [`announce`](#announce) | `String` | — | `` | | [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2369,6 +2338,69 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
ip = "0.0.0.0" ip = "0.0.0.0"
port = 443 port = 443
``` ```
## client_mss (server.listeners)
- **Constraints / validation**: `String` (optional). Same values as `[server].client_mss`.
- **Description**: Per-listener MSS override. When omitted, inherits `[server].client_mss`; when set to an empty string, disables MSS shaping for this listener even if the global value is set. Changes require listener restart/rebind.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
client_mss = "256"
```
## synlimit (server.listeners)
- **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener.
- **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
[[server.listeners]]
ip = "::"
port = 443
synlimit = "nftables"
```
## synlimit_seconds (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
- **Description**: Token-bucket interval for both SYN limiter backends. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`).
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_seconds = 1
```
## synlimit_hitcount (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
- **Description**: Token-bucket rate amount for both SYN limiter backends. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets are dropped.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hitcount = 1
```
## synlimit_burst (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `2`.
- **Description**: Token-bucket burst size for both SYN limiter backends. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_burst = 2
```
## announce ## announce
- **Constraints / validation**: `String` (optional). Must not be empty when set. - **Constraints / validation**: `String` (optional). Must not be empty when set.
- **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`. - **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`.
@@ -2525,41 +2557,6 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
# [censorship] # [censorship]
| Key | Type | Default |
| --- | ---- | ------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` |
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` |
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
| [`mask`](#mask) | `bool` | `true` |
| [`mask_host`](#mask_host) | `String` | — |
| [`mask_port`](#mask_port) | `u16` | `443` |
| [`exclusive_mask`](#exclusive_mask) | `Map<String,String>` | `{}` |
| [`mask_unix_sock`](#mask_unix_sock) | `String` | — |
| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` |
| [`tls_emulation`](#tls_emulation) | `bool` | `true` |
| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` |
| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` |
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` |
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` |
| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` |
| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` |
| [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` |
| [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` |
| [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` |
| [`mask_relay_timeout_ms`](#mask_relay_timeout_ms) | `u64` | `60_000` |
| [`mask_relay_idle_timeout_ms`](#mask_relay_idle_timeout_ms) | `u64` | `5_000` |
| [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` |
| [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` |
| [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` |
| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` |
| Key | Type | Default | Hot-Reload | | Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `` | | [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `` |
@@ -3107,6 +3104,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
| Key | Type | Default | Hot-Reload | | Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` | `` | | [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` | `` |
| [`user_enabled`](#user_enabled-1) | `Map<String, bool>` | `{}` | `` |
| [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` | `` | | [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` | `` |
| [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` | `` | | [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` | `` |
| [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` | `` | | [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` | `` |
@@ -3133,6 +3131,16 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
alice = "00112233445566778899aabbccddeeff" alice = "00112233445566778899aabbccddeeff"
bob = "0123456789abcdef0123456789abcdef" bob = "0123456789abcdef0123456789abcdef"
``` ```
## user_enabled
- **Constraints / validation**: `Map<String, bool>`.
- **Description**: Optional per-user enable overrides. Missing users are enabled by default. A value of `false` disables new sessions for that user; setting the value to `true` is accepted but equivalent to removing the override. API enable operations remove the override, while disable operations write `false`.
- **Runtime behavior**: Hot reload applies this map immediately. Users disabled through API or config reload are rejected after successful authentication and active runtime sessions for that username are cancelled.
- **Example**:
```toml
[access.user_enabled]
alice = false
```
## user_ad_tags ## user_ad_tags
- **Constraints / validation**: Each value must be **exactly 32 hex characters** (same format as `general.ad_tag`). An all-zero tag is allowed but logs a warning. - **Constraints / validation**: Each value must be **exactly 32 hex characters** (same format as `general.ad_tag`). An all-zero tag is allowed but logs a warning.
- **Description**: Per-user sponsored-channel ad tag override. When a user has an entry here, it takes precedence over `general.ad_tag`. - **Description**: Per-user sponsored-channel ad tag override. When a user has an entry here, it takes precedence over `general.ad_tag`.
@@ -3266,7 +3274,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
## user_rate_limits ## user_rate_limits
- **Constraints / validation**: Table `username -> { up_bps, down_bps }`. At least one direction must be non-zero. - **Constraints / validation**: Table `username -> { up_bps, down_bps }`. At least one direction must be non-zero.
- **Description**: Per-user bandwidth caps in bytes/sec for upload (`up_bps`) and download (`down_bps`). - **Description**: Per-user bandwidth caps in bits/sec for upload (`up_bps`) and download (`down_bps`).
- **Example**: - **Example**:
```toml ```toml
@@ -3293,6 +3301,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
| [`scopes`](#scopes) | `String` | `""` | `` | | [`scopes`](#scopes) | `String` | `""` | `` |
| [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `` | | [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `` |
| [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `` | | [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `` |
| [`prefer`](#prefer-upstreams) | `4` or `6` | effective `[network].prefer` | `` |
| [`interface`](#interface) | `String` | — | `` | | [`interface`](#interface) | `String` | — | `` |
| [`bind_addresses`](#bind_addresses) | `String[]` | — | `` | | [`bind_addresses`](#bind_addresses) | `String[]` | — | `` |
| [`bindtodevice`](#bindtodevice) | `String` | — | `` | | [`bindtodevice`](#bindtodevice) | `String` | — | `` |
@@ -3364,7 +3373,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
``` ```
## ipv6 (upstreams) ## ipv6 (upstreams)
- **Constraints / validation**: `bool` (optional). - **Constraints / validation**: `bool` (optional).
- **Description**: Allows IPv6 DC targets for this upstream. When omitted, Telemt auto-detects support from runtime connectivity state. - **Description**: Allows IPv6 DC targets for this upstream. When omitted, Telemt auto-detects support from runtime connectivity state. Set this to `true` when the upstream proxy is reachable from the local host over IPv4 but the proxy itself can connect to Telegram DCs over IPv6.
- **Example**: - **Example**:
```toml ```toml
@@ -3372,6 +3381,18 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
type = "direct" type = "direct"
ipv6 = false ipv6 = false
``` ```
## prefer (upstreams)
- **Constraints / validation**: Optional integer. Must be `4` or `6`.
- **Description**: Overrides the IP family preference for Telegram DC targets selected through this upstream. When omitted, the upstream inherits the effective global `[network].prefer` decision. Use `prefer = 6` together with `ipv6 = true` for a SOCKS or Shadowsocks upstream that can egress over IPv6 even when the local Telemt host is IPv4-only.
- **Example**:
```toml
[[upstreams]]
type = "socks5"
address = "192.0.2.10:1080"
ipv6 = true
prefer = 6
```
## interface ## interface
- **Constraints / validation**: `String` (optional). - **Constraints / validation**: `String` (optional).
- For `"direct"`: may be an IP address (used as explicit local bind) or an OS interface name (resolved to an IP at runtime; Unix only). - For `"direct"`: may be an IP address (used as explicit local bind) or an OS interface name (resolved to an IP at runtime; Unix only).
+109 -179
View File
@@ -85,145 +85,7 @@
# [general] # [general]
| Ключ | Тип | По умолчанию |
| --- | ---- | ------- |
| [`data_path`](#data_path) | `String` | — |
| [`prefer_ipv6`](#prefer_ipv6) | `bool` | `false` |
| [`fast_mode`](#fast_mode) | `bool` | `true` |
| [`use_middle_proxy`](#use_middle_proxy) | `bool` | `true` |
| [`proxy_secret_path`](#proxy_secret_path) | `String` | `"proxy-secret"` |
| [`proxy_config_v4_cache_path`](#proxy_config_v4_cache_path) | `String` | `"cache/proxy-config-v4.txt"` |
| [`proxy_config_v6_cache_path`](#proxy_config_v6_cache_path) | `String` | `"cache/proxy-config-v6.txt"` |
| [`ad_tag`](#ad_tag) | `String` | — |
| [`middle_proxy_nat_ip`](#middle_proxy_nat_ip) | `IpAddr` | — |
| [`middle_proxy_nat_probe`](#middle_proxy_nat_probe) | `bool` | `true` |
| [`middle_proxy_nat_stun`](#middle_proxy_nat_stun) | `String` | — |
| [`middle_proxy_nat_stun_servers`](#middle_proxy_nat_stun_servers) | `String[]` | `[]` |
| [`stun_nat_probe_concurrency`](#stun_nat_probe_concurrency) | `usize` | `8` |
| [`middle_proxy_pool_size`](#middle_proxy_pool_size) | `usize` | `8` |
| [`middle_proxy_warm_standby`](#middle_proxy_warm_standby) | `usize` | `16` |
| [`me_init_retry_attempts`](#me_init_retry_attempts) | `u32` | `0` |
| [`me2dc_fallback`](#me2dc_fallback) | `bool` | `true` |
| [`me2dc_fast`](#me2dc_fast) | `bool` | `true` |
| [`me_keepalive_enabled`](#me_keepalive_enabled) | `bool` | `true` |
| [`me_keepalive_interval_secs`](#me_keepalive_interval_secs) | `u64` | `8` |
| [`me_keepalive_jitter_secs`](#me_keepalive_jitter_secs) | `u64` | `2` |
| [`me_keepalive_payload_random`](#me_keepalive_payload_random) | `bool` | `true` |
| [`rpc_proxy_req_every`](#rpc_proxy_req_every) | `u64` | `0` |
| [`me_writer_cmd_channel_capacity`](#me_writer_cmd_channel_capacity) | `usize` | `4096` |
| [`me_route_channel_capacity`](#me_route_channel_capacity) | `usize` | `768` |
| [`me_c2me_channel_capacity`](#me_c2me_channel_capacity) | `usize` | `1024` |
| [`me_c2me_send_timeout_ms`](#me_c2me_send_timeout_ms) | `u64` | `4000` |
| [`me_reader_route_data_wait_ms`](#me_reader_route_data_wait_ms) | `u64` | `2` |
| [`me_d2c_flush_batch_max_frames`](#me_d2c_flush_batch_max_frames) | `usize` | `32` |
| [`me_d2c_flush_batch_max_bytes`](#me_d2c_flush_batch_max_bytes) | `usize` | `131072` |
| [`me_d2c_flush_batch_max_delay_us`](#me_d2c_flush_batch_max_delay_us) | `u64` | `500` |
| [`me_d2c_ack_flush_immediate`](#me_d2c_ack_flush_immediate) | `bool` | `true` |
| [`me_quota_soft_overshoot_bytes`](#me_quota_soft_overshoot_bytes) | `u64` | `65536` |
| [`me_d2c_frame_buf_shrink_threshold_bytes`](#me_d2c_frame_buf_shrink_threshold_bytes) | `usize` | `262144` |
| [`direct_relay_copy_buf_c2s_bytes`](#direct_relay_copy_buf_c2s_bytes) | `usize` | `65536` |
| [`direct_relay_copy_buf_s2c_bytes`](#direct_relay_copy_buf_s2c_bytes) | `usize` | `262144` |
| [`crypto_pending_buffer`](#crypto_pending_buffer) | `usize` | `262144` |
| [`max_client_frame`](#max_client_frame) | `usize` | `16777216` |
| [`desync_all_full`](#desync_all_full) | `bool` | `false` |
| [`beobachten`](#beobachten) | `bool` | `true` |
| [`beobachten_minutes`](#beobachten_minutes) | `u64` | `10` |
| [`beobachten_flush_secs`](#beobachten_flush_secs) | `u64` | `15` |
| [`beobachten_file`](#beobachten_file) | `String` | `"cache/beobachten.txt"` |
| [`hardswap`](#hardswap) | `bool` | `true` |
| [`me_warmup_stagger_enabled`](#me_warmup_stagger_enabled) | `bool` | `true` |
| [`me_warmup_step_delay_ms`](#me_warmup_step_delay_ms) | `u64` | `500` |
| [`me_warmup_step_jitter_ms`](#me_warmup_step_jitter_ms) | `u64` | `300` |
| [`me_reconnect_max_concurrent_per_dc`](#me_reconnect_max_concurrent_per_dc) | `u32` | `8` |
| [`me_reconnect_backoff_base_ms`](#me_reconnect_backoff_base_ms) | `u64` | `500` |
| [`me_reconnect_backoff_cap_ms`](#me_reconnect_backoff_cap_ms) | `u64` | `30000` |
| [`me_reconnect_fast_retry_count`](#me_reconnect_fast_retry_count) | `u32` | `16` |
| [`me_single_endpoint_shadow_writers`](#me_single_endpoint_shadow_writers) | `u8` | `2` |
| [`me_single_endpoint_outage_mode_enabled`](#me_single_endpoint_outage_mode_enabled) | `bool` | `true` |
| [`me_single_endpoint_outage_disable_quarantine`](#me_single_endpoint_outage_disable_quarantine) | `bool` | `true` |
| [`me_single_endpoint_outage_backoff_min_ms`](#me_single_endpoint_outage_backoff_min_ms) | `u64` | `250` |
| [`me_single_endpoint_outage_backoff_max_ms`](#me_single_endpoint_outage_backoff_max_ms) | `u64` | `3000` |
| [`me_single_endpoint_shadow_rotate_every_secs`](#me_single_endpoint_shadow_rotate_every_secs) | `u64` | `900` |
| [`me_floor_mode`](#me_floor_mode) | `"static"` or `"adaptive"` | `"adaptive"` |
| [`me_adaptive_floor_idle_secs`](#me_adaptive_floor_idle_secs) | `u64` | `90` |
| [`me_adaptive_floor_min_writers_single_endpoint`](#me_adaptive_floor_min_writers_single_endpoint) | `u8` | `1` |
| [`me_adaptive_floor_min_writers_multi_endpoint`](#me_adaptive_floor_min_writers_multi_endpoint) | `u8` | `1` |
| [`me_adaptive_floor_recover_grace_secs`](#me_adaptive_floor_recover_grace_secs) | `u64` | `180` |
| [`me_adaptive_floor_writers_per_core_total`](#me_adaptive_floor_writers_per_core_total) | `u16` | `48` |
| [`me_adaptive_floor_cpu_cores_override`](#me_adaptive_floor_cpu_cores_override) | `u16` | `0` |
| [`me_adaptive_floor_max_extra_writers_single_per_core`](#me_adaptive_floor_max_extra_writers_single_per_core) | `u16` | `1` |
| [`me_adaptive_floor_max_extra_writers_multi_per_core`](#me_adaptive_floor_max_extra_writers_multi_per_core) | `u16` | `2` |
| [`me_adaptive_floor_max_active_writers_per_core`](#me_adaptive_floor_max_active_writers_per_core) | `u16` | `64` |
| [`me_adaptive_floor_max_warm_writers_per_core`](#me_adaptive_floor_max_warm_writers_per_core) | `u16` | `64` |
| [`me_adaptive_floor_max_active_writers_global`](#me_adaptive_floor_max_active_writers_global) | `u32` | `256` |
| [`me_adaptive_floor_max_warm_writers_global`](#me_adaptive_floor_max_warm_writers_global) | `u32` | `256` |
| [`upstream_connect_retry_attempts`](#upstream_connect_retry_attempts) | `u32` | `2` |
| [`upstream_connect_retry_backoff_ms`](#upstream_connect_retry_backoff_ms) | `u64` | `100` |
| [`upstream_connect_budget_ms`](#upstream_connect_budget_ms) | `u64` | `3000` |
| [`upstream_unhealthy_fail_threshold`](#upstream_unhealthy_fail_threshold) | `u32` | `5` |
| [`upstream_connect_failfast_hard_errors`](#upstream_connect_failfast_hard_errors) | `bool` | `false` |
| [`stun_iface_mismatch_ignore`](#stun_iface_mismatch_ignore) | `bool` | `false` |
| [`unknown_dc_log_path`](#unknown_dc_log_path) | `String` | `"unknown-dc.txt"` |
| [`unknown_dc_file_log_enabled`](#unknown_dc_file_log_enabled) | `bool` | `false` |
| [`log_level`](#log_level) | `"debug"`, `"verbose"`, `"normal"`, or `"silent"` | `"normal"` |
| [`disable_colors`](#disable_colors) | `bool` | `false` |
| [`me_socks_kdf_policy`](#me_socks_kdf_policy) | `"strict"` or `"compat"` | `"strict"` |
| [`me_route_backpressure_enabled`](#me_route_backpressure_enabled) | `bool` | `false` |
| [`me_route_fairshare_enabled`](#me_route_fairshare_enabled) | `bool` | `false` |
| [`me_route_backpressure_base_timeout_ms`](#me_route_backpressure_base_timeout_ms) | `u64` | `25` |
| [`me_route_backpressure_high_timeout_ms`](#me_route_backpressure_high_timeout_ms) | `u64` | `120` |
| [`me_route_backpressure_high_watermark_pct`](#me_route_backpressure_high_watermark_pct) | `u8` | `80` |
| [`me_health_interval_ms_unhealthy`](#me_health_interval_ms_unhealthy) | `u64` | `1000` |
| [`me_health_interval_ms_healthy`](#me_health_interval_ms_healthy) | `u64` | `3000` |
| [`me_admission_poll_ms`](#me_admission_poll_ms) | `u64` | `1000` |
| [`me_warn_rate_limit_ms`](#me_warn_rate_limit_ms) | `u64` | `5000` |
| [`me_route_no_writer_mode`](#me_route_no_writer_mode) | `"async_recovery_failfast"`, `"inline_recovery_legacy"`, or `"hybrid_async_persistent"` | `"hybrid_async_persistent"` |
| [`me_route_no_writer_wait_ms`](#me_route_no_writer_wait_ms) | `u64` | `250` |
| [`me_route_hybrid_max_wait_ms`](#me_route_hybrid_max_wait_ms) | `u64` | `3000` |
| [`me_route_blocking_send_timeout_ms`](#me_route_blocking_send_timeout_ms) | `u64` | `250` |
| [`me_route_inline_recovery_attempts`](#me_route_inline_recovery_attempts) | `u32` | `3` |
| [`me_route_inline_recovery_wait_ms`](#me_route_inline_recovery_wait_ms) | `u64` | `3000` |
| [`fast_mode_min_tls_record`](#fast_mode_min_tls_record) | `usize` | `0` |
| [`update_every`](#update_every) | `u64` | `300` |
| [`me_reinit_every_secs`](#me_reinit_every_secs) | `u64` | `900` |
| [`me_hardswap_warmup_delay_min_ms`](#me_hardswap_warmup_delay_min_ms) | `u64` | `1000` |
| [`me_hardswap_warmup_delay_max_ms`](#me_hardswap_warmup_delay_max_ms) | `u64` | `2000` |
| [`me_hardswap_warmup_extra_passes`](#me_hardswap_warmup_extra_passes) | `u8` | `3` |
| [`me_hardswap_warmup_pass_backoff_base_ms`](#me_hardswap_warmup_pass_backoff_base_ms) | `u64` | `500` |
| [`me_config_stable_snapshots`](#me_config_stable_snapshots) | `u8` | `2` |
| [`me_config_apply_cooldown_secs`](#me_config_apply_cooldown_secs) | `u64` | `300` |
| [`me_snapshot_require_http_2xx`](#me_snapshot_require_http_2xx) | `bool` | `true` |
| [`me_snapshot_reject_empty_map`](#me_snapshot_reject_empty_map) | `bool` | `true` |
| [`me_snapshot_min_proxy_for_lines`](#me_snapshot_min_proxy_for_lines) | `u32` | `1` |
| [`proxy_secret_stable_snapshots`](#proxy_secret_stable_snapshots) | `u8` | `2` |
| [`proxy_secret_rotate_runtime`](#proxy_secret_rotate_runtime) | `bool` | `true` |
| [`me_secret_atomic_snapshot`](#me_secret_atomic_snapshot) | `bool` | `true` |
| [`proxy_secret_len_max`](#proxy_secret_len_max) | `usize` | `256` |
| [`me_pool_drain_ttl_secs`](#me_pool_drain_ttl_secs) | `u64` | `90` |
| [`me_instadrain`](#me_instadrain) | `bool` | `false` |
| [`me_pool_drain_threshold`](#me_pool_drain_threshold) | `u64` | `32` |
| [`me_pool_drain_soft_evict_enabled`](#me_pool_drain_soft_evict_enabled) | `bool` | `true` |
| [`me_pool_drain_soft_evict_grace_secs`](#me_pool_drain_soft_evict_grace_secs) | `u64` | `10` |
| [`me_pool_drain_soft_evict_per_writer`](#me_pool_drain_soft_evict_per_writer) | `u8` | `2` |
| [`me_pool_drain_soft_evict_budget_per_core`](#me_pool_drain_soft_evict_budget_per_core) | `u16` | `16` |
| [`me_pool_drain_soft_evict_cooldown_ms`](#me_pool_drain_soft_evict_cooldown_ms) | `u64` | `1000` |
| [`me_bind_stale_mode`](#me_bind_stale_mode) | `"never"`, `"ttl"`, or `"always"` | `"ttl"` |
| [`me_bind_stale_ttl_secs`](#me_bind_stale_ttl_secs) | `u64` | `90` |
| [`me_pool_min_fresh_ratio`](#me_pool_min_fresh_ratio) | `f32` | `0.8` |
| [`me_reinit_drain_timeout_secs`](#me_reinit_drain_timeout_secs) | `u64` | `90` |
| [`proxy_secret_auto_reload_secs`](#proxy_secret_auto_reload_secs) | `u64` | `3600` |
| [`proxy_config_auto_reload_secs`](#proxy_config_auto_reload_secs) | `u64` | `3600` |
| [`me_reinit_singleflight`](#me_reinit_singleflight) | `bool` | `true` |
| [`me_reinit_trigger_channel`](#me_reinit_trigger_channel) | `usize` | `64` |
| [`me_reinit_coalesce_window_ms`](#me_reinit_coalesce_window_ms) | `u64` | `200` |
| [`me_deterministic_writer_sort`](#me_deterministic_writer_sort) | `bool` | `true` |
| [`me_writer_pick_mode`](#me_writer_pick_mode) | `"sorted_rr"` or `"p2c"` | `"p2c"` |
| [`me_writer_pick_sample_size`](#me_writer_pick_sample_size) | `u8` | `3` |
| [`ntp_check`](#ntp_check) | `bool` | `true` |
| [`ntp_servers`](#ntp_servers) | `String[]` | `["pool.ntp.org"]` |
| [`auto_degradation_enabled`](#auto_degradation_enabled) | `bool` | `true` |
| [`degradation_min_unavailable_dc_groups`](#degradation_min_unavailable_dc_groups) | `u8` | `2` |
| [`rst_on_close`](#rst_on_close) | `"off"`, `"errors"` или `"always"` | `"off"` |
| Ключ | Тип | По умолчанию | Hot-Reload | | Ключ | Тип | По умолчанию | Hot-Reload |
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`data_path`](#data_path) | `String` | — | `` | | [`data_path`](#data_path) | `String` | — | `` |
@@ -770,7 +632,7 @@
``` ```
## beobachten ## beobachten
- **Ограничения / валидация**: `bool`. - **Ограничения / валидация**: `bool`.
- **Описание**: Включает "криминалистическое" наблюдения для каждого IP-адреса. Анализирует поведение всех подключений и записывает возможные типы клиентов, которые посылают active-probing запросы. - **Описание**: Включает "криминалистическое" наблюдения для каждого IP-адреса. Анализирует поведение всех подключений, записывает возможные типы клиентов, которые посылают active-probing запросы, и добавляет snapshot’ы TLS JA3/JA4 fingerprint’ов в Beobachten output, когда есть данные.
- **Пример**: - **Пример**:
```toml ```toml
@@ -779,7 +641,7 @@
``` ```
## beobachten_minutes ## beobachten_minutes
- **Ограничения / валидация**: Должно быть `> 0` (минут). - **Ограничения / валидация**: Должно быть `> 0` (минут).
- **Описание**: Время хранения (минуты) для сегментов наблюдения по каждому IP-адресу. - **Описание**: Время хранения (минуты) для сегментов наблюдения по каждому IP-адресу и in-memory bucket’ов TLS fingerprint’ов.
- **Пример**: - **Пример**:
```toml ```toml
@@ -1945,6 +1807,8 @@
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `` | | [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `` |
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` | | [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` | | [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` |
| [`client_mss`](#client_mss) | `String` | `""` | `` |
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` | | [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` | | [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` |
@@ -2027,6 +1891,26 @@
listen_unix_sock = "/run/telemt.sock" listen_unix_sock = "/run/telemt.sock"
listen_tcp = true listen_tcp = true
``` ```
## client_mss
- **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`.
- **Описание**: MSS для входящих TCP-соединений клиентов. Значение применяется к TCP listener-сокетам до `listen(2)`, чтобы Linux мог объявить его в SYN/ACK. Параметр влияет только на proxy client TCP listeners и не применяется к API, metrics, Unix sockets, Telegram upstreams, ME sockets или mask backend connections. Изменение требует restart/rebind listener’ов.
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
- **Пример**:
```toml
[server]
client_mss = "tspu"
```
## client_mss_bulk
- **Ограничения / валидация**: `String`. Грамматика та же, что у [`client_mss`](#client_mss) (пусто/не задано, пресеты `"extreme-low"`/`"tspu"`/`"2in8"` либо десятичное число в диапазоне `88..=4096`).
- **Описание**: Необязательный MSS для bulk-фазы. Если задан, низкий `client_mss` применяется только на время TLS-handshake (включая инспектируемый DPI ServerHello); как только соединение переходит в фазу relay, MSS клиентского сокета поднимается до `client_mss_bulk` для передачи полезной нагрузки. Так сохраняется anti-DPI фрагментация handshake, но для данных возвращаются пакеты нормального размера — это снижает исходящий packets-per-second примерно во столько раз, каков segment multiplier у `client_mss` (например, ~10x для `"tspu"`). Полезно на хостингах, где abuse-детекция считает packets-per-second, а не полосу. Если пусто/не задано — MSS handshake сохраняется на всё соединение (прежнее поведение). Только Linux; на прочих платформах — no-op.
- **Пример**:
```toml
[server]
client_mss = "tspu"
client_mss_bulk = "1400"
```
## proxy_protocol ## proxy_protocol
- **Ограничения / валидация**: `bool`. - **Ограничения / валидация**: `bool`.
- **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка. - **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка.
@@ -2317,7 +2201,7 @@
``` ```
## runtime_edge_top_n ## runtime_edge_top_n
- **Ограничения / валидация**: `1..=1000`. - **Ограничения / валидация**: `1..=1000`.
- **Описание**: Размер выборки Top-N для рейтинга (leaderboard) edge-соединений. - **Описание**: Размер выборки Top-N для snapshot’ов рейтинга edge-соединений и TLS fingerprint’ов.
- **Пример**: - **Пример**:
```toml ```toml
@@ -2351,6 +2235,11 @@
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`ip`](#ip) | `IpAddr` | — | `` | | [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `` |
| [`announce`](#announce) | `String` | — | `` | | [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2375,6 +2264,69 @@
ip = "0.0.0.0" ip = "0.0.0.0"
port = 443 port = 443
``` ```
## client_mss (server.listeners)
- **Ограничения / валидация**: `String` (необязательный параметр). Допустимые значения совпадают с `[server].client_mss`.
- **Описание**: Per-listener override для MSS. Если параметр не задан, listener наследует `[server].client_mss`; если задана пустая строка, MSS shaping отключается только для этого listener’а, даже когда глобальный параметр задан. Изменение требует restart/rebind listener’а.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
client_mss = "256"
```
## synlimit (server.listeners)
- **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен.
- **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
[[server.listeners]]
ip = "::"
port = 443
synlimit = "nftables"
```
## synlimit_seconds (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
- **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`).
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_seconds = 1
```
## synlimit_hitcount (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
- **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hitcount = 1
```
## synlimit_burst (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`.
- **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_burst = 2
```
## announce ## announce
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
- **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`. - **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`.
@@ -2531,41 +2483,6 @@
# [censorship] # [censorship]
| Ключ | Тип | По умолчанию |
| --- | ---- | ------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` |
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` |
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
| [`mask`](#mask) | `bool` | `true` |
| [`mask_host`](#mask_host) | `String` | — |
| [`mask_port`](#mask_port) | `u16` | `443` |
| [`exclusive_mask`](#exclusive_mask) | `Map<String,String>` | `{}` |
| [`mask_unix_sock`](#mask_unix_sock) | `String` | — |
| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` |
| [`tls_emulation`](#tls_emulation) | `bool` | `true` |
| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` |
| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` |
| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` |
| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` |
| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` |
| [`serverhello_compact`](#serverhello_compact) | `bool` | `false` |
| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` |
| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` |
| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` |
| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` |
| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` |
| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` |
| [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` |
| [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` |
| [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` |
| [`mask_relay_timeout_ms`](mask_relay_timeout_ms) | `u64` | `60_000` |
| [`mask_relay_idle_timeout_ms`](mask_relay_idle_timeout_ms) | `u64` | `5_000` |
| [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` |
| [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` |
| [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` |
| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` |
| Ключ | Тип | По умолчанию | Hot-Reload | | Ключ | Тип | По умолчанию | Hot-Reload |
| --- | ---- | ------- | ---------- | | --- | ---- | ------- | ---------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `` | | [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `` |
@@ -3273,7 +3190,7 @@
## user_rate_limits ## user_rate_limits
- **Ограничения / валидация**: Таблица `username -> { up_bps, down_bps }`. Должно быть ненулевое значение хотя бы в одном направлении. - **Ограничения / валидация**: Таблица `username -> { up_bps, down_bps }`. Должно быть ненулевое значение хотя бы в одном направлении.
- **Описание**: Персональные лимиты скорости по пользователям в байтах/сек для отправки (`up_bps`) и получения (`down_bps`). - **Описание**: Персональные лимиты скорости по пользователям в битах/сек для отправки (`up_bps`) и получения (`down_bps`).
- **Example**: - **Example**:
```toml ```toml
@@ -3300,6 +3217,7 @@
| [`scopes`](#scopes) | `String` | `""` | `` | | [`scopes`](#scopes) | `String` | `""` | `` |
| [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `` | | [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `` |
| [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `` | | [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `` |
| [`prefer`](#prefer-upstreams) | `4` или `6` | эффективный `[network].prefer` | `` |
| [`interface`](#interface) | `String` | — | `` | | [`interface`](#interface) | `String` | — | `` |
| [`bind_addresses`](#bind_addresses) | `String[]` | — | `` | | [`bind_addresses`](#bind_addresses) | `String[]` | — | `` |
| [`bindtodevice`](#bindtodevice) | `String` | — | `` | | [`bindtodevice`](#bindtodevice) | `String` | — | `` |
@@ -3371,7 +3289,7 @@
``` ```
## ipv6 (upstreams) ## ipv6 (upstreams)
- **Ограничения / валидация**: `bool` (необязательный параметр). - **Ограничения / валидация**: `bool` (необязательный параметр).
- **Описание**: Разрешает IPv6 DC-targets для этого upstream. Если не задан, Telemt определяет поддержку автоматически по runtime-состоянию connectivity. - **Описание**: Разрешает IPv6 DC-targets для этого upstream. Если не задан, Telemt определяет поддержку автоматически по runtime-состоянию connectivity. Установите `true`, если upstream proxy доступен с локального хоста по IPv4, но сам proxy умеет подключаться к Telegram DC по IPv6.
- **Пример**: - **Пример**:
```toml ```toml
@@ -3379,6 +3297,18 @@
type = "direct" type = "direct"
ipv6 = false ipv6 = false
``` ```
## prefer (upstreams)
- **Ограничения / валидация**: Необязательное число. Должно быть `4` или `6`.
- **Описание**: Переопределяет предпочтительное IP-семейство для Telegram DC-targets, выбранных через этот upstream. Если параметр не задан, upstream наследует эффективное глобальное решение `[network].prefer`. Используйте `prefer = 6` вместе с `ipv6 = true` для SOCKS или Shadowsocks upstream, который умеет выходить в IPv6, даже если локальный хост с Telemt работает только по IPv4.
- **Пример**:
```toml
[[upstreams]]
type = "socks5"
address = "192.0.2.10:1080"
ipv6 = true
prefer = 6
```
## interface ## interface
- **Ограничения / валидация**: `String` (необязательный параметр). - **Ограничения / валидация**: `String` (необязательный параметр).
- для `"direct"`: может быть IP-адресом (используется как явный local bind) или именем сетевого интерфейса ОС (резолвится в IP во время выполнения; только Unix). - для `"direct"`: может быть IP-адресом (используется как явный local bind) или именем сетевого интерфейса ОС (резолвится в IP во время выполнения; только Unix).
+1 -1
View File
@@ -172,7 +172,7 @@ Those cross-DC requests are normal and happen constantly.
> If your home DC is DC2 and DC2 goes down, you **cannot** reach DC5 even though DC5 itself is perfectly healthy. > If your home DC is DC2 and DC2 goes down, you **cannot** reach DC5 even though DC5 itself is perfectly healthy.
> The client has no valid session to route the request through. > The client has no valid session to route the request through.
This is also why an MTProxy only needs to reach Telegram's DC infrastructure as a whole. This is also why it is required for MTProxy to reach Telegram's DC infrastructure as a whole.
The proxy itself doesn't care which DC your account lives on. The client negotiates the correct DC through the proxy after connecting. The proxy itself doesn't care which DC your account lives on. The client negotiates the correct DC through the proxy after connecting.
### How many people can use one link ### How many people can use one link
+4 -2
View File
@@ -40,6 +40,8 @@ hello2 = "ad_tag2"
> Проблема с TLS отпечатком исправлена в последних версиях клиентов Telegram для Desktop / Android / iOS. > Проблема с TLS отпечатком исправлена в последних версиях клиентов Telegram для Desktop / Android / iOS.
> Обновите свой клиент для корректной работы с MTProxy Fake-TLS! > Обновите свой клиент для корректной работы с MTProxy Fake-TLS!
- Для расследования блокировок на базе JA4 ClientHello используйте отдельную инструкцию: [`JA3 и JA4 анализ в Telemt`](Architecture/Fronting-splitting/TLS_JA3_JA4_ANALYSIS.ru.md).
- Мы считаем это прорывом, которому на сегодняшний день нет стабильных аналогов; - Мы считаем это прорывом, которому на сегодняшний день нет стабильных аналогов;
- Исходя из этого: если `telemt` настроен правильно, **режим TLS полностью идентичен реальному «рукопожатию» + обмену данными** с указанным хостом; - Исходя из этого: если `telemt` настроен правильно, **режим TLS полностью идентичен реальному «рукопожатию» + обмену данными** с указанным хостом;
- Вот наши доказательства: - Вот наши доказательства:
@@ -157,7 +159,7 @@ https://github.com/telemt/telemt/discussions/167
## Как клиенты взаимодействуют с дата-центрами Telegram ## Как клиенты взаимодействуют с дата-центрами Telegram
При регистрации аккаунта Telegram он навсегда привязывается к одному из дата-центров (DC). При регистрации аккаунта Telegram он навсегда привязывается к одному из дата-центров (DC).
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относиться номер телефона. Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относится номер телефона.
Этот DC становится вашим **домашним**: именно там хранится весь контент, который вы загружаете (фото, видео, файлы, сообщения). Этот DC становится вашим **домашним**: именно там хранится весь контент, который вы загружаете (фото, видео, файлы, сообщения).
И именно на нем клиент авторизуется при каждом подключении. И именно на нем клиент авторизуется при каждом подключении.
@@ -170,7 +172,7 @@ Telegram заранее определяет к какому DC привязат
> Если ваш домашний DC — DC2, и DC2 лежит, вы **не сможете** достучаться и до DC5, даже если сам DC5 полностью исправен. > Если ваш домашний DC — DC2, и DC2 лежит, вы **не сможете** достучаться и до DC5, даже если сам DC5 полностью исправен.
> У клиента просто нет валидной сессии, через которую можно было бы направить запрос. > У клиента просто нет валидной сессии, через которую можно было бы направить запрос.
По той же причине MTProxy достаточно иметь доступ к инфраструктуре Telegram в целом. По той же причине MTProxy необходимо иметь доступ к инфраструктуре Telegram целиком, а не частично.
Cамому MTProxy всё равно, на каком DC живёт ваш аккаунт. Клиент cам договаривается о нужном DC через прокси уже после подключения. Cамому MTProxy всё равно, на каком DC живёт ваш аккаунт. Клиент cам договаривается о нужном DC через прокси уже после подключения.
## Что такое dd и ee в контексте MTProxy? ## Что такое dd и ee в контексте MTProxy?
+5 -2
View File
@@ -235,7 +235,10 @@ curl -s http://127.0.0.1:9091/v1/users | jq -r '.data[] | "[\(.username)]", (.li
# Telemt через Docker Compose # Telemt через Docker Compose
**1. Отредактируйте `config.toml` в корневом каталоге репозитория (как минимум: порт, пользовательские секреты, tls_domain)** **1. Создайте директорию `config/` и поместите в неё отрдеактированный `config.toml` (указав как минимум: порт, пользовательские секреты, tls_domain):**
```bash
mkdir config && mv config.toml config/
```
**2. Запустите контейнер:** **2. Запустите контейнер:**
```bash ```bash
docker compose up -d --build docker compose up -d --build
@@ -249,7 +252,7 @@ docker compose logs -f telemt
docker compose down docker compose down
``` ```
> [!NOTE] > [!NOTE]
> - В `docker-compose.yml` файл `./config.toml` монтируется в `/app/config.toml` (доступно только для чтения) > - Директория `./config/` монтируется в `/etc/telemt/` (read-write), что позволяет API атомарно обновлять config.toml
> - По умолчанию публикуются порты 443:443, а контейнер запускается со сброшенными привилегиями (добавлена только `NET_BIND_SERVICE`) > - По умолчанию публикуются порты 443:443, а контейнер запускается со сброшенными привилегиями (добавлена только `NET_BIND_SERVICE`)
> - Если вам действительно нужна сеть хоста (обычно это требуется только для некоторых конфигураций IPv6), раскомментируйте `network_mode: host` > - Если вам действительно нужна сеть хоста (обычно это требуется только для некоторых конфигураций IPv6), раскомментируйте `network_mode: host`
+1 -1
View File
@@ -206,7 +206,7 @@ File content:
"publicKey": "<SERVER_B_PUBLIC_KEY>", "publicKey": "<SERVER_B_PUBLIC_KEY>",
"shortId": "<SHORT_ID>", "shortId": "<SHORT_ID>",
"spiderX": "/", "spiderX": "/",
"fingerprint": "chrome" "fingerprint": "firefox"
}, },
"xhttpSettings": { "xhttpSettings": {
"path": "/<YOUR_RANDOM_PATH>" "path": "/<YOUR_RANDOM_PATH>"
+1 -1
View File
@@ -206,7 +206,7 @@ nano /usr/local/etc/xray/config.json
"publicKey": "<SERVER_B_PUBLIC_KEY>", "publicKey": "<SERVER_B_PUBLIC_KEY>",
"shortId": "<SHORT_ID>", "shortId": "<SHORT_ID>",
"spiderX": "/", "spiderX": "/",
"fingerprint": "chrome" "fingerprint": "firefox"
}, },
"xhttpSettings": { "xhttpSettings": {
"path": "/<YOUR_RANDOM_PATH>" "path": "/<YOUR_RANDOM_PATH>"
+58 -25
View File
@@ -84,21 +84,22 @@ set_language() {
L_INFO_KEEP_CONF="Примечание: Конфигурация сохранена. Используйте 'purge' для очистки." L_INFO_KEEP_CONF="Примечание: Конфигурация сохранена. Используйте 'purge' для очистки."
L_INFO_I_START="Начинается установка" L_INFO_I_START="Начинается установка"
L_I_STAGE_1=">>> Этап 1: Проверка окружения и зависимостей" L_I_STAGE_1=">>> Этап 1: Проверка окружения и зависимостей"
L_I_STAGE_1_5=">>> Этап 1.5: Интерактивная настройка" L_I_STAGE_2=">>> Этап 2: Интерактивная настройка"
L_I_PROMPT_DOM="\nПожалуйста, укажите домен TLS\nНажмите Enter, чтобы оставить по умолчанию [%s]: " L_I_PROMPT_DOM="\nПожалуйста, укажите домен TLS\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
L_I_PROMPT_PORT="\nПожалуйста, укажите порт сервера\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
L_WARN_NO_TTY="Интерактивный режим недоступен (нет TTY). Используется:" L_WARN_NO_TTY="Интерактивный режим недоступен (нет TTY). Используется:"
L_I_STAGE_2=">>> Этап 2: Загрузка архива" L_I_STAGE_3=">>> Этап 3: Загрузка архива"
L_ERR_TMP_DIR="Не удалось создать временную директорию" L_ERR_TMP_DIR="Не удалось создать временную директорию"
L_ERR_TMP_INV="Временная директория недействительна" L_ERR_TMP_INV="Временная директория недействительна"
L_INFO_FALLBACK="Сборка x86_64-v3 не найдена, откат к стандартной x86_64..." L_INFO_FALLBACK="Сборка x86_64-v3 не найдена, откат к стандартной x86_64..."
L_ERR_DL_FAIL="Ошибка загрузки архива" L_ERR_DL_FAIL="Ошибка загрузки архива"
L_I_STAGE_3=">>> Этап 3: Распаковка архива" L_I_STAGE_4=">>> Этап 4: Распаковка архива"
L_ERR_EXTRACT="Ошибка распаковки архива." L_ERR_EXTRACT="Ошибка распаковки архива."
L_ERR_BIN_NOT_FOUND="Бинарный файл не найден в архиве" L_ERR_BIN_NOT_FOUND="Бинарный файл не найден в архиве"
L_I_STAGE_4=">>> Этап 4: Настройка окружения (Юзер, Группа, Папки)" L_I_STAGE_5=">>> Этап 5: Настройка окружения (Юзер, Группа, Папки)"
L_I_STAGE_5=">>> Этап 5: Установка бинарного файла" L_I_STAGE_6=">>> Этап 6: Установка бинарного файла"
L_I_STAGE_6=">>> Этап 6: Генерация/Обновление конфигурации" L_I_STAGE_7=">>> Этап 7: Генерация/Обновление конфигурации"
L_I_STAGE_7=">>> Этап 7: Установка и запуск службы" L_I_STAGE_8=">>> Этап 8: Установка и запуск службы"
L_OUT_WARN_H="УСТАНОВКА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ" L_OUT_WARN_H="УСТАНОВКА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ"
L_OUT_WARN_D="Служба установлена, но не запустилась.\nПожалуйста, проверьте логи.\n" L_OUT_WARN_D="Служба установлена, но не запустилась.\nПожалуйста, проверьте логи.\n"
L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА" L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА"
@@ -160,21 +161,22 @@ set_language() {
L_INFO_KEEP_CONF="Note: Configuration kept. Run with 'purge' to remove completely." L_INFO_KEEP_CONF="Note: Configuration kept. Run with 'purge' to remove completely."
L_INFO_I_START="Starting installation of" L_INFO_I_START="Starting installation of"
L_I_STAGE_1=">>> Stage 1: Verifying environment and dependencies" L_I_STAGE_1=">>> Stage 1: Verifying environment and dependencies"
L_I_STAGE_1_5=">>> Stage 1.5: Interactive Setup" L_I_STAGE_2=">>> Stage 2: Interactive Setup"
L_I_PROMPT_DOM="\nPlease specify the TLS Domain\nPress Enter to keep default [%s]: " L_I_PROMPT_DOM="\nPlease specify the TLS Domain\nPress Enter to keep default [%s]: "
L_I_PROMPT_PORT="\nPlease specify the Server Port\nPress Enter to keep default [%s]: "
L_WARN_NO_TTY="Interactive mode unavailable (no TTY). Using:" L_WARN_NO_TTY="Interactive mode unavailable (no TTY). Using:"
L_I_STAGE_2=">>> Stage 2: Downloading archive" L_I_STAGE_3=">>> Stage 3: Downloading archive"
L_ERR_TMP_DIR="Temp directory creation failed" L_ERR_TMP_DIR="Temp directory creation failed"
L_ERR_TMP_INV="Temp directory is invalid or was not created" L_ERR_TMP_INV="Temp directory is invalid or was not created"
L_INFO_FALLBACK="x86_64-v3 build not found, falling back to standard x86_64..." L_INFO_FALLBACK="x86_64-v3 build not found, falling back to standard x86_64..."
L_ERR_DL_FAIL="Download failed" L_ERR_DL_FAIL="Download failed"
L_I_STAGE_3=">>> Stage 3: Extracting archive" L_I_STAGE_4=">>> Stage 4: Extracting archive"
L_ERR_EXTRACT="Extraction failed." L_ERR_EXTRACT="Extraction failed."
L_ERR_BIN_NOT_FOUND="Binary not found in archive" L_ERR_BIN_NOT_FOUND="Binary not found in archive"
L_I_STAGE_4=">>> Stage 4: Setting up environment (User, Group, Directories)" L_I_STAGE_5=">>> Stage 5: Setting up environment (User, Group, Directories)"
L_I_STAGE_5=">>> Stage 5: Installing binary" L_I_STAGE_6=">>> Stage 6: Installing binary"
L_I_STAGE_6=">>> Stage 6: Generating/Updating configuration" L_I_STAGE_7=">>> Stage 7: Generating/Updating configuration"
L_I_STAGE_7=">>> Stage 7: Installing and starting service" L_I_STAGE_8=">>> Stage 8: Installing and starting service"
L_OUT_WARN_H="INSTALLATION COMPLETED WITH WARNINGS" L_OUT_WARN_H="INSTALLATION COMPLETED WITH WARNINGS"
L_OUT_WARN_D="The service was installed but failed to start.\nPlease check the logs to determine the issue.\n" L_OUT_WARN_D="The service was installed but failed to start.\nPlease check the logs to determine the issue.\n"
L_OUT_SUCC_H="INSTALLATION SUCCESS" L_OUT_SUCC_H="INSTALLATION SUCCESS"
@@ -269,7 +271,10 @@ say() {
if [ "$#" -eq 0 ] || [ -z "${1:-}" ]; then if [ "$#" -eq 0 ] || [ -z "${1:-}" ]; then
printf '\n' printf '\n'
else else
printf '[INFO] %s\n' "$*" case "$*" in
\[*\]*) printf '%s\n' "$*" ;;
*) printf '[INFO] %s\n' "$*" ;;
esac
fi fi
} }
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; } die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
@@ -527,9 +532,9 @@ setup_dirs() {
stop_service() { stop_service() {
svc="$(get_svc_mgr)" svc="$(get_svc_mgr)"
if [ "$svc" = "systemd" ] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then if [ "$svc" = "systemd" ] && $SUDO systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
$SUDO systemctl stop "$SERVICE_NAME" 2>/dev/null || true $SUDO systemctl stop "$SERVICE_NAME" 2>/dev/null || true
elif [ "$svc" = "openrc" ] && rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then elif [ "$svc" = "openrc" ] && $SUDO rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
$SUDO rc-service "$SERVICE_NAME" stop 2>/dev/null || true $SUDO rc-service "$SERVICE_NAME" stop 2>/dev/null || true
fi fi
} }
@@ -832,10 +837,36 @@ case "$ACTION" in
fi fi
fi fi
check_port_availability if [ "$PORT_PROVIDED" -eq 0 ] || [ "$DOMAIN_PROVIDED" -eq 0 ]; then
say "$L_I_STAGE_2"
fi
if [ "$PORT_PROVIDED" -eq 0 ]; then
if [ -t 0 ] || [ -c /dev/tty ]; then
while true; do
printf "$L_I_PROMPT_PORT" "$SERVER_PORT"
read -r input_port </dev/tty || input_port=""
if [ -z "$input_port" ]; then
break
fi
case "$input_port" in
*[!0-9]*) printf '[ERROR] %s\n' "$L_ERR_PORT_NUM" >&2; continue ;;
esac
port_num="$(printf '%s\n' "$input_port" | sed 's/^0*//')"
[ -z "$port_num" ] && port_num="0"
if [ "${#port_num}" -gt 5 ] || [ "$port_num" -lt 1 ] || [ "$port_num" -gt 65535 ]; then
printf '[ERROR] %s\n' "$L_ERR_PORT_RANGE" >&2; continue
fi
SERVER_PORT="$port_num"
break
done
else
say "[WARNING] $L_WARN_NO_TTY $SERVER_PORT"
fi
PORT_PROVIDED=1
fi
if [ "$DOMAIN_PROVIDED" -eq 0 ]; then if [ "$DOMAIN_PROVIDED" -eq 0 ]; then
say "$L_I_STAGE_1_5"
if [ -t 0 ] || [ -c /dev/tty ]; then if [ -t 0 ] || [ -c /dev/tty ]; then
printf "$L_I_PROMPT_DOM" "$TLS_DOMAIN" printf "$L_I_PROMPT_DOM" "$TLS_DOMAIN"
read -r input_domain </dev/tty || input_domain="" read -r input_domain </dev/tty || input_domain=""
@@ -848,6 +879,8 @@ case "$ACTION" in
DOMAIN_PROVIDED=1 DOMAIN_PROVIDED=1
fi fi
check_port_availability
if [ "$TARGET_VERSION" != "latest" ]; then if [ "$TARGET_VERSION" != "latest" ]; then
TARGET_VERSION="${TARGET_VERSION#v}" TARGET_VERSION="${TARGET_VERSION#v}"
fi fi
@@ -861,7 +894,7 @@ case "$ACTION" in
DL_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}/${FILE_NAME}" DL_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}/${FILE_NAME}"
fi fi
say "$L_I_STAGE_2" say "$L_I_STAGE_3"
TEMP_DIR="$(mktemp -d)" || die "$L_ERR_TMP_DIR" TEMP_DIR="$(mktemp -d)" || die "$L_ERR_TMP_DIR"
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
die "$L_ERR_TMP_INV" die "$L_ERR_TMP_INV"
@@ -883,7 +916,7 @@ case "$ACTION" in
fi fi
fi fi
say "$L_I_STAGE_3" say "$L_I_STAGE_4"
if ! gzip -dc "${TEMP_DIR}/${FILE_NAME}" | tar -xf - -C "$TEMP_DIR" 2>/dev/null; then if ! gzip -dc "${TEMP_DIR}/${FILE_NAME}" | tar -xf - -C "$TEMP_DIR" 2>/dev/null; then
die "$L_ERR_EXTRACT" die "$L_ERR_EXTRACT"
fi fi
@@ -891,16 +924,16 @@ case "$ACTION" in
EXTRACTED_BIN="$(find "$TEMP_DIR" -type f -name "$BIN_NAME" -print 2>/dev/null | head -n 1 || true)" EXTRACTED_BIN="$(find "$TEMP_DIR" -type f -name "$BIN_NAME" -print 2>/dev/null | head -n 1 || true)"
[ -n "$EXTRACTED_BIN" ] || die "$L_ERR_BIN_NOT_FOUND" [ -n "$EXTRACTED_BIN" ] || die "$L_ERR_BIN_NOT_FOUND"
say "$L_I_STAGE_4" say "$L_I_STAGE_5"
ensure_user_group; setup_dirs; stop_service ensure_user_group; setup_dirs; stop_service
say "$L_I_STAGE_5" say "$L_I_STAGE_6"
install_binary "$EXTRACTED_BIN" "${INSTALL_DIR}/${BIN_NAME}" install_binary "$EXTRACTED_BIN" "${INSTALL_DIR}/${BIN_NAME}"
say "$L_I_STAGE_6" say "$L_I_STAGE_7"
install_config install_config
say "$L_I_STAGE_7" say "$L_I_STAGE_8"
install_service install_service
if [ "${SERVICE_START_FAILED:-0}" -eq 1 ]; then if [ "${SERVICE_START_FAILED:-0}" -eq 1 ]; then
+411
View File
@@ -0,0 +1,411 @@
//! Config-editing API: read managed sections and apply sparse field patches.
//! `access.*` is intentionally not editable here (owned by the users API).
use serde_json::Value as Json;
use toml::Value as Toml;
use super::ApiShared;
use super::config_store::{
EDITABLE_SECTIONS, compute_revision, current_revision, save_sections_to_disk,
};
use super::model::ApiFailure;
use crate::config::ProxyConfig;
use crate::config::hot_reload::classify_config_changes;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
pub(super) struct PatchConfigResponse {
pub revision: String,
pub restart_required: bool,
pub changed: Vec<String>,
}
/// Shared-state wrapper around [`apply_patch_to_path`]: serializes config
/// mutations behind `mutation_lock`, then records a runtime event. The route
/// handler calls this; the core logic stays decoupled for unit tests.
pub(super) async fn patch_config(
patch_json: Json,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<PatchConfigResponse, ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
drop(_guard);
shared
.runtime_events
.record("api.config.patch.ok", format!("changed={:?}", resp.changed));
Ok(resp)
}
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
/// against a temp file. The route handler holds `mutation_lock` while calling this.
pub(super) async fn apply_patch_to_path(
config_path: &Path,
patch_json: &Json,
expected_revision: Option<String>,
) -> Result<PatchConfigResponse, ApiFailure> {
// 1. optimistic concurrency
let current = current_revision(config_path).await?;
if expected_revision.is_some_and(|expected| expected != current) {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"revision_conflict",
"Config revision mismatch",
));
}
// 2. convert + reject access / unknown sections
let patch_toml = json_to_toml(patch_json)
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
let patch_table = patch_toml
.as_table()
.ok_or_else(|| ApiFailure::bad_request("patch must be a JSON object"))?;
if patch_table.contains_key("access") {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"access_not_editable",
"access.* is managed via the users API, not editable here",
));
}
for key in patch_table.keys() {
if !EDITABLE_SECTIONS.contains(&key.as_str()) {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"section_not_editable",
format!("section not editable: {}", key),
));
}
}
let touched: Vec<&str> = patch_table
.keys()
.map(|k| k.as_str())
.filter(|k| EDITABLE_SECTIONS.contains(k))
.collect();
if touched.is_empty() {
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
}
// 3. Parse old + merged from the SAME deserialize path so the classifier
// sees only the delta this patch introduces. `ProxyConfig::load` applies
// include-expansion / legacy-compat / normalization that a bare
// `try_into` does not; mixing the two paths would make unrelated fields
// compare unequal and spuriously force `restart_required`.
let original = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
let original_toml: Toml = toml::from_str(&original)
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
let old_cfg: ProxyConfig = original_toml
.clone()
.try_into()
.map_err(|e| ApiFailure::internal(format!("config does not deserialize: {}", e)))?;
let mut merged = original_toml;
deep_merge(&mut merged, &patch_toml);
let new_cfg: ProxyConfig = merged
.clone()
.try_into()
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
new_cfg
.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
// 4. classify changes (Telemt's own hot/restart rule)
let class = classify_config_changes(&old_cfg, &new_cfg);
// 5. write only the touched top-level sections
let revision = save_sections_to_disk(config_path, &new_cfg, &touched).await?;
Ok(PatchConfigResponse {
revision,
restart_required: class.restart_required,
changed: class.changed,
})
}
/// Return only the editable config sections + current revision.
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
let original = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
let parsed: Toml = toml::from_str(&original)
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
let parsed_table = parsed
.as_table()
.cloned()
.unwrap_or_else(toml::value::Table::new);
// Whitelist: return ONLY the editable sections. A blacklist (just removing
// `access`) would leak `server` (carries the API `auth_header` + per-node
// identity) and `network` (per-node addresses). Mirror the PATCH contract.
let mut table = toml::value::Table::new();
for section in EDITABLE_SECTIONS {
if let Some(value) = parsed_table.get(*section) {
table.insert((*section).to_string(), value.clone());
}
}
let revision = compute_revision(&original);
Ok((Toml::Table(table), revision))
}
/// Convert a serde_json value to a toml value. `null` is dropped from objects
/// (a patch never sets a key to TOML-null). Numbers become integers when exact,
/// otherwise floats.
fn json_to_toml(j: &Json) -> Result<Toml, String> {
Ok(match j {
Json::Null => return Err("null is not representable in TOML".into()),
Json::Bool(b) => Toml::Boolean(*b),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
Toml::Integer(i)
} else if let Some(f) = n.as_f64() {
Toml::Float(f)
} else {
return Err(format!("unrepresentable number: {}", n));
}
}
Json::String(s) => Toml::String(s.clone()),
Json::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(json_to_toml(item)?);
}
Toml::Array(out)
}
Json::Object(map) => {
let mut table = toml::value::Table::new();
for (k, v) in map {
if v.is_null() {
continue; // skip nulls instead of erroring at object level
}
table.insert(k.clone(), json_to_toml(v)?);
}
Toml::Table(table)
}
})
}
/// Recursively overlay `patch` onto `base`. Tables merge key-by-key; every
/// other value type (scalars, arrays) replaces wholesale.
fn deep_merge(base: &mut Toml, patch: &Toml) {
match (base, patch) {
(Toml::Table(b), Toml::Table(p)) => {
for (k, pv) in p {
match b.get_mut(k) {
Some(bv) => deep_merge(bv, pv),
None => {
b.insert(k.clone(), pv.clone());
}
}
}
}
(b, p) => *b = p.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_object_converts_to_toml_table() {
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
let t = json_to_toml(&j).expect("convertible");
let table = t.as_table().unwrap();
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
assert_eq!(table["default_dc"].as_integer(), Some(2));
}
#[test]
fn deep_merge_overlays_tables_and_replaces_scalars() {
let mut base: Toml =
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
deep_merge(&mut base, &patch);
let cens = base["censorship"].as_table().unwrap();
assert_eq!(cens["tls_domain"].as_str(), Some("new")); // overlaid
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100)); // preserved
}
use std::path::PathBuf;
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, body).unwrap();
(path, dir)
}
#[tokio::test]
async fn patch_rejects_access_section() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "access_not_editable");
}
#[tokio::test]
async fn patch_revision_conflict() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
.await
.unwrap_err();
assert_eq!(err.code, "revision_conflict");
}
#[tokio::test]
async fn patch_sni_reports_restart_required() {
let (path, _d) =
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(resp.restart_required);
assert!(resp.changed.iter().any(|c| c == "censorship"));
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("tls_domain = \"b.com\""));
assert_eq!(
resp.revision,
crate::api::config_store::compute_revision(&written)
);
}
#[tokio::test]
async fn read_managed_config_strips_access() {
let (path, _d) = temp_config(
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"deadbeef\"\n",
);
let (value, revision) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
assert!(!table.contains_key("access")); // secrets never leave the box here
assert_eq!(revision, current_revision(&path).await.unwrap());
}
#[tokio::test]
async fn read_managed_config_returns_only_editable_sections() {
// server carries the API auth_header + per-node identity; network carries
// per-node addresses. Neither must be exposed by GET /v1/config.
let (path, _d) = temp_config(concat!(
"[censorship]\ntls_domain = \"a\"\n",
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
"[network]\nipv4 = \"1.2.3.4\"\n",
"[access.users]\nbob = \"deadbeef\"\n",
));
let (value, _rev) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
assert!(!table.contains_key("server")); // no API auth_header / identity leak
assert!(!table.contains_key("network")); // no per-node identity leak
assert!(!table.contains_key("access")); // no users/secrets
}
#[tokio::test]
async fn patch_rejects_server_section() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"server": {"port": 1}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
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");
let patch: Json = serde_json::json!({});
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
}
#[tokio::test]
async fn patch_log_level_is_hot() {
// general.log_level is hot-reloadable -> a patch changing only it must
// report restart_required = false (exercises the full apply path, not
// just the classifier). Default LogLevel is Normal; patch to "debug".
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(!resp.restart_required);
assert!(resp.changed.iter().any(|c| c == "general"));
}
}
+338 -10
View File
@@ -14,6 +14,7 @@ use super::model::ApiFailure;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AccessSection { pub(super) enum AccessSection {
Users, Users,
UserEnabled,
UserAdTags, UserAdTags,
UserMaxTcpConns, UserMaxTcpConns,
UserExpirations, UserExpirations,
@@ -26,6 +27,7 @@ impl AccessSection {
fn table_name(self) -> &'static str { fn table_name(self) -> &'static str {
match self { match self {
Self::Users => "access.users", Self::Users => "access.users",
Self::UserEnabled => "access.user_enabled",
Self::UserAdTags => "access.user_ad_tags", Self::UserAdTags => "access.user_ad_tags",
Self::UserMaxTcpConns => "access.user_max_tcp_conns", Self::UserMaxTcpConns => "access.user_max_tcp_conns",
Self::UserExpirations => "access.user_expirations", Self::UserExpirations => "access.user_expirations",
@@ -95,6 +97,90 @@ pub(super) async fn save_config_to_disk(
Ok(compute_revision(&serialized)) Ok(compute_revision(&serialized))
} }
/// Top-level config tables that may be edited via the config API.
///
/// 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`).
/// - `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.
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
"general",
"timeouts",
"censorship",
"upstreams",
"dc_overrides",
];
/// Re-render the given top-level tables from `cfg` and upsert each into the
/// on-disk file, preserving every untouched section (and its comments).
pub(super) async fn save_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
sections: &[&str],
) -> Result<String, ApiFailure> {
let mut content = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
for section in sections {
let rendered = render_top_level_section(cfg, section)?;
content = upsert_toml_table(&content, section, &rendered);
}
write_atomic(config_path.to_path_buf(), content.clone()).await?;
Ok(compute_revision(&content))
}
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
/// output matches the canonical format Telemt parses.
fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String, ApiFailure> {
let value = toml::Value::try_from(cfg)
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
let table = value
.get(section)
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
if let toml::Value::Array(items) = table {
let mut out = String::new();
for item in items {
out.push_str(&format!("[[{}]]\n", section));
out.push_str(&toml::to_string(item).map_err(|e| {
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
})?);
if !out.ends_with('\n') {
out.push('\n');
}
}
return Ok(out);
}
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
// crate emits correctly dotted headers for nested sub-tables, e.g.
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
// inner table alone would render bare `[modes]`/`[links]` headers, which
// would leak as duplicate top-level tables and break config load.
let mut wrapper = toml::value::Table::new();
wrapper.insert(section.to_string(), table.clone());
let mut out = toml::to_string(&toml::Value::Table(wrapper))
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
pub(super) async fn save_access_sections_to_disk( pub(super) async fn save_access_sections_to_disk(
config_path: &Path, config_path: &Path,
cfg: &ProxyConfig, cfg: &ProxyConfig,
@@ -135,6 +221,15 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
.collect(); .collect();
serialize_table_body(&rows)? serialize_table_body(&rows)?
} }
AccessSection::UserEnabled => {
let rows: BTreeMap<String, bool> = cfg
.access
.user_enabled
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserAdTags => { AccessSection::UserAdTags => {
let rows: BTreeMap<String, String> = cfg let rows: BTreeMap<String, String> = cfg
.access .access
@@ -204,6 +299,7 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool { fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
match section { match section {
AccessSection::Users => cfg.access.users.is_empty(), AccessSection::Users => cfg.access.users.is_empty(),
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(), AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(), AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(), AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
@@ -241,11 +337,22 @@ fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
} }
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String { fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
if let Some((start, end)) = find_toml_table_bounds(source, table_name) { let blocks = find_all_table_blocks(source, table_name);
if let Some(&(first_start, first_end)) = blocks.first() {
// Replace the first block in place and delete any further blocks that
// also belong to this table. Telemt writes a section's sub-tables
// contiguously, but a hand-edited config may scatter them; dropping the
// extras here prevents the duplicate-table corruption that would
// otherwise break config load.
let mut out = String::with_capacity(source.len() + replacement.len()); let mut out = String::with_capacity(source.len() + replacement.len());
out.push_str(&source[..start]); out.push_str(&source[..first_start]);
out.push_str(replacement); out.push_str(replacement);
out.push_str(&source[end..]); let mut cursor = first_end;
for &(start, end) in &blocks[1..] {
out.push_str(&source[cursor..start]);
cursor = end;
}
out.push_str(&source[cursor..]);
return out; return out;
} }
@@ -260,24 +367,62 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin
out out
} }
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
/// prefixes — `access.users` must not match `access.user_enabled`.
fn header_belongs_to(header: &str, table_name: &str) -> bool {
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
Some(body) => body,
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
Some(body) => body,
None => return false,
},
};
let body = body.trim();
body == table_name
|| body
.strip_prefix(table_name)
.is_some_and(|rest| rest.starts_with('.'))
}
/// Locate the first contiguous byte range covering `table_name` and the nested
/// sub-tables immediately following it. Used for existence checks; see
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> { fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
let target = 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 offset = 0usize;
let mut start = None; let mut start: Option<usize> = None;
for line in source.split_inclusive('\n') { for line in source.split_inclusive('\n') {
let trimmed = line.trim(); // 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 { if let Some(start_offset) = start {
if trimmed.starts_with('[') { if is_header && !header_belongs_to(header, table_name) {
return Some((start_offset, offset)); blocks.push((start_offset, offset));
start = None;
} }
} else if trimmed == target { }
if start.is_none() && header_belongs_to(header, table_name) {
start = Some(offset); start = Some(offset);
} }
offset = offset.saturating_add(line.len()); offset = offset.saturating_add(line.len());
} }
start.map(|start_offset| (start_offset, source.len())) if let Some(start_offset) = start {
blocks.push((start_offset, source.len()));
}
blocks
} }
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> { async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
@@ -324,6 +469,189 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn save_sections_preserves_other_tables_and_comments() {
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(
&path,
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
)
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.censorship.tls_domain = "new.example".to_string();
cfg.server.port = 443;
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
.await
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("tls_domain = \"new.example\""));
assert!(written.contains("# top comment")); // untouched comment kept
assert!(written.contains("[server]\nport = 443")); // untouched table kept
assert_eq!(rev, compute_revision(&written));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn find_bounds_matches_array_of_tables() {
let src =
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
let bounds = find_toml_table_bounds(src, "upstreams");
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[[upstreams]]"));
assert!(slice.contains("kind = \"b\"")); // spans through the last upstream block
}
#[test]
fn find_bounds_matches_header_with_inline_comment() {
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
let bounds = find_toml_table_bounds(src, "censorship");
assert!(bounds.is_some(), "commented header must still match");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[censorship] # notes"));
assert!(slice.contains("tls_domain"));
assert!(!slice.contains("[server]")); // terminates at the next header
}
#[tokio::test]
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
tokio::fs::write(
&path,
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
)
.await
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.general.prefer_ipv6 = true;
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
// No bare top-level [modes] / [links] headers leaked.
for line in written.lines() {
let header = line.trim();
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
}
// Sub-tables kept their dotted prefix exactly once each.
assert_eq!(
written.matches("[general.modes]").count(),
1,
"[general.modes] must appear exactly once:\n{written}"
);
assert_eq!(
written.matches("[general.links]").count(),
1,
"[general.links] must appear exactly once:\n{written}"
);
// Result parses (duplicate tables would error here).
toml::from_str::<toml::Value>(&written)
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
assert!(written.contains("[server]\nport = 443")); // untouched table kept
}
#[tokio::test]
async fn save_general_section_is_idempotent_across_repeated_saves() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
tokio::fs::write(
&path,
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
[general.links]\npublic_host = \"old.example\"\n",
)
.await
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.general.prefer_ipv6 = true;
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
assert_eq!(written.matches("[general]").count(), 1, "{written}");
toml::from_str::<toml::Value>(&written)
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
}
#[test]
fn find_bounds_spans_dotted_subtables() {
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
let bounds = find_toml_table_bounds(src, "general");
assert!(bounds.is_some(), "should locate [general] block");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[general]"));
assert!(slice.contains("[general.modes]")); // spans nested sub-tables
assert!(slice.contains("[general.links]"));
assert!(!slice.contains("[server]")); // terminates at the next unrelated header
}
#[test]
fn find_bounds_does_not_overrun_sibling_prefix() {
// access.users must not swallow access.user_enabled (dot guards the prefix).
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
let slice = &src[bounds.0..bounds.1];
assert!(slice.starts_with("[access.users]"));
assert!(!slice.contains("[access.user_enabled]"));
}
#[tokio::test]
async fn save_general_handles_non_contiguous_subtables() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
tokio::fs::write(
&path,
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
[general.modes]\ntls = true\n",
)
.await
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.general.prefer_ipv6 = true;
save_sections_to_disk(&path, &cfg, &["general"])
.await
.unwrap();
let written = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(
written.matches("[general.modes]").count(),
1,
"non-contiguous [general.modes] must not duplicate:\n{written}"
);
toml::from_str::<toml::Value>(&written)
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
assert!(written.contains("[server]")); // unrelated section preserved
}
#[test] #[test]
fn render_user_rate_limits_section() { fn render_user_rate_limits_section() {
let mut cfg = ProxyConfig::default(); let mut cfg = ProxyConfig::default();
+10 -5
View File
@@ -1,6 +1,7 @@
use http_body_util::{BodyExt, Full}; use http_body_util::{BodyExt, Full};
use hyper::StatusCode; use hyper::StatusCode;
use hyper::body::{Bytes, Incoming}; use hyper::body::{Bytes, Incoming};
use hyper::header::ALLOW;
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@@ -25,6 +26,8 @@ pub(super) fn success_response<T: Serialize>(
} }
pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Response<Full<Bytes>> { pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Response<Full<Bytes>> {
let status = failure.status;
let allow = failure.allow;
let payload = ErrorResponse { let payload = ErrorResponse {
ok: false, ok: false,
error: ErrorBody { error: ErrorBody {
@@ -40,11 +43,13 @@ pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Res
) )
.into_bytes() .into_bytes()
}); });
hyper::Response::builder() let mut builder = hyper::Response::builder()
.status(failure.status) .status(status)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8");
.body(Full::new(Bytes::from(body))) if let Some(allow) = allow {
.unwrap() builder = builder.header(ALLOW, allow);
}
builder.body(Full::new(Bytes::from(body))).unwrap()
} }
pub(super) async fn read_json<T: DeserializeOwned>( pub(super) async fn read_json<T: DeserializeOwned>(
+275 -17
View File
@@ -22,11 +22,13 @@ use tracing::{debug, info, warn};
use crate::config::{ApiGrayAction, ProxyConfig}; use crate::config::{ApiGrayAction, ProxyConfig};
use crate::ip_tracker::UserIpTracker; use crate::ip_tracker::UserIpTracker;
use crate::proxy::route_mode::RouteRuntimeController; use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::startup::StartupTracker; use crate::startup::StartupTracker;
use crate::stats::Stats; use crate::stats::Stats;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool; use crate::transport::middle_proxy::MePool;
mod config_edit;
mod config_store; mod config_store;
mod events; mod events;
mod http_utils; mod http_utils;
@@ -41,7 +43,9 @@ mod runtime_watch;
mod runtime_zero; mod runtime_zero;
mod users; mod users;
use config_store::{current_revision, load_config_from_disk, parse_if_match}; use config_store::{
current_revision, ensure_expected_revision, load_config_from_disk, parse_if_match,
};
use events::ApiEventStore; use events::ApiEventStore;
use http_utils::{error_response, read_json, read_optional_json, success_response}; use http_utils::{error_response, read_json, read_optional_json, success_response};
use model::{ use model::{
@@ -49,9 +53,10 @@ use model::{
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps, PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
is_valid_username, is_valid_username,
}; };
use patch::Patch;
use runtime_edge::{ use runtime_edge::{
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data, EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
build_runtime_events_recent_data, build_runtime_events_recent_data, build_runtime_tls_fingerprints_data,
}; };
use runtime_init::build_runtime_initialization_data; use runtime_init::build_runtime_initialization_data;
use runtime_min::{ use runtime_min::{
@@ -69,12 +74,18 @@ use runtime_zero::{
build_system_info_data, build_system_info_data,
}; };
use users::{ use users::{
build_user_quota_list, create_user, delete_user, patch_user, rotate_secret, users_from_config, build_user_quota_list, create_user, delete_user, patch_user, rotate_secret, set_user_enabled,
users_from_config,
}; };
const API_MAX_CONTROL_CONNECTIONS: usize = 1024; const API_MAX_CONTROL_CONNECTIONS: usize = 1024;
const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
const ROUTE_USERNAME_ERROR: &str = "username must match [A-Za-z0-9_.-] and be 1..64 chars"; const ROUTE_USERNAME_ERROR: &str = "username must match [A-Za-z0-9_.-] and be 1..64 chars";
const ALLOW_GET: &str = "GET";
const ALLOW_POST: &str = "POST";
const ALLOW_GET_POST: &str = "GET, POST";
const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE";
const ALLOW_GET_PATCH: &str = "GET, PATCH";
pub(super) struct ApiRuntimeState { pub(super) struct ApiRuntimeState {
pub(super) process_started_at_epoch_secs: u64, pub(super) process_started_at_epoch_secs: u64,
@@ -101,6 +112,7 @@ pub(super) struct ApiShared {
pub(super) runtime_state: Arc<ApiRuntimeState>, pub(super) runtime_state: Arc<ApiRuntimeState>,
pub(super) startup_tracker: Arc<StartupTracker>, pub(super) startup_tracker: Arc<StartupTracker>,
pub(super) route_runtime: Arc<RouteRuntimeController>, pub(super) route_runtime: Arc<RouteRuntimeController>,
pub(super) proxy_shared: Arc<ProxySharedState>,
} }
impl ApiShared { impl ApiShared {
@@ -125,12 +137,68 @@ fn parse_route_username(user: &str) -> Result<&str, ApiFailure> {
} }
} }
fn user_action_route_matches(path: &str, suffix: &str) -> bool {
path.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix(suffix))
.map(|user| !user.is_empty() && !user.contains('/'))
.unwrap_or(false)
}
fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
match path {
"/v1/health"
| "/v1/health/ready"
| "/v1/system/info"
| "/v1/runtime/gates"
| "/v1/runtime/initialization"
| "/v1/limits/effective"
| "/v1/security/posture"
| "/v1/security/whitelist"
| "/v1/stats/summary"
| "/v1/stats/zero/all"
| "/v1/stats/upstreams"
| "/v1/stats/minimal/all"
| "/v1/stats/me-writers"
| "/v1/stats/dcs"
| "/v1/runtime/me-pool-state"
| "/v1/runtime/me_pool_state"
| "/v1/runtime/me-quality"
| "/v1/runtime/me_quality"
| "/v1/runtime/upstream-quality"
| "/v1/runtime/upstream_quality"
| "/v1/runtime/nat-stun"
| "/v1/runtime/nat_stun"
| "/v1/runtime/me-selftest"
| "/v1/runtime/connections/summary"
| "/v1/runtime/events/recent"
| "/v1/runtime/tls-fingerprints"
| "/v1/stats/users/active-ips"
| "/v1/stats/users/quota"
| "/v1/stats/users" => Some(ALLOW_GET),
"/v1/users" => Some(ALLOW_GET_POST),
"/v1/config" => Some(ALLOW_GET_PATCH),
_ if user_action_route_matches(path, "/reset-quota") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/rotate-secret") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/enable") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/disable") => Some(ALLOW_POST),
_ if path
.strip_prefix("/v1/users/")
.map(|user| !user.is_empty() && !user.contains('/'))
.unwrap_or(false) =>
{
Some(ALLOW_GET_PATCH_DELETE)
}
_ => None,
}
}
pub async fn serve( pub async fn serve(
listen: SocketAddr, listen: SocketAddr,
stats: Arc<Stats>, stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>, ip_tracker: Arc<UserIpTracker>,
me_pool: Arc<RwLock<Option<Arc<MePool>>>>, me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
route_runtime: Arc<RouteRuntimeController>, route_runtime: Arc<RouteRuntimeController>,
proxy_shared: Arc<ProxySharedState>,
upstream_manager: Arc<UpstreamManager>, upstream_manager: Arc<UpstreamManager>,
config_rx: watch::Receiver<Arc<ProxyConfig>>, config_rx: watch::Receiver<Arc<ProxyConfig>>,
admission_rx: watch::Receiver<bool>, admission_rx: watch::Receiver<bool>,
@@ -180,6 +248,7 @@ pub async fn serve(
runtime_state: runtime_state.clone(), runtime_state: runtime_state.clone(),
startup_tracker, startup_tracker,
route_runtime, route_runtime,
proxy_shared,
}); });
spawn_runtime_watchers( spawn_runtime_watchers(
@@ -435,22 +504,22 @@ async fn handle(
let data = build_dcs_data(shared.as_ref(), api_cfg).await; let data = build_dcs_data(shared.as_ref(), api_cfg).await;
Ok(success_response(StatusCode::OK, data, revision)) Ok(success_response(StatusCode::OK, data, revision))
} }
("GET", "/v1/runtime/me_pool_state") => { ("GET", "/v1/runtime/me-pool-state") | ("GET", "/v1/runtime/me_pool_state") => {
let revision = current_revision(&shared.config_path).await?; let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_me_pool_state_data(shared.as_ref()).await; let data = build_runtime_me_pool_state_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision)) Ok(success_response(StatusCode::OK, data, revision))
} }
("GET", "/v1/runtime/me_quality") => { ("GET", "/v1/runtime/me-quality") | ("GET", "/v1/runtime/me_quality") => {
let revision = current_revision(&shared.config_path).await?; let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_me_quality_data(shared.as_ref()).await; let data = build_runtime_me_quality_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision)) Ok(success_response(StatusCode::OK, data, revision))
} }
("GET", "/v1/runtime/upstream_quality") => { ("GET", "/v1/runtime/upstream-quality") | ("GET", "/v1/runtime/upstream_quality") => {
let revision = current_revision(&shared.config_path).await?; let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_upstream_quality_data(shared.as_ref()).await; let data = build_runtime_upstream_quality_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision)) Ok(success_response(StatusCode::OK, data, revision))
} }
("GET", "/v1/runtime/nat_stun") => { ("GET", "/v1/runtime/nat-stun") | ("GET", "/v1/runtime/nat_stun") => {
let revision = current_revision(&shared.config_path).await?; let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_nat_stun_data(shared.as_ref()).await; let data = build_runtime_nat_stun_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision)) Ok(success_response(StatusCode::OK, data, revision))
@@ -475,6 +544,15 @@ async fn handle(
); );
Ok(success_response(StatusCode::OK, data, revision)) Ok(success_response(StatusCode::OK, data, revision))
} }
("GET", "/v1/runtime/tls-fingerprints") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_tls_fingerprints_data(
shared.as_ref(),
cfg.as_ref(),
query.as_deref(),
);
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/stats/users/active-ips") => { ("GET", "/v1/stats/users/active-ips") => {
let revision = current_revision(&shared.config_path).await?; let revision = current_revision(&shared.config_path).await?;
let usernames: Vec<_> = cfg.access.users.keys().cloned().collect(); let usernames: Vec<_> = cfg.access.users.keys().cloned().collect();
@@ -506,7 +584,7 @@ async fn handle(
.await; .await;
Ok(success_response(StatusCode::OK, users, revision)) Ok(success_response(StatusCode::OK, users, revision))
} }
("GET", "/v1/users/quota") => { ("GET", "/v1/stats/users/quota") => {
let revision = current_revision(&shared.config_path).await?; let revision = current_revision(&shared.config_path).await?;
let disk_cfg = load_config_from_disk(&shared.config_path).await?; let disk_cfg = load_config_from_disk(&shared.config_path).await?;
let data = build_user_quota_list(&disk_cfg, shared.stats.as_ref()); let data = build_user_quota_list(&disk_cfg, shared.stats.as_ref());
@@ -525,6 +603,7 @@ async fn handle(
} }
let expected_revision = parse_if_match(req.headers()); let expected_revision = parse_if_match(req.headers());
let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?; let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?;
let requested_enabled = body.enabled;
let result = create_user(body, expected_revision, &shared).await; let result = create_user(body, expected_revision, &shared).await;
let (mut data, revision) = match result { let (mut data, revision) = match result {
Ok(ok) => ok, Ok(ok) => ok,
@@ -537,6 +616,25 @@ async fn handle(
}; };
let runtime_cfg = config_rx.borrow().clone(); let runtime_cfg = config_rx.borrow().clone();
data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username); data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username);
if let Some(enabled) = requested_enabled {
shared
.proxy_shared
.set_user_enabled(&data.user.username, enabled);
if !enabled {
let cancelled = shared
.proxy_shared
.cancel_user_sessions(&data.user.username);
if cancelled > 0 {
shared.runtime_events.record(
"api.user.disable.runtime",
format!(
"username={} cancelled_sessions={}",
data.user.username, cancelled
),
);
}
}
}
shared.runtime_events.record( shared.runtime_events.record(
"api.user.create.ok", "api.user.create.ok",
format!("username={}", data.user.username), format!("username={}", data.user.username),
@@ -548,7 +646,131 @@ async fn handle(
}; };
Ok(success_response(status, data, revision)) Ok(success_response(status, data, revision))
} }
("GET", "/v1/config") => {
let (value, revision) =
config_edit::read_managed_config(&shared.config_path).await?;
Ok(success_response(StatusCode::OK, value, revision))
}
("PATCH", "/v1/config") => {
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let body = read_json::<serde_json::Value>(req.into_body(), body_limit).await?;
match config_edit::patch_config(body, expected_revision, &shared).await {
Ok(resp) => {
let revision = resp.revision.clone();
Ok(success_response(StatusCode::OK, resp, revision))
}
Err(error) => {
shared
.runtime_events
.record("api.config.patch.failed", error.code);
Err(error)
}
}
}
_ => { _ => {
if method == Method::POST
&& let Some(base_user) = normalized_path
.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix("/enable"))
&& !base_user.is_empty()
&& !base_user.contains('/')
{
let base_user = parse_route_username(base_user)?;
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let result =
set_user_enabled(base_user, true, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
Err(error) => {
shared.runtime_events.record(
"api.user.enable.failed",
format!("username={} code={}", base_user, error.code),
);
return Err(error);
}
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
shared.proxy_shared.set_user_enabled(base_user, true);
shared
.runtime_events
.record("api.user.enable.ok", format!("username={}", base_user));
let status = if data.in_runtime {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok(success_response(status, data, revision));
}
if method == Method::POST
&& let Some(base_user) = normalized_path
.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix("/disable"))
&& !base_user.is_empty()
&& !base_user.contains('/')
{
let base_user = parse_route_username(base_user)?;
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let result =
set_user_enabled(base_user, false, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
Err(error) => {
shared.runtime_events.record(
"api.user.disable.failed",
format!("username={} code={}", base_user, error.code),
);
return Err(error);
}
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
let newly_disabled = shared.proxy_shared.set_user_enabled(base_user, false);
let cancelled = shared.proxy_shared.cancel_user_sessions(base_user);
shared.runtime_events.record(
"api.user.disable.ok",
format!(
"username={} newly_disabled={} cancelled_sessions={}",
base_user, newly_disabled, cancelled
),
);
let status = if data.in_runtime {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok(success_response(status, data, revision));
}
if method == Method::POST if method == Method::POST
&& let Some(user) = normalized_path && let Some(user) = normalized_path
.strip_prefix("/v1/users/") .strip_prefix("/v1/users/")
@@ -567,6 +789,16 @@ async fn handle(
), ),
)); ));
} }
let expected_revision = parse_if_match(req.headers());
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref())
.await?;
if !disk_cfg.access.users.contains_key(user) {
return Ok(error_response(
request_id,
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "User not found"),
));
}
let snapshot = match crate::quota_state::reset_user_quota( let snapshot = match crate::quota_state::reset_user_quota(
&shared.quota_state_path, &shared.quota_state_path,
shared.stats.as_ref(), shared.stats.as_ref(),
@@ -696,6 +928,11 @@ async fn handle(
let expected_revision = parse_if_match(req.headers()); let expected_revision = parse_if_match(req.headers());
let body = let body =
read_json::<PatchUserRequest>(req.into_body(), body_limit).await?; read_json::<PatchUserRequest>(req.into_body(), body_limit).await?;
let enabled_update = match &body.enabled {
Patch::Unchanged => None,
Patch::Remove => Some(true),
Patch::Set(enabled) => Some(*enabled),
};
let result = patch_user(user, body, expected_revision, &shared).await; let result = patch_user(user, body, expected_revision, &shared).await;
let (mut data, revision) = match result { let (mut data, revision) = match result {
Ok(ok) => ok, Ok(ok) => ok,
@@ -709,6 +946,22 @@ async fn handle(
}; };
let runtime_cfg = config_rx.borrow().clone(); let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username); data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
if let Some(enabled) = enabled_update {
shared
.proxy_shared
.set_user_enabled(&data.username, enabled);
if !enabled {
let cancelled =
shared.proxy_shared.cancel_user_sessions(&data.username);
shared.runtime_events.record(
"api.user.disable.runtime",
format!(
"username={} cancelled_sessions={}",
data.username, cancelled
),
);
}
}
shared shared
.runtime_events .runtime_events
.record("api.user.patch.ok", format!("username={}", data.username)); .record("api.user.patch.ok", format!("username={}", data.username));
@@ -742,9 +995,12 @@ async fn handle(
return Err(error); return Err(error);
} }
}; };
shared shared.proxy_shared.set_user_enabled(&deleted_user, true);
.runtime_events let cancelled = shared.proxy_shared.cancel_user_sessions(&deleted_user);
.record("api.user.delete.ok", format!("username={}", deleted_user)); shared.runtime_events.record(
"api.user.delete.ok",
format!("username={} cancelled_sessions={}", deleted_user, cancelled),
);
let runtime_cfg = config_rx.borrow().clone(); let runtime_cfg = config_rx.borrow().clone();
let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user); let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user);
let response = DeleteUserResponse { let response = DeleteUserResponse {
@@ -761,16 +1017,18 @@ async fn handle(
if method == Method::POST { if method == Method::POST {
return Ok(error_response( return Ok(error_response(
request_id, request_id,
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "Route not found"), ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
)); ));
} }
return Ok(error_response( return Ok(error_response(
request_id, request_id,
ApiFailure::new( ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
StatusCode::METHOD_NOT_ALLOWED, ));
"method_not_allowed", }
"Unsupported HTTP method for this route", if let Some(allow) = allowed_methods_for_path(normalized_path) {
), return Ok(error_response(
request_id,
ApiFailure::method_not_allowed(allow),
)); ));
} }
debug!( debug!(
+15
View File
@@ -15,6 +15,7 @@ pub(super) struct ApiFailure {
pub(super) status: StatusCode, pub(super) status: StatusCode,
pub(super) code: &'static str, pub(super) code: &'static str,
pub(super) message: String, pub(super) message: String,
pub(super) allow: Option<&'static str>,
} }
impl ApiFailure { impl ApiFailure {
@@ -23,6 +24,7 @@ impl ApiFailure {
status, status,
code, code,
message: message.into(), message: message.into(),
allow: None,
} }
} }
@@ -33,6 +35,15 @@ impl ApiFailure {
pub(super) fn bad_request(message: impl Into<String>) -> Self { pub(super) fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message) Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
} }
pub(super) fn method_not_allowed(allow: &'static str) -> Self {
Self {
status: StatusCode::METHOD_NOT_ALLOWED,
code: "method_not_allowed",
message: "Unsupported HTTP method for this route".to_string(),
allow: Some(allow),
}
}
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -468,6 +479,7 @@ pub(super) struct TlsDomainLink {
#[derive(Serialize)] #[derive(Serialize)]
pub(super) struct UserInfo { pub(super) struct UserInfo {
pub(super) username: String, pub(super) username: String,
pub(super) enabled: bool,
pub(super) in_runtime: bool, pub(super) in_runtime: bool,
pub(super) user_ad_tag: Option<String>, pub(super) user_ad_tag: Option<String>,
pub(super) max_tcp_conns: Option<usize>, pub(super) max_tcp_conns: Option<usize>,
@@ -534,6 +546,7 @@ pub(super) struct CreateUserRequest {
pub(super) rate_limit_up_bps: Option<u64>, pub(super) rate_limit_up_bps: Option<u64>,
pub(super) rate_limit_down_bps: Option<u64>, pub(super) rate_limit_down_bps: Option<u64>,
pub(super) max_unique_ips: Option<usize>, pub(super) max_unique_ips: Option<usize>,
pub(super) enabled: Option<bool>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -553,6 +566,8 @@ pub(super) struct PatchUserRequest {
pub(super) rate_limit_down_bps: Patch<u64>, pub(super) rate_limit_down_bps: Patch<u64>,
#[serde(default, deserialize_with = "patch_field")] #[serde(default, deserialize_with = "patch_field")]
pub(super) max_unique_ips: Patch<usize>, pub(super) max_unique_ips: Patch<usize>,
#[serde(default, deserialize_with = "patch_field")]
pub(super) enabled: Patch<bool>,
} }
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
+128
View File
@@ -12,6 +12,8 @@ const FEATURE_DISABLED_REASON: &str = "feature_disabled";
const SOURCE_UNAVAILABLE_REASON: &str = "source_unavailable"; const SOURCE_UNAVAILABLE_REASON: &str = "source_unavailable";
const EVENTS_DEFAULT_LIMIT: usize = 50; const EVENTS_DEFAULT_LIMIT: usize = 50;
const EVENTS_MAX_LIMIT: usize = 1000; const EVENTS_MAX_LIMIT: usize = 1000;
const TLS_FINGERPRINTS_MAX_LIMIT: usize = 1000;
const RUNTIME_EDGE_RETENTION_MAX_MINUTES: u64 = 24 * 60;
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
pub(super) struct RuntimeEdgeConnectionUserData { pub(super) struct RuntimeEdgeConnectionUserData {
@@ -90,6 +92,44 @@ pub(super) struct RuntimeEdgeEventsData {
pub(super) data: Option<RuntimeEdgeEventsPayload>, pub(super) data: Option<RuntimeEdgeEventsPayload>,
} }
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintRow {
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) scope: Option<String>,
pub(super) ja3: String,
pub(super) ja3_raw: String,
pub(super) ja4: String,
pub(super) ja4_raw: String,
pub(super) total: u64,
pub(super) auth_success: u64,
pub(super) bad_or_probe: u64,
pub(super) first_seen_epoch_secs: u64,
pub(super) last_seen_epoch_secs: u64,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintsPayload {
pub(super) limit: usize,
pub(super) retention_secs: u64,
pub(super) capacity: usize,
pub(super) dropped_total: u64,
pub(super) parse_error_total: u64,
pub(super) by_fingerprint: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_ip: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_cidr: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_user: Vec<RuntimeEdgeTlsFingerprintRow>,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintsData {
pub(super) enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) reason: Option<&'static str>,
pub(super) generated_at_epoch_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) data: Option<RuntimeEdgeTlsFingerprintsPayload>,
}
pub(super) async fn build_runtime_connections_summary_data( pub(super) async fn build_runtime_connections_summary_data(
shared: &ApiShared, shared: &ApiShared,
cfg: &ProxyConfig, cfg: &ProxyConfig,
@@ -162,6 +202,65 @@ pub(super) fn build_runtime_events_recent_data(
} }
} }
pub(super) fn build_runtime_tls_fingerprints_data(
shared: &ApiShared,
cfg: &ProxyConfig,
query: Option<&str>,
) -> RuntimeEdgeTlsFingerprintsData {
let now_epoch_secs = now_epoch_secs();
let api_cfg = &cfg.server.api;
if !api_cfg.runtime_edge_enabled {
return RuntimeEdgeTlsFingerprintsData {
enabled: false,
reason: Some(FEATURE_DISABLED_REASON),
generated_at_epoch_secs: now_epoch_secs,
data: None,
};
}
let limit = parse_recent_events_limit(
query,
api_cfg.runtime_edge_top_n.max(1),
TLS_FINGERPRINTS_MAX_LIMIT,
);
let snapshot = shared
.stats
.tls_fingerprint_snapshot(runtime_edge_retention(cfg), limit);
RuntimeEdgeTlsFingerprintsData {
enabled: true,
reason: None,
generated_at_epoch_secs: now_epoch_secs,
data: Some(RuntimeEdgeTlsFingerprintsPayload {
limit,
retention_secs: snapshot.retention_secs,
capacity: snapshot.capacity,
dropped_total: snapshot.dropped_total,
parse_error_total: snapshot.parse_error_total,
by_fingerprint: snapshot
.by_fingerprint
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_ip: snapshot
.by_ip
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_cidr: snapshot
.by_cidr
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_user: snapshot
.by_user
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
}),
}
}
async fn get_connections_payload_cached( async fn get_connections_payload_cached(
shared: &ApiShared, shared: &ApiShared,
cache_ttl_ms: u64, cache_ttl_ms: u64,
@@ -286,6 +385,35 @@ fn parse_recent_events_limit(query: Option<&str>, default_limit: usize, max_limi
default_limit default_limit
} }
fn runtime_edge_retention(cfg: &ProxyConfig) -> Duration {
let minutes = cfg
.general
.beobachten_minutes
.clamp(1, RUNTIME_EDGE_RETENTION_MAX_MINUTES);
Duration::from_secs(minutes.saturating_mul(60))
}
fn runtime_tls_fingerprint_row(
row: crate::stats::TlsFingerprintSnapshotRow,
) -> RuntimeEdgeTlsFingerprintRow {
RuntimeEdgeTlsFingerprintRow {
scope: if row.scope_key.is_empty() {
None
} else {
Some(row.scope_key)
},
ja3: row.ja3,
ja3_raw: row.ja3_raw,
ja4: row.ja4,
ja4_raw: row.ja4_raw,
total: row.total,
auth_success: row.auth_success,
bad_or_probe: row.bad_or_probe,
first_seen_epoch_secs: row.first_seen_epoch_secs,
last_seen_epoch_secs: row.last_seen_epoch_secs,
}
}
fn now_epoch_secs() -> u64 { fn now_epoch_secs() -> u64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
+111
View File
@@ -32,6 +32,7 @@ pub(super) async fn create_user(
let touches_user_rate_limits = let touches_user_rate_limits =
body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some(); body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some();
let touches_user_max_unique_ips = body.max_unique_ips.is_some(); let touches_user_max_unique_ips = body.max_unique_ips.is_some();
let touches_user_enabled = matches!(body.enabled, Some(false));
if !is_valid_username(&body.username) { if !is_valid_username(&body.username) {
return Err(ApiFailure::bad_request( return Err(ApiFailure::bad_request(
@@ -111,6 +112,9 @@ pub(super) async fn create_user(
.user_max_unique_ips .user_max_unique_ips
.insert(body.username.clone(), limit); .insert(body.username.clone(), limit);
} }
if matches!(body.enabled, Some(false)) {
cfg.access.user_enabled.insert(body.username.clone(), false);
}
cfg.validate() cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
@@ -134,6 +138,9 @@ pub(super) async fn create_user(
if touches_user_max_unique_ips { if touches_user_max_unique_ips {
touched_sections.push(AccessSection::UserMaxUniqueIps); touched_sections.push(AccessSection::UserMaxUniqueIps);
} }
if touches_user_enabled {
touched_sections.push(AccessSection::UserEnabled);
}
let revision = let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?; save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
@@ -161,6 +168,7 @@ pub(super) async fn create_user(
.find(|entry| entry.username == body.username) .find(|entry| entry.username == body.username)
.unwrap_or(UserInfo { .unwrap_or(UserInfo {
username: body.username.clone(), username: body.username.clone(),
enabled: cfg.access.is_user_enabled(&body.username),
in_runtime: false, in_runtime: false,
user_ad_tag: None, user_ad_tag: None,
max_tcp_conns: cfg max_tcp_conns: cfg
@@ -202,6 +210,7 @@ pub(super) async fn patch_user(
let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged) let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged)
|| !matches!(&body.rate_limit_down_bps, Patch::Unchanged); || !matches!(&body.rate_limit_down_bps, Patch::Unchanged);
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged); let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged);
if let Some(secret) = body.secret.as_ref() if let Some(secret) = body.secret.as_ref()
&& !is_valid_user_secret(secret) && !is_valid_user_secret(secret)
@@ -313,6 +322,15 @@ pub(super) async fn patch_user(
Some(Some(limit)) Some(Some(limit))
} }
}; };
match body.enabled {
Patch::Unchanged => {}
Patch::Remove | Patch::Set(true) => {
cfg.access.user_enabled.remove(user);
}
Patch::Set(false) => {
cfg.access.user_enabled.insert(user.to_string(), false);
}
}
cfg.validate() cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
@@ -339,6 +357,9 @@ pub(super) async fn patch_user(
if touches_user_max_unique_ips { if touches_user_max_unique_ips {
touched_sections.push(AccessSection::UserMaxUniqueIps); touched_sections.push(AccessSection::UserMaxUniqueIps);
} }
if touches_user_enabled {
touched_sections.push(AccessSection::UserEnabled);
}
let revision = if touched_sections.is_empty() { let revision = if touched_sections.is_empty() {
current_revision(&shared.config_path).await? current_revision(&shared.config_path).await?
@@ -399,6 +420,7 @@ pub(super) async fn rotate_secret(
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let touched_sections = [ let touched_sections = [
AccessSection::Users, AccessSection::Users,
AccessSection::UserEnabled,
AccessSection::UserAdTags, AccessSection::UserAdTags,
AccessSection::UserMaxTcpConns, AccessSection::UserMaxTcpConns,
AccessSection::UserExpirations, AccessSection::UserExpirations,
@@ -434,6 +456,55 @@ pub(super) async fn rotate_secret(
)) ))
} }
pub(super) async fn set_user_enabled(
user: &str,
enabled: bool,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<(UserInfo, String), ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
if !cfg.access.users.contains_key(user) {
return Err(ApiFailure::new(
StatusCode::NOT_FOUND,
"not_found",
"User not found",
));
}
if enabled {
cfg.access.user_enabled.remove(user);
} else {
cfg.access.user_enabled.insert(user.to_string(), false);
}
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
.await?;
drop(_guard);
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
let users = users_from_config(
&cfg,
&shared.stats,
&shared.ip_tracker,
detected_ip_v4,
detected_ip_v6,
None,
)
.await;
let user_info = users
.into_iter()
.find(|entry| entry.username == user)
.ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?;
Ok((user_info, revision))
}
pub(super) async fn delete_user( pub(super) async fn delete_user(
user: &str, user: &str,
expected_revision: Option<String>, expected_revision: Option<String>,
@@ -459,6 +530,7 @@ pub(super) async fn delete_user(
} }
cfg.access.users.remove(user); cfg.access.users.remove(user);
cfg.access.user_enabled.remove(user);
cfg.access.user_ad_tags.remove(user); cfg.access.user_ad_tags.remove(user);
cfg.access.user_max_tcp_conns.remove(user); cfg.access.user_max_tcp_conns.remove(user);
cfg.access.user_expirations.remove(user); cfg.access.user_expirations.remove(user);
@@ -470,6 +542,7 @@ pub(super) async fn delete_user(
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let touched_sections = [ let touched_sections = [
AccessSection::Users, AccessSection::Users,
AccessSection::UserEnabled,
AccessSection::UserAdTags, AccessSection::UserAdTags,
AccessSection::UserMaxTcpConns, AccessSection::UserMaxTcpConns,
AccessSection::UserExpirations, AccessSection::UserExpirations,
@@ -518,6 +591,7 @@ pub(super) async fn users_from_config(
}) })
.unwrap_or_else(empty_user_links); .unwrap_or_else(empty_user_links);
users.push(UserInfo { users.push(UserInfo {
enabled: cfg.access.is_user_enabled(&username),
in_runtime: runtime_cfg in_runtime: runtime_cfg
.map(|runtime| runtime.access.users.contains_key(&username)) .map(|runtime| runtime.access.users.contains_key(&username))
.unwrap_or(false), .unwrap_or(false),
@@ -876,6 +950,43 @@ mod tests {
assert_eq!(alice.rate_limit_down_bps, None); assert_eq!(alice.rate_limit_down_bps, None);
} }
#[tokio::test]
async fn users_from_config_reports_user_enabled_default_and_override() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.users.insert(
"bob".to_string(),
"fedcba9876543210fedcba9876543210".to_string(),
);
cfg.access.user_enabled.insert("bob".to_string(), false);
let stats = Stats::new();
let tracker = UserIpTracker::new();
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
let bob = users
.iter()
.find(|entry| entry.username == "bob")
.expect("bob must be present");
assert!(alice.enabled);
assert!(!bob.enabled);
cfg.access.user_enabled.insert("bob".to_string(), true);
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let bob = users
.iter()
.find(|entry| entry.username == "bob")
.expect("bob must be present");
assert!(bob.enabled);
}
#[tokio::test] #[tokio::test]
async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() { async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() {
let mut disk_cfg = ProxyConfig::default(); let mut disk_cfg = ProxyConfig::default();
+3
View File
@@ -705,6 +705,9 @@ ignore_time_skew = false
type = "direct" type = "direct"
enabled = true enabled = true
weight = 10 weight = 10
# Optional per-upstream DC family policy:
# ipv6 = true
# prefer = 6
"#, "#,
username = username, username = username,
secret = secret, secret = secret,
+15
View File
@@ -54,6 +54,9 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true;
const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85; const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85;
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70; const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096; const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1;
const DEFAULT_SYNLIMIT_BURST: u32 = 2;
const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2; const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
@@ -243,6 +246,18 @@ pub(crate) fn default_conntrack_delete_budget_per_sec() -> u64 {
DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC
} }
pub(crate) fn default_synlimit_seconds() -> u32 {
DEFAULT_SYNLIMIT_SECONDS
}
pub(crate) fn default_synlimit_hitcount() -> u32 {
DEFAULT_SYNLIMIT_HITCOUNT
}
pub(crate) fn default_synlimit_burst() -> u32 {
DEFAULT_SYNLIMIT_BURST
}
pub(crate) fn default_prefer_4() -> u8 { pub(crate) fn default_prefer_4() -> u8 {
4 4
} }
+151 -2
View File
@@ -16,10 +16,12 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately | //! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately | //! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately | //! | `access` | All user/quota fields | Effective immediately |
//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately |
//! //!
//! Fields that require re-binding sockets (`server.listeners`, legacy //! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. //! applied, except for SYN limiter fields on unchanged listener endpoints; a
//! warning is emitted.
//! Non-hot changes are never mixed into the runtime config snapshot. //! Non-hot changes are never mixed into the runtime config snapshot.
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -34,7 +36,8 @@ use tracing::{error, info, warn};
use super::load::{LoadedConfig, ProxyConfig}; use super::load::{LoadedConfig, ProxyConfig};
use crate::config::{ use crate::config::{
LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
MeWriterPickMode, SynLimitMode,
}; };
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50); const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
@@ -118,6 +121,7 @@ pub struct HotFields {
pub me_admission_poll_ms: u64, pub me_admission_poll_ms: u64,
pub me_warn_rate_limit_ms: u64, pub me_warn_rate_limit_ms: u64,
pub users: std::collections::HashMap<String, String>, pub users: std::collections::HashMap<String, String>,
pub user_enabled: std::collections::HashMap<String, bool>,
pub user_ad_tags: std::collections::HashMap<String, String>, pub user_ad_tags: std::collections::HashMap<String, String>,
pub user_max_tcp_conns: std::collections::HashMap<String, usize>, pub user_max_tcp_conns: std::collections::HashMap<String, usize>,
pub user_max_tcp_conns_global_each: usize, pub user_max_tcp_conns_global_each: usize,
@@ -130,6 +134,17 @@ pub struct HotFields {
pub user_max_unique_ips_global_each: usize, pub user_max_unique_ips_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode, pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64, pub user_max_unique_ips_window_secs: u64,
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerSynLimitHotFields {
pub ip: IpAddr,
pub port: Option<u16>,
pub synlimit: SynLimitMode,
pub synlimit_seconds: u32,
pub synlimit_hitcount: u32,
pub synlimit_burst: u32,
} }
impl HotFields { impl HotFields {
@@ -247,6 +262,7 @@ impl HotFields {
me_admission_poll_ms: cfg.general.me_admission_poll_ms, me_admission_poll_ms: cfg.general.me_admission_poll_ms,
me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms, me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms,
users: cfg.access.users.clone(), users: cfg.access.users.clone(),
user_enabled: cfg.access.user_enabled.clone(),
user_ad_tags: cfg.access.user_ad_tags.clone(), user_ad_tags: cfg.access.user_ad_tags.clone(),
user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(), user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(),
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each, user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
@@ -258,6 +274,25 @@ impl HotFields {
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each, user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode, user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs, user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
listener_synlimit: cfg
.server
.listeners
.iter()
.map(ListenerSynLimitHotFields::from_listener)
.collect(),
}
}
}
impl ListenerSynLimitHotFields {
fn from_listener(listener: &ListenerConfig) -> Self {
Self {
ip: listener.ip,
port: listener.port,
synlimit: listener.synlimit,
synlimit_seconds: listener.synlimit_seconds,
synlimit_hitcount: listener.synlimit_hitcount,
synlimit_burst: listener.synlimit_burst,
} }
} }
} }
@@ -310,6 +345,7 @@ fn listeners_equal(
lhs.iter().zip(rhs.iter()).all(|(a, b)| { lhs.iter().zip(rhs.iter()).all(|(a, b)| {
a.ip == b.ip a.ip == b.ip
&& a.port == b.port && a.port == b.port
&& a.client_mss == b.client_mss
&& a.announce == b.announce && a.announce == b.announce
&& a.announce_ip == b.announce_ip && a.announce_ip == b.announce_ip
&& a.proxy_protocol == b.proxy_protocol && a.proxy_protocol == b.proxy_protocol
@@ -551,6 +587,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms; cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms;
cfg.access.users = new.access.users.clone(); cfg.access.users = new.access.users.clone();
cfg.access.user_enabled = new.access.user_enabled.clone();
cfg.access.user_ad_tags = new.access.user_ad_tags.clone(); cfg.access.user_ad_tags = new.access.user_ad_tags.clone();
cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone(); cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone();
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each; cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
@@ -562,6 +599,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners);
if cfg.rebuild_runtime_user_auth().is_err() { if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None; cfg.runtime_user_auth = None;
@@ -570,6 +608,21 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg cfg
} }
fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) {
if old.len() != new.len() {
return;
}
for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) {
if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port {
continue;
}
old_listener.synlimit = new_listener.synlimit;
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
old_listener.synlimit_burst = new_listener.synlimit_burst;
}
}
/// Warn if any non-hot fields changed (require restart). /// Warn if any non-hot fields changed (require restart).
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
let mut warned = false; let mut warned = false;
@@ -605,6 +658,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4 || old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4
|| old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6 || old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6
|| old.server.listen_tcp != new.server.listen_tcp || old.server.listen_tcp != new.server.listen_tcp
|| old.server.client_mss != new.server.client_mss
|| old.server.listen_unix_sock != new.server.listen_unix_sock || old.server.listen_unix_sock != new.server.listen_unix_sock
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm || old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
{ {
@@ -615,6 +669,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.censorship.tls_domains != new.censorship.tls_domains || old.censorship.tls_domains != new.censorship.tls_domains
|| old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope || old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope
|| old.censorship.mask != new.censorship.mask || old.censorship.mask != new.censorship.mask
|| old.censorship.mask_dynamic != new.censorship.mask_dynamic
|| old.censorship.mask_host != new.censorship.mask_host || old.censorship.mask_host != new.censorship.mask_host
|| old.censorship.mask_port != new.censorship.mask_port || old.censorship.mask_port != new.censorship.mask_port
|| old.censorship.exclusive_mask != new.censorship.exclusive_mask || old.censorship.exclusive_mask != new.censorship.exclusive_mask
@@ -844,6 +899,13 @@ fn log_changes(
); );
} }
if old_hot.listener_synlimit != new_hot.listener_synlimit {
info!(
"config reload: server.listeners SYN limiter updated ({} listeners)",
new_hot.listener_synlimit.len()
);
}
if old_hot.desync_all_full != new_hot.desync_all_full { if old_hot.desync_all_full != new_hot.desync_all_full {
info!( info!(
"config reload: desync_all_full: {} → {}", "config reload: desync_all_full: {} → {}",
@@ -1178,6 +1240,16 @@ fn log_changes(
} }
} }
if old_hot.user_enabled != new_hot.user_enabled {
info!(
"config reload: user_enabled updated ({} disabled overrides)",
new_hot
.user_enabled
.values()
.filter(|enabled| !**enabled)
.count()
);
}
if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns { if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns {
info!( info!(
"config reload: user_max_tcp_conns updated ({} entries)", "config reload: user_max_tcp_conns updated ({} entries)",
@@ -1474,6 +1546,48 @@ pub fn spawn_config_watcher(
(config_rx, log_rx) (config_rx, log_rx)
} }
// ── Change classification ─────────────────────────────────────────────────────
/// Which top-level config sections changed and whether any require a restart.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct ChangeClassification {
pub changed: Vec<String>,
pub restart_required: bool,
}
/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and
/// see if anything non-hot remains different. This guarantees `restart_required`
/// matches actual runtime behavior and never drifts as new fields are added.
pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification {
let applied = overlay_hot_fields(old, new);
let restart_required = !config_equal(&applied, new);
ChangeClassification {
changed: changed_sections(old, new),
restart_required,
}
}
/// Top-level config sections whose canonical serialized form differs between
/// old and new. Uses the same serialize+canonicalize path as `config_equal`.
fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null);
let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null);
canonicalize_json(&mut lhs);
canonicalize_json(&mut rhs);
let mut out = Vec::new();
if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) {
let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect();
keys.extend(ro.keys());
for key in keys {
if lo.get(key) != ro.get(key) {
out.push(key.clone());
}
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1646,6 +1760,41 @@ mod tests {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
fn classify_sni_change_requires_restart() {
// censorship.* is not in overlay_hot_fields -> restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.censorship.tls_domain = "front.example".to_string();
let class = classify_config_changes(&old, &new);
assert!(class.restart_required);
assert!(class.changed.iter().any(|c| c == "censorship"));
}
#[test]
fn classify_dns_overrides_change_is_hot() {
// network.dns_overrides IS in overlay_hot_fields -> no restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.network.dns_overrides.push("1.1.1.1".to_string());
let class = classify_config_changes(&old, &new);
assert!(!class.restart_required);
assert!(class.changed.iter().any(|c| c == "network"));
}
#[test]
fn classify_timeouts_change_requires_restart() {
// timeouts.* is NOT in overlay_hot_fields -> restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.timeouts.client_handshake = old.timeouts.client_handshake + 1;
let class = classify_config_changes(&old, &new);
assert!(class.restart_required);
}
#[test] #[test]
fn reload_recovers_after_parse_error_on_next_attempt() { fn reload_recovers_after_parse_error_on_next_attempt() {
let initial_tag = "cccccccccccccccccccccccccccccccc"; let initial_tag = "cccccccccccccccccccccccccccccccc";
+351 -6
View File
@@ -114,6 +114,7 @@ fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String>
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"general", "general",
"logging",
"network", "network",
"server", "server",
"timeouts", "timeouts",
@@ -299,6 +300,8 @@ const SERVER_CONFIG_KEYS: &[&str] = &[
"listen_unix_sock", "listen_unix_sock",
"listen_unix_sock_perm", "listen_unix_sock_perm",
"listen_tcp", "listen_tcp",
"client_mss",
"client_mss_bulk",
"proxy_protocol", "proxy_protocol",
"proxy_protocol_header_timeout_ms", "proxy_protocol_header_timeout_ms",
"proxy_protocol_trusted_cidrs", "proxy_protocol_trusted_cidrs",
@@ -344,6 +347,11 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
const LISTENER_CONFIG_KEYS: &[&str] = &[ const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip", "ip",
"port", "port",
"client_mss",
"synlimit",
"synlimit_seconds",
"synlimit_hitcount",
"synlimit_burst",
"announce", "announce",
"announce_ip", "announce_ip",
"proxy_protocol", "proxy_protocol",
@@ -370,6 +378,7 @@ const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
"tls_fetch_scope", "tls_fetch_scope",
"tls_fetch", "tls_fetch",
"mask", "mask",
"mask_dynamic",
"mask_host", "mask_host",
"mask_port", "mask_port",
"exclusive_mask", "exclusive_mask",
@@ -411,6 +420,7 @@ const TLS_FETCH_CONFIG_KEYS: &[&str] = &[
const ACCESS_CONFIG_KEYS: &[&str] = &[ const ACCESS_CONFIG_KEYS: &[&str] = &[
"users", "users",
"user_enabled",
"user_ad_tags", "user_ad_tags",
"user_max_tcp_conns", "user_max_tcp_conns",
"user_max_tcp_conns_global_each", "user_max_tcp_conns_global_each",
@@ -450,6 +460,14 @@ const UPSTREAM_CONFIG_KEYS: &[&str] = &[
const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"]; const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"];
const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"]; const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"];
const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"]; const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"];
const LOGGING_CONFIG_KEYS: &[&str] = &[
"destination",
"path",
"rotation",
"max_size_bytes",
"max_files",
"max_age_secs",
];
#[derive(Debug)] #[derive(Debug)]
struct UnknownConfigKey { struct UnknownConfigKey {
@@ -491,6 +509,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
PROXY_MODES_CONFIG_KEYS, PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS, TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS, LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] { ] {
keys.extend_from_slice(group); keys.extend_from_slice(group);
} }
@@ -624,6 +643,13 @@ fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKe
&["general", "links"], &["general", "links"],
LINKS_CONFIG_KEYS, LINKS_CONFIG_KEYS,
); );
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table( check_known_table(
parsed_toml, parsed_toml,
&mut unknown, &mut unknown,
@@ -989,6 +1015,24 @@ fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
} }
} }
fn validate_logging_config(logging: &LoggingConfig) -> Result<()> {
if let Some(path) = logging.path.as_ref()
&& path.trim().is_empty()
{
return Err(ProxyError::Config(
"logging.path cannot be empty when provided".to_string(),
));
}
if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() {
return Err(ProxyError::Config(
"logging.path must be set when logging.destination=\"file\"".to_string(),
));
}
Ok(())
}
fn validate_upstreams(config: &ProxyConfig) -> Result<()> { fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| { let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| {
upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. }) upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. })
@@ -1006,6 +1050,14 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
"upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(), "upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(),
)); ));
} }
if let Some(prefer) = upstream.prefer
&& prefer != 4
&& prefer != 6
{
return Err(ProxyError::Config(
"upstream.prefer must be 4 or 6".to_string(),
));
}
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type { if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
let parsed = ShadowsocksServerConfig::from_url(url) let parsed = ShadowsocksServerConfig::from_url(url)
@@ -1021,13 +1073,37 @@ fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
Ok(()) Ok(())
} }
// ============= Main Config ============= fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
warn!(
upstream = idx,
"upstream.prefer=4 but upstream.ipv4=false; forcing prefer=6"
);
upstream.prefer = Some(6);
}
if matches!(upstream.ipv6, Some(false)) && upstream.prefer == Some(6) {
warn!(
upstream = idx,
"upstream.prefer=6 but upstream.ipv6=false; forcing prefer=4"
);
upstream.prefer = Some(4);
}
}
}
// Main runtime configuration loaded from TOML.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyConfig { pub struct ProxyConfig {
#[serde(default)] #[serde(default)]
pub general: GeneralConfig, pub general: GeneralConfig,
/// Runtime logging destination, rotation, and retention configuration.
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)] #[serde(default)]
pub network: NetworkConfig, pub network: NetworkConfig,
@@ -1904,6 +1980,42 @@ 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()
.map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?;
for (idx, listener) in config.server.listeners.iter().enumerate() {
if listener.client_mss.is_some() {
listener
.effective_client_mss(&config.server)
.map_err(|error| {
ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}"))
})?;
}
if listener.synlimit_seconds == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_seconds must be > 0"
)));
}
if listener.synlimit_hitcount == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_hitcount must be > 0"
)));
}
if listener.synlimit_burst == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_burst must be > 0"
)));
}
}
if config.server.accept_permit_timeout_ms > 60_000 { if config.server.accept_permit_timeout_ms > 60_000 {
return Err(ProxyError::Config( return Err(ProxyError::Config(
"server.accept_permit_timeout_ms must be within [0, 60000]".to_string(), "server.accept_permit_timeout_ms must be within [0, 60000]".to_string(),
@@ -2002,11 +2114,6 @@ impl ProxyConfig {
*mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?; *mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?;
} }
// Default mask_host to tls_domain if not set and no unix socket configured.
if config.censorship.mask_host.is_none() && config.censorship.mask_unix_sock.is_none() {
config.censorship.mask_host = Some(config.censorship.tls_domain.clone());
}
for (domain, target) in &config.censorship.exclusive_mask { for (domain, target) in &config.censorship.exclusive_mask {
if !is_valid_tls_domain_name(domain) { if !is_valid_tls_domain_name(domain) {
return Err(ProxyError::Config(format!( return Err(ProxyError::Config(format!(
@@ -2144,6 +2251,11 @@ impl ProxyConfig {
config.server.listeners.push(ListenerConfig { config.server.listeners.push(ListenerConfig {
ip: ipv4, ip: ipv4,
port: Some(config.server.port), port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
synlimit_seconds: default_synlimit_seconds(),
synlimit_hitcount: default_synlimit_hitcount(),
synlimit_burst: default_synlimit_burst(),
announce: None, announce: None,
announce_ip: None, announce_ip: None,
proxy_protocol: None, proxy_protocol: None,
@@ -2156,6 +2268,11 @@ impl ProxyConfig {
config.server.listeners.push(ListenerConfig { config.server.listeners.push(ListenerConfig {
ip: ipv6, ip: ipv6,
port: Some(config.server.port), port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
synlimit_seconds: default_synlimit_seconds(),
synlimit_hitcount: default_synlimit_hitcount(),
synlimit_burst: default_synlimit_burst(),
announce: None, announce: None,
announce_ip: None, announce_ip: None,
proxy_protocol: None, proxy_protocol: None,
@@ -2199,8 +2316,10 @@ impl ProxyConfig {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}); });
} }
normalize_upstream_family_policy(&mut config);
// Ensure default DC203 override is present. // Ensure default DC203 override is present.
config config
@@ -2208,6 +2327,7 @@ impl ProxyConfig {
.entry("203".to_string()) .entry("203".to_string())
.or_insert_with(|| vec!["91.105.192.100:443".to_string()]); .or_insert_with(|| vec!["91.105.192.100:443".to_string()]);
validate_logging_config(&config.logging)?;
validate_upstreams(&config)?; validate_upstreams(&config)?;
config.rebuild_runtime_user_auth()?; config.rebuild_runtime_user_auth()?;
@@ -2233,6 +2353,8 @@ impl ProxyConfig {
return Err(ProxyError::Config("No users configured".to_string())); return Err(ProxyError::Config("No users configured".to_string()));
} }
validate_logging_config(&self.logging)?;
if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls { if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls {
return Err(ProxyError::Config("No modes enabled".to_string())); return Err(ProxyError::Config("No modes enabled".to_string()));
} }
@@ -2329,6 +2451,21 @@ mod tests {
cfg cfg
} }
fn load_config_error_from_temp_toml(toml: &str) -> String {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("telemt_load_cfg_error_{nonce}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, toml).unwrap();
let error = ProxyConfig::load(&path).unwrap_err().to_string();
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(dir);
error
}
#[test] #[test]
fn serde_defaults_remain_unchanged_for_present_sections() { fn serde_defaults_remain_unchanged_for_present_sections() {
let toml = r#" let toml = r#"
@@ -2339,6 +2476,7 @@ mod tests {
"#; "#;
let cfg: ProxyConfig = toml::from_str(toml).unwrap(); let cfg: ProxyConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.logging, LoggingConfig::default());
assert_eq!(cfg.network.ipv6, default_network_ipv6()); assert_eq!(cfg.network.ipv6, default_network_ipv6());
assert_eq!(cfg.network.stun_use, default_true()); assert_eq!(cfg.network.stun_use, default_true());
assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback());
@@ -2429,6 +2567,7 @@ mod tests {
assert_eq!(cfg.general.update_every, default_update_every()); assert_eq!(cfg.general.update_every, default_update_every());
assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4());
assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt());
assert_eq!(cfg.server.client_mss_value(), Ok(None));
assert_eq!( assert_eq!(
cfg.server.proxy_protocol_trusted_cidrs, cfg.server.proxy_protocol_trusted_cidrs,
default_proxy_protocol_trusted_cidrs() default_proxy_protocol_trusted_cidrs()
@@ -2505,6 +2644,65 @@ mod tests {
); );
} }
#[test]
fn logging_config_is_loaded_from_strict_config() {
let cfg = load_config_from_temp_toml(
r#"
[general]
config_strict = true
[general.modes]
classic = false
secure = false
tls = true
[logging]
destination = "file"
path = "/tmp/telemt.log"
rotation = "daily"
max_size_bytes = 1024
max_files = 3
max_age_secs = 60
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
assert_eq!(cfg.logging.destination, LoggingDestination::File);
assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log"));
assert_eq!(cfg.logging.rotation, LogRotation::Daily);
assert_eq!(cfg.logging.max_size_bytes, 1024);
assert_eq!(cfg.logging.max_files, 3);
assert_eq!(cfg.logging.max_age_secs, 60);
}
#[test]
fn file_logging_requires_path() {
let error = load_config_error_from_temp_toml(
r#"
[general.modes]
classic = false
secure = false
tls = true
[logging]
destination = "file"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
assert!(error.contains("logging.path must be set"));
}
#[test] #[test]
fn impl_defaults_are_sourced_from_default_helpers() { fn impl_defaults_are_sourced_from_default_helpers() {
let network = NetworkConfig::default(); let network = NetworkConfig::default();
@@ -3756,6 +3954,153 @@ mod tests {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
fn client_mss_presets_and_listener_override_are_resolved() {
let toml = r#"
[server]
client_mss = "tspu"
[[server.listeners]]
ip = "127.0.0.1"
port = 1443
[[server.listeners]]
ip = "127.0.0.2"
port = 1444
client_mss = "2in8"
[[server.listeners]]
ip = "127.0.0.3"
port = 1445
client_mss = ""
[[server.listeners]]
ip = "127.0.0.4"
port = 1446
client_mss = "extreme-low"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(92)));
assert_eq!(
cfg.server.listeners[0].effective_client_mss(&cfg.server),
Ok(Some(92))
);
assert_eq!(
cfg.server.listeners[1].effective_client_mss(&cfg.server),
Ok(Some(256))
);
assert_eq!(
cfg.server.listeners[2].effective_client_mss(&cfg.server),
Ok(None)
);
assert_eq!(
cfg.server.listeners[3].effective_client_mss(&cfg.server),
Ok(Some(88))
);
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_custom_value_is_accepted() {
let toml = r#"
[server]
client_mss = "4096"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_custom_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096)));
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_out_of_range_is_rejected() {
for value in ["87", "4097"] {
let toml = format!(
r#"
[server]
client_mss = "{value}"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#
);
let dir = std::env::temp_dir();
let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml"));
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.client_mss custom value must be within [88, 4096]"));
let _ = std::fs::remove_file(path);
}
}
#[test]
fn client_mss_unquoted_number_is_rejected() {
let toml = r#"
[server]
client_mss = 256
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_unquoted_number_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("client_mss"));
let _ = std::fs::remove_file(path);
}
#[test]
fn listener_client_mss_invalid_preset_is_rejected() {
let toml = r#"
[[server.listeners]]
ip = "127.0.0.1"
port = 1443
client_mss = "tiny"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_listener_client_mss_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.listeners[0].client_mss"));
assert!(err.contains("must be \"\", extreme-low, tspu, 2in8"));
let _ = std::fs::remove_file(path);
}
#[test] #[test]
fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() {
let toml = r#" let toml = r#"
@@ -1,14 +1,21 @@
use super::*; use super::*;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_CONFIG_COUNTER: AtomicU64 = AtomicU64::new(0);
fn write_temp_config(contents: &str) -> PathBuf { fn write_temp_config(contents: &str) -> PathBuf {
let nonce = SystemTime::now() let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.expect("system time must be after unix epoch") .expect("system time must be after unix epoch")
.as_nanos(); .as_nanos();
let path = std::env::temp_dir().join(format!("telemt-load-mask-shape-security-{nonce}.toml")); let seq = TEMP_CONFIG_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!(
"telemt-load-mask-shape-security-{pid}-{seq}-{nonce}.toml"
));
fs::write(&path, contents).expect("temp config write must succeed"); fs::write(&path, contents).expect("temp config write must succeed");
path path
} }
@@ -95,6 +95,44 @@ max_client_frame = 16777217
remove_temp_config(&path); remove_temp_config(&path);
} }
#[test]
fn load_rejects_listen_backlog_above_i32_upper_bound() {
let path = write_temp_config(
r#"
[server]
listen_backlog = 2147483648
"#,
);
let err = ProxyConfig::load(&path).expect_err("listen_backlog above socket cap must fail");
let msg = err.to_string();
assert!(
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
"error must explain listen_backlog hard cap, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_zero_listen_backlog() {
let path = write_temp_config(
r#"
[server]
listen_backlog = 0
"#,
);
let err = ProxyConfig::load(&path).expect_err("zero listen_backlog must fail");
let msg = err.to_string();
assert!(
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
"error must explain listen_backlog lower bound, got: {msg}"
);
remove_temp_config(&path);
}
#[test] #[test]
fn load_accepts_memory_limits_at_hard_upper_bounds() { fn load_accepts_memory_limits_at_hard_upper_bounds() {
let path = write_temp_config( let path = write_temp_config(
+274 -1
View File
@@ -63,6 +63,86 @@ impl std::fmt::Display for LogLevel {
} }
} }
/// Logging output destination.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LoggingDestination {
/// Write logs to stderr.
#[default]
Stderr,
/// Write logs to syslog on Unix platforms.
Syslog,
/// Write logs to a file.
File,
}
/// Time-based log rotation interval for file logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogRotation {
/// Do not rotate logs by time.
#[default]
Never,
/// Rotate once per minute.
Minutely,
/// Rotate once per hour.
Hourly,
/// Rotate once per day.
Daily,
/// Rotate once per week.
Weekly,
}
impl LogRotation {
/// Parse a CLI rotation value.
pub fn from_cli_arg(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"never" | "none" | "off" => Some(Self::Never),
"minutely" | "minute" => Some(Self::Minutely),
"hourly" | "hour" => Some(Self::Hourly),
"daily" | "day" => Some(Self::Daily),
"weekly" | "week" => Some(Self::Weekly),
_ => None,
}
}
}
/// File logging and retention settings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggingConfig {
/// Effective logging destination.
#[serde(default)]
pub destination: LoggingDestination,
/// File path used when `destination = "file"`.
#[serde(default)]
pub path: Option<String>,
/// Time rotation interval for file logs.
#[serde(default)]
pub rotation: LogRotation,
/// Maximum active log file size before rotating. `0` disables size rotation.
#[serde(default)]
pub max_size_bytes: u64,
/// Maximum number of matching log files to keep. `0` disables count retention.
#[serde(default)]
pub max_files: usize,
/// Maximum age for rotated log files in seconds. `0` disables age retention.
#[serde(default)]
pub max_age_secs: u64,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
destination: LoggingDestination::Stderr,
path: None,
rotation: LogRotation::Never,
max_size_bytes: 0,
max_files: 0,
max_age_secs: 0,
}
}
}
/// Middle-End telemetry verbosity level. /// Middle-End telemetry verbosity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -429,7 +509,7 @@ pub struct GeneralConfig {
pub ad_tag: Option<String>, pub ad_tag: Option<String>,
/// Public IP override for middle-proxy NAT environments. /// Public IP override for middle-proxy NAT environments.
/// When set, this IP is used in ME key derivation and RPC_PROXY_REQ "our_addr". /// When set, this IP is used in ME key derivation and local address translation.
#[serde(default)] #[serde(default)]
pub middle_proxy_nat_ip: Option<IpAddr>, pub middle_proxy_nat_ip: Option<IpAddr>,
@@ -1369,6 +1449,77 @@ impl ConntrackPressureProfile {
} }
} }
/// Per-listener SYN limiter mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SynLimitMode {
/// Disable SYN limiting for this listener.
#[default]
Off,
/// Use iptables/ip6tables filter rules with the hashlimit match.
Iptables,
/// Use nftables rules with per-source token-bucket meters.
Nftables,
}
impl Serialize for SynLimitMode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Off => serializer.serialize_bool(false),
Self::Iptables => serializer.serialize_str("iptables"),
Self::Nftables => serializer.serialize_str("nftables"),
}
}
}
impl<'de> Deserialize<'de> for SynLimitMode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SynLimitModeVisitor;
impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor {
type Value = SynLimitMode;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("false, iptables, or nftables")
}
fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
if value {
Err(E::custom(
"synlimit=true is ambiguous; use \"iptables\" or \"nftables\"",
))
} else {
Ok(SynLimitMode::Off)
}
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
match value.trim().to_ascii_lowercase().as_str() {
"false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off),
"iptables" => Ok(SynLimitMode::Iptables),
"nftables" => Ok(SynLimitMode::Nftables),
_ => Err(E::custom(
"synlimit must be false, \"iptables\", or \"nftables\"",
)),
}
}
}
deserializer.deserialize_any(SynLimitModeVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConntrackControlConfig { pub struct ConntrackControlConfig {
/// Enables runtime conntrack-control worker for pressure mitigation. /// Enables runtime conntrack-control worker for pressure mitigation.
@@ -1451,6 +1602,20 @@ pub struct ServerConfig {
#[serde(default)] #[serde(default)]
pub listen_tcp: Option<bool>, pub listen_tcp: Option<bool>,
/// Client-facing TCP MSS preset or custom value for all TCP listeners.
/// Empty string or omitted value keeps the kernel default.
#[serde(default)]
pub client_mss: Option<String>,
/// Client-facing TCP MSS to switch to AFTER the TLS handshake (ServerHello)
/// is sent. Lets `client_mss` fragment ONLY the handshake (the DPI-inspected
/// part) while the bulk transfer uses normal-size packets — avoids the ~10x
/// packets-per-second blowup that triggers anti-DDoS abuse blocks on
/// pps-policing hosts. Empty/omitted = keep the handshake MSS for the whole
/// connection (previous behavior). Same preset/int grammar as `client_mss`.
#[serde(default)]
pub client_mss_bulk: Option<String>,
/// Accept HAProxy PROXY protocol headers on incoming connections. /// Accept HAProxy PROXY protocol headers on incoming connections.
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers. /// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
#[serde(default)] #[serde(default)]
@@ -1517,6 +1682,8 @@ impl Default for ServerConfig {
listen_unix_sock: None, listen_unix_sock: None,
listen_unix_sock_perm: None, listen_unix_sock_perm: None,
listen_tcp: None, listen_tcp: None,
client_mss: None,
client_mss_bulk: None,
proxy_protocol: false, proxy_protocol: false,
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(), proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(), proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
@@ -1720,6 +1887,10 @@ pub struct AntiCensorshipConfig {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub mask: bool, pub mask: bool,
/// Use the ClientHello SNI as the mask TCP target for configured TLS domains.
#[serde(default = "default_true")]
pub mask_dynamic: bool,
#[serde(default)] #[serde(default)]
pub mask_host: Option<String>, pub mask_host: Option<String>,
@@ -1855,6 +2026,7 @@ impl Default for AntiCensorshipConfig {
tls_fetch_scope: default_tls_fetch_scope(), tls_fetch_scope: default_tls_fetch_scope(),
tls_fetch: TlsFetchConfig::default(), tls_fetch: TlsFetchConfig::default(),
mask: default_true(), mask: default_true(),
mask_dynamic: default_true(),
mask_host: None, mask_host: None,
mask_port: default_mask_port(), mask_port: default_mask_port(),
exclusive_mask: HashMap::new(), exclusive_mask: HashMap::new(),
@@ -1892,6 +2064,9 @@ pub struct AccessConfig {
#[serde(default = "default_access_users")] #[serde(default = "default_access_users")]
pub users: HashMap<String, String>, pub users: HashMap<String, String>,
#[serde(default)]
pub user_enabled: HashMap<String, bool>,
/// Per-user ad_tag (32 hex chars from @MTProxybot). /// Per-user ad_tag (32 hex chars from @MTProxybot).
#[serde(default)] #[serde(default)]
pub user_ad_tags: HashMap<String, String>, pub user_ad_tags: HashMap<String, String>,
@@ -1963,6 +2138,7 @@ impl Default for AccessConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
users: default_access_users(), users: default_access_users(),
user_enabled: HashMap::new(),
user_ad_tags: HashMap::new(), user_ad_tags: HashMap::new(),
user_max_tcp_conns: HashMap::new(), user_max_tcp_conns: HashMap::new(),
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(), user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
@@ -1983,6 +2159,10 @@ impl Default for AccessConfig {
} }
impl AccessConfig { impl AccessConfig {
pub fn is_user_enabled(&self, username: &str) -> bool {
self.user_enabled.get(username).copied().unwrap_or(true)
}
/// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`. /// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`.
pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool { pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool {
self.user_source_deny self.user_source_deny
@@ -2057,6 +2237,20 @@ pub struct UpstreamConfig {
/// `None` means auto-detect from runtime connectivity state. /// `None` means auto-detect from runtime connectivity state.
#[serde(default)] #[serde(default)]
pub ipv6: Option<bool>, pub ipv6: Option<bool>,
/// Per-upstream IP family preference for Telegram DC targets.
/// `None` inherits the effective global `[network].prefer` decision.
#[serde(default)]
pub prefer: Option<u8>,
}
impl UpstreamConfig {
pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool {
match self.prefer {
Some(6) => true,
Some(4) => false,
_ => default_prefer_ipv6,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -2065,6 +2259,22 @@ pub struct ListenerConfig {
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`. /// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
#[serde(default)] #[serde(default)]
pub port: Option<u16>, pub port: Option<u16>,
/// Per-listener client-facing TCP MSS preset or custom value.
/// Empty string disables MSS shaping for this listener.
#[serde(default)]
pub client_mss: Option<String>,
/// Per-listener SYN limiter mode.
#[serde(default)]
pub synlimit: SynLimitMode,
/// Token-bucket rate interval for the per-listener SYN limiter.
#[serde(default = "default_synlimit_seconds")]
pub synlimit_seconds: u32,
/// Token-bucket rate amount for the per-listener SYN limiter.
#[serde(default = "default_synlimit_hitcount")]
pub synlimit_hitcount: u32,
/// Token-bucket burst size for the per-listener SYN limiter.
#[serde(default = "default_synlimit_burst")]
pub synlimit_burst: u32,
/// IP address or hostname to announce in proxy links. /// IP address or hostname to announce in proxy links.
/// Takes precedence over `announce_ip` if both are set. /// Takes precedence over `announce_ip` if both are set.
#[serde(default)] #[serde(default)]
@@ -2082,6 +2292,69 @@ pub struct ListenerConfig {
pub reuse_allow: bool, pub reuse_allow: bool,
} }
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
pub const CLIENT_MSS_EXTREME_LOW: u16 = 88;
/// Client-facing TCP MSS preset matching TSPU-oriented deployments.
pub const CLIENT_MSS_TSPU: u16 = 92;
/// Client-facing TCP MSS preset for 2-in-8 segment shaping.
pub const CLIENT_MSS_2IN8: u16 = 256;
/// Minimum accepted custom client-facing TCP MSS value.
pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW;
/// Maximum accepted custom client-facing TCP MSS value.
pub const CLIENT_MSS_MAX: u16 = 4096;
impl ServerConfig {
/// Resolves the global client-facing TCP MSS setting.
pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> {
parse_client_mss(self.client_mss.as_deref())
}
/// Resolves the post-handshake (bulk transfer) client MSS, if configured.
pub fn client_mss_bulk_value(&self) -> std::result::Result<Option<u16>, String> {
parse_client_mss(self.client_mss_bulk.as_deref())
}
}
impl ListenerConfig {
/// Resolves the listener MSS override, falling back to the global server value.
pub fn effective_client_mss(
&self,
server: &ServerConfig,
) -> std::result::Result<Option<u16>, String> {
match self.client_mss.as_deref() {
Some(value) => parse_client_mss(Some(value)),
None => server.client_mss_value(),
}
}
}
fn parse_client_mss(raw: Option<&str>) -> std::result::Result<Option<u16>, String> {
let Some(raw) = raw else {
return Ok(None);
};
let value = raw.trim();
if value.is_empty() {
return Ok(None);
}
match value.to_ascii_lowercase().as_str() {
"extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)),
"tspu" => return Ok(Some(CLIENT_MSS_TSPU)),
"2in8" => return Ok(Some(CLIENT_MSS_2IN8)),
_ => {}
}
let parsed = value
.parse::<u16>()
.map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?;
if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) {
return Err(format!(
"custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]"
));
}
Ok(Some(parsed))
}
// ============= ShowLink ============= // ============= ShowLink =============
/// Controls which users' proxy links are displayed at startup. /// Controls which users' proxy links are displayed at startup.
+1 -1
View File
@@ -705,7 +705,7 @@ fn nofile_soft_limit() -> Option<u64> {
if rc != 0 { if rc != 0 {
return None; return None;
} }
return Some(lim.rlim_cur); return Some(lim.rlim_cur.into());
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
{ {
+3
View File
@@ -245,6 +245,9 @@ pub enum ProxyError {
InvalidSecret { user: String, reason: String }, InvalidSecret { user: String, reason: String },
// ============= User Errors ============= // ============= User Errors =============
#[error("User {user} disabled")]
UserDisabled { user: String },
#[error("User {user} expired")] #[error("User {user} expired")]
UserExpired { user: String }, UserExpired { user: String },
+222 -101
View File
@@ -5,16 +5,41 @@
//! - syslog (Unix only, for traditional init systems) //! - syslog (Unix only, for traditional init systems)
//! - file (with optional rotation) //! - file (with optional rotation)
#![allow(dead_code)] // Infrastructure module - used via CLI flags // Infrastructure module used via CLI flags.
#![allow(dead_code)]
use std::path::Path; use std::path::Path;
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt, reload}; use tracing_subscriber::{EnvFilter, fmt, reload};
// Submodules:
// - file: bounded file appender for size and retention controls.
mod file;
#[cfg(test)]
mod tests;
/// File logging and retention options resolved from config and CLI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileLogOptions {
/// Log file path or rolling filename prefix path.
pub path: String,
/// Time rotation interval.
pub rotation: LogRotation,
/// Maximum active file size before size rotation. `0` disables it.
pub max_size_bytes: u64,
/// Maximum number of matching log files to keep. `0` disables it.
pub max_files: usize,
/// Maximum rotated file age in seconds. `0` disables it.
pub max_age_secs: u64,
}
/// Log destination configuration. /// Log destination configuration.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LogDestination { pub enum LogDestination {
/// Log to stderr (default, captured by systemd journald). /// Log to stderr (default, captured by systemd journald).
#[default] #[default]
@@ -24,12 +49,29 @@ pub enum LogDestination {
Syslog, Syslog,
/// Log to a file with optional rotation. /// Log to a file with optional rotation.
File { File {
path: String, /// Resolved file logging options.
/// Rotate daily if true. options: FileLogOptions,
rotate_daily: bool,
}, },
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LogCliDestination {
Stderr,
Syslog,
File,
}
/// Logging-related CLI overrides.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LogCliOptions {
destination: Option<LogCliDestination>,
path: Option<String>,
rotation: Option<LogRotation>,
max_size_bytes: Option<u64>,
max_files: Option<usize>,
max_age_secs: Option<u64>,
}
/// Logging options parsed from CLI/config. /// Logging options parsed from CLI/config.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct LoggingOptions { pub struct LoggingOptions {
@@ -101,23 +143,29 @@ pub fn init_logging(
(filter_handle, LoggingGuard::noop()) (filter_handle, LoggingGuard::noop())
} }
LogDestination::File { path, rotate_daily } => { LogDestination::File { options } => {
let (non_blocking, guard) = if *rotate_daily { let (non_blocking, guard) = if options.max_size_bytes > 0
// Extract directory and filename prefix || options.max_files > 0
let path = Path::new(path); || options.max_age_secs > 0
let dir = path.parent().unwrap_or(Path::new("/var/log")); {
let prefix = path let file_appender = file::BoundedFileAppender::new(options.clone())
.file_name() .expect("Failed to open log file");
.and_then(|s| s.to_str()) tracing_appender::non_blocking(file_appender)
.unwrap_or("telemt"); } else if !matches!(options.rotation, LogRotation::Never) {
let path = Path::new(&options.path);
let file_appender = tracing_appender::rolling::daily(dir, prefix); let dir = log_file_dir(path);
let prefix = log_file_name(path);
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(to_tracing_rotation(options.rotation))
.filename_prefix(prefix)
.build(dir)
.expect("Failed to open log file");
tracing_appender::non_blocking(file_appender) tracing_appender::non_blocking(file_appender)
} else { } else {
let file = std::fs::OpenOptions::new() let file = std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(path) .open(&options.path)
.expect("Failed to open log file"); .expect("Failed to open log file");
tracing_appender::non_blocking(file) tracing_appender::non_blocking(file)
}; };
@@ -137,6 +185,28 @@ pub fn init_logging(
} }
} }
fn log_file_dir(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn log_file_name(path: &Path) -> &str {
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("telemt")
}
fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation {
match rotation {
LogRotation::Never => tracing_appender::rolling::Rotation::NEVER,
LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY,
LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY,
}
}
/// Syslog writer for tracing. /// Syslog writer for tracing.
#[cfg(unix)] #[cfg(unix)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter {
} }
} }
/// Parse log destination from CLI arguments. /// Parse logging overrides from CLI arguments.
pub fn parse_log_destination(args: &[String]) -> LogDestination { pub fn parse_log_cli_options(args: &[String]) -> Result<LogCliOptions, String> {
let mut options = LogCliOptions::default();
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
match args[i].as_str() { match args[i].as_str() {
#[cfg(unix)] #[cfg(unix)]
"--syslog" => { "--syslog" => {
return LogDestination::Syslog; options.destination = Some(LogCliDestination::Syslog);
}
#[cfg(not(unix))]
"--syslog" => {
options.destination = Some(LogCliDestination::Syslog);
} }
"--log-file" => { "--log-file" => {
i += 1; i += 1;
if i < args.len() { if i < args.len() {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: args[i].clone(), options.path = Some(args[i].clone());
rotate_daily: false, } else {
}; return Err("Missing value for --log-file".to_string());
} }
} }
s if s.starts_with("--log-file=") => { s if s.starts_with("--log-file=") => {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: s.trim_start_matches("--log-file=").to_string(), options.path = Some(s.trim_start_matches("--log-file=").to_string());
rotate_daily: false,
};
} }
"--log-file-daily" => { "--log-file-daily" => {
i += 1; i += 1;
if i < args.len() { if i < args.len() {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: args[i].clone(), options.path = Some(args[i].clone());
rotate_daily: true, options.rotation = Some(LogRotation::Daily);
}; } else {
return Err("Missing value for --log-file-daily".to_string());
} }
} }
s if s.starts_with("--log-file-daily=") => { s if s.starts_with("--log-file-daily=") => {
return LogDestination::File { options.destination = Some(LogCliDestination::File);
path: s.trim_start_matches("--log-file-daily=").to_string(), options.path = Some(s.trim_start_matches("--log-file-daily=").to_string());
rotate_daily: true, options.rotation = Some(LogRotation::Daily);
}; }
"--log-rotation" => {
i += 1;
if i < args.len() {
options.rotation = Some(parse_rotation_cli_value(&args[i])?);
} else {
return Err("Missing value for --log-rotation".to_string());
}
}
s if s.starts_with("--log-rotation=") => {
options.rotation = Some(parse_rotation_cli_value(
s.trim_start_matches("--log-rotation="),
)?);
}
"--log-max-size-bytes" => {
i += 1;
if i < args.len() {
options.max_size_bytes =
Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?);
} else {
return Err("Missing value for --log-max-size-bytes".to_string());
}
}
s if s.starts_with("--log-max-size-bytes=") => {
options.max_size_bytes = Some(parse_u64_cli_value(
"--log-max-size-bytes",
s.trim_start_matches("--log-max-size-bytes="),
)?);
}
"--log-max-files" => {
i += 1;
if i < args.len() {
options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?);
} else {
return Err("Missing value for --log-max-files".to_string());
}
}
s if s.starts_with("--log-max-files=") => {
options.max_files = Some(parse_usize_cli_value(
"--log-max-files",
s.trim_start_matches("--log-max-files="),
)?);
}
"--log-max-age-secs" => {
i += 1;
if i < args.len() {
options.max_age_secs =
Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?);
} else {
return Err("Missing value for --log-max-age-secs".to_string());
}
}
s if s.starts_with("--log-max-age-secs=") => {
options.max_age_secs = Some(parse_u64_cli_value(
"--log-max-age-secs",
s.trim_start_matches("--log-max-age-secs="),
)?);
} }
_ => {} _ => {}
} }
i += 1; i += 1;
} }
LogDestination::Stderr Ok(options)
} }
#[cfg(test)] fn parse_rotation_cli_value(value: &str) -> Result<LogRotation, String> {
mod tests { LogRotation::from_cli_arg(value).ok_or_else(|| {
use super::*; format!(
"Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly"
)
})
}
#[test] fn parse_u64_cli_value(flag: &str, value: &str) -> Result<u64, String> {
fn test_parse_log_destination_default() { value
let args: Vec<String> = vec![]; .parse::<u64>()
assert!(matches!( .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
parse_log_destination(&args), }
LogDestination::Stderr
));
}
#[test] fn parse_usize_cli_value(flag: &str, value: &str) -> Result<usize, String> {
fn test_parse_log_destination_file() { value
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()]; .parse::<usize>()
match parse_log_destination(&args) { .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
LogDestination::File { path, rotate_daily } => { }
assert_eq!(path, "/var/log/telemt.log");
assert!(!rotate_daily); /// Resolve effective logging destination from config and CLI overrides.
pub fn resolve_log_destination(
config: &LoggingConfig,
cli: &LogCliOptions,
) -> Result<LogDestination, String> {
let destination = cli.destination.unwrap_or(match config.destination {
LoggingDestination::Stderr => LogCliDestination::Stderr,
LoggingDestination::Syslog => LogCliDestination::Syslog,
LoggingDestination::File => LogCliDestination::File,
});
match destination {
LogCliDestination::Stderr => Ok(LogDestination::Stderr),
LogCliDestination::Syslog => {
#[cfg(unix)]
{
Ok(LogDestination::Syslog)
} }
_ => panic!("Expected File destination"), #[cfg(not(unix))]
} {
} Err("Syslog logging is only supported on Unix platforms".to_string())
#[test]
fn test_parse_log_destination_file_daily() {
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
match parse_log_destination(&args) {
LogDestination::File { path, rotate_daily } => {
assert_eq!(path, "/var/log/telemt");
assert!(rotate_daily);
} }
_ => panic!("Expected File destination"),
} }
} LogCliDestination::File => {
let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| {
"logging.path or --log-file must be set when file logging is enabled".to_string()
})?;
if path.trim().is_empty() {
return Err("Log file path cannot be empty".to_string());
}
#[cfg(unix)] Ok(LogDestination::File {
#[test] options: FileLogOptions {
fn test_parse_log_destination_syslog() { path: path.clone(),
let args = vec!["--syslog".to_string()]; rotation: cli.rotation.unwrap_or(config.rotation),
assert!(matches!( max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes),
parse_log_destination(&args), max_files: cli.max_files.unwrap_or(config.max_files),
LogDestination::Syslog max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs),
)); },
} })
}
#[cfg(unix)]
#[test]
fn test_syslog_priority_for_level_mapping() {
assert_eq!(
syslog_priority_for_level(&tracing::Level::ERROR),
libc::LOG_ERR
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::WARN),
libc::LOG_WARNING
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::INFO),
libc::LOG_INFO
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::DEBUG),
libc::LOG_DEBUG
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::TRACE),
libc::LOG_DEBUG
);
} }
} }
+395
View File
@@ -0,0 +1,395 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc};
use crate::config::LogRotation;
use super::FileLogOptions;
const CLEANUP_INTERVAL_SECS: i64 = 60;
/// File appender with size rotation and local retention cleanup.
pub(crate) struct BoundedFileAppender {
options: FileLogOptions,
dir: PathBuf,
base_name: String,
current_path: PathBuf,
current_size: u64,
last_cleanup: DateTime<Utc>,
file: Option<File>,
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
}
impl BoundedFileAppender {
pub(crate) fn new(options: FileLogOptions) -> io::Result<Self> {
Self::with_now(options, Box::new(Utc::now))
}
fn with_now(
options: FileLogOptions,
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
) -> io::Result<Self> {
let path = Path::new(&options.path);
let dir = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let base_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("telemt")
.to_string();
let start = now();
let current_path = active_path_for(&dir, &base_name, options.rotation, &start);
let (file, current_size) = open_append_file(&current_path)?;
let mut appender = Self {
options,
dir,
base_name,
current_path,
current_size,
last_cleanup: start,
file: Some(file),
now,
};
appender.cleanup(&start);
Ok(appender)
}
fn now(&self) -> DateTime<Utc> {
(self.now)()
}
fn refresh_active_path(&mut self, now: &DateTime<Utc>) -> io::Result<bool> {
let next_path = active_path_for(&self.dir, &self.base_name, self.options.rotation, now);
if next_path == self.current_path {
return Ok(false);
}
self.close_current()?;
self.current_path = next_path;
self.open_current()?;
Ok(true)
}
fn rotate_for_size(&mut self, now: &DateTime<Utc>) -> io::Result<()> {
self.close_current()?;
if self.current_path.exists() {
let archive_path = self.archive_path(now);
fs::rename(&self.current_path, archive_path)?;
}
self.open_current()
}
fn archive_path(&self, now: &DateTime<Utc>) -> PathBuf {
let file_name = self
.current_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&self.base_name);
let stamp = now.format("%Y%m%d%H%M%S");
for seq in 0..1000 {
let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}"));
if !candidate.exists() {
return candidate;
}
}
self.dir.join(format!("{file_name}.{stamp}.overflow"))
}
fn open_current(&mut self) -> io::Result<()> {
let (file, current_size) = open_append_file(&self.current_path)?;
self.file = Some(file);
self.current_size = current_size;
Ok(())
}
fn close_current(&mut self) -> io::Result<()> {
if let Some(mut file) = self.file.take() {
file.flush()?;
}
Ok(())
}
fn should_rotate_for_size(&self, incoming_len: usize) -> bool {
self.options.max_size_bytes > 0
&& self.current_size > 0
&& self.current_size.saturating_add(incoming_len as u64) > self.options.max_size_bytes
}
fn cleanup_due(&self, now: &DateTime<Utc>) -> bool {
self.options.max_age_secs > 0
&& now.signed_duration_since(self.last_cleanup)
>= ChronoDuration::seconds(CLEANUP_INTERVAL_SECS)
}
fn cleanup(&mut self, now: &DateTime<Utc>) {
self.last_cleanup = now.clone();
let Ok(entries) = fs::read_dir(&self.dir) else {
return;
};
let mut candidates = Vec::new();
let prefix = format!("{}.", self.base_name);
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let is_current = path == self.current_path;
let Some(name) = entry.file_name().to_str().map(|name| name.to_string()) else {
continue;
};
if !is_current && !name.starts_with(&prefix) {
continue;
}
let Ok(metadata) = entry.metadata() else {
continue;
};
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
candidates.push(LogFileCandidate {
path,
modified,
is_current,
});
}
if self.options.max_age_secs > 0 {
let cutoff = system_time_from_utc(now)
.checked_sub(Duration::from_secs(self.options.max_age_secs))
.unwrap_or(UNIX_EPOCH);
candidates.retain(|candidate| {
if candidate.is_current || candidate.modified >= cutoff {
true
} else {
let _ = fs::remove_file(&candidate.path);
false
}
});
}
if self.options.max_files > 0 && candidates.len() > self.options.max_files {
let mut archives: Vec<_> = candidates
.into_iter()
.filter(|candidate| !candidate.is_current)
.collect();
archives.sort_by_key(|candidate| candidate.modified);
let mut total = archives.len() + 1;
for candidate in archives {
if total <= self.options.max_files {
break;
}
let _ = fs::remove_file(candidate.path);
total -= 1;
}
}
}
}
impl Write for BoundedFileAppender {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let now = self.now();
let rotated_by_time = self.refresh_active_path(&now)?;
if self.should_rotate_for_size(buf.len()) {
self.rotate_for_size(&now)?;
self.cleanup(&now);
} else if rotated_by_time || self.cleanup_due(&now) {
self.cleanup(&now);
}
let Some(file) = self.file.as_mut() else {
return Err(io::Error::new(
io::ErrorKind::Other,
"bounded log file is not open",
));
};
file.write_all(buf)?;
self.current_size = self.current_size.saturating_add(buf.len() as u64);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if let Some(file) = self.file.as_mut() {
file.flush()
} else {
Ok(())
}
}
}
struct LogFileCandidate {
path: PathBuf,
modified: SystemTime,
is_current: bool,
}
fn open_append_file(path: &Path) -> io::Result<(File, u64)> {
let mut options = OpenOptions::new();
options.create(true).append(true);
let file = match options.open(path) {
Ok(file) => file,
Err(error) => {
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return Err(error);
};
fs::create_dir_all(parent)?;
options.open(path)?
}
};
let current_size = file.metadata()?.len();
Ok((file, current_size))
}
fn active_path_for(
dir: &Path,
base_name: &str,
rotation: LogRotation,
now: &DateTime<Utc>,
) -> PathBuf {
match rotation {
LogRotation::Never => dir.join(base_name),
LogRotation::Minutely | LogRotation::Hourly | LogRotation::Daily | LogRotation::Weekly => {
dir.join(format!("{base_name}.{}", period_suffix_for(rotation, now)))
}
}
}
fn period_suffix_for(rotation: LogRotation, now: &DateTime<Utc>) -> String {
match rotation {
LogRotation::Never | LogRotation::Daily => now.format("%Y-%m-%d").to_string(),
LogRotation::Hourly => now.format("%Y-%m-%d-%H").to_string(),
LogRotation::Minutely => now.format("%Y-%m-%d-%H-%M").to_string(),
LogRotation::Weekly => {
let days_since_sunday = now.weekday().num_days_from_sunday() as i64;
let week_start = now.date_naive() - ChronoDuration::days(days_since_sunday);
week_start.format("%Y-%m-%d").to_string()
}
}
}
fn system_time_from_utc(now: &DateTime<Utc>) -> SystemTime {
let duration = Duration::new(now.timestamp().unsigned_abs(), now.timestamp_subsec_nanos());
if now.timestamp() >= 0 {
UNIX_EPOCH + duration
} else {
UNIX_EPOCH - duration
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::tempdir;
use super::*;
fn fixed_now() -> DateTime<Utc> {
DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(10))
}
fn options(path: PathBuf) -> FileLogOptions {
FileLogOptions {
path: path.to_string_lossy().to_string(),
rotation: LogRotation::Never,
max_size_bytes: 0,
max_files: 0,
max_age_secs: 0,
}
}
fn matching_logs(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<_> = fs::read_dir(dir)
.unwrap()
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("telemt.log"))
.unwrap_or(false)
})
.collect();
files.sort();
files
}
#[test]
fn size_rotation_keeps_latest_write_in_active_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("telemt.log");
let mut options = options(path.clone());
options.max_size_bytes = 6;
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
appender.write_all(b"abc\n").unwrap();
appender.write_all(b"def\n").unwrap();
appender.flush().unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "def\n");
assert_eq!(matching_logs(dir.path()).len(), 2);
}
#[test]
fn max_files_retention_removes_oldest_archives() {
let dir = tempdir().unwrap();
let path = dir.path().join("telemt.log");
let mut options = options(path);
options.max_size_bytes = 4;
options.max_files = 2;
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] {
appender.write_all(line).unwrap();
}
appender.flush().unwrap();
assert!(matching_logs(dir.path()).len() <= 2);
}
#[cfg(unix)]
#[test]
fn max_age_retention_removes_old_archives() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let dir = tempdir().unwrap();
let path = dir.path().join("telemt.log");
let old_archive = dir.path().join("telemt.log.20000101000000.0");
fs::write(&old_archive, "old").unwrap();
let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap();
let times = [
libc::timespec {
tv_sec: 0,
tv_nsec: 0,
},
libc::timespec {
tv_sec: 0,
tv_nsec: 0,
},
];
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
assert_eq!(rc, 0);
let mut options = options(path);
options.max_age_secs = 1;
let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
assert!(!old_archive.exists());
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::*;
#[test]
fn test_parse_log_cli_options_default() {
let args: Vec<String> = vec![];
let options = parse_log_cli_options(&args).unwrap();
assert_eq!(
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
LogDestination::Stderr
);
}
#[test]
fn test_parse_log_cli_options_file() {
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
let options = parse_log_cli_options(&args).unwrap();
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
LogDestination::File { options } => {
assert_eq!(options.path, "/var/log/telemt.log");
assert_eq!(options.rotation, LogRotation::Never);
}
_ => panic!("Expected File destination"),
}
}
#[test]
fn test_parse_log_cli_options_file_daily() {
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
let options = parse_log_cli_options(&args).unwrap();
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
LogDestination::File { options } => {
assert_eq!(options.path, "/var/log/telemt");
assert_eq!(options.rotation, LogRotation::Daily);
}
_ => panic!("Expected File destination"),
}
}
#[test]
fn test_parse_log_cli_options_bounds() {
let args = vec![
"--log-file=/var/log/telemt.log".to_string(),
"--log-rotation=hourly".to_string(),
"--log-max-size-bytes=1024".to_string(),
"--log-max-files=3".to_string(),
"--log-max-age-secs=60".to_string(),
];
let options = parse_log_cli_options(&args).unwrap();
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
LogDestination::File { options } => {
assert_eq!(options.rotation, LogRotation::Hourly);
assert_eq!(options.max_size_bytes, 1024);
assert_eq!(options.max_files, 3);
assert_eq!(options.max_age_secs, 60);
}
_ => panic!("Expected File destination"),
}
}
#[test]
fn test_parse_log_cli_options_rejects_bad_rotation() {
let args = vec!["--log-rotation=yearly".to_string()];
assert!(parse_log_cli_options(&args).is_err());
}
#[cfg(unix)]
#[test]
fn test_parse_log_cli_options_syslog() {
let args = vec!["--syslog".to_string()];
let options = parse_log_cli_options(&args).unwrap();
assert_eq!(
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
LogDestination::Syslog
);
}
#[cfg(unix)]
#[test]
fn test_syslog_priority_for_level_mapping() {
assert_eq!(
syslog_priority_for_level(&tracing::Level::ERROR),
libc::LOG_ERR
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::WARN),
libc::LOG_WARNING
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::INFO),
libc::LOG_INFO
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::DEBUG),
libc::LOG_DEBUG
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::TRACE),
libc::LOG_DEBUG
);
}
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) async fn run_startup_connectivity(
.any(|r| r.rtt_ms.is_some()); .any(|r| r.rtt_ms.is_some());
if upstream_result.both_available { if upstream_result.both_available {
if prefer_ipv6 { if upstream_result.prefer_ipv6 {
info!(" IPv6 in use / IPv4 is fallback"); info!(" IPv6 in use / IPv4 is fallback");
} else { } else {
info!(" IPv4 in use / IPv6 is fallback"); info!(" IPv4 in use / IPv6 is fallback");
+67 -9
View File
@@ -1,6 +1,7 @@
#![allow(clippy::items_after_test_module)] #![allow(clippy::items_after_test_module)]
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
@@ -8,7 +9,7 @@ use tracing::{debug, error, info, warn};
use crate::cli; use crate::cli;
use crate::config::ProxyConfig; use crate::config::ProxyConfig;
use crate::logging::LogDestination; use crate::logging::LogCliOptions;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::{ use crate::transport::middle_proxy::{
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache, ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
@@ -18,8 +19,27 @@ use crate::transport::middle_proxy::{
const MAESTRO_COLOR: &str = "\x1b[92m"; const MAESTRO_COLOR: &str = "\x1b[92m";
const COLOR_RESET: &str = "\x1b[0m"; const COLOR_RESET: &str = "\x1b[0m";
static MAESTRO_COLORS_ENABLED: AtomicBool = AtomicBool::new(true);
/// Enables or disables ANSI color in direct MAESTRO status lines.
pub(crate) fn set_maestro_colors_enabled(enabled: bool) {
MAESTRO_COLORS_ENABLED.store(enabled, Ordering::Relaxed);
}
fn format_maestro_line(message: impl AsRef<str>, colors_enabled: bool) -> String {
if colors_enabled {
format!("{MAESTRO_COLOR}MAESTRO{COLOR_RESET}: {}", message.as_ref())
} else {
format!("MAESTRO: {}", message.as_ref())
}
}
/// Prints a direct MAESTRO status line outside the tracing subscriber.
pub(crate) fn print_maestro_line(message: impl AsRef<str>) { pub(crate) fn print_maestro_line(message: impl AsRef<str>) {
eprintln!("{MAESTRO_COLOR}MAESTRO{COLOR_RESET}: {}", message.as_ref()); eprintln!(
"{}",
format_maestro_line(message, MAESTRO_COLORS_ENABLED.load(Ordering::Relaxed))
);
} }
pub(crate) fn resolve_runtime_config_path( pub(crate) fn resolve_runtime_config_path(
@@ -93,7 +113,7 @@ pub(crate) struct CliArgs {
pub data_path: Option<PathBuf>, pub data_path: Option<PathBuf>,
pub silent: bool, pub silent: bool,
pub log_level: Option<String>, pub log_level: Option<String>,
pub log_destination: LogDestination, pub log_cli_options: LogCliOptions,
} }
pub(crate) fn parse_cli() -> CliArgs { pub(crate) fn parse_cli() -> CliArgs {
@@ -105,8 +125,13 @@ pub(crate) fn parse_cli() -> CliArgs {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
// Parse log destination let log_cli_options = match crate::logging::parse_log_cli_options(&args) {
let log_destination = crate::logging::parse_log_destination(&args); Ok(options) => options,
Err(error) => {
eprintln!("[telemt] {error}");
std::process::exit(2);
}
};
// Check for --init first (handled before tokio) // Check for --init first (handled before tokio)
if let Some(init_opts) = cli::parse_init_args(&args) { if let Some(init_opts) = cli::parse_init_args(&args) {
@@ -160,6 +185,21 @@ pub(crate) fn parse_cli() -> CliArgs {
s if s.starts_with("--log-level=") => { s if s.starts_with("--log-level=") => {
log_level = Some(s.trim_start_matches("--log-level=").to_string()); log_level = Some(s.trim_start_matches("--log-level=").to_string());
} }
"--log-file" | "--log-file-daily" => {
i += 1;
}
s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {}
"--log-rotation"
| "--log-max-size-bytes"
| "--log-max-files"
| "--log-max-age-secs" => {
i += 1;
}
s if s.starts_with("--log-rotation=")
|| s.starts_with("--log-max-size-bytes=")
|| s.starts_with("--log-max-files=")
|| s.starts_with("--log-max-age-secs=") => {}
"--syslog" => {}
"--help" | "-h" => { "--help" | "-h" => {
print_help(); print_help();
std::process::exit(0); std::process::exit(0);
@@ -172,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs {
"--daemon" | "-d" | "--foreground" | "-f" => {} "--daemon" | "-d" | "--foreground" | "-f" => {}
s if s.starts_with("--pid-file") => { s if s.starts_with("--pid-file") => {
if !s.contains('=') { if !s.contains('=') {
i += 1; // skip value // Skip the pid-file value consumed by daemon argument parsing.
i += 1;
} }
} }
s if s.starts_with("--run-as-user") => { s if s.starts_with("--run-as-user") => {
@@ -204,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs {
data_path, data_path,
silent, silent,
log_level, log_level,
log_destination, log_cli_options,
} }
} }
@@ -234,6 +275,10 @@ fn print_help() {
eprintln!("Logging options:"); eprintln!("Logging options:");
eprintln!(" --log-file <PATH> Log to file (default: stderr)"); eprintln!(" --log-file <PATH> Log to file (default: stderr)");
eprintln!(" --log-file-daily <PATH> Log to file with daily rotation"); eprintln!(" --log-file-daily <PATH> Log to file with daily rotation");
eprintln!(" --log-rotation <MODE> never|minutely|hourly|daily|weekly");
eprintln!(" --log-max-size-bytes N Rotate file logs when active file exceeds N bytes");
eprintln!(" --log-max-files N Keep at most N matching file logs (0 disables)");
eprintln!(" --log-max-age-secs N Remove rotated file logs older than N seconds");
#[cfg(unix)] #[cfg(unix)]
eprintln!(" --syslog Log to syslog (Unix only)"); eprintln!(" --syslog Log to syslog (Unix only)");
eprintln!(); eprintln!();
@@ -274,11 +319,24 @@ mod tests {
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use super::{ use super::{
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, expected_handshake_close_description, format_maestro_line, is_expected_handshake_eof,
resolve_runtime_base_dir, resolve_runtime_config_path, peer_close_description, resolve_runtime_base_dir, resolve_runtime_config_path,
}; };
use crate::error::{ProxyError, StreamError}; use crate::error::{ProxyError, StreamError};
#[test]
fn maestro_line_formatter_respects_disabled_colors() {
let plain = format_maestro_line("boot", false);
assert_eq!(plain, "MAESTRO: boot");
assert!(!plain.contains('\x1b'));
}
#[test]
fn maestro_line_formatter_keeps_color_when_enabled() {
let colored = format_maestro_line("boot", true);
assert!(colored.contains("\x1b[92mMAESTRO\x1b[0m"));
}
#[test] #[test]
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() { fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
let nonce = std::time::SystemTime::now() let nonce = std::time::SystemTime::now()
+24
View File
@@ -47,6 +47,10 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
.unwrap_or(config.server.port) .unwrap_or(config.server.port)
} }
fn mss_segment_multiplier(client_mss: u16) -> u16 {
1460u16.div_ceil(client_mss)
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn bind_listeners( pub(crate) async fn bind_listeners(
config: &Arc<ProxyConfig>, config: &Arc<ProxyConfig>,
@@ -90,10 +94,22 @@ pub(crate) async fn bind_listeners(
warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]"); warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]");
continue; continue;
} }
let client_mss = match listener_conf.effective_client_mss(&config.server) {
Ok(value) => value,
Err(error) => {
warn!(
%addr,
error = %error,
"Invalid listener client MSS after config validation; using kernel default"
);
None
}
};
let options = ListenOptions { let options = ListenOptions {
reuse_port: listener_conf.reuse_allow, reuse_port: listener_conf.reuse_allow,
ipv6_only: listener_conf.ip.is_ipv6(), ipv6_only: listener_conf.ip.is_ipv6(),
backlog: config.server.listen_backlog, backlog: config.server.listen_backlog,
client_mss,
..Default::default() ..Default::default()
}; };
@@ -101,6 +117,14 @@ pub(crate) async fn bind_listeners(
Ok(socket) => { Ok(socket) => {
let listener = TcpListener::from_std(socket.into())?; let listener = TcpListener::from_std(socket.into())?;
info!("Listening on {}", addr); info!("Listening on {}", addr);
if let Some(client_mss) = client_mss {
info!(
%addr,
client_mss,
segment_multiplier = mss_segment_multiplier(client_mss),
"Client-facing TCP MSS configured"
);
}
let listener_proxy_protocol = listener_conf let listener_proxy_protocol = listener_conf
.proxy_protocol .proxy_protocol
.unwrap_or(config.server.proxy_protocol); .unwrap_or(config.server.proxy_protocol);
+2
View File
@@ -208,6 +208,8 @@ pub(crate) async fn initialize_me_pool(
me_nat_probe, me_nat_probe,
None, None,
config.network.stun_servers.clone(), config.network.stun_servers.clone(),
config.network.stun_tcp_fallback,
config.network.http_ip_detect_urls.clone(),
config.general.stun_nat_probe_concurrency, config.general.stun_nat_probe_concurrency,
probe.detected_ipv6, probe.detected_ipv6,
config.timeouts.me_one_retry, config.timeouts.me_one_retry,
+23 -6
View File
@@ -45,10 +45,12 @@ use crate::stats::beobachten::BeobachtenStore;
use crate::stats::telemetry::TelemetryPolicy; use crate::stats::telemetry::TelemetryPolicy;
use crate::stats::{ReplayChecker, Stats}; use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool; use crate::stream::BufferPool;
use crate::synlimit_control;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool; use crate::transport::middle_proxy::MePool;
use helpers::{ use helpers::{
parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path, parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path,
set_maestro_colors_enabled,
}; };
#[cfg(unix)] #[cfg(unix)]
@@ -106,7 +108,7 @@ async fn run_telemt_core(
let data_path = cli_args.data_path; let data_path = cli_args.data_path;
let cli_silent = cli_args.silent; let cli_silent = cli_args.silent;
let cli_log_level = cli_args.log_level; let cli_log_level = cli_args.log_level;
let log_destination = cli_args.log_destination; let log_cli_options = cli_args.log_cli_options;
let startup_cwd = match std::env::current_dir() { let startup_cwd = match std::env::current_dir() {
Ok(cwd) => cwd, Ok(cwd) => cwd,
Err(e) => { Err(e) => {
@@ -314,6 +316,7 @@ async fn run_telemt_core(
eprintln!("[telemt] Invalid network.dns_overrides: {}", e); eprintln!("[telemt] Invalid network.dns_overrides: {}", e);
std::process::exit(1); std::process::exit(1);
} }
set_maestro_colors_enabled(!config.general.disable_colors);
startup_tracker startup_tracker
.complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string())) .complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string()))
.await; .await;
@@ -328,6 +331,14 @@ async fn run_telemt_core(
}; };
let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level); let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level);
let log_destination =
match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) {
Ok(destination) => destination,
Err(error) => {
eprintln!("[telemt] {error}");
std::process::exit(1);
}
};
let (filter_layer, filter_handle) = let (filter_layer, filter_handle) =
reload::Layer::new(EnvFilter::new(initial_filter_spec.clone())); reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
startup_tracker startup_tracker
@@ -462,6 +473,12 @@ async fn run_telemt_core(
config.network.dns_overrides.len() config.network.dns_overrides.len()
); );
} }
let shared_state = ProxySharedState::new();
shared_state.apply_user_enabled_config(&config.access.user_enabled);
shared_state.traffic_limiter.apply_policy(
config.access.user_rate_limits.clone(),
config.access.cidr_rate_limits.clone(),
);
let (api_config_tx, api_config_rx) = watch::channel(Arc::new(config.clone())); let (api_config_tx, api_config_rx) = watch::channel(Arc::new(config.clone()));
let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>)); let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>));
@@ -500,6 +517,7 @@ async fn run_telemt_core(
let me_pool_api = api_me_pool.clone(); let me_pool_api = api_me_pool.clone();
let upstream_manager_api = upstream_manager.clone(); let upstream_manager_api = upstream_manager.clone();
let route_runtime_api = route_runtime.clone(); let route_runtime_api = route_runtime.clone();
let proxy_shared_api = shared_state.clone();
let config_rx_api = api_config_rx.clone(); let config_rx_api = api_config_rx.clone();
let admission_rx_api = admission_rx.clone(); let admission_rx_api = admission_rx.clone();
let config_path_api = config_path.clone(); let config_path_api = config_path.clone();
@@ -513,6 +531,7 @@ async fn run_telemt_core(
ip_tracker_api, ip_tracker_api,
me_pool_api, me_pool_api,
route_runtime_api, route_runtime_api,
proxy_shared_api,
upstream_manager_api, upstream_manager_api,
config_rx_api, config_rx_api,
admission_rx_api, admission_rx_api,
@@ -730,11 +749,6 @@ async fn run_telemt_core(
)); ));
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096)); let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
let shared_state = ProxySharedState::new();
shared_state.traffic_limiter.apply_policy(
config.access.user_rate_limits.clone(),
config.access.cidr_rate_limits.clone(),
);
if direct_first_startup { if direct_first_startup {
startup_tracker startup_tracker
@@ -904,6 +918,9 @@ async fn run_telemt_core(
// On Unix, caller supplies privilege drop after bind (may require root for port < 1024). // On Unix, caller supplies privilege drop after bind (may require root for port < 1024).
drop_after_bind(); drop_after_bind();
synlimit_control::reconcile_synlimit_rules(&config).await;
synlimit_control::spawn_synlimit_controller(config_rx.clone());
runtime_tasks::apply_runtime_log_filter( runtime_tasks::apply_runtime_log_filter(
has_rust_log, has_rust_log,
&effective_log_level, &effective_log_level,
+22 -1
View File
@@ -3,7 +3,7 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use tracing_subscriber::reload; use tracing_subscriber::reload;
@@ -234,6 +234,27 @@ pub(crate) async fn spawn_runtime_tasks(
} }
}); });
let shared_user_enabled = shared_state.clone();
let mut config_rx_user_enabled = config_rx.clone();
tokio::spawn(async move {
loop {
if config_rx_user_enabled.changed().await.is_err() {
break;
}
let cfg = config_rx_user_enabled.borrow_and_update().clone();
for user in shared_user_enabled.apply_user_enabled_config(&cfg.access.user_enabled) {
let cancelled = shared_user_enabled.cancel_user_sessions(&user);
if cancelled > 0 {
info!(
user = %user,
cancelled,
"Disabled user sessions cancelled after config reload"
);
}
}
}
});
let beobachten_writer = beobachten.clone(); let beobachten_writer = beobachten.clone();
let config_rx_beobachten = config_rx.clone(); let config_rx_beobachten = config_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
+5
View File
@@ -19,6 +19,7 @@ use tokio::signal::unix::{SignalKind, signal};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::stats::Stats; use crate::stats::Stats;
use crate::synlimit_control;
use crate::transport::middle_proxy::MePool; use crate::transport::middle_proxy::MePool;
use super::helpers::{format_uptime, unit_label}; use super::helpers::{format_uptime, unit_label};
@@ -102,6 +103,10 @@ async fn perform_shutdown(
let uptime_secs = process_started_at.elapsed().as_secs(); let uptime_secs = process_started_at.elapsed().as_secs();
info!("Uptime: {}", format_uptime(uptime_secs)); info!("Uptime: {}", format_uptime(uptime_secs));
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
}
// Graceful ME pool shutdown // Graceful ME pool shutdown
if let Some(pool) = &me_pool { if let Some(pool) = &me_pool {
match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all()) match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all())
+1
View File
@@ -30,6 +30,7 @@ mod service;
mod startup; mod startup;
mod stats; mod stats;
mod stream; mod stream;
mod synlimit_control;
mod tls_front; mod tls_front;
mod transport; mod transport;
mod util; mod util;
+82 -6
View File
@@ -55,8 +55,10 @@ pub async fn serve(
return; return;
} }
}; };
let is_ipv6 = addr.is_ipv6(); // Match `server.api.listen`: `[::]:port` is a dual-stack wildcard
match bind_metrics_listener(addr, is_ipv6, listen_backlog) { // on Linux when `net.ipv6.bindv6only=0`.
let ipv6_only = addr.is_ipv6() && !addr.ip().is_unspecified();
match bind_metrics_listener(addr, ipv6_only, listen_backlog) {
Ok(listener) => { Ok(listener) => {
info!("Metrics endpoint: http://{}/metrics and /beobachten", addr); info!("Metrics endpoint: http://{}/metrics and /beobachten", addr);
serve_listener( serve_listener(
@@ -286,7 +288,7 @@ async fn handle<B>(
} }
if req.uri().path() == "/beobachten" { if req.uri().path() == "/beobachten" {
let body = render_beobachten(beobachten, config); let body = render_beobachten(stats, beobachten, config);
let resp = Response::builder() let resp = Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "text/plain; charset=utf-8") .header("content-type", "text/plain; charset=utf-8")
@@ -302,13 +304,22 @@ async fn handle<B>(
Ok(resp) Ok(resp)
} }
fn render_beobachten(beobachten: &BeobachtenStore, config: &ProxyConfig) -> String { fn render_beobachten(stats: &Stats, beobachten: &BeobachtenStore, config: &ProxyConfig) -> String {
if !config.general.beobachten { if !config.general.beobachten {
return "beobachten disabled\n".to_string(); return "beobachten disabled\n".to_string();
} }
let ttl = Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60)); let ttl = Duration::from_secs(config.general.beobachten_minutes.saturating_mul(60));
beobachten.snapshot_text(ttl) let mut body = beobachten.snapshot_text(ttl);
let tls_text = stats.tls_fingerprint_snapshot_text(ttl, 20);
if !tls_text.is_empty() {
if !body.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str(&tls_text);
}
body
} }
fn tls_front_domains(config: &ProxyConfig) -> Vec<String> { fn tls_front_domains(config: &ProxyConfig) -> Vec<String> {
@@ -370,11 +381,32 @@ async fn render_tls_front_profile_health(
"# HELP telemt_tls_front_profile_info TLS front profile source and feature flags per configured domain" "# HELP telemt_tls_front_profile_info TLS front profile source and feature flags per configured domain"
); );
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_info gauge"); let _ = writeln!(out, "# TYPE telemt_tls_front_profile_info gauge");
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_quality_info TLS front profile quality and key-share group per configured domain"
);
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_quality_info gauge");
let _ = writeln!( let _ = writeln!(
out, out,
"# HELP telemt_tls_front_profile_age_seconds Age of cached TLS front profile data per configured domain" "# HELP telemt_tls_front_profile_age_seconds Age of cached TLS front profile data per configured domain"
); );
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_age_seconds gauge"); let _ = writeln!(out, "# TYPE telemt_tls_front_profile_age_seconds gauge");
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_server_hello_bytes TLS front cached ServerHello record body bytes per configured domain"
);
let _ = writeln!(
out,
"# TYPE telemt_tls_front_profile_server_hello_bytes gauge"
);
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_server_hello_extensions TLS front cached visible ServerHello extension count per configured domain"
);
let _ = writeln!(
out,
"# TYPE telemt_tls_front_profile_server_hello_extensions gauge"
);
let _ = writeln!( let _ = writeln!(
out, out,
"# HELP telemt_tls_front_profile_app_data_records TLS front cached app-data record count per configured domain" "# HELP telemt_tls_front_profile_app_data_records TLS front cached app-data record count per configured domain"
@@ -409,11 +441,26 @@ async fn render_tls_front_profile_health(
"telemt_tls_front_profile_info{{domain=\"{}\",source=\"{}\",is_default=\"{}\",has_cert_info=\"{}\",has_cert_payload=\"{}\"}} 1", "telemt_tls_front_profile_info{{domain=\"{}\",source=\"{}\",is_default=\"{}\",has_cert_info=\"{}\",has_cert_payload=\"{}\"}} 1",
domain, item.source, item.is_default, item.has_cert_info, item.has_cert_payload domain, item.source, item.is_default, item.has_cert_info, item.has_cert_payload
); );
let _ = writeln!(
out,
"telemt_tls_front_profile_quality_info{{domain=\"{}\",quality=\"{}\",key_share_group=\"{}\"}} 1",
domain, item.quality, item.key_share_group
);
let _ = writeln!( let _ = writeln!(
out, out,
"telemt_tls_front_profile_age_seconds{{domain=\"{}\"}} {}", "telemt_tls_front_profile_age_seconds{{domain=\"{}\"}} {}",
domain, item.age_seconds domain, item.age_seconds
); );
let _ = writeln!(
out,
"telemt_tls_front_profile_server_hello_bytes{{domain=\"{}\"}} {}",
domain, item.server_hello_record_len
);
let _ = writeln!(
out,
"telemt_tls_front_profile_server_hello_extensions{{domain=\"{}\"}} {}",
domain, item.server_hello_extensions
);
let _ = writeln!( let _ = writeln!(
out, out,
"telemt_tls_front_profile_app_data_records{{domain=\"{}\"}} {}", "telemt_tls_front_profile_app_data_records{{domain=\"{}\"}} {}",
@@ -3890,7 +3937,20 @@ mod tests {
session_id: Vec::new(), session_id: Vec::new(),
cipher_suite: [0x13, 0x01], cipher_suite: [0x13, 0x01],
compression: 0, compression: 0,
extensions: Vec::new(), extensions: {
let mut key_share = vec![0x00, 0x1d, 0x00, 0x20];
key_share.resize(36, 0x42);
vec![
crate::tls_front::types::TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
crate::tls_front::types::TlsExtension {
ext_type: 0x0033,
data: key_share,
},
]
},
}, },
cert_info: None, cert_info: None,
cert_payload: Some(TlsCertPayload { cert_payload: Some(TlsCertPayload {
@@ -3904,6 +3964,7 @@ mod tests {
app_data_record_sizes: vec![1024, 512], app_data_record_sizes: vec![1024, 512],
ticket_record_sizes: vec![69], ticket_record_sizes: vec![69],
source: TlsProfileSource::Merged, source: TlsProfileSource::Merged,
..TlsBehaviorProfile::default()
}, },
fetched_at: SystemTime::now(), fetched_at: SystemTime::now(),
domain: "primary.example".to_string(), domain: "primary.example".to_string(),
@@ -3922,6 +3983,18 @@ mod tests {
assert!( assert!(
output.contains("telemt_tls_front_profile_info{domain=\"fallback.example\",source=\"default\",is_default=\"true\",has_cert_info=\"false\",has_cert_payload=\"false\"} 1") output.contains("telemt_tls_front_profile_info{domain=\"fallback.example\",source=\"default\",is_default=\"true\",has_cert_info=\"false\",has_cert_payload=\"false\"} 1")
); );
assert!(
output.contains("telemt_tls_front_profile_quality_info{domain=\"primary.example\",quality=\"raw_strict\",key_share_group=\"x25519\"} 1")
);
assert!(
output.contains("telemt_tls_front_profile_quality_info{domain=\"fallback.example\",quality=\"fallback\",key_share_group=\"none\"} 1")
);
assert!(output.contains(
"telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 90"
));
assert!(output.contains(
"telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 2"
));
assert!( assert!(
output.contains( output.contains(
"telemt_tls_front_profile_app_data_records{domain=\"primary.example\"} 2" "telemt_tls_front_profile_app_data_records{domain=\"primary.example\"} 2"
@@ -4034,7 +4107,10 @@ mod tests {
); );
assert!(output.contains("# TYPE telemt_tls_front_profile_domains gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_domains gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_info gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_info gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_quality_info gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_age_seconds gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_age_seconds gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_bytes gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_extensions gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_app_data_records gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_app_data_records gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_ticket_records gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_ticket_records gauge"));
assert!( assert!(
+33 -7
View File
@@ -12,7 +12,7 @@ use tracing::{debug, info, warn};
use crate::config::{NetworkConfig, UpstreamConfig, UpstreamType}; use crate::config::{NetworkConfig, UpstreamConfig, UpstreamType};
use crate::error::Result; use crate::error::Result;
use crate::network::stun::{ use crate::network::stun::{
DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind, DualStunResult, IpFamily, StunProbeResult, stun_probe_family_with_bind_and_tcp_fallback,
}; };
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
@@ -58,6 +58,7 @@ impl NetworkDecision {
} }
const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5); const STUN_BATCH_TIMEOUT: Duration = Duration::from_secs(5);
const STUN_BATCH_TCP_FALLBACK_TIMEOUT: Duration = Duration::from_secs(12);
pub async fn run_probe( pub async fn run_probe(
config: &NetworkConfig, config: &NetworkConfig,
@@ -81,8 +82,14 @@ pub async fn run_probe(
warn!("STUN probe is enabled but network.stun_servers is empty"); warn!("STUN probe is enabled but network.stun_servers is empty");
DualStunResult::default() DualStunResult::default()
} else { } else {
probe_stun_servers_parallel(&servers, stun_nat_probe_concurrency.max(1), None, None) probe_stun_servers_parallel(
.await &servers,
stun_nat_probe_concurrency.max(1),
None,
None,
config.stun_tcp_fallback,
)
.await
} }
} else if nat_probe { } else if nat_probe {
info!("STUN probe is disabled by network.stun_use=false"); info!("STUN probe is disabled by network.stun_use=false");
@@ -163,6 +170,7 @@ pub async fn run_probe(
stun_nat_probe_concurrency.max(1), stun_nat_probe_concurrency.max(1),
bind_v4, bind_v4,
bind_v6, bind_v6,
config.stun_tcp_fallback,
) )
.await; .await;
if let Some(reflected) = direct_stun_res.v4.map(|r| r.reflected_addr) { if let Some(reflected) = direct_stun_res.v4.map(|r| r.reflected_addr) {
@@ -234,7 +242,7 @@ pub async fn run_probe(
Ok(probe) Ok(probe)
} }
async fn detect_public_ipv4_http(urls: &[String]) -> Option<Ipv4Addr> { pub(crate) async fn detect_public_ipv4_http(urls: &[String]) -> Option<Ipv4Addr> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3)) .timeout(Duration::from_secs(3))
.build() .build()
@@ -277,6 +285,7 @@ async fn probe_stun_servers_parallel(
concurrency: usize, concurrency: usize,
bind_v4: Option<IpAddr>, bind_v4: Option<IpAddr>,
bind_v6: Option<IpAddr>, bind_v6: Option<IpAddr>,
tcp_fallback: bool,
) -> DualStunResult { ) -> DualStunResult {
let mut join_set = JoinSet::new(); let mut join_set = JoinSet::new();
let mut next_idx = 0usize; let mut next_idx = 0usize;
@@ -288,9 +297,26 @@ async fn probe_stun_servers_parallel(
let stun_addr = servers[next_idx].clone(); let stun_addr = servers[next_idx].clone();
next_idx += 1; next_idx += 1;
join_set.spawn(async move { join_set.spawn(async move {
let res = timeout(STUN_BATCH_TIMEOUT, async { let batch_timeout = if tcp_fallback {
let v4 = stun_probe_family_with_bind(&stun_addr, IpFamily::V4, bind_v4).await?; STUN_BATCH_TCP_FALLBACK_TIMEOUT
let v6 = stun_probe_family_with_bind(&stun_addr, IpFamily::V6, bind_v6).await?; } else {
STUN_BATCH_TIMEOUT
};
let res = timeout(batch_timeout, async {
let v4 = stun_probe_family_with_bind_and_tcp_fallback(
&stun_addr,
IpFamily::V4,
bind_v4,
tcp_fallback,
)
.await?;
let v6 = stun_probe_family_with_bind_and_tcp_fallback(
&stun_addr,
IpFamily::V6,
bind_v6,
tcp_fallback,
)
.await?;
Ok::<DualStunResult, crate::error::ProxyError>(DualStunResult { v4, v6 }) Ok::<DualStunResult, crate::error::ProxyError>(DualStunResult { v4, v6 })
}) })
.await; .await;
+241 -41
View File
@@ -4,7 +4,8 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::OnceLock; use std::sync::OnceLock;
use tokio::net::{UdpSocket, lookup_host}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpSocket, UdpSocket, lookup_host};
use tokio::time::{Duration, sleep, timeout}; use tokio::time::{Duration, sleep, timeout};
use crate::crypto::SecureRandom; use crate::crypto::SecureRandom;
@@ -36,9 +37,16 @@ pub struct DualStunResult {
} }
pub async fn stun_probe_dual(stun_addr: &str) -> Result<DualStunResult> { pub async fn stun_probe_dual(stun_addr: &str) -> Result<DualStunResult> {
stun_probe_dual_with_tcp_fallback(stun_addr, false).await
}
pub async fn stun_probe_dual_with_tcp_fallback(
stun_addr: &str,
tcp_fallback: bool,
) -> Result<DualStunResult> {
let (v4, v6) = tokio::join!( let (v4, v6) = tokio::join!(
stun_probe_family(stun_addr, IpFamily::V4), stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V4, tcp_fallback),
stun_probe_family(stun_addr, IpFamily::V6), stun_probe_family_with_tcp_fallback(stun_addr, IpFamily::V6, tcp_fallback),
); );
Ok(DualStunResult { v4: v4?, v6: v6? }) Ok(DualStunResult { v4: v4?, v6: v6? })
@@ -48,13 +56,44 @@ pub async fn stun_probe_family(
stun_addr: &str, stun_addr: &str,
family: IpFamily, family: IpFamily,
) -> Result<Option<StunProbeResult>> { ) -> Result<Option<StunProbeResult>> {
stun_probe_family_with_bind(stun_addr, family, None).await stun_probe_family_with_tcp_fallback(stun_addr, family, false).await
}
pub async fn stun_probe_family_with_tcp_fallback(
stun_addr: &str,
family: IpFamily,
tcp_fallback: bool,
) -> Result<Option<StunProbeResult>> {
stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, None, tcp_fallback).await
} }
pub async fn stun_probe_family_with_bind( pub async fn stun_probe_family_with_bind(
stun_addr: &str, stun_addr: &str,
family: IpFamily, family: IpFamily,
bind_ip: Option<IpAddr>, bind_ip: Option<IpAddr>,
) -> Result<Option<StunProbeResult>> {
stun_probe_family_with_bind_and_tcp_fallback(stun_addr, family, bind_ip, false).await
}
pub async fn stun_probe_family_with_bind_and_tcp_fallback(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
tcp_fallback: bool,
) -> Result<Option<StunProbeResult>> {
let udp_attempts = if tcp_fallback { 1 } else { 3 };
let udp_result = stun_probe_family_udp(stun_addr, family, bind_ip, udp_attempts).await?;
if udp_result.is_some() || !tcp_fallback {
return Ok(udp_result);
}
stun_probe_family_tcp(stun_addr, family, bind_ip).await
}
async fn stun_probe_family_udp(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
max_attempts: u8,
) -> Result<Option<StunProbeResult>> { ) -> Result<Option<StunProbeResult>> {
let bind_addr = match (family, bind_ip) { let bind_addr = match (family, bind_ip) {
(IpFamily::V4, Some(IpAddr::V4(ip))) => SocketAddr::new(IpAddr::V4(ip), 0), (IpFamily::V4, Some(IpAddr::V4(ip))) => SocketAddr::new(IpAddr::V4(ip), 0),
@@ -94,12 +133,7 @@ pub async fn stun_probe_family_with_bind(
return Ok(None); return Ok(None);
} }
let mut req = [0u8; 20]; let req = build_binding_request();
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes()); // Binding Request
req[2..4].copy_from_slice(&0u16.to_be_bytes()); // length
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes()); // magic cookie
stun_rng().fill(&mut req[8..20]); // transaction ID
let mut buf = [0u8; 256]; let mut buf = [0u8; 256];
let mut attempt = 0; let mut attempt = 0;
let mut backoff = Duration::from_secs(1); let mut backoff = Duration::from_secs(1);
@@ -115,7 +149,7 @@ pub async fn stun_probe_family_with_bind(
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN recv failed: {e}"))), Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN recv failed: {e}"))),
Err(_) => { Err(_) => {
attempt += 1; attempt += 1;
if attempt >= 3 { if attempt >= max_attempts {
return Ok(None); return Ok(None);
} }
sleep(backoff).await; sleep(backoff).await;
@@ -128,19 +162,139 @@ pub async fn stun_probe_family_with_bind(
return Ok(None); return Ok(None);
} }
let magic = 0x2112A442u32.to_be_bytes();
let txid = &req[8..20]; let txid = &req[8..20];
let mut idx = 20; if let Some(reflected_addr) = parse_reflected_addr(&buf[..n], txid) {
while idx + 4 <= n { let local_addr = socket
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().unwrap()); .local_addr()
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().unwrap()) as usize; .map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
idx += 4; return Ok(Some(StunProbeResult {
if idx + alen > n { local_addr,
break; reflected_addr,
} family,
}));
}
}
match atype { Ok(None)
0x0020 /* XOR-MAPPED-ADDRESS */ | 0x0001 /* MAPPED-ADDRESS */ => { }
async fn stun_probe_family_tcp(
stun_addr: &str,
family: IpFamily,
bind_ip: Option<IpAddr>,
) -> Result<Option<StunProbeResult>> {
let target_addr = match resolve_stun_addr(stun_addr, family).await? {
Some(addr) => addr,
None => return Ok(None),
};
let socket = match family {
IpFamily::V4 => TcpSocket::new_v4(),
IpFamily::V6 => TcpSocket::new_v6(),
}
.map_err(|e| ProxyError::Proxy(format!("STUN TCP socket failed: {e}")))?;
match (family, bind_ip) {
(IpFamily::V4, Some(IpAddr::V4(ip))) => {
if socket.bind(SocketAddr::new(IpAddr::V4(ip), 0)).is_err() {
return Ok(None);
}
}
(IpFamily::V6, Some(IpAddr::V6(ip))) => {
if socket.bind(SocketAddr::new(IpAddr::V6(ip), 0)).is_err() {
return Ok(None);
}
}
(IpFamily::V4, Some(IpAddr::V6(_))) | (IpFamily::V6, Some(IpAddr::V4(_))) => {
return Ok(None);
}
(_, None) => {}
}
let connect_res = timeout(Duration::from_secs(3), socket.connect(target_addr)).await;
let mut stream = match connect_res {
Ok(Ok(stream)) => stream,
Ok(Err(e))
if family == IpFamily::V6
&& matches!(
e.kind(),
std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::HostUnreachable
| std::io::ErrorKind::Unsupported
| std::io::ErrorKind::NetworkDown
) =>
{
return Ok(None);
}
Ok(Err(e)) => return Err(ProxyError::Proxy(format!("STUN TCP connect failed: {e}"))),
Err(_) => return Ok(None),
};
let req = build_binding_request();
timeout(Duration::from_secs(3), stream.write_all(&req))
.await
.map_err(|_| ProxyError::Proxy("STUN TCP send timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP send failed: {e}")))?;
let mut header = [0u8; 20];
timeout(Duration::from_secs(3), stream.read_exact(&mut header))
.await
.map_err(|_| ProxyError::Proxy("STUN TCP header timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP header read failed: {e}")))?;
let body_len = u16::from_be_bytes([header[2], header[3]]) as usize;
if body_len > 236 {
return Ok(None);
}
let mut buf = [0u8; 256];
buf[..20].copy_from_slice(&header);
if body_len > 0 {
timeout(
Duration::from_secs(3),
stream.read_exact(&mut buf[20..20 + body_len]),
)
.await
.map_err(|_| ProxyError::Proxy("STUN TCP body timeout".to_string()))?
.map_err(|e| ProxyError::Proxy(format!("STUN TCP body read failed: {e}")))?;
}
let txid = &req[8..20];
let Some(reflected_addr) = parse_reflected_addr(&buf[..20 + body_len], txid) else {
return Ok(None);
};
let local_addr = stream
.local_addr()
.map_err(|e| ProxyError::Proxy(format!("STUN TCP local_addr failed: {e}")))?;
Ok(Some(StunProbeResult {
local_addr,
reflected_addr,
family,
}))
}
fn build_binding_request() -> [u8; 20] {
let mut req = [0u8; 20];
req[0..2].copy_from_slice(&0x0001u16.to_be_bytes());
req[2..4].copy_from_slice(&0u16.to_be_bytes());
req[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
stun_rng().fill(&mut req[8..20]);
req
}
fn parse_reflected_addr(buf: &[u8], txid: &[u8]) -> Option<SocketAddr> {
if buf.len() < 20 {
return None;
}
let magic = 0x2112A442u32.to_be_bytes();
let mut idx = 20;
while idx + 4 <= buf.len() {
let atype = u16::from_be_bytes(buf[idx..idx + 2].try_into().ok()?);
let alen = u16::from_be_bytes(buf[idx + 2..idx + 4].try_into().ok()?) as usize;
idx += 4;
if idx + alen > buf.len() {
break;
}
match atype {
0x0020 | 0x0001 => {
if alen < 8 { if alen < 8 {
break; break;
} }
@@ -157,7 +311,6 @@ pub async fn stun_probe_family_with_bind(
let raw_ip = &buf[idx + 4..idx + 4 + len_check]; let raw_ip = &buf[idx + 4..idx + 4 + len_check];
let mut port = u16::from_be_bytes(port_bytes); let mut port = u16::from_be_bytes(port_bytes);
let reflected_ip = if atype == 0x0020 { let reflected_ip = if atype == 0x0020 {
port ^= ((magic[0] as u16) << 8) | magic[1] as u16; port ^= ((magic[0] as u16) << 8) | magic[1] as u16;
match family_byte { match family_byte {
@@ -172,7 +325,9 @@ pub async fn stun_probe_family_with_bind(
} }
0x02 => { 0x02 => {
let mut ip = [0u8; 16]; let mut ip = [0u8; 16];
let xor_key = [magic.as_slice(), txid].concat(); let mut xor_key = [0u8; 16];
xor_key[..4].copy_from_slice(&magic);
xor_key[4..].copy_from_slice(txid.get(..12)?);
for (i, b) in raw_ip.iter().enumerate().take(16) { for (i, b) in raw_ip.iter().enumerate().take(16) {
ip[i] = *b ^ xor_key[i]; ip[i] = *b ^ xor_key[i];
} }
@@ -185,34 +340,24 @@ pub async fn stun_probe_family_with_bind(
} }
} else { } else {
match family_byte { match family_byte {
0x01 => IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3])), 0x01 => {
0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).unwrap())), IpAddr::V4(Ipv4Addr::new(raw_ip[0], raw_ip[1], raw_ip[2], raw_ip[3]))
}
0x02 => IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(raw_ip).ok()?)),
_ => { _ => {
idx += (alen + 3) & !3; idx += (alen + 3) & !3;
continue; continue;
} }
} }
}; };
return Some(SocketAddr::new(reflected_ip, port));
let reflected_addr = SocketAddr::new(reflected_ip, port);
let local_addr = socket
.local_addr()
.map_err(|e| ProxyError::Proxy(format!("STUN local_addr failed: {e}")))?;
return Ok(Some(StunProbeResult {
local_addr,
reflected_addr,
family,
}));
} }
_ => {} _ => {}
} }
idx += (alen + 3) & !3; idx += (alen + 3) & !3;
}
} }
None
Ok(None)
} }
async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<SocketAddr>> { async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<SocketAddr>> {
@@ -245,3 +390,58 @@ async fn resolve_stun_addr(stun_addr: &str, family: IpFamily) -> Result<Option<S
}); });
Ok(target) Ok(target)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_reflected_addr_reads_mapped_ipv4() {
let txid = [0u8; 12];
let mut response = [0u8; 32];
response[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
response[2..4].copy_from_slice(&12u16.to_be_bytes());
response[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
response[20..22].copy_from_slice(&0x0001u16.to_be_bytes());
response[22..24].copy_from_slice(&8u16.to_be_bytes());
response[25] = 0x01;
response[26..28].copy_from_slice(&443u16.to_be_bytes());
response[28..32].copy_from_slice(&[203, 0, 113, 9]);
let reflected = parse_reflected_addr(&response, &txid).unwrap();
assert_eq!(
reflected,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), 443)
);
}
#[test]
fn parse_reflected_addr_reads_xor_mapped_ipv4() {
let txid = [0u8; 12];
let magic = 0x2112A442u32.to_be_bytes();
let port = 443u16;
let ip = [203u8, 0, 113, 9];
let xport = port ^ (((magic[0] as u16) << 8) | magic[1] as u16);
let xip = [
ip[0] ^ magic[0],
ip[1] ^ magic[1],
ip[2] ^ magic[2],
ip[3] ^ magic[3],
];
let mut response = [0u8; 32];
response[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
response[2..4].copy_from_slice(&12u16.to_be_bytes());
response[4..8].copy_from_slice(&0x2112A442u32.to_be_bytes());
response[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
response[22..24].copy_from_slice(&8u16.to_be_bytes());
response[25] = 0x01;
response[26..28].copy_from_slice(&xport.to_be_bytes());
response[28..32].copy_from_slice(&xip);
let reflected = parse_reflected_addr(&response, &txid).unwrap();
assert_eq!(
reflected,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), 443)
);
}
}
+20 -15
View File
@@ -5,6 +5,9 @@
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use crate::crypto::SecureRandom; use crate::crypto::SecureRandom;
use crate::protocol::framing::{
secure_version_d_body_len_from_wire_len, secure_version_d_padding_len,
};
use std::sync::LazyLock; use std::sync::LazyLock;
// ============= Telegram Datacenters ============= // ============= Telegram Datacenters =============
@@ -236,22 +239,20 @@ pub fn is_valid_secure_payload_len(data_len: usize) -> bool {
} }
/// Compute Secure Intermediate payload length from wire length. /// Compute Secure Intermediate payload length from wire length.
/// Secure mode strips up to 3 random tail bytes by truncating to 4-byte boundary. /// Secure mode cannot distinguish full-word padding from payload, so only the
/// non-aligned tail bytes are stripped.
pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option<usize> { pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option<usize> {
if wire_len < 4 { secure_version_d_body_len_from_wire_len(wire_len)
return None;
}
Some(wire_len - (wire_len % 4))
} }
/// Generate padding length for Secure Intermediate protocol. /// Generate padding length for Secure Intermediate protocol.
/// Data must be 4-byte aligned; padding is 1..=3 so total is never divisible by 4. /// Telegram Desktop uses a 4-bit random padding length for VersionD packets.
pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize { pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize {
debug_assert!( debug_assert!(
is_valid_secure_payload_len(data_len), is_valid_secure_payload_len(data_len),
"Secure payload must be 4-byte aligned, got {data_len}" "Secure payload must be 4-byte aligned, got {data_len}"
); );
rng.range(3) + 1 secure_version_d_padding_len(rng)
} }
// ============= Timeouts ============= // ============= Timeouts =============
@@ -424,21 +425,15 @@ mod tests {
} }
#[test] #[test]
fn secure_padding_never_produces_aligned_total() { fn secure_padding_matches_tdesktop_range() {
let rng = SecureRandom::new(); let rng = SecureRandom::new();
for data_len in (0..1000).step_by(4) { for data_len in (0..1000).step_by(4) {
for _ in 0..100 { for _ in 0..100 {
let padding = secure_padding_len(data_len, &rng); let padding = secure_padding_len(data_len, &rng);
assert!( assert!(
padding <= 3, padding <= 15,
"padding out of range: data_len={data_len}, padding={padding}" "padding out of range: data_len={data_len}, padding={padding}"
); );
assert_ne!(
(data_len + padding) % 4,
0,
"invariant violated: data_len={data_len}, padding={padding}, total={}",
data_len + padding
);
} }
} }
} }
@@ -454,6 +449,16 @@ mod tests {
} }
} }
#[test]
fn secure_wire_len_preserves_full_word_tail() {
let payload_len = 64;
for padding in [4usize, 8, 12] {
let wire_len = payload_len + padding;
let recovered = secure_payload_len_from_wire_len(wire_len);
assert_eq!(recovered, Some(wire_len));
}
}
#[test] #[test]
fn secure_wire_len_rejects_too_short_frames() { fn secure_wire_len_rejects_too_short_frames() {
assert_eq!(secure_payload_len_from_wire_len(0), None); assert_eq!(secure_payload_len_from_wire_len(0), None);
+92
View File
@@ -0,0 +1,92 @@
//! Shared MTProto transport framing helpers.
use crate::crypto::SecureRandom;
/// QuickACK marker bit used by Intermediate and Secure Intermediate headers.
pub(crate) const INTERMEDIATE_QUICKACK_FLAG: u32 = 0x8000_0000;
/// Payload length mask used by Intermediate and Secure Intermediate headers.
pub(crate) const INTERMEDIATE_WIRE_LEN_MASK: u32 = 0x7fff_ffff;
/// Maximum random tail length used by Telegram Desktop VersionD packets.
pub(crate) const SECURE_VERSION_D_PADDING_MAX: usize = 15;
/// Parsed Intermediate/Secure Intermediate length header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct IntermediateHeader {
/// Payload length on the wire, excluding the four-byte header.
pub(crate) wire_len: usize,
/// Whether the QuickACK marker bit was set in the length header.
pub(crate) quickack: bool,
}
/// Parse an Intermediate/Secure Intermediate length header.
pub(crate) fn parse_intermediate_header(header: [u8; 4]) -> IntermediateHeader {
let raw = u32::from_le_bytes(header);
IntermediateHeader {
wire_len: (raw & INTERMEDIATE_WIRE_LEN_MASK) as usize,
quickack: (raw & INTERMEDIATE_QUICKACK_FLAG) != 0,
}
}
/// Encode an Intermediate/Secure Intermediate length header.
pub(crate) fn encode_intermediate_header(wire_len: usize, quickack: bool) -> Option<u32> {
if wire_len > INTERMEDIATE_WIRE_LEN_MASK as usize {
return None;
}
let mut raw = u32::try_from(wire_len).ok()?;
if quickack {
raw |= INTERMEDIATE_QUICKACK_FLAG;
}
Some(raw)
}
/// Recover the VersionD body length visible to MTProto from the encrypted wire length.
pub(crate) fn secure_version_d_body_len_from_wire_len(wire_len: usize) -> Option<usize> {
if wire_len < 4 {
return None;
}
Some(wire_len - (wire_len % 4))
}
/// Generate Telegram Desktop-compatible VersionD random tail length.
pub(crate) fn secure_version_d_padding_len(rng: &SecureRandom) -> usize {
rng.range(SECURE_VERSION_D_PADDING_MAX + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intermediate_header_roundtrip_preserves_quickack_zero_length() {
let encoded = encode_intermediate_header(0, true).unwrap();
assert_eq!(encoded, INTERMEDIATE_QUICKACK_FLAG);
let parsed = parse_intermediate_header(encoded.to_le_bytes());
assert_eq!(parsed.wire_len, 0);
assert!(parsed.quickack);
}
#[test]
fn intermediate_header_rejects_lengths_above_31_bits() {
assert_eq!(
encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize, false),
Some(INTERMEDIATE_WIRE_LEN_MASK)
);
assert_eq!(
encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize + 1, false),
None
);
}
#[test]
fn secure_version_d_body_len_strips_only_non_word_tail() {
assert_eq!(secure_version_d_body_len_from_wire_len(3), None);
assert_eq!(secure_version_d_body_len_from_wire_len(8), Some(8));
assert_eq!(secure_version_d_body_len_from_wire_len(11), Some(8));
assert_eq!(secure_version_d_body_len_from_wire_len(12), Some(12));
}
}
+4
View File
@@ -2,8 +2,10 @@
pub mod constants; pub mod constants;
pub mod frame; pub mod frame;
pub(crate) mod framing;
pub mod obfuscation; pub mod obfuscation;
pub mod tls; pub mod tls;
pub mod tls_fingerprint;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use constants::*; pub use constants::*;
@@ -13,3 +15,5 @@ pub use frame::*;
pub use obfuscation::*; pub use obfuscation::*;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use tls::*; pub use tls::*;
#[allow(unused_imports)]
pub use tls_fingerprint::*;
+418 -14
View File
@@ -1239,6 +1239,18 @@ fn test_gen_fake_x25519_key() {
assert_ne!(key1, key2); assert_ne!(key1, key2);
} }
#[test]
fn test_gen_fake_x25519mlkem768_server_key_share_shape() {
let rng = crate::crypto::SecureRandom::new();
let key_share = gen_fake_x25519mlkem768_server_key_share(&rng);
assert_eq!(key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN);
assert!(
key_share.iter().any(|byte| *byte != 0),
"hybrid ServerHello key_share must not collapse to all-zero bytes"
);
}
#[test] #[test]
fn test_fake_x25519_key_is_nonzero_and_varies() { fn test_fake_x25519_key_is_nonzero_and_varies() {
let rng = crate::crypto::SecureRandom::new(); let rng = crate::crypto::SecureRandom::new();
@@ -1325,6 +1337,69 @@ fn server_hello_extension_types(record: &[u8]) -> Vec<u16> {
out out
} }
fn server_hello_key_share(record: &[u8]) -> Option<(u16, usize)> {
if record.len() < 9 || record[0] != TLS_RECORD_HANDSHAKE || record[5] != 0x02 {
return None;
}
let record_len = u16::from_be_bytes([record[3], record[4]]) as usize;
if record.len() < 5 + record_len {
return None;
}
let hs_len = u32::from_be_bytes([0, record[6], record[7], record[8]]) as usize;
let hs_start = 5;
let hs_end = hs_start + 4 + hs_len;
if hs_end > record.len() {
return None;
}
let mut pos = hs_start + 4 + 2 + 32;
if pos >= hs_end {
return None;
}
let sid_len = record[pos] as usize;
pos += 1 + sid_len;
if pos + 2 + 1 + 2 > hs_end {
return None;
}
pos += 2 + 1;
let ext_len = u16::from_be_bytes([record[pos], record[pos + 1]]) as usize;
pos += 2;
let ext_end = pos + ext_len;
if ext_end > hs_end {
return None;
}
while pos + 4 <= ext_end {
let etype = u16::from_be_bytes([record[pos], record[pos + 1]]);
let elen = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize;
pos += 4;
if pos + elen > ext_end {
return None;
}
if etype == extension_type::KEY_SHARE {
if elen < 4 {
return None;
}
let group = u16::from_be_bytes([record[pos], record[pos + 1]]);
let key_exchange_len = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize;
if 4 + key_exchange_len != elen {
return None;
}
return Some((group, key_exchange_len));
}
pos += elen;
}
None
}
fn test_server_key_share(group: u16, len: usize) -> ServerHelloKeyShare {
ServerHelloKeyShare::new(group, vec![0x42; len])
}
#[test] #[test]
fn build_server_hello_never_places_alpn_in_server_hello_extensions() { fn build_server_hello_never_places_alpn_in_server_hello_extensions() {
let secret = b"alpn_sh_forbidden"; let secret = b"alpn_sh_forbidden";
@@ -1372,6 +1447,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
app_data_record_sizes: vec![1024], app_data_record_sizes: vec![1024],
ticket_record_sizes: Vec::new(), ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default, source: TlsProfileSource::Default,
..TlsBehaviorProfile::default()
}, },
fetched_at: SystemTime::now(), fetched_at: SystemTime::now(),
domain: "example.com".to_string(), domain: "example.com".to_string(),
@@ -1385,6 +1461,11 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
false, false,
true, true,
ClientHelloTlsVersion::Tls13, ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN,
),
&rng, &rng,
Some(b"h2".to_vec()), Some(b"h2".to_vec()),
0, 0,
@@ -1394,14 +1475,21 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
!exts.contains(&0x0010), !exts.contains(&0x0010),
"ALPN extension must not appear in emulated ServerHello" "ALPN extension must not appear in emulated ServerHello"
); );
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
} }
#[test] #[test]
fn test_tls_extension_builder() { fn test_tls_extension_builder() {
let key = [0x42u8; 32]; let key = vec![0x42u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let mut builder = TlsExtensionBuilder::new(); let mut builder = TlsExtensionBuilder::new();
builder.add_key_share(&key); builder.add_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key);
builder.add_supported_versions(0x0304); builder.add_supported_versions(0x0304);
let result = builder.build(); let result = builder.build();
@@ -1414,10 +1502,10 @@ fn test_tls_extension_builder() {
#[test] #[test]
fn test_server_hello_builder() { fn test_server_hello_builder() {
let session_id = vec![0x01, 0x02, 0x03, 0x04]; let session_id = vec![0x01, 0x02, 0x03, 0x04];
let key = [0x55u8; 32]; let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let builder = ServerHelloBuilder::new(session_id.clone()) let builder = ServerHelloBuilder::new(session_id.clone())
.with_x25519_key(&key) .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key)
.with_tls13_version(); .with_tls13_version();
let record = builder.build_record(); let record = builder.build_record();
@@ -1451,6 +1539,41 @@ fn test_build_server_hello_structure() {
let app_start = ccs_start + ccs_len; let app_start = ccs_start + ccs_len;
assert!(response.len() > app_start + 5); assert!(response.len() > app_start + 5);
assert_eq!(response[app_start], TLS_RECORD_APPLICATION); assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
}
#[test]
fn test_build_server_hello_with_cipher_uses_selected_key_share_group() {
let secret = b"test secret";
let client_digest = [0x42u8; 32];
let session_id = vec![0xAA; 32];
let key_share =
ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519, vec![0x55u8; X25519_KEY_SHARE_LEN]);
let rng = crate::crypto::SecureRandom::new();
let response = build_server_hello_with_cipher(
secret,
&client_digest,
&session_id,
2048,
&rng,
[0x13, 0x01],
&key_share,
None,
0,
);
assert_eq!(
server_hello_key_share(&response),
Some((TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN))
);
} }
#[test] #[test]
@@ -1473,10 +1596,10 @@ fn test_build_server_hello_digest() {
#[test] #[test]
fn test_server_hello_extensions_length() { fn test_server_hello_extensions_length() {
let session_id = vec![0x01; 32]; let session_id = vec![0x01; 32];
let key = [0x55u8; 32]; let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let builder = ServerHelloBuilder::new(session_id) let builder = ServerHelloBuilder::new(session_id)
.with_x25519_key(&key) .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key)
.with_tls13_version(); .with_tls13_version();
let record = builder.build_record(); let record = builder.build_record();
@@ -1509,12 +1632,55 @@ fn test_validate_tls_handshake_format() {
} }
fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8> { fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8> {
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x01]], exts, host)
}
fn client_key_share_extension(entries: &[(u16, usize)]) -> Vec<u8> {
let mut shares = Vec::new();
for (group, key_exchange_len) in entries {
assert!(*key_exchange_len <= u16::MAX as usize);
shares.extend_from_slice(&group.to_be_bytes());
shares.extend_from_slice(&(*key_exchange_len as u16).to_be_bytes());
let start = shares.len();
shares.resize(start + *key_exchange_len, 0x42);
}
assert!(shares.len() <= u16::MAX as usize);
let mut extension = Vec::new();
extension.extend_from_slice(&(shares.len() as u16).to_be_bytes());
extension.extend_from_slice(&shares);
extension
}
fn client_key_share_extension_with_payloads(entries: &[(u16, &[u8])]) -> Vec<u8> {
let mut shares = Vec::new();
for (group, key_exchange) in entries {
assert!(key_exchange.len() <= u16::MAX as usize);
shares.extend_from_slice(&group.to_be_bytes());
shares.extend_from_slice(&(key_exchange.len() as u16).to_be_bytes());
shares.extend_from_slice(key_exchange);
}
assert!(shares.len() <= u16::MAX as usize);
let mut extension = Vec::new();
extension.extend_from_slice(&(shares.len() as u16).to_be_bytes());
extension.extend_from_slice(&shares);
extension
}
fn build_client_hello_with_ciphers_and_exts(
cipher_suites: &[[u8; 2]],
exts: Vec<(u16, Vec<u8>)>,
host: &str,
) -> Vec<u8> {
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
body.push(0); body.push(0);
body.extend_from_slice(&2u16.to_be_bytes()); body.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes());
body.extend_from_slice(&[0x13, 0x01]); for suite in cipher_suites {
body.extend_from_slice(suite);
}
body.push(1); body.push(1);
body.push(0); body.push(0);
@@ -1654,6 +1820,244 @@ fn detect_client_hello_tls_version_rejects_malformed_supported_versions() {
assert!(detect_client_hello_tls_version(&ch).is_none()); assert!(detect_client_hello_tls_version(&ch).is_none());
} }
#[test]
fn select_server_hello_cipher_suite_keeps_profile_cipher_when_offered() {
let ch = build_client_hello_with_ciphers_and_exts(
&[[0x13, 0x01], [0x13, 0x03]],
Vec::new(),
"example.com",
);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x03]),
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() {
let ch = build_client_hello_with_ciphers_and_exts(
&[[0xc0, 0x2f], [0x13, 0x03]],
Vec::new(),
"example.com",
);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0xc0, 0x2f]),
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_rejects_without_offered_tls13_suite() {
let ch = build_client_hello_with_ciphers_and_exts(&[[0xc0, 0x2f]], Vec::new(), "example.com");
assert_eq!(select_server_hello_cipher_suite(&ch, [0x13, 0x01]), None);
}
#[test]
fn select_server_hello_cipher_suite_falls_back_to_offered_tls13_suite() {
let ch = build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_rejects_malformed_clienthello() {
let mut ch =
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
ch.truncate(12);
assert_eq!(select_server_hello_cipher_suite(&ch, [0x13, 0x01]), None);
}
#[test]
fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offered() {
let key_share = client_key_share_extension(&[
(0x0a0a, 1),
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group(&ch),
Some(TLS_NAMED_GROUP_X25519MLKEM768)
);
}
#[test]
fn select_server_hello_key_share_group_prefers_profiled_x25519_when_valid_share_is_offered() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group_with_preference(&ch, Some(TLS_NAMED_GROUP_X25519)),
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn build_x25519mlkem768_server_key_share_accepts_tdesktop_canonical_share() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_x25519mlkem768_server_key_share(&ch, &rng)
.expect("tdesktop-like canonical share must build a ServerHello share");
assert_eq!(server_key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN);
assert!(
server_key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN]
.iter()
.any(|byte| *byte != 0),
"ML-KEM ciphertext must not be all zero"
);
assert!(
server_key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..]
.iter()
.any(|byte| *byte != 0),
"X25519 server share must not be all zero"
);
}
#[test]
fn build_x25519_server_key_share_accepts_tdesktop_fallback_share() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_x25519_server_key_share(&ch, &rng)
.expect("tdesktop-like X25519 share must build a ServerHello share");
assert_eq!(server_key_share.len(), X25519_KEY_SHARE_LEN);
assert!(
server_key_share.iter().any(|byte| *byte != 0),
"X25519 server share must not be all zero"
);
}
#[test]
fn build_server_hello_key_share_prefers_profiled_x25519() {
let key_share = client_key_share_extension(&[
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng)
.expect("profiled X25519 share must be selected when client offers it");
assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519);
assert_eq!(server_key_share.key_exchange().len(), X25519_KEY_SHARE_LEN);
}
#[test]
fn build_server_hello_key_share_falls_back_from_bad_profiled_x25519_to_hybrid() {
let key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
let server_key_share = build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng)
.expect("hybrid share must be selected when profiled X25519 is unavailable");
assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519MLKEM768);
assert_eq!(
server_key_share.key_exchange().len(),
X25519MLKEM768_SERVER_KEY_SHARE_LEN
);
}
#[test]
fn build_x25519mlkem768_server_key_share_rejects_noncanonical_mlkem_key() {
let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN];
key_exchange[..3].copy_from_slice(&[0xff, 0xff, 0xff]);
let key_share = client_key_share_extension_with_payloads(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
&key_exchange,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
assert!(build_x25519mlkem768_server_key_share(&ch, &rng).is_none());
}
#[test]
fn build_x25519mlkem768_server_key_share_rejects_all_zero_x25519_share() {
let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN];
key_exchange[MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN..].fill(0);
let key_share = client_key_share_extension_with_payloads(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
&key_exchange,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
let rng = crate::crypto::SecureRandom::new();
assert!(build_x25519mlkem768_server_key_share(&ch, &rng).is_none());
}
#[test]
fn select_server_hello_key_share_group_accepts_x25519_when_hybrid_is_absent() {
let key_share = client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group(&ch),
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn select_server_hello_key_share_group_rejects_malformed_hybrid_len() {
let key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN - 1,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test]
fn select_server_hello_key_share_group_rejects_malformed_key_share_tail() {
let mut key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)]);
let shares_len = u16::from_be_bytes([key_share[0], key_share[1]]) + 1;
key_share[0..2].copy_from_slice(&shares_len.to_be_bytes());
key_share.push(0);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test] #[test]
fn extract_sni_rejects_zero_length_host_name() { fn extract_sni_rejects_zero_length_host_name() {
let mut sni_ext = Vec::new(); let mut sni_ext = Vec::new();
@@ -2179,7 +2583,7 @@ fn light_fuzz_boot_time_timestamp_matrix_with_short_replay_window_obeys_boot_cap
} }
#[test] #[test]
fn server_hello_application_data_contains_alpn_marker_when_selected() { fn server_hello_application_data_omits_alpn_marker_when_selected() {
let secret = b"alpn_marker_test"; let secret = b"alpn_marker_test";
let client_digest = [0x55u8; TLS_DIGEST_LEN]; let client_digest = [0x55u8; TLS_DIGEST_LEN];
let session_id = vec![0xAB; 32]; let session_id = vec![0xAB; 32];
@@ -2206,8 +2610,8 @@ fn server_hello_application_data_contains_alpn_marker_when_selected() {
assert!( assert!(
app_payload app_payload
.windows(expected.len()) .windows(expected.len())
.any(|window| window == expected), .all(|window| window != expected),
"first application payload must carry ALPN marker for selected protocol" "first application payload must not expose plaintext ALPN marker bytes"
); );
} }
@@ -2303,14 +2707,14 @@ fn server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
} }
#[test] #[test]
fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() { fn server_hello_omits_alpn_marker_even_when_it_would_fit_fake_cert_len() {
let secret = b"alpn_exact_fit_test"; let secret = b"alpn_exact_fit_test";
let client_digest = [0x58u8; TLS_DIGEST_LEN]; let client_digest = [0x58u8; TLS_DIGEST_LEN];
let session_id = vec![0xA5; 32]; let session_id = vec![0xA5; 32];
let rng = crate::crypto::SecureRandom::new(); let rng = crate::crypto::SecureRandom::new();
let proto = vec![b'z'; 57]; let proto = vec![b'z'; 57];
// marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64 // marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64.
let response = build_server_hello( let response = build_server_hello(
secret, secret,
&client_digest, &client_digest,
@@ -2336,7 +2740,7 @@ fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() {
expected_marker.extend_from_slice(&proto); expected_marker.extend_from_slice(&proto);
assert_eq!(app_payload.len(), expected_marker.len()); assert_eq!(app_payload.len(), expected_marker.len());
assert_eq!(app_payload, expected_marker.as_slice()); assert_ne!(app_payload, expected_marker.as_slice());
} }
#[test] #[test]
+519 -41
View File
@@ -65,6 +65,7 @@ use super::constants::*;
use crate::crypto::{SecureRandom, sha256_hmac}; use crate::crypto::{SecureRandom, sha256_hmac};
#[cfg(test)] #[cfg(test)]
use crate::error::ProxyError; use crate::error::ProxyError;
use ml_kem::{B32, EncapsulationKey as MlKemEncapsulationKey, Key as MlKemKey, MlKem768};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use x25519_dalek::{X25519_BASEPOINT_BYTES, x25519}; use x25519_dalek::{X25519_BASEPOINT_BYTES, x25519};
@@ -105,11 +106,49 @@ mod extension_type {
/// TLS Cipher Suites /// TLS Cipher Suites
mod cipher_suite { mod cipher_suite {
pub const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01]; pub const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
pub const TLS_AES_256_GCM_SHA384: [u8; 2] = [0x13, 0x02];
pub const TLS_CHACHA20_POLY1305_SHA256: [u8; 2] = [0x13, 0x03];
} }
/// TLS Named Curves /// TLS named groups used in KeyShare extensions.
mod named_curve { mod named_curve {
pub const X25519: u16 = 0x001d; pub const X25519: u16 = 0x001d;
pub const X25519MLKEM768: u16 = 0x11ec;
}
/// TLS X25519 named group.
pub(crate) const TLS_NAMED_GROUP_X25519: u16 = named_curve::X25519;
/// TLS X25519MLKEM768 named group.
pub(crate) const TLS_NAMED_GROUP_X25519MLKEM768: u16 = named_curve::X25519MLKEM768;
const X25519_KEY_SHARE_LEN: usize = 32;
const X25519MLKEM768_CLIENT_KEY_SHARE_LEN: usize = 1216;
const X25519MLKEM768_SERVER_KEY_SHARE_LEN: usize = 1120;
const MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN: usize = 1184;
const MLKEM768_SERVER_CIPHERTEXT_LEN: usize = 1088;
/// ServerHello key_share selected for the authenticated ClientHello.
#[derive(Clone, Debug)]
pub(crate) struct ServerHelloKeyShare {
group: u16,
key_exchange: Vec<u8>,
}
impl ServerHelloKeyShare {
pub(crate) fn new(group: u16, key_exchange: Vec<u8>) -> Self {
Self {
group,
key_exchange,
}
}
pub(crate) fn group(&self) -> u16 {
self.group
}
pub(crate) fn key_exchange(&self) -> &[u8] {
&self.key_exchange
}
} }
// ============= TLS Validation Result ============= // ============= TLS Validation Result =============
@@ -142,26 +181,28 @@ impl TlsExtensionBuilder {
} }
} }
/// Add Key Share extension with X25519 key /// Add KeyShare extension with the selected named group.
fn add_key_share(&mut self, public_key: &[u8; 32]) -> &mut Self { fn add_key_share(&mut self, group: u16, key_exchange: &[u8]) -> &mut Self {
let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else {
return self;
};
let Some(entry_len) = key_exchange.len().checked_add(4) else {
return self;
};
let Ok(entry_len) = u16::try_from(entry_len) else {
return self;
};
// Extension type: key_share (0x0033) // Extension type: key_share (0x0033)
self.extensions self.extensions
.extend_from_slice(&extension_type::KEY_SHARE.to_be_bytes()); .extend_from_slice(&extension_type::KEY_SHARE.to_be_bytes());
// Key share entry: curve (2) + key_len (2) + key (32) = 36 bytes // ServerHello key_share data is exactly one KeyShareEntry.
// Extension data length
let entry_len: u16 = 2 + 2 + 32; // curve + length + key
self.extensions.extend_from_slice(&entry_len.to_be_bytes()); self.extensions.extend_from_slice(&entry_len.to_be_bytes());
self.extensions.extend_from_slice(&group.to_be_bytes());
// Named curve: x25519
self.extensions self.extensions
.extend_from_slice(&named_curve::X25519.to_be_bytes()); .extend_from_slice(&key_exchange_len.to_be_bytes());
self.extensions.extend_from_slice(key_exchange);
// Key length
self.extensions.extend_from_slice(&(32u16).to_be_bytes());
// Key data
self.extensions.extend_from_slice(public_key);
self self
} }
@@ -230,8 +271,8 @@ impl ServerHelloBuilder {
} }
} }
fn with_x25519_key(mut self, key: &[u8; 32]) -> Self { fn with_key_share(mut self, group: u16, key_exchange: &[u8]) -> Self {
self.extensions.add_key_share(key); self.extensions.add_key_share(group, key_exchange);
self self
} }
@@ -241,6 +282,13 @@ impl ServerHelloBuilder {
self self
} }
fn with_cipher_suite(mut self, cipher_suite: [u8; 2]) -> Self {
if cipher_suite != [0, 0] {
self.cipher_suite = cipher_suite;
}
self
}
/// Build ServerHello message (without record header) /// Build ServerHello message (without record header)
fn build_message(&self) -> Vec<u8> { fn build_message(&self) -> Vec<u8> {
let Ok(session_id_len) = u8::try_from(self.session_id.len()) else { let Ok(session_id_len) = u8::try_from(self.session_id.len()) else {
@@ -499,9 +547,137 @@ fn validate_tls_handshake_at_time_with_boot_cap(
/// Uses RFC 7748 X25519 scalar multiplication over the canonical basepoint, /// Uses RFC 7748 X25519 scalar multiplication over the canonical basepoint,
/// yielding distribution-consistent public keys for anti-fingerprinting. /// yielding distribution-consistent public keys for anti-fingerprinting.
pub fn gen_fake_x25519_key(rng: &SecureRandom) -> [u8; 32] { pub fn gen_fake_x25519_key(rng: &SecureRandom) -> [u8; 32] {
let mut scalar = [0u8; 32]; let (_scalar, public_key) = gen_x25519_key_pair(rng);
scalar.copy_from_slice(&rng.bytes(32)); public_key
x25519(scalar, X25519_BASEPOINT_BYTES) }
fn gen_x25519_key_pair(rng: &SecureRandom) -> ([u8; 32], [u8; 32]) {
let mut scalar = [0u8; X25519_KEY_SHARE_LEN];
rng.fill(&mut scalar);
let public_key = x25519(scalar, X25519_BASEPOINT_BYTES);
(scalar, public_key)
}
/// Generate a fake X25519MLKEM768 ServerHello key_share payload.
pub(crate) fn gen_fake_x25519mlkem768_server_key_share(rng: &SecureRandom) -> Vec<u8> {
let mut key_share = vec![0u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
// FakeTLS never derives TLS traffic secrets from this payload; only the
// externally visible named group and vector lengths are protocol-facing.
rng.fill(&mut key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN]);
let x25519_key = gen_fake_x25519_key(rng);
key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..].copy_from_slice(&x25519_key);
key_share
}
fn mlkem768_encapsulate_to_client(client_key: &[u8], rng: &SecureRandom) -> Option<Vec<u8>> {
let key_bytes = MlKemKey::<MlKemEncapsulationKey<MlKem768>>::try_from(client_key).ok()?;
let encapsulation_key = MlKemEncapsulationKey::<MlKem768>::new(&key_bytes).ok()?;
let mut randomness = [0u8; 32];
rng.fill(&mut randomness);
let randomness = B32::try_from(randomness.as_slice()).ok()?;
let (ciphertext, _shared_key) = encapsulation_key.encapsulate_deterministic(&randomness);
let ciphertext = ciphertext.as_slice().to_vec();
if ciphertext.len() == MLKEM768_SERVER_CIPHERTEXT_LEN {
Some(ciphertext)
} else {
None
}
}
/// Build a valid X25519MLKEM768 ServerHello key_share for the authenticated ClientHello.
pub(crate) fn build_x25519mlkem768_server_key_share(
handshake: &[u8],
rng: &SecureRandom,
) -> Option<Vec<u8>> {
let client_key_exchange = client_hello_key_share_group_entry(
handshake,
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)?;
let client_mlkem_key = client_key_exchange.get(..MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN)?;
let client_x25519_key = client_key_exchange.get(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN..)?;
let mlkem_ciphertext = mlkem768_encapsulate_to_client(client_mlkem_key, rng)?;
let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN];
client_x25519.copy_from_slice(client_x25519_key);
let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng);
let x25519_shared = x25519(server_x25519_scalar, client_x25519);
if bool::from(x25519_shared.ct_eq(&[0u8; X25519_KEY_SHARE_LEN])) {
return None;
}
let mut key_share = Vec::with_capacity(X25519MLKEM768_SERVER_KEY_SHARE_LEN);
key_share.extend_from_slice(&mlkem_ciphertext);
key_share.extend_from_slice(&server_x25519_key);
Some(key_share)
}
/// Build a valid X25519 ServerHello key_share for the authenticated ClientHello.
pub(crate) fn build_x25519_server_key_share(
handshake: &[u8],
rng: &SecureRandom,
) -> Option<Vec<u8>> {
let client_key_exchange = client_hello_key_share_group_entry(
handshake,
TLS_NAMED_GROUP_X25519,
X25519_KEY_SHARE_LEN,
)?;
let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN];
client_x25519.copy_from_slice(client_key_exchange);
let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng);
let x25519_shared = x25519(server_x25519_scalar, client_x25519);
if bool::from(x25519_shared.ct_eq(&[0u8; X25519_KEY_SHARE_LEN])) {
return None;
}
Some(server_x25519_key.to_vec())
}
fn build_server_hello_key_share_for_group(
handshake: &[u8],
group: u16,
rng: &SecureRandom,
) -> Option<ServerHelloKeyShare> {
let expected_key_exchange_len = client_hello_key_share_group_len(group)?;
client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len)?;
// FakeTLS clients validate ServerHello shape and digest, not TLS traffic
// secrets, so the response must mirror the offered group without binding to
// the camouflage key bytes embedded in ClientHello.
match group {
TLS_NAMED_GROUP_X25519MLKEM768 => Some(ServerHelloKeyShare::new(
group,
gen_fake_x25519mlkem768_server_key_share(rng),
)),
TLS_NAMED_GROUP_X25519 => Some(ServerHelloKeyShare::new(
group,
gen_fake_x25519_key(rng).to_vec(),
)),
_ => None,
}
}
fn server_hello_key_share_candidate_order(preferred_group: Option<u16>) -> [u16; 2] {
if preferred_group == Some(TLS_NAMED_GROUP_X25519) {
[TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768]
} else {
[TLS_NAMED_GROUP_X25519MLKEM768, TLS_NAMED_GROUP_X25519]
}
}
/// Build a ServerHello key_share using a profile-preferred group when possible.
pub(crate) fn build_server_hello_key_share(
handshake: &[u8],
preferred_group: Option<u16>,
rng: &SecureRandom,
) -> Option<ServerHelloKeyShare> {
for group in server_hello_key_share_candidate_order(preferred_group) {
if let Some(key_share) = build_server_hello_key_share_for_group(handshake, group, rng) {
return Some(key_share);
}
}
None
} }
/// Build TLS ServerHello response /// Build TLS ServerHello response
@@ -520,15 +696,48 @@ pub fn build_server_hello(
rng: &SecureRandom, rng: &SecureRandom,
alpn: Option<Vec<u8>>, alpn: Option<Vec<u8>>,
new_session_tickets: u8, new_session_tickets: u8,
) -> Vec<u8> {
let server_key_share = ServerHelloKeyShare::new(
TLS_NAMED_GROUP_X25519MLKEM768,
gen_fake_x25519mlkem768_server_key_share(rng),
);
build_server_hello_with_cipher(
secret,
client_digest,
session_id,
fake_cert_len,
rng,
cipher_suite::TLS_AES_128_GCM_SHA256,
&server_key_share,
alpn,
new_session_tickets,
)
}
/// Build TLS ServerHello response with a caller-selected cipher suite.
///
/// The caller is responsible for selecting a suite that is compatible with the
/// already-authenticated ClientHello. Keeping the selection outside this
/// builder avoids extra ClientHello parsing in the response construction path.
pub(crate) fn build_server_hello_with_cipher(
secret: &[u8],
client_digest: &[u8; TLS_DIGEST_LEN],
session_id: &[u8],
fake_cert_len: usize,
rng: &SecureRandom,
selected_cipher_suite: [u8; 2],
server_key_share: &ServerHelloKeyShare,
alpn: Option<Vec<u8>>,
new_session_tickets: u8,
) -> Vec<u8> { ) -> Vec<u8> {
const MIN_APP_DATA: usize = 64; const MIN_APP_DATA: usize = 64;
const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE;
let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA); let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA);
let x25519_key = gen_fake_x25519_key(rng);
// Build ServerHello // Build ServerHello
let server_hello = ServerHelloBuilder::new(session_id.to_vec()) let server_hello = ServerHelloBuilder::new(session_id.to_vec())
.with_x25519_key(&x25519_key) .with_cipher_suite(selected_cipher_suite)
.with_key_share(server_key_share.group(), server_key_share.key_exchange())
.with_tls13_version() .with_tls13_version()
.build_record(); .build_record();
@@ -538,28 +747,14 @@ pub fn build_server_hello(
TLS_VERSION[0], TLS_VERSION[0],
TLS_VERSION[1], TLS_VERSION[1],
0x00, 0x00,
0x01, // length = 1 0x01,
0x01, // CCS byte 0x01,
]; ];
// Build first encrypted flight mimic as opaque ApplicationData bytes. // Build first encrypted flight mimic as opaque ApplicationData bytes.
// Embed a compact EncryptedExtensions-like ALPN block when selected. // ALPN belongs inside encrypted EncryptedExtensions in real TLS 1.3.
let mut fake_cert = Vec::with_capacity(fake_cert_len); let mut fake_cert = Vec::with_capacity(fake_cert_len);
if let Some(proto) = alpn let _ = alpn;
.as_ref()
.filter(|p| !p.is_empty() && p.len() <= u8::MAX as usize)
{
let proto_list_len = 1usize + proto.len();
let ext_data_len = 2usize + proto_list_len;
let marker_len = 4usize + ext_data_len;
if marker_len <= fake_cert_len {
fake_cert.extend_from_slice(&0x0010u16.to_be_bytes());
fake_cert.extend_from_slice(&(ext_data_len as u16).to_be_bytes());
fake_cert.extend_from_slice(&(proto_list_len as u16).to_be_bytes());
fake_cert.push(proto.len() as u8);
fake_cert.extend_from_slice(proto);
}
}
if fake_cert.len() < fake_cert_len { if fake_cert.len() < fake_cert_len {
fake_cert.extend_from_slice(&rng.bytes(fake_cert_len - fake_cert.len())); fake_cert.extend_from_slice(&rng.bytes(fake_cert_len - fake_cert.len()));
} else if fake_cert.len() > fake_cert_len { } else if fake_cert.len() > fake_cert_len {
@@ -580,7 +775,7 @@ pub fn build_server_hello(
let ticket_count = new_session_tickets.min(4); let ticket_count = new_session_tickets.min(4);
if ticket_count > 0 { if ticket_count > 0 {
for _ in 0..ticket_count { for _ in 0..ticket_count {
let ticket_len: usize = rng.range(48) + 48; // 48-95 bytes let ticket_len: usize = rng.range(48) + 48;
let mut record = Vec::with_capacity(5 + ticket_len); let mut record = Vec::with_capacity(5 + ticket_len);
record.push(TLS_RECORD_APPLICATION); record.push(TLS_RECORD_APPLICATION);
record.extend_from_slice(&TLS_VERSION); record.extend_from_slice(&TLS_VERSION);
@@ -927,6 +1122,289 @@ pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option<ClientHelloTl
} }
} }
fn client_hello_cipher_suites_range(handshake: &[u8]) -> Option<(usize, usize)> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5;
if handshake.get(pos) != Some(&0x01) {
return None;
}
pos += 1;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((handshake[pos] as usize) << 16)
| ((handshake[pos + 1] as usize) << 8)
| handshake[pos + 2] as usize;
pos += 3;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
pos += 2 + 32;
let session_id_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
if cipher_len == 0 || cipher_len % 2 != 0 {
return None;
}
pos += 2;
let cipher_end = pos.checked_add(cipher_len)?;
if cipher_end > handshake_end {
return None;
}
Some((pos, cipher_end))
}
fn client_hello_extensions_range(handshake: &[u8]) -> Option<(usize, usize)> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5;
if handshake.get(pos) != Some(&0x01) {
return None;
}
pos += 1;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((handshake[pos] as usize) << 16)
| ((handshake[pos + 1] as usize) << 8)
| handshake[pos + 2] as usize;
pos += 3;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
pos += 2 + 32;
let session_id_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
if cipher_len == 0 || cipher_len % 2 != 0 {
return None;
}
pos += 2;
pos = pos.checked_add(cipher_len)?;
if pos + 1 > handshake_end {
return None;
}
let compression_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(compression_len)?;
if pos == handshake_end {
return Some((handshake_end, handshake_end));
}
if pos + 2 > handshake_end {
return None;
}
let extensions_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
pos += 2;
let extensions_end = pos.checked_add(extensions_len)?;
if extensions_end > handshake_end {
return None;
}
Some((pos, extensions_end))
}
fn key_share_extension_group_entry<'a>(
data: &'a [u8],
group: u16,
expected_key_exchange_len: usize,
) -> Option<&'a [u8]> {
if data.len() < 2 {
return None;
}
let shares_len = u16::from_be_bytes([data[0], data[1]]) as usize;
if shares_len != data.len().saturating_sub(2) {
return None;
}
let mut pos = 2usize;
let shares_end = 2 + shares_len;
let mut found_group = None;
while pos + 4 <= shares_end {
let entry_group = u16::from_be_bytes([data[pos], data[pos + 1]]);
let key_exchange_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
let Some(key_exchange_end) = pos.checked_add(key_exchange_len) else {
return None;
};
if key_exchange_end > shares_end {
return None;
}
if entry_group == group {
if key_exchange_len != expected_key_exchange_len || found_group.is_some() {
return None;
}
found_group = Some(&data[pos..key_exchange_end]);
}
pos = key_exchange_end;
}
if pos == shares_end { found_group } else { None }
}
fn client_hello_key_share_group_entry<'a>(
handshake: &'a [u8],
group: u16,
expected_key_exchange_len: usize,
) -> Option<&'a [u8]> {
let Some((mut pos, extensions_end)) = client_hello_extensions_range(handshake) else {
return None;
};
while pos + 4 <= extensions_end {
let ext_type = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
let ext_len = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize;
pos += 4;
let Some(ext_end) = pos.checked_add(ext_len) else {
return None;
};
if ext_end > extensions_end {
return None;
}
if ext_type == extension_type::KEY_SHARE {
return key_share_extension_group_entry(
&handshake[pos..ext_end],
group,
expected_key_exchange_len,
);
}
pos = ext_end;
}
None
}
fn client_hello_offers_cipher_suite(
handshake: &[u8],
range: (usize, usize),
suite: [u8; 2],
) -> bool {
let mut pos = range.0;
while pos + 1 < range.1 {
if handshake[pos] == suite[0] && handshake[pos + 1] == suite[1] {
return true;
}
pos += 2;
}
false
}
fn is_tls13_cipher_suite(suite: [u8; 2]) -> bool {
suite == cipher_suite::TLS_AES_128_GCM_SHA256
|| suite == cipher_suite::TLS_AES_256_GCM_SHA384
|| suite == cipher_suite::TLS_CHACHA20_POLY1305_SHA256
}
/// Select the ServerHello cipher suite from the already-received ClientHello.
///
/// This is intentionally a borrowed, zero-allocation scan. It runs only for an
/// authenticated success response and fails closed for malformed or unsupported
/// ClientHello shapes that cannot produce a DPI-consistent ServerHello.
pub(crate) fn select_server_hello_cipher_suite(
handshake: &[u8],
preferred: [u8; 2],
) -> Option<[u8; 2]> {
let preferred = if is_tls13_cipher_suite(preferred) {
preferred
} else {
cipher_suite::TLS_AES_128_GCM_SHA256
};
let Some(range) = client_hello_cipher_suites_range(handshake) else {
return None;
};
if client_hello_offers_cipher_suite(handshake, range, preferred) {
return Some(preferred);
}
for fallback in [
cipher_suite::TLS_AES_128_GCM_SHA256,
cipher_suite::TLS_CHACHA20_POLY1305_SHA256,
cipher_suite::TLS_AES_256_GCM_SHA384,
] {
if client_hello_offers_cipher_suite(handshake, range, fallback) {
return Some(fallback);
}
}
None
}
fn client_hello_key_share_group_len(group: u16) -> Option<usize> {
match group {
TLS_NAMED_GROUP_X25519MLKEM768 => Some(X25519MLKEM768_CLIENT_KEY_SHARE_LEN),
TLS_NAMED_GROUP_X25519 => Some(X25519_KEY_SHARE_LEN),
_ => None,
}
}
/// Select the ServerHello key_share named group from the authenticated ClientHello.
///
/// Malformed key_share structures fail closed so authenticated but
/// DPI-inconsistent ClientHellos take the ordinary masking fallback path.
pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option<u16> {
select_server_hello_key_share_group_with_preference(handshake, None)
}
/// Select the ServerHello key_share named group with an origin-profile preference.
pub(crate) fn select_server_hello_key_share_group_with_preference(
handshake: &[u8],
preferred_group: Option<u16>,
) -> Option<u16> {
for group in server_hello_key_share_candidate_order(preferred_group) {
let expected_key_exchange_len = client_hello_key_share_group_len(group)?;
if client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len).is_some()
{
return Some(group);
}
}
None
}
/// Check if bytes look like a TLS ClientHello /// Check if bytes look like a TLS ClientHello
pub fn is_tls_handshake(first_bytes: &[u8]) -> bool { pub fn is_tls_handshake(first_bytes: &[u8]) -> bool {
if first_bytes.len() < 3 { if first_bytes.len() < 3 {
+450
View File
@@ -0,0 +1,450 @@
//! Passive JA3 / JA4 TLS ClientHello fingerprinting.
use crate::crypto::hash::md5;
use crate::crypto::sha256;
use crate::protocol::constants::TLS_RECORD_HANDSHAKE;
const EXT_SNI: u16 = 0x0000;
const EXT_SUPPORTED_GROUPS: u16 = 0x000a;
const EXT_EC_POINT_FORMATS: u16 = 0x000b;
const EXT_SIGNATURE_ALGORITHMS: u16 = 0x000d;
const EXT_ALPN: u16 = 0x0010;
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TlsClientFingerprint {
pub ja3: String,
pub ja3_raw: String,
pub ja4: String,
pub ja4_raw: String,
}
#[derive(Default)]
struct ParsedClientHello {
legacy_version: u16,
ciphers: Vec<u16>,
extensions: Vec<u16>,
supported_groups: Vec<u16>,
ec_point_formats: Vec<u8>,
signature_algorithms: Vec<u16>,
supported_versions: Vec<u16>,
alpn_first: Option<Vec<u8>>,
sni_present: bool,
}
pub fn fingerprint_client_hello(handshake: &[u8]) -> Option<TlsClientFingerprint> {
let parsed = parse_client_hello(handshake)?;
let ja3_raw = ja3_raw(&parsed);
let ja3 = hex::encode(md5(ja3_raw.as_bytes()));
let (ja4, ja4_raw) = ja4(&parsed);
Some(TlsClientFingerprint {
ja3,
ja3_raw,
ja4,
ja4_raw,
})
}
fn parse_client_hello(handshake: &[u8]) -> Option<ParsedClientHello> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = read_u16_at(handshake, 3)? as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5usize;
if *handshake.get(pos)? != 0x01 {
return None;
}
pos = pos.checked_add(1)?;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((usize::from(handshake[pos])) << 16)
| ((usize::from(handshake[pos + 1])) << 8)
| usize::from(handshake[pos + 2]);
pos = pos.checked_add(3)?;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
let legacy_version = read_u16_at(handshake, pos)?;
pos = pos.checked_add(2 + 32)?;
let session_id_len = usize::from(*handshake.get(pos)?);
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = read_u16_at(handshake, pos)? as usize;
pos = pos.checked_add(2)?;
let cipher_end = pos.checked_add(cipher_len)?;
if cipher_end > handshake_end || cipher_len % 2 != 0 {
return None;
}
let mut ciphers = Vec::with_capacity(cipher_len / 2);
while pos + 1 < cipher_end {
let value = read_u16_at(handshake, pos)?;
if !is_grease(value) {
ciphers.push(value);
}
pos = pos.checked_add(2)?;
}
let comp_len = usize::from(*handshake.get(pos)?);
pos = pos.checked_add(1)?.checked_add(comp_len)?;
if pos > handshake_end {
return None;
}
let mut parsed = ParsedClientHello {
legacy_version,
ciphers,
..ParsedClientHello::default()
};
if pos == handshake_end {
return Some(parsed);
}
if pos + 2 > handshake_end {
return None;
}
let ext_len = read_u16_at(handshake, pos)? as usize;
pos = pos.checked_add(2)?;
let ext_end = pos.checked_add(ext_len)?;
if ext_end > handshake_end {
return None;
}
while pos + 4 <= ext_end {
let etype = read_u16_at(handshake, pos)?;
let elen = read_u16_at(handshake, pos + 2)? as usize;
pos = pos.checked_add(4)?;
let data_end = pos.checked_add(elen)?;
if data_end > ext_end {
return None;
}
let data = handshake.get(pos..data_end)?;
if !is_grease(etype) {
parsed.extensions.push(etype);
match etype {
EXT_SNI => parsed.sni_present = true,
EXT_SUPPORTED_GROUPS => {
parsed.supported_groups = parse_u16_vector(data, 2)?;
}
EXT_EC_POINT_FORMATS => {
parsed.ec_point_formats = parse_u8_vector(data)?;
}
EXT_SIGNATURE_ALGORITHMS => {
parsed.signature_algorithms = parse_u16_vector(data, 2)?;
}
EXT_ALPN => {
parsed.alpn_first = parse_alpn_first(data)?;
}
EXT_SUPPORTED_VERSIONS => {
parsed.supported_versions = parse_u16_vector(data, 1)?;
}
_ => {}
}
}
pos = data_end;
}
if pos != ext_end {
return None;
}
Some(parsed)
}
fn parse_u16_vector(data: &[u8], len_prefix_len: usize) -> Option<Vec<u16>> {
let (list_len, mut pos) = match len_prefix_len {
1 => (usize::from(*data.first()?), 1usize),
2 => (read_u16_at(data, 0)? as usize, 2usize),
_ => return None,
};
let list_end = pos.checked_add(list_len)?;
if list_end > data.len() || list_len % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(list_len / 2);
while pos + 1 < list_end {
let value = read_u16_at(data, pos)?;
if !is_grease(value) {
out.push(value);
}
pos = pos.checked_add(2)?;
}
Some(out)
}
fn parse_u8_vector(data: &[u8]) -> Option<Vec<u8>> {
let list_len = usize::from(*data.first()?);
let list_start = 1usize;
let list_end = list_start.checked_add(list_len)?;
if list_end > data.len() {
return None;
}
Some(data.get(list_start..list_end)?.to_vec())
}
fn parse_alpn_first(data: &[u8]) -> Option<Option<Vec<u8>>> {
if data.len() < 2 {
return None;
}
let list_len = read_u16_at(data, 0)? as usize;
let mut pos = 2usize;
let list_end = pos.checked_add(list_len)?;
if list_end > data.len() {
return None;
}
if pos == list_end {
return Some(None);
}
let protocol_len = usize::from(*data.get(pos)?);
pos = pos.checked_add(1)?;
let protocol_end = pos.checked_add(protocol_len)?;
if protocol_end > list_end {
return None;
}
if protocol_len == 0 {
return Some(None);
}
Some(Some(data.get(pos..protocol_end)?.to_vec()))
}
fn ja3_raw(parsed: &ParsedClientHello) -> String {
format!(
"{},{},{},{},{}",
parsed.legacy_version,
join_decimal_u16(&parsed.ciphers),
join_decimal_u16(&parsed.extensions),
join_decimal_u16(&parsed.supported_groups),
join_decimal_u8(&parsed.ec_point_formats)
)
}
fn ja4(parsed: &ParsedClientHello) -> (String, String) {
let a = format!(
"t{}{}{:02}{:02}{}",
ja4_version_code(parsed),
if parsed.sni_present { "d" } else { "i" },
count_ja4(parsed.ciphers.len()),
count_ja4(parsed.extensions.len()),
ja4_alpn_marker(parsed.alpn_first.as_deref())
);
let mut ciphers = parsed.ciphers.clone();
ciphers.sort_unstable();
let cipher_raw = join_hex_u16(&ciphers);
let cipher_hash = if ciphers.is_empty() {
"000000000000".to_string()
} else {
sha256_truncated_12(&cipher_raw)
};
let mut extensions_for_hash = parsed
.extensions
.iter()
.copied()
.filter(|value| *value != EXT_SNI && *value != EXT_ALPN)
.collect::<Vec<_>>();
extensions_for_hash.sort_unstable();
let extension_raw = join_hex_u16(&extensions_for_hash);
let signature_raw = join_hex_u16(&parsed.signature_algorithms);
let extension_hash_input = if signature_raw.is_empty() {
extension_raw.clone()
} else {
format!("{extension_raw}_{signature_raw}")
};
let extension_hash = if extensions_for_hash.is_empty() {
"000000000000".to_string()
} else {
sha256_truncated_12(&extension_hash_input)
};
(
format!("{a}_{cipher_hash}_{extension_hash}"),
format!("{a}_{cipher_raw}_{extension_hash_input}"),
)
}
fn ja4_version_code(parsed: &ParsedClientHello) -> &'static str {
let version = parsed
.supported_versions
.iter()
.copied()
.max()
.unwrap_or(parsed.legacy_version);
match version {
0x0304 => "13",
0x0303 => "12",
0x0302 => "11",
0x0301 => "10",
0x0300 => "s3",
0x0002 => "s2",
0xfeff => "d1",
0xfefd => "d2",
0xfefc => "d3",
_ => "00",
}
}
fn ja4_alpn_marker(alpn_first: Option<&[u8]>) -> String {
let Some(value) = alpn_first else {
return "00".to_string();
};
let Some(first) = value.first().copied() else {
return "00".to_string();
};
let last = value.last().copied().unwrap_or(first);
if first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric() {
return format!("{}{}", first as char, last as char);
}
let encoded = hex::encode(value);
if encoded.is_empty() {
return "00".to_string();
}
let first_hex = encoded.as_bytes()[0] as char;
let last_hex = encoded.as_bytes()[encoded.len().saturating_sub(1)] as char;
format!("{first_hex}{last_hex}")
}
fn count_ja4(count: usize) -> usize {
count.min(99)
}
fn sha256_truncated_12(input: &str) -> String {
let mut encoded = hex::encode(sha256(input.as_bytes()));
encoded.truncate(12);
encoded
}
fn join_decimal_u16(values: &[u16]) -> String {
values
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join("-")
}
fn join_decimal_u8(values: &[u8]) -> String {
values
.iter()
.map(u8::to_string)
.collect::<Vec<_>>()
.join("-")
}
fn join_hex_u16(values: &[u16]) -> String {
values
.iter()
.map(|value| format!("{value:04x}"))
.collect::<Vec<_>>()
.join(",")
}
fn read_u16_at(buf: &[u8], pos: usize) -> Option<u16> {
Some(u16::from_be_bytes([
*buf.get(pos)?,
*buf.get(pos.checked_add(1)?)?,
]))
}
fn is_grease(value: u16) -> bool {
let high = (value >> 8) as u8;
let low = value as u8;
high == low && (high & 0x0f) == 0x0a
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_client_hello() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&[0x03, 0x03]);
body.extend_from_slice(&[0x11; 32]);
body.push(0);
body.extend_from_slice(&10u16.to_be_bytes());
body.extend_from_slice(&[0x0a, 0x0a, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f, 0x00, 0xff]);
body.push(1);
body.push(0);
let mut extensions = Vec::new();
append_ext(&mut extensions, EXT_SNI, &[0, 0]);
append_ext(&mut extensions, EXT_ALPN, &[0, 3, 2, b'h', b'2']);
append_ext(
&mut extensions,
EXT_SUPPORTED_GROUPS,
&[0, 6, 0x0a, 0x0a, 0x00, 0x17, 0x00, 0x1d],
);
append_ext(&mut extensions, EXT_EC_POINT_FORMATS, &[1, 0]);
append_ext(
&mut extensions,
EXT_SIGNATURE_ALGORITHMS,
&[0, 4, 0x04, 0x03, 0x08, 0x04],
);
append_ext(
&mut extensions,
EXT_SUPPORTED_VERSIONS,
&[4, 0x03, 0x04, 0x03, 0x03],
);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
let mut record = Vec::new();
record.push(TLS_RECORD_HANDSHAKE);
record.extend_from_slice(&[0x03, 0x01]);
record.extend_from_slice(&((body.len() + 4) as u16).to_be_bytes());
record.push(0x01);
record.extend_from_slice(&[
((body.len() >> 16) & 0xff) as u8,
((body.len() >> 8) & 0xff) as u8,
(body.len() & 0xff) as u8,
]);
record.extend_from_slice(&body);
record
}
fn append_ext(out: &mut Vec<u8>, etype: u16, data: &[u8]) {
out.extend_from_slice(&etype.to_be_bytes());
out.extend_from_slice(&(data.len() as u16).to_be_bytes());
out.extend_from_slice(data);
}
#[test]
fn ja3_and_ja4_ignore_grease_and_remain_stable() {
let fp = fingerprint_client_hello(&sample_client_hello())
.expect("sample ClientHello must fingerprint");
assert_eq!(
fp.ja3_raw,
"771,4865-4866-49199-255,0-16-10-11-13-43,23-29,0"
);
assert!(fp.ja4.starts_with("t13d0406h2_"));
}
#[test]
fn malformed_client_hello_returns_none() {
let mut hello = sample_client_hello();
hello.truncate(12);
assert!(fingerprint_client_hello(&hello).is_none());
}
}
+145 -2
View File
@@ -98,6 +98,7 @@ use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
use crate::ip_tracker::UserIpTracker; use crate::ip_tracker::UserIpTracker;
use crate::protocol::constants::*; use crate::protocol::constants::*;
use crate::protocol::tls; use crate::protocol::tls;
use crate::protocol::tls_fingerprint::{self, TlsClientFingerprint};
use crate::stats::beobachten::BeobachtenStore; use crate::stats::beobachten::BeobachtenStore;
use crate::stats::{ReplayChecker, Stats}; use crate::stats::{ReplayChecker, Stats};
use crate::stream::{BufferPool, CryptoReader, CryptoWriter}; use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
@@ -112,7 +113,7 @@ use crate::proxy::handshake::{
}; };
#[cfg(test)] #[cfg(test)]
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake}; use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
use crate::proxy::masking::handle_bad_client; use crate::proxy::masking::handle_bad_client_with_shared;
use crate::proxy::middle_relay::handle_via_middle_proxy; use crate::proxy::middle_relay::handle_via_middle_proxy;
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
use crate::proxy::shared_state::ProxySharedState; use crate::proxy::shared_state::ProxySharedState;
@@ -309,6 +310,7 @@ fn masking_outcome<R, W>(
local_addr: SocketAddr, local_addr: SocketAddr,
config: Arc<ProxyConfig>, config: Arc<ProxyConfig>,
beobachten: Arc<BeobachtenStore>, beobachten: Arc<BeobachtenStore>,
shared: Arc<ProxySharedState>,
) -> HandshakeOutcome ) -> HandshakeOutcome
where where
R: AsyncRead + Unpin + Send + 'static, R: AsyncRead + Unpin + Send + 'static,
@@ -324,7 +326,7 @@ where
) )
.await; .await;
handle_bad_client( handle_bad_client_with_shared(
reader, reader,
writer, writer,
&initial_data, &initial_data,
@@ -332,6 +334,7 @@ where
local_addr, local_addr,
&config, &config,
&beobachten, &beobachten,
shared.as_ref(),
) )
.await; .await;
Ok(()) Ok(())
@@ -350,6 +353,60 @@ fn record_beobachten_class(
beobachten.record(class, peer_ip, beobachten_ttl(config)); beobachten.record(class, peer_ip, beobachten_ttl(config));
} }
fn tls_fingerprint_collection_enabled(config: &ProxyConfig) -> bool {
config.general.beobachten || config.server.api.runtime_edge_enabled
}
fn observe_tls_client_fingerprint(
stats: &Stats,
config: &ProxyConfig,
peer_ip: IpAddr,
handshake: &[u8],
) -> Option<TlsClientFingerprint> {
if !tls_fingerprint_collection_enabled(config) {
return None;
}
match tls_fingerprint::fingerprint_client_hello(handshake) {
Some(fingerprint) => {
stats.record_tls_fingerprint_observed(&fingerprint, peer_ip, beobachten_ttl(config));
Some(fingerprint)
}
None => {
stats.increment_tls_fingerprint_parse_error();
None
}
}
}
fn record_tls_fingerprint_auth_success(
stats: &Stats,
config: &ProxyConfig,
peer_ip: IpAddr,
fingerprint: Option<&TlsClientFingerprint>,
user: &str,
) {
if let Some(fingerprint) = fingerprint {
stats.record_tls_fingerprint_auth_success(
fingerprint,
peer_ip,
user,
beobachten_ttl(config),
);
}
}
fn record_tls_fingerprint_bad_or_probe(
stats: &Stats,
config: &ProxyConfig,
peer_ip: IpAddr,
fingerprint: Option<&TlsClientFingerprint>,
) {
if let Some(fingerprint) = fingerprint {
stats.record_tls_fingerprint_bad_or_probe(fingerprint, peer_ip, beobachten_ttl(config));
}
}
fn classify_expected_64_got_0(kind: std::io::ErrorKind) -> Option<&'static str> { fn classify_expected_64_got_0(kind: std::io::ErrorKind) -> Option<&'static str> {
match kind { match kind {
std::io::ErrorKind::UnexpectedEof => Some("expected_64_got_0_unexpected_eof"), std::io::ErrorKind::UnexpectedEof => Some("expected_64_got_0_unexpected_eof"),
@@ -663,6 +720,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
@@ -684,6 +742,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
}; };
@@ -702,9 +761,13 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
let tls_fingerprint =
observe_tls_client_fingerprint(stats.as_ref(), &config, real_peer.ip(), &handshake);
let (read_half, write_half) = tokio::io::split(stream); let (read_half, write_half) = tokio::io::split(stream);
let (mut tls_reader, tls_writer, tls_user) = match handle_tls_handshake_with_shared( let (mut tls_reader, tls_writer, tls_user) = match handle_tls_handshake_with_shared(
@@ -715,6 +778,12 @@ where
HandshakeResult::Success(result) => result, HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader, writer } => { HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad_with_class("tls_handshake_bad_client"); stats.increment_connects_bad_with_class("tls_handshake_bad_client");
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
real_peer.ip(),
tls_fingerprint.as_ref(),
);
return Ok(masking_outcome( return Ok(masking_outcome(
reader, reader,
writer, writer,
@@ -723,13 +792,27 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => { HandshakeResult::Error(e) => {
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
real_peer.ip(),
tls_fingerprint.as_ref(),
);
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e); increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
return Err(e); return Err(e);
} }
}; };
record_tls_fingerprint_auth_success(
stats.as_ref(),
&config,
real_peer.ip(),
tls_fingerprint.as_ref(),
tls_user.as_str(),
);
debug!(peer = %peer, "Reading MTProto handshake through TLS"); debug!(peer = %peer, "Reading MTProto handshake through TLS");
let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?; let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?;
@@ -767,6 +850,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -796,6 +880,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
@@ -821,6 +906,7 @@ where
local_addr, local_addr,
config.clone(), config.clone(),
beobachten.clone(), beobachten.clone(),
shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -1019,6 +1105,12 @@ impl RunningClientHandler {
#[cfg(unix)] #[cfg(unix)]
let raw_fd = self.raw_fd; let raw_fd = self.raw_fd;
let rst_on_close = self.rst_on_close; let rst_on_close = self.rst_on_close;
// MSS for the bulk data phase: once the handshake (incl. ServerHello) is
// sent, restore a normal MSS so only the handshake stays fragmented by the
// low listener `client_mss`. Cuts pps ~10x (anti-DDoS abuse on pps-policing
// hosts like FastVPS). None = keep handshake MSS for the whole connection.
#[cfg(unix)]
let bulk_mss: Option<u16> = self.config.server.client_mss_bulk_value().ok().flatten();
let outcome = match self.do_handshake().await? { let outcome = match self.do_handshake().await? {
Some(outcome) => outcome, Some(outcome) => outcome,
@@ -1032,6 +1124,14 @@ impl RunningClientHandler {
if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) { if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) {
let _ = crate::transport::socket::clear_linger_fd(raw_fd); let _ = crate::transport::socket::clear_linger_fd(raw_fd);
} }
// Handshake (ServerHello) done — raise MSS for bulk transfer.
#[cfg(unix)]
if let Some(mss) = bulk_mss {
if let Err(e) = crate::transport::socket::set_tcp_mss_fd(raw_fd, u32::from(mss))
{
debug!(error = %e, "Failed to raise bulk MSS; keeping handshake MSS");
}
}
fut.await fut.await
} }
HandshakeOutcome::NeedsMasking(fut) => fut.await, HandshakeOutcome::NeedsMasking(fut) => fut.await,
@@ -1252,6 +1352,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
@@ -1273,6 +1374,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
}; };
@@ -1292,9 +1394,17 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
let tls_fingerprint = observe_tls_client_fingerprint(
self.stats.as_ref(),
&self.config,
peer.ip(),
&handshake,
);
let config = self.config.clone(); let config = self.config.clone();
let replay_checker = self.replay_checker.clone(); let replay_checker = self.replay_checker.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
@@ -1318,6 +1428,12 @@ impl RunningClientHandler {
HandshakeResult::Success(result) => result, HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader, writer } => { HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad_with_class("tls_handshake_bad_client"); stats.increment_connects_bad_with_class("tls_handshake_bad_client");
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
return Ok(masking_outcome( return Ok(masking_outcome(
reader, reader,
writer, writer,
@@ -1326,13 +1442,27 @@ impl RunningClientHandler {
local_addr, local_addr,
config.clone(), config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => { HandshakeResult::Error(e) => {
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e); increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
return Err(e); return Err(e);
} }
}; };
record_tls_fingerprint_auth_success(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
tls_user.as_str(),
);
debug!(peer = %peer, "Reading MTProto handshake through TLS"); debug!(peer = %peer, "Reading MTProto handshake through TLS");
let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?; let mtproto_data = tls_reader.read_exact(HANDSHAKE_LEN).await?;
@@ -1380,6 +1510,7 @@ impl RunningClientHandler {
local_addr, local_addr,
config.clone(), config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -1427,6 +1558,7 @@ impl RunningClientHandler {
local_addr, local_addr,
self.config.clone(), self.config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
@@ -1465,6 +1597,7 @@ impl RunningClientHandler {
local_addr, local_addr,
config.clone(), config.clone(),
self.beobachten.clone(), self.beobachten.clone(),
self.shared.clone(),
)); ));
} }
HandshakeResult::Error(e) => return Err(e), HandshakeResult::Error(e) => return Err(e),
@@ -1558,6 +1691,11 @@ impl RunningClientHandler {
{ {
let user = success.user.clone(); let user = success.user.clone();
if !shared.is_user_enabled(&user) {
warn!(user = %user, "Disabled user rejected");
return Err(ProxyError::UserDisabled { user });
}
let user_limit_reservation = match Self::acquire_user_connection_reservation_static( let user_limit_reservation = match Self::acquire_user_connection_reservation_static(
&user, &user,
&config, &config,
@@ -1576,6 +1714,8 @@ impl RunningClientHandler {
let route_snapshot = route_runtime.snapshot(); let route_snapshot = route_runtime.snapshot();
let session_id = rng.u64(); let session_id = rng.u64();
let _user_session = shared.register_user_session(&user, session_id);
let session_cancel = _user_session.token();
let selected_me_pool = if config.general.use_middle_proxy let selected_me_pool = if config.general.use_middle_proxy
&& matches!(route_snapshot.mode, RelayRouteMode::Middle) && matches!(route_snapshot.mode, RelayRouteMode::Middle)
{ {
@@ -1607,6 +1747,7 @@ impl RunningClientHandler {
route_runtime.subscribe(), route_runtime.subscribe(),
route_snapshot, route_snapshot,
session_id, session_id,
session_cancel.clone(),
shared.clone(), shared.clone(),
) )
.await .await
@@ -1625,6 +1766,7 @@ impl RunningClientHandler {
route_snapshot, route_snapshot,
session_id, session_id,
local_addr, local_addr,
session_cancel.clone(),
shared.clone(), shared.clone(),
) )
.await .await
@@ -1644,6 +1786,7 @@ impl RunningClientHandler {
route_snapshot, route_snapshot,
session_id, session_id,
local_addr, local_addr,
session_cancel,
shared.clone(), shared.clone(),
) )
.await .await
+40 -19
View File
@@ -10,6 +10,7 @@ use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadHalf, WriteHalf, split}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadHalf, WriteHalf, split};
use tokio::sync::watch; use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::config::ProxyConfig; use crate::config::ProxyConfig;
@@ -258,6 +259,7 @@ where
route_snapshot, route_snapshot,
session_id, session_id,
SocketAddr::from(([0, 0, 0, 0], config.server.port)), SocketAddr::from(([0, 0, 0, 0], config.server.port)),
CancellationToken::new(),
ProxySharedState::new(), ProxySharedState::new(),
) )
.await .await
@@ -276,6 +278,7 @@ pub(crate) async fn handle_via_direct_with_shared<R, W>(
route_snapshot: RouteCutoverState, route_snapshot: RouteCutoverState,
session_id: u64, session_id: u64,
local_addr: SocketAddr, local_addr: SocketAddr,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>, shared: Arc<ProxySharedState>,
) -> Result<()> ) -> Result<()>
where where
@@ -302,14 +305,25 @@ where
"Ignoring invalid scope hint and falling back to default upstream selection" "Ignoring invalid scope hint and falling back to default upstream selection"
); );
} }
let tg_stream = upstream_manager let tg_stream = tokio::select! {
.connect(dc_addr, Some(success.dc_idx), scope_hint) result = upstream_manager.connect(dc_addr, Some(success.dc_idx), scope_hint) => result?,
.await?; _ = session_cancel.cancelled() => {
return Err(ProxyError::UserDisabled {
user: user.to_string(),
});
}
};
debug!(peer = %success.peer, dc_addr = %dc_addr, "Connected, performing TG handshake"); debug!(peer = %success.peer, dc_addr = %dc_addr, "Connected, performing TG handshake");
let (tg_reader, tg_writer) = let (tg_reader, tg_writer) = tokio::select! {
do_tg_handshake_static(tg_stream, &success, &config, rng.as_ref()).await?; result = do_tg_handshake_static(tg_stream, &success, &config, rng.as_ref()) => result?,
_ = session_cancel.cancelled() => {
return Err(ProxyError::UserDisabled {
user: user.to_string(),
});
}
};
debug!(peer = %success.peer, "TG handshake complete, starting relay"); debug!(peer = %success.peer, "TG handshake complete, starting relay");
@@ -331,20 +345,22 @@ where
} else { } else {
Duration::from_secs(1800) Duration::from_secs(1800)
}; };
let relay_result = crate::proxy::relay::relay_bidirectional_with_activity_timeout_and_lease( let relay_result =
client_reader, crate::proxy::relay::relay_bidirectional_with_activity_timeout_lease_and_cancel(
client_writer, client_reader,
tg_reader, client_writer,
tg_writer, tg_reader,
config.general.direct_relay_copy_buf_c2s_bytes, tg_writer,
config.general.direct_relay_copy_buf_s2c_bytes, config.general.direct_relay_copy_buf_c2s_bytes,
user, config.general.direct_relay_copy_buf_s2c_bytes,
Arc::clone(&stats), user,
config.access.user_data_quota.get(user).copied(), Arc::clone(&stats),
buffer_pool, config.access.user_data_quota.get(user).copied(),
traffic_lease, buffer_pool,
relay_activity_timeout, traffic_lease,
); relay_activity_timeout,
session_cancel.clone(),
);
tokio::pin!(relay_result); tokio::pin!(relay_result);
let relay_result = loop { let relay_result = loop {
if let Some(cutover) = if let Some(cutover) =
@@ -371,6 +387,11 @@ where
break relay_result.await; break relay_result.await;
} }
} }
_ = session_cancel.cancelled() => {
break Err(ProxyError::UserDisabled {
user: user.to_string(),
});
}
} }
}; };
+73 -130
View File
@@ -4,7 +4,6 @@
use dashmap::DashMap; use dashmap::DashMap;
use dashmap::mapref::entry::Entry; use dashmap::mapref::entry::Entry;
use hmac::{Hmac, Mac};
#[cfg(test)] #[cfg(test)]
use std::collections::HashSet; use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -33,8 +32,10 @@ use crate::stream::{CryptoReader, CryptoWriter, FakeTlsReader, FakeTlsWriter};
use crate::tls_front::{TlsFrontCache, emulator}; use crate::tls_front::{TlsFrontCache, emulator};
#[cfg(test)] #[cfg(test)]
use rand::RngExt; use rand::RngExt;
use sha2::Sha256;
use subtle::ConstantTimeEq; mod tls_auth;
use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate};
const ACCESS_SECRET_BYTES: usize = 16; const ACCESS_SECRET_BYTES: usize = 16;
const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5; const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5;
@@ -58,8 +59,6 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8;
const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64; const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64;
const RECENT_USER_RING_SCAN_LIMIT: usize = 32; const RECENT_USER_RING_SCAN_LIMIT: usize = 32;
type HmacSha256 = Hmac<Sha256>;
#[cfg(test)] #[cfg(test)]
const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1;
#[cfg(not(test))] #[cfg(not(test))]
@@ -104,23 +103,6 @@ fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> b
true true
} }
#[derive(Clone, Copy)]
struct ParsedTlsAuthMaterial {
digest: [u8; tls::TLS_DIGEST_LEN],
session_id: [u8; 32],
session_id_len: usize,
now: i64,
ignore_time_skew: bool,
boot_time_cap_secs: u32,
}
#[derive(Clone, Copy)]
struct TlsCandidateValidation {
digest: [u8; tls::TLS_DIGEST_LEN],
session_id: [u8; 32],
session_id_len: usize,
}
struct MtprotoCandidateValidation { struct MtprotoCandidateValidation {
proto_tag: ProtoTag, proto_tag: ProtoTag,
dc_idx: i16, dc_idx: i16,
@@ -251,104 +233,6 @@ fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) ->
total_users.min(cap.max(1)) total_users.min(cap.max(1))
} }
fn parse_tls_auth_material(
handshake: &[u8],
ignore_time_skew: bool,
replay_window_secs: u64,
) -> Option<ParsedTlsAuthMaterial> {
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
return None;
}
let digest: [u8; tls::TLS_DIGEST_LEN] = handshake
[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.try_into()
.ok()?;
let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN;
let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?);
if session_id_len > 32 {
return None;
}
let session_id_start = session_id_len_pos + 1;
if handshake.len() < session_id_start + session_id_len {
return None;
}
let mut session_id = [0u8; 32];
session_id[..session_id_len]
.copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]);
let now = if !ignore_time_skew {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
i64::try_from(d.as_secs()).ok()?
} else {
0_i64
};
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
let boot_time_cap_secs = if ignore_time_skew {
0
} else {
tls::BOOT_TIME_MAX_SECS
.min(replay_window_u32)
.min(tls::BOOT_TIME_COMPAT_MAX_SECS)
};
Some(ParsedTlsAuthMaterial {
digest,
session_id,
session_id_len,
now,
ignore_time_skew,
boot_time_cap_secs,
})
}
fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(&handshake[..tls::TLS_DIGEST_POS]);
mac.update(&[0u8; tls::TLS_DIGEST_LEN]);
mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]);
mac.finalize().into_bytes().into()
}
fn validate_tls_secret_candidate(
parsed: &ParsedTlsAuthMaterial,
handshake: &[u8],
secret: &[u8],
) -> Option<TlsCandidateValidation> {
let computed = compute_tls_hmac_zeroed_digest(secret, handshake);
if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) {
return None;
}
let timestamp = u32::from_le_bytes([
parsed.digest[28] ^ computed[28],
parsed.digest[29] ^ computed[29],
parsed.digest[30] ^ computed[30],
parsed.digest[31] ^ computed[31],
]);
if !parsed.ignore_time_skew {
let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs;
if !is_boot_time {
let time_diff = parsed.now - i64::from(timestamp);
if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) {
return None;
}
}
}
Some(TlsCandidateValidation {
digest: parsed.digest,
session_id: parsed.session_id,
session_id_len: parsed.session_id_len,
})
}
fn validate_mtproto_secret_candidate( fn validate_mtproto_secret_candidate(
handshake: &[u8; HANDSHAKE_LEN], handshake: &[u8; HANDSHAKE_LEN],
dec_prekey: &[u8; PREKEY_LEN], dec_prekey: &[u8; PREKEY_LEN],
@@ -1473,14 +1357,60 @@ where
return HandshakeResult::BadClient { reader, writer }; return HandshakeResult::BadClient { reader, writer };
} }
let cached = if config.censorship.tls_emulation { let cached_entry = if config.censorship.tls_emulation {
if let Some(cache) = tls_cache.as_ref() { if let Some(cache) = tls_cache.as_ref() {
let selected_domain = let selected_domain =
matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
let cached_entry = cache.get(selected_domain).await; let cached_entry = cache.get(selected_domain).await;
let use_full_cert_payload = if config.censorship.serverhello_compact Some(cached_entry)
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) } else {
{ None
}
} else {
None
};
let preferred_key_share_group = cached_entry
.as_ref()
.and_then(|cached_entry| emulator::profiled_server_hello_key_share_group(cached_entry));
let Some(server_key_share) =
tls::build_server_hello_key_share(handshake, preferred_key_share_group, rng)
else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
debug!(
peer = %peer,
"TLS handshake rejected: ClientHello did not offer a usable TLS 1.3 key_share"
);
return HandshakeResult::BadClient { reader, writer };
};
let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() {
if cached_entry.server_hello_template.cipher_suite == [0, 0] {
[0x13, 0x01]
} else {
cached_entry.server_hello_template.cipher_suite
}
} else {
[0x13, 0x01]
};
let Some(selected_cipher_suite) =
tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite)
else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
debug!(
peer = %peer,
"TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite"
);
return HandshakeResult::BadClient { reader, writer };
};
let cached = if let Some(cached_entry) = cached_entry {
let use_full_cert_payload = if config.censorship.serverhello_compact
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
{
if let Some(cache) = tls_cache.as_ref() {
cache cache
.take_full_cert_budget_for_ip( .take_full_cert_budget_for_ip(
peer.ip(), peer.ip(),
@@ -1489,11 +1419,11 @@ where
.await .await
} else { } else {
true true
}; }
Some((cached_entry, use_full_cert_payload))
} else { } else {
None true
} };
Some((cached_entry, use_full_cert_payload))
} else { } else {
None None
}; };
@@ -1512,17 +1442,21 @@ where
use_full_cert_payload, use_full_cert_payload,
config.censorship.serverhello_compact, config.censorship.serverhello_compact,
client_tls_version, client_tls_version,
selected_cipher_suite,
&server_key_share,
rng, rng,
selected_alpn.clone(), selected_alpn.clone(),
config.censorship.tls_new_session_tickets, config.censorship.tls_new_session_tickets,
) )
} else { } else {
tls::build_server_hello( tls::build_server_hello_with_cipher(
&validated_secret, &validated_secret,
&validation_digest, &validation_digest,
validation_session_id_slice, validation_session_id_slice,
config.censorship.fake_cert_len, config.censorship.fake_cert_len,
rng, rng,
selected_cipher_suite,
&server_key_share,
selected_alpn.clone(), selected_alpn.clone(),
config.censorship.tls_new_session_tickets, config.censorship.tls_new_session_tickets,
) )
@@ -1807,7 +1741,16 @@ where
return HandshakeResult::BadClient { reader, writer }; return HandshakeResult::BadClient { reader, writer };
} }
let validation = matched_validation.expect("validation must exist when matched"); let Some(validation) = matched_validation else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
warn!(
peer = %peer,
user = %matched_user,
"MTProto handshake matched user without validation material"
);
return HandshakeResult::BadClient { reader, writer };
};
if config if config
.access .access
+126
View File
@@ -0,0 +1,126 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::protocol::tls;
type HmacSha256 = Hmac<Sha256>;
/// Parsed TLS authentication material extracted from a ClientHello candidate.
#[derive(Clone, Copy)]
pub(super) struct ParsedTlsAuthMaterial {
digest: [u8; tls::TLS_DIGEST_LEN],
session_id: [u8; 32],
session_id_len: usize,
now: i64,
ignore_time_skew: bool,
boot_time_cap_secs: u32,
}
/// Successful TLS secret validation output used by the handshake state machine.
#[derive(Clone, Copy)]
pub(super) struct TlsCandidateValidation {
pub(super) digest: [u8; tls::TLS_DIGEST_LEN],
pub(super) session_id: [u8; 32],
pub(super) session_id_len: usize,
}
/// Parse TLS auth digest and session-id material from a candidate handshake.
pub(super) fn parse_tls_auth_material(
handshake: &[u8],
ignore_time_skew: bool,
replay_window_secs: u64,
) -> Option<ParsedTlsAuthMaterial> {
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
return None;
}
let digest: [u8; tls::TLS_DIGEST_LEN] = handshake
[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.try_into()
.ok()?;
let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN;
let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?);
if session_id_len > 32 {
return None;
}
let session_id_start = session_id_len_pos + 1;
if handshake.len() < session_id_start + session_id_len {
return None;
}
let mut session_id = [0u8; 32];
session_id[..session_id_len]
.copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]);
let now = if !ignore_time_skew {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
i64::try_from(d.as_secs()).ok()?
} else {
0_i64
};
let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX);
let boot_time_cap_secs = if ignore_time_skew {
0
} else {
tls::BOOT_TIME_MAX_SECS
.min(replay_window_u32)
.min(tls::BOOT_TIME_COMPAT_MAX_SECS)
};
Some(ParsedTlsAuthMaterial {
digest,
session_id,
session_id_len,
now,
ignore_time_skew,
boot_time_cap_secs,
})
}
fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> Option<[u8; 32]> {
let mut mac = HmacSha256::new_from_slice(secret).ok()?;
mac.update(&handshake[..tls::TLS_DIGEST_POS]);
mac.update(&[0u8; tls::TLS_DIGEST_LEN]);
mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]);
Some(mac.finalize().into_bytes().into())
}
/// Validate a candidate secret against parsed TLS authentication material.
pub(super) fn validate_tls_secret_candidate(
parsed: &ParsedTlsAuthMaterial,
handshake: &[u8],
secret: &[u8],
) -> Option<TlsCandidateValidation> {
let computed = compute_tls_hmac_zeroed_digest(secret, handshake)?;
if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) {
return None;
}
let timestamp = u32::from_le_bytes([
parsed.digest[28] ^ computed[28],
parsed.digest[29] ^ computed[29],
parsed.digest[30] ^ computed[30],
parsed.digest[31] ^ computed[31],
]);
if !parsed.ignore_time_skew {
let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs;
if !is_boot_time {
let time_diff = parsed.now - i64::from(timestamp);
if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) {
return None;
}
}
}
Some(TlsCandidateValidation {
digest: parsed.digest,
session_id: parsed.session_id,
session_id_len: parsed.session_id_len,
})
}
+211 -86
View File
@@ -3,12 +3,15 @@
use crate::config::ProxyConfig; use crate::config::ProxyConfig;
use crate::network::dns_overrides::resolve_socket_addr; use crate::network::dns_overrides::resolve_socket_addr;
use crate::protocol::tls; use crate::protocol::tls;
use crate::proxy::shared_state::ProxySharedState;
use crate::stats::beobachten::BeobachtenStore; use crate::stats::beobachten::BeobachtenStore;
use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder}; use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder};
use crate::transport::socket::configure_tcp_socket;
#[cfg(unix)] #[cfg(unix)]
use nix::ifaddrs::getifaddrs; use nix::ifaddrs::getifaddrs;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, RngExt, SeedableRng}; use rand::{Rng, RngExt, SeedableRng};
use std::io::{Error as IoError, ErrorKind};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::str; use std::str;
#[cfg(test)] #[cfg(test)]
@@ -17,9 +20,9 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant as StdInstant}; use std::time::{Duration, Instant as StdInstant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
#[cfg(unix)] #[cfg(unix)]
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::net::{TcpStream, lookup_host};
#[cfg(unix)] #[cfg(unix)]
use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Mutex as AsyncMutex;
use tokio::time::{Instant, timeout}; use tokio::time::{Instant, timeout};
@@ -36,6 +39,8 @@ const MASK_RELAY_TIMEOUT: Duration = Duration::from_millis(200);
#[cfg(test)] #[cfg(test)]
const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100); const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
const MASK_BUFFER_SIZE: usize = 8192; const MASK_BUFFER_SIZE: usize = 8192;
const MASK_BUFFER_GROW_AFTER_BYTES: usize = 256 * 1024;
const MASK_BUFFER_MAX_SIZE: usize = 64 * 1024;
#[cfg(unix)] #[cfg(unix)]
#[cfg(not(test))] #[cfg(not(test))]
const LOCAL_INTERFACE_CACHE_TTL: Duration = Duration::from_secs(300); const LOCAL_INTERFACE_CACHE_TTL: Duration = Duration::from_secs(300);
@@ -53,6 +58,27 @@ struct MaskTcpTarget<'a> {
port: u16, port: u16,
} }
fn mask_copy_read_len(total: usize, byte_cap: usize) -> usize {
// Keep short scanner probes on the small baseline buffer and grow only
// after the session has proven to be sustained masking relay traffic.
let active_buffer_size = if total >= MASK_BUFFER_GROW_AFTER_BYTES {
MASK_BUFFER_MAX_SIZE
} else {
MASK_BUFFER_SIZE
};
if byte_cap == 0 {
return active_buffer_size;
}
let remaining_budget = byte_cap.saturating_sub(total);
if remaining_budget == 0 {
return 0;
}
remaining_budget.min(active_buffer_size)
}
async fn copy_with_idle_timeout<R, W>( async fn copy_with_idle_timeout<R, W>(
reader: &mut R, reader: &mut R,
writer: &mut W, writer: &mut W,
@@ -64,21 +90,18 @@ where
R: AsyncRead + Unpin, R: AsyncRead + Unpin,
W: AsyncWrite + Unpin, W: AsyncWrite + Unpin,
{ {
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]); let mut buf = vec![0u8; MASK_BUFFER_SIZE];
let mut total = 0usize; let mut total = 0usize;
let mut ended_by_eof = false; let mut ended_by_eof = false;
let unlimited = byte_cap == 0;
loop { loop {
let read_len = if unlimited { let read_len = mask_copy_read_len(total, byte_cap);
MASK_BUFFER_SIZE if read_len == 0 {
} else { break;
let remaining_budget = byte_cap.saturating_sub(total); }
if remaining_budget == 0 { if buf.len() < read_len {
break; buf.resize(read_len, 0);
} }
remaining_budget.min(MASK_BUFFER_SIZE)
};
let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await; let read_res = timeout(idle_timeout, reader.read(&mut buf[..read_len])).await;
let n = match read_res { let n = match read_res {
Ok(Ok(n)) => n, Ok(Ok(n)) => n,
@@ -250,6 +273,32 @@ async fn consume_client_data_with_timeout_and_cap<R>(
} }
} }
fn mask_failure_drain_cap(config: &ProxyConfig) -> usize {
let configured_cap = config.censorship.mask_relay_max_bytes;
if configured_cap == 0 {
return MASK_BUFFER_SIZE;
}
configured_cap.min(MASK_BUFFER_SIZE)
}
async fn consume_mask_failure_path<R>(
reader: R,
config: &ProxyConfig,
relay_timeout: Duration,
idle_timeout: Duration,
) where
R: AsyncRead + Unpin,
{
consume_client_data_with_timeout_and_cap(
reader,
mask_failure_drain_cap(config),
relay_timeout,
idle_timeout,
)
.await;
}
async fn wait_mask_connect_budget(started: Instant) { async fn wait_mask_connect_budget(started: Instant) {
let elapsed = started.elapsed(); let elapsed = started.elapsed();
if elapsed < MASK_TIMEOUT { if elapsed < MASK_TIMEOUT {
@@ -385,7 +434,7 @@ mod tls_domain_mask_host_tests {
let mut config = ProxyConfig::default(); let mut config = ProxyConfig::default();
config.censorship.tls_domain = "a.com".to_string(); config.censorship.tls_domain = "a.com".to_string();
config.censorship.tls_domains = vec!["b.com".to_string(), "c.com".to_string()]; config.censorship.tls_domains = vec!["b.com".to_string(), "c.com".to_string()];
config.censorship.mask_host = Some("a.com".to_string()); config.censorship.mask_host = None;
config config
} }
@@ -419,6 +468,15 @@ mod tls_domain_mask_host_tests {
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com"); assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com");
} }
#[test]
fn mask_host_uses_primary_domain_when_dynamic_masking_is_disabled() {
let mut config = config_with_tls_domains();
config.censorship.mask_dynamic = false;
let initial_data = client_hello_with_sni("b.com");
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "a.com");
}
#[test] #[test]
fn exclusive_mask_target_overrides_only_matching_sni() { fn exclusive_mask_target_overrides_only_matching_sni() {
let mut config = config_with_tls_domains(); let mut config = config_with_tls_domains();
@@ -471,6 +529,32 @@ fn parse_mask_host_ip_literal(host: &str) -> Option<IpAddr> {
host.parse::<IpAddr>().ok() host.parse::<IpAddr>().ok()
} }
async fn resolve_mask_target_addrs(
mask_host: &str,
mask_port: u16,
) -> std::io::Result<Vec<SocketAddr>> {
if let Some(addr) = resolve_socket_addr(mask_host, mask_port) {
return Ok(vec![addr]);
}
if let Some(ip) = parse_mask_host_ip_literal(mask_host) {
return Ok(vec![SocketAddr::new(ip, mask_port)]);
}
let addrs = timeout(MASK_TIMEOUT, lookup_host((mask_host, mask_port)))
.await
.map_err(|_| IoError::new(ErrorKind::TimedOut, "mask target DNS lookup timed out"))??;
let addrs = addrs.collect::<Vec<_>>();
if addrs.is_empty() {
return Err(IoError::new(
ErrorKind::NotFound,
"mask target DNS lookup returned no addresses",
));
}
Ok(addrs)
}
fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> { fn matching_tls_domain_for_sni<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> {
if config.censorship.tls_domain.eq_ignore_ascii_case(sni) { if config.censorship.tls_domain.eq_ignore_ascii_case(sni) {
return Some(config.censorship.tls_domain.as_str()); return Some(config.censorship.tls_domain.as_str());
@@ -577,24 +661,32 @@ fn default_mask_tcp_target_for_initial_data<'a>(
.as_deref() .as_deref()
.unwrap_or(&config.censorship.tls_domain); .unwrap_or(&config.censorship.tls_domain);
if !configured_mask_host.eq_ignore_ascii_case(&config.censorship.tls_domain) { if config.censorship.mask_host.is_none() && config.censorship.mask_dynamic {
let extracted_sni = if sni.is_none() {
tls::extract_sni_from_client_hello(initial_data)
} else {
None
};
if let Some(host) = sni
.or(extracted_sni.as_deref())
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
{
return MaskTcpTarget {
host,
port: config.censorship.mask_port,
};
}
}
if let Some(mask_host) = config.censorship.mask_host.as_deref() {
return MaskTcpTarget { return MaskTcpTarget {
host: configured_mask_host, host: mask_host,
port: config.censorship.mask_port, port: config.censorship.mask_port,
}; };
} }
let extracted_sni = if sni.is_none() {
tls::extract_sni_from_client_hello(initial_data)
} else {
None
};
let host = sni
.or(extracted_sni.as_deref())
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
.unwrap_or(configured_mask_host);
MaskTcpTarget { MaskTcpTarget {
host, host: configured_mask_host,
port: config.censorship.mask_port, port: config.censorship.mask_port,
} }
} }
@@ -744,7 +836,7 @@ fn is_mask_target_local_listener_with_interfaces(
mask_host: &str, mask_host: &str,
mask_port: u16, mask_port: u16,
local_addr: SocketAddr, local_addr: SocketAddr,
resolved_override: Option<SocketAddr>, resolved_addrs: &[SocketAddr],
interface_ips: &[IpAddr], interface_ips: &[IpAddr],
) -> bool { ) -> bool {
if mask_port != local_addr.port() { if mask_port != local_addr.port() {
@@ -754,7 +846,7 @@ fn is_mask_target_local_listener_with_interfaces(
let local_ip = canonical_ip(local_addr.ip()); let local_ip = canonical_ip(local_addr.ip());
let literal_mask_ip = parse_mask_host_ip_literal(mask_host).map(canonical_ip); let literal_mask_ip = parse_mask_host_ip_literal(mask_host).map(canonical_ip);
if let Some(addr) = resolved_override { for addr in resolved_addrs {
let resolved_ip = canonical_ip(addr.ip()); let resolved_ip = canonical_ip(addr.ip());
if resolved_ip == local_ip { if resolved_ip == local_ip {
return true; return true;
@@ -791,7 +883,7 @@ fn is_mask_target_local_listener(
mask_host: &str, mask_host: &str,
mask_port: u16, mask_port: u16,
local_addr: SocketAddr, local_addr: SocketAddr,
resolved_override: Option<SocketAddr>, resolved_addrs: &[SocketAddr],
) -> bool { ) -> bool {
if mask_port != local_addr.port() { if mask_port != local_addr.port() {
return false; return false;
@@ -802,7 +894,7 @@ fn is_mask_target_local_listener(
mask_host, mask_host,
mask_port, mask_port,
local_addr, local_addr,
resolved_override, resolved_addrs,
&interfaces, &interfaces,
) )
} }
@@ -811,7 +903,7 @@ async fn is_mask_target_local_listener_async(
mask_host: &str, mask_host: &str,
mask_port: u16, mask_port: u16,
local_addr: SocketAddr, local_addr: SocketAddr,
resolved_override: Option<SocketAddr>, resolved_addrs: &[SocketAddr],
) -> bool { ) -> bool {
if mask_port != local_addr.port() { if mask_port != local_addr.port() {
return false; return false;
@@ -822,7 +914,7 @@ async fn is_mask_target_local_listener_async(
mask_host, mask_host,
mask_port, mask_port,
local_addr, local_addr,
resolved_override, resolved_addrs,
&interfaces, &interfaces,
) )
} }
@@ -860,7 +952,13 @@ fn build_mask_proxy_header(
} }
} }
/// Handle a bad client by forwarding to mask host fn configure_mask_backend_socket(stream: &TcpStream) {
if let Err(e) = configure_tcp_socket(stream, false, Duration::from_secs(0)) {
debug!(error = %e, "Failed to configure mask backend socket");
}
}
/// Handles a bad client by forwarding it to the configured mask target.
pub async fn handle_bad_client<R, W>( pub async fn handle_bad_client<R, W>(
reader: R, reader: R,
writer: W, writer: W,
@@ -872,6 +970,34 @@ pub async fn handle_bad_client<R, W>(
) where ) where
R: AsyncRead + Unpin + Send + 'static, R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static,
{
let shared = ProxySharedState::new();
handle_bad_client_with_shared(
reader,
writer,
initial_data,
peer,
local_addr,
config,
beobachten,
shared.as_ref(),
)
.await;
}
/// Handles a bad client with shared pre-auth fallback admission state.
pub(crate) async fn handle_bad_client_with_shared<R, W>(
reader: R,
writer: W,
initial_data: &[u8],
peer: SocketAddr,
local_addr: SocketAddr,
config: &ProxyConfig,
beobachten: &BeobachtenStore,
shared: &ProxySharedState,
) where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{ {
let client_type = detect_client_type(initial_data); let client_type = detect_client_type(initial_data);
if config.general.beobachten { if config.general.beobachten {
@@ -894,6 +1020,17 @@ pub async fn handle_bad_client<R, W>(
return; return;
} }
let Some(_masking_permit) = shared.try_acquire_masking_fallback_permit() else {
let outcome_started = Instant::now();
debug!(
client_type = client_type,
"Masking fallback concurrency limit reached"
);
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
wait_mask_outcome_budget(outcome_started, config).await;
return;
};
let client_sni = tls::extract_sni_from_client_hello(initial_data); let client_sni = tls::extract_sni_from_client_hello(initial_data);
let exclusive_tcp_target = client_sni let exclusive_tcp_target = client_sni
.as_deref() .as_deref()
@@ -956,24 +1093,12 @@ pub async fn handle_bad_client<R, W>(
Ok(Err(e)) => { Ok(Err(e)) => {
wait_mask_connect_budget_if_needed(connect_started, config).await; wait_mask_connect_budget_if_needed(connect_started, config).await;
debug!(error = %e, "Failed to connect to mask unix socket"); debug!(error = %e, "Failed to connect to mask unix socket");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
Err(_) => { Err(_) => {
debug!("Timeout connecting to mask unix socket"); debug!("Timeout connecting to mask unix socket");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
} }
@@ -986,11 +1111,27 @@ pub async fn handle_bad_client<R, W>(
let mask_host = mask_target.host; let mask_host = mask_target.host;
let mask_port = mask_target.port; let mask_port = mask_target.port;
let resolved_mask_addrs = match resolve_mask_target_addrs(mask_host, mask_port).await {
Ok(addrs) => addrs,
Err(e) => {
let outcome_started = Instant::now();
debug!(
client_type = client_type,
host = %mask_host,
port = mask_port,
error = %e,
"Failed to resolve mask target"
);
consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
wait_mask_outcome_budget(outcome_started, config).await;
return;
}
};
// Fail closed when fallback points at our own listener endpoint. // Fail closed when fallback points at our own listener endpoint.
// Self-referential masking can create recursive proxy loops under // Self-referential masking can create recursive proxy loops under
// misconfiguration and leak distinguishable load spikes to adversaries. // misconfiguration and leak distinguishable load spikes to adversaries.
let resolved_mask_addr = resolve_socket_addr(mask_host, mask_port); if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, &resolved_mask_addrs)
if is_mask_target_local_listener_async(mask_host, mask_port, local_addr, resolved_mask_addr)
.await .await
{ {
let outcome_started = Instant::now(); let outcome_started = Instant::now();
@@ -1001,13 +1142,7 @@ pub async fn handle_bad_client<R, W>(
local = %local_addr, local = %local_addr,
"Mask target resolves to local listener; refusing self-referential masking fallback" "Mask target resolves to local listener; refusing self-referential masking fallback"
); );
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
return; return;
} }
@@ -1022,14 +1157,15 @@ pub async fn handle_bad_client<R, W>(
"Forwarding bad client to mask host" "Forwarding bad client to mask host"
); );
// Apply runtime DNS override for mask target when configured.
let mask_addr = resolved_mask_addr
.map(|addr| addr.to_string())
.unwrap_or_else(|| format!("{}:{}", mask_host, mask_port));
let connect_started = Instant::now(); let connect_started = Instant::now();
let connect_result = timeout(MASK_TIMEOUT, TcpStream::connect(&mask_addr)).await; let connect_result = timeout(
MASK_TIMEOUT,
TcpStream::connect(resolved_mask_addrs.as_slice()),
)
.await;
match connect_result { match connect_result {
Ok(Ok(stream)) => { Ok(Ok(stream)) => {
configure_mask_backend_socket(&stream);
let proxy_header = let proxy_header =
build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr); build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr);
@@ -1068,24 +1204,12 @@ pub async fn handle_bad_client<R, W>(
Ok(Err(e)) => { Ok(Err(e)) => {
wait_mask_connect_budget_if_needed(connect_started, config).await; wait_mask_connect_budget_if_needed(connect_started, config).await;
debug!(error = %e, "Failed to connect to mask host"); debug!(error = %e, "Failed to connect to mask host");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
Err(_) => { Err(_) => {
debug!("Timeout connecting to mask host"); debug!("Timeout connecting to mask host");
consume_client_data_with_timeout_and_cap( consume_mask_failure_path(reader, config, relay_timeout, idle_timeout).await;
reader,
config.censorship.mask_relay_max_bytes,
relay_timeout,
idle_timeout,
)
.await;
wait_mask_outcome_budget(outcome_started, config).await; wait_mask_outcome_budget(outcome_started, config).await;
} }
} }
@@ -1173,20 +1297,17 @@ async fn consume_client_data<R: AsyncRead + Unpin>(
idle_timeout: Duration, idle_timeout: Duration,
) { ) {
// Keep drain path fail-closed under slow-loris stalls. // Keep drain path fail-closed under slow-loris stalls.
let mut buf = Box::new([0u8; MASK_BUFFER_SIZE]); let mut buf = vec![0u8; MASK_BUFFER_SIZE];
let mut total = 0usize; let mut total = 0usize;
let unlimited = byte_cap == 0;
loop { loop {
let read_len = if unlimited { let read_len = mask_copy_read_len(total, byte_cap);
MASK_BUFFER_SIZE if read_len == 0 {
} else { break;
let remaining_budget = byte_cap.saturating_sub(total); }
if remaining_budget == 0 { if buf.len() < read_len {
break; buf.resize(read_len, 0);
} }
remaining_budget.min(MASK_BUFFER_SIZE)
};
let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await { let n = match timeout(idle_timeout, reader.read(&mut buf[..read_len])).await {
Ok(Ok(n)) => n, Ok(Ok(n)) => n,
Ok(Err(_)) | Err(_) => break, Ok(Err(_)) | Err(_) => break,
@@ -1197,7 +1318,7 @@ async fn consume_client_data<R: AsyncRead + Unpin>(
} }
total = total.saturating_add(n); total = total.saturating_add(n);
if !unlimited && total >= byte_cap { if byte_cap != 0 && total >= byte_cap {
break; break;
} }
} }
@@ -1315,6 +1436,10 @@ mod masking_interface_cache_concurrency_security_tests;
#[path = "tests/masking_production_cap_regression_security_tests.rs"] #[path = "tests/masking_production_cap_regression_security_tests.rs"]
mod masking_production_cap_regression_security_tests; mod masking_production_cap_regression_security_tests;
#[cfg(test)]
#[path = "tests/masking_relay_manual_perf_tests.rs"]
mod masking_relay_manual_perf_tests;
#[cfg(test)] #[cfg(test)]
#[path = "tests/masking_extended_attack_surface_security_tests.rs"] #[path = "tests/masking_extended_attack_surface_security_tests.rs"]
mod masking_extended_attack_surface_security_tests; mod masking_extended_attack_surface_security_tests;
+2 -2
View File
@@ -1,6 +1,6 @@
use std::collections::BTreeSet;
#[cfg(test)] #[cfg(test)]
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeSet, HashMap};
#[cfg(test)] #[cfg(test)]
use std::future::Future; use std::future::Future;
#[cfg(test)] #[cfg(test)]
@@ -52,7 +52,7 @@ use self::c2me::{
}; };
use self::d2c::{ use self::d2c::{
MeD2cFlushPolicy, MeWriterResponseOutcome, classify_me_d2c_flush_reason, MeD2cFlushPolicy, MeWriterResponseOutcome, classify_me_d2c_flush_reason,
flush_client_or_cancel, observe_me_d2c_flush_event, flush_client_or_cancel, me_d2c_flush_reason_requires_client_flush, observe_me_d2c_flush_event,
process_me_writer_response_with_traffic_lease, process_me_writer_response_with_traffic_lease,
}; };
use self::desync::{RelayForensicsState, hash_ip_in, report_desync_frame_too_large_in}; use self::desync::{RelayForensicsState, hash_ip_in, report_desync_frame_too_large_in};
+35 -11
View File
@@ -55,6 +55,37 @@ pub(super) fn classify_me_d2c_flush_reason(
MeD2cFlushReason::QueueDrain MeD2cFlushReason::QueueDrain
} }
pub(super) fn me_d2c_flush_reason_requires_client_flush(reason: MeD2cFlushReason) -> bool {
!matches!(reason, MeD2cFlushReason::QueueDrain)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn queue_drain_is_not_a_physical_flush_trigger() {
assert!(!me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::QueueDrain
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::AckImmediate
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::BatchFrames
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::BatchBytes
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::MaxDelay
));
assert!(me_d2c_flush_reason_requires_client_flush(
MeD2cFlushReason::Close
));
}
}
pub(super) fn observe_me_d2c_flush_event( pub(super) fn observe_me_d2c_flush_event(
stats: &Stats, stats: &Stats,
reason: MeD2cFlushReason, reason: MeD2cFlushReason,
@@ -276,20 +307,13 @@ pub(in crate::proxy::middle_relay) fn compute_intermediate_secure_wire_len(
let wire_len = data_len let wire_len = data_len
.checked_add(padding_len) .checked_add(padding_len)
.ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?; .ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?;
if wire_len > 0x7fff_ffffusize { let len_val = crate::protocol::framing::encode_intermediate_header(wire_len, quickack)
return Err(ProxyError::Proxy(format!( .ok_or_else(|| {
"Intermediate/Secure frame too large: {wire_len}" ProxyError::Proxy(format!("Intermediate/Secure frame too large: {wire_len}"))
))); })?;
}
let total = 4usize let total = 4usize
.checked_add(wire_len) .checked_add(wire_len)
.ok_or_else(|| ProxyError::Proxy("Frame buffer size overflow".into()))?; .ok_or_else(|| ProxyError::Proxy("Frame buffer size overflow".into()))?;
let mut len_val = u32::try_from(wire_len)
.map_err(|_| ProxyError::Proxy("Frame length conversion overflow".into()))?;
if quickack {
len_val |= 0x8000_0000;
}
Ok((len_val, total)) Ok((len_val, total))
} }
+107 -113
View File
@@ -1,4 +1,5 @@
use super::*; use super::*;
use dashmap::DashMap;
mod read; mod read;
@@ -10,10 +11,10 @@ pub(crate) use self::read::{
#[derive(Default)] #[derive(Default)]
pub(crate) struct RelayIdleCandidateRegistry { pub(crate) struct RelayIdleCandidateRegistry {
pub(in crate::proxy::middle_relay) by_conn_id: HashMap<u64, RelayIdleCandidateMeta>, pub(in crate::proxy::middle_relay) by_conn_id: DashMap<u64, RelayIdleCandidateMeta>,
pub(in crate::proxy::middle_relay) ordered: BTreeSet<(u64, u64)>, pub(in crate::proxy::middle_relay) ordered: parking_lot::Mutex<BTreeSet<(u64, u64)>>,
pressure_event_seq: u64, pressure_event_seq: AtomicU64,
pressure_consumed_seq: u64, pressure_consumed_seq: AtomicU64,
} }
/// Queue metadata used to preserve FIFO ordering for idle relay eviction. /// Queue metadata used to preserve FIFO ordering for idle relay eviction.
@@ -23,25 +24,10 @@ pub(in crate::proxy::middle_relay) struct RelayIdleCandidateMeta {
pub(in crate::proxy::middle_relay) mark_pressure_seq: u64, pub(in crate::proxy::middle_relay) mark_pressure_seq: u64,
} }
pub(super) fn relay_idle_candidate_registry_lock_in(
shared: &ProxySharedState,
) -> std::sync::MutexGuard<'_, RelayIdleCandidateRegistry> {
let registry = &shared.middle_relay.relay_idle_registry;
match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
}
}
pub(super) fn mark_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) -> bool { pub(super) fn mark_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) -> bool {
let mut guard = relay_idle_candidate_registry_lock_in(shared); let registry = &shared.middle_relay.relay_idle_registry;
if guard.by_conn_id.contains_key(&conn_id) { if registry.by_conn_id.contains_key(&conn_id) {
return false; return false;
} }
@@ -52,24 +38,38 @@ pub(super) fn mark_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u
.saturating_add(1); .saturating_add(1);
let meta = RelayIdleCandidateMeta { let meta = RelayIdleCandidateMeta {
mark_order_seq, mark_order_seq,
mark_pressure_seq: guard.pressure_event_seq, mark_pressure_seq: registry.pressure_event_seq.load(Ordering::Relaxed),
}; };
guard.by_conn_id.insert(conn_id, meta); match registry.by_conn_id.entry(conn_id) {
guard.ordered.insert((meta.mark_order_seq, conn_id)); dashmap::mapref::entry::Entry::Occupied(_) => false,
true dashmap::mapref::entry::Entry::Vacant(entry) => {
entry.insert(meta);
registry
.ordered
.lock()
.insert((meta.mark_order_seq, conn_id));
true
}
}
} }
pub(super) fn clear_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) { pub(super) fn clear_relay_idle_candidate_in(shared: &ProxySharedState, conn_id: u64) {
let mut guard = relay_idle_candidate_registry_lock_in(shared); let registry = &shared.middle_relay.relay_idle_registry;
if let Some(meta) = guard.by_conn_id.remove(&conn_id) { if let Some((_, meta)) = registry.by_conn_id.remove(&conn_id) {
guard.ordered.remove(&(meta.mark_order_seq, conn_id)); registry
.ordered
.lock()
.remove(&(meta.mark_order_seq, conn_id));
} }
} }
pub(super) fn note_relay_pressure_event_in(shared: &ProxySharedState) { pub(super) fn note_relay_pressure_event_in(shared: &ProxySharedState) {
let mut guard = relay_idle_candidate_registry_lock_in(shared); shared
guard.pressure_event_seq = guard.pressure_event_seq.wrapping_add(1); .middle_relay
.relay_idle_registry
.pressure_event_seq
.fetch_add(1, Ordering::Relaxed);
} }
pub(crate) fn note_global_relay_pressure(shared: &ProxySharedState) { pub(crate) fn note_global_relay_pressure(shared: &ProxySharedState) {
@@ -77,8 +77,11 @@ pub(crate) fn note_global_relay_pressure(shared: &ProxySharedState) {
} }
pub(super) fn relay_pressure_event_seq_in(shared: &ProxySharedState) -> u64 { pub(super) fn relay_pressure_event_seq_in(shared: &ProxySharedState) -> u64 {
let guard = relay_idle_candidate_registry_lock_in(shared); shared
guard.pressure_event_seq .middle_relay
.relay_idle_registry
.pressure_event_seq
.load(Ordering::Relaxed)
} }
pub(super) fn maybe_evict_idle_candidate_on_pressure_in( pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
@@ -87,33 +90,52 @@ pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
seen_pressure_seq: &mut u64, seen_pressure_seq: &mut u64,
stats: &Stats, stats: &Stats,
) -> bool { ) -> bool {
let mut guard = relay_idle_candidate_registry_lock_in(shared); let registry = &shared.middle_relay.relay_idle_registry;
let latest_pressure_seq = guard.pressure_event_seq; let latest_pressure_seq = registry.pressure_event_seq.load(Ordering::Relaxed);
if latest_pressure_seq == *seen_pressure_seq { if latest_pressure_seq == *seen_pressure_seq {
return false; return false;
} }
*seen_pressure_seq = latest_pressure_seq; *seen_pressure_seq = latest_pressure_seq;
if latest_pressure_seq == guard.pressure_consumed_seq { let consumed_pressure_seq = registry.pressure_consumed_seq.load(Ordering::Relaxed);
if latest_pressure_seq == consumed_pressure_seq {
return false; return false;
} }
if guard.ordered.is_empty() { let oldest = {
guard.pressure_consumed_seq = latest_pressure_seq; let mut ordered = registry.ordered.lock();
return false; loop {
} let Some((mark_order_seq, candidate_conn_id)) = ordered.iter().next().copied() else {
// Empty queues consume the event so later candidates cannot replay stale pressure.
let oldest = guard let _ = registry.pressure_consumed_seq.compare_exchange(
.ordered consumed_pressure_seq,
.iter() latest_pressure_seq,
.next() Ordering::Relaxed,
.map(|(_, candidate_conn_id)| *candidate_conn_id); Ordering::Relaxed,
);
return false;
};
let Some(candidate_meta) = registry.by_conn_id.get(&candidate_conn_id) else {
ordered.remove(&(mark_order_seq, candidate_conn_id));
continue;
};
if candidate_meta.mark_order_seq != mark_order_seq {
ordered.remove(&(mark_order_seq, candidate_conn_id));
continue;
}
break Some(candidate_conn_id);
}
};
if oldest != Some(conn_id) { if oldest != Some(conn_id) {
return false; return false;
} }
let Some(candidate_meta) = guard.by_conn_id.get(&conn_id).copied() else { let Some(candidate_meta) = registry
.by_conn_id
.get(&conn_id)
.map(|entry| *entry.value())
else {
return false; return false;
}; };
@@ -121,10 +143,27 @@ pub(super) fn maybe_evict_idle_candidate_on_pressure_in(
return false; return false;
} }
if let Some(meta) = guard.by_conn_id.remove(&conn_id) { // Claim the global pressure budget before removal; otherwise racing sessions
guard.ordered.remove(&(meta.mark_order_seq, conn_id)); // can observe the next FIFO item and spend the same event more than once.
if registry
.pressure_consumed_seq
.compare_exchange(
consumed_pressure_seq,
latest_pressure_seq,
Ordering::Relaxed,
Ordering::Relaxed,
)
.is_err()
{
return false;
}
if let Some((_, meta)) = registry.by_conn_id.remove(&conn_id) {
registry
.ordered
.lock()
.remove(&(meta.mark_order_seq, conn_id));
} }
guard.pressure_consumed_seq = latest_pressure_seq;
stats.increment_relay_pressure_evict_total(); stats.increment_relay_pressure_evict_total();
true true
} }
@@ -220,72 +259,32 @@ pub(crate) fn mark_relay_idle_candidate_for_testing(
shared: &ProxySharedState, shared: &ProxySharedState,
conn_id: u64, conn_id: u64,
) -> bool { ) -> bool {
let registry = &shared.middle_relay.relay_idle_registry; mark_relay_idle_candidate_in(shared, conn_id)
let mut guard = match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
};
if guard.by_conn_id.contains_key(&conn_id) {
return false;
}
let mark_order_seq = shared
.middle_relay
.relay_idle_mark_seq
.fetch_add(1, Ordering::Relaxed);
let mark_pressure_seq = guard.pressure_event_seq;
let meta = RelayIdleCandidateMeta {
mark_order_seq,
mark_pressure_seq,
};
guard.by_conn_id.insert(conn_id, meta);
guard.ordered.insert((mark_order_seq, conn_id));
true
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn oldest_relay_idle_candidate_for_testing(shared: &ProxySharedState) -> Option<u64> { pub(crate) fn oldest_relay_idle_candidate_for_testing(shared: &ProxySharedState) -> Option<u64> {
let registry = &shared.middle_relay.relay_idle_registry; let registry = &shared.middle_relay.relay_idle_registry;
let guard = match registry.lock() { registry
Ok(guard) => guard, .ordered
Err(poisoned) => { .lock()
let mut guard = poisoned.into_inner(); .iter()
*guard = RelayIdleCandidateRegistry::default(); .next()
registry.clear_poison(); .map(|(_, conn_id)| *conn_id)
guard
}
};
guard.ordered.iter().next().map(|(_, conn_id)| *conn_id)
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn clear_relay_idle_candidate_for_testing(shared: &ProxySharedState, conn_id: u64) { pub(crate) fn clear_relay_idle_candidate_for_testing(shared: &ProxySharedState, conn_id: u64) {
let registry = &shared.middle_relay.relay_idle_registry; clear_relay_idle_candidate_in(shared, conn_id);
let mut guard = match registry.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = RelayIdleCandidateRegistry::default();
registry.clear_poison();
guard
}
};
if let Some(meta) = guard.by_conn_id.remove(&conn_id) {
guard.ordered.remove(&(meta.mark_order_seq, conn_id));
}
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn clear_relay_idle_pressure_state_for_testing_in_shared(shared: &ProxySharedState) { pub(crate) fn clear_relay_idle_pressure_state_for_testing_in_shared(shared: &ProxySharedState) {
if let Ok(mut guard) = shared.middle_relay.relay_idle_registry.lock() { let registry = &shared.middle_relay.relay_idle_registry;
*guard = RelayIdleCandidateRegistry::default(); registry.by_conn_id.clear();
} registry.ordered.lock().clear();
registry.pressure_event_seq.store(0, Ordering::Relaxed);
registry.pressure_consumed_seq.store(0, Ordering::Relaxed);
shared shared
.middle_relay .middle_relay
.relay_idle_mark_seq .relay_idle_mark_seq
@@ -327,15 +326,10 @@ pub(crate) fn set_relay_pressure_state_for_testing(
pressure_consumed_seq: u64, pressure_consumed_seq: u64,
) { ) {
let registry = &shared.middle_relay.relay_idle_registry; let registry = &shared.middle_relay.relay_idle_registry;
let mut guard = match registry.lock() { registry
Ok(guard) => guard, .pressure_event_seq
Err(poisoned) => { .store(pressure_event_seq, Ordering::Relaxed);
let mut guard = poisoned.into_inner(); registry
*guard = RelayIdleCandidateRegistry::default(); .pressure_consumed_seq
registry.clear_poison(); .store(pressure_consumed_seq, Ordering::Relaxed);
guard
}
};
guard.pressure_event_seq = pressure_event_seq;
guard.pressure_consumed_seq = pressure_consumed_seq;
} }
+4 -7
View File
@@ -236,12 +236,8 @@ where
} }
Err(e) => return Err(e), Err(e) => return Err(e),
} }
let quickack = (len_buf[3] & 0x80) != 0; let header = crate::protocol::framing::parse_intermediate_header(len_buf);
( (header.wire_len, header.quickack, Some(len_buf))
(u32::from_le_bytes(len_buf) & 0x7fff_ffff) as usize,
quickack,
Some(len_buf),
)
} }
}; };
@@ -331,7 +327,8 @@ where
) )
.await?; .await?;
// Secure Intermediate: strip validated trailing padding bytes. // Secure Intermediate strips only non-aligned tail padding; full-word
// padding is indistinguishable from payload in VersionD framing.
if proto_tag == ProtoTag::Secure { if proto_tag == ProtoTag::Secure {
payload.truncate(secure_payload_len); payload.truncate(secure_payload_len);
} }
+4 -2
View File
@@ -41,11 +41,12 @@ pub(super) async fn reserve_user_quota_with_yield(
return Err(MiddleQuotaReserveError::DeadlineExceeded); return Err(MiddleQuotaReserveError::DeadlineExceeded);
} }
tokio::select! { tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(backoff_ms)) => {} biased;
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
stats.increment_quota_acquire_cancelled_total(); stats.increment_quota_acquire_cancelled_total();
return Err(MiddleQuotaReserveError::Cancelled); return Err(MiddleQuotaReserveError::Cancelled);
} }
_ = tokio::time::sleep(Duration::from_millis(backoff_ms)) => {}
} }
backoff_rounds = backoff_rounds.saturating_add(1); backoff_rounds = backoff_rounds.saturating_add(1);
if backoff_rounds >= QUOTA_RESERVE_MAX_BACKOFF_ROUNDS { if backoff_rounds >= QUOTA_RESERVE_MAX_BACKOFF_ROUNDS {
@@ -128,11 +129,12 @@ pub(super) async fn wait_for_traffic_budget_or_cancel(
return Err(ProxyError::TrafficBudgetWaitDeadlineExceeded); return Err(ProxyError::TrafficBudgetWaitDeadlineExceeded);
} }
tokio::select! { tokio::select! {
_ = tokio::time::sleep(next_refill_delay()) => {} biased;
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
stats.increment_flow_wait_middle_rate_limit_cancelled_total(); stats.increment_flow_wait_middle_rate_limit_cancelled_total();
return Err(ProxyError::TrafficBudgetWaitCancelled); return Err(ProxyError::TrafficBudgetWaitCancelled);
} }
_ = tokio::time::sleep(next_refill_delay()) => {}
} }
let wait_ms = wait_started_at let wait_ms = wait_started_at
.elapsed() .elapsed()
+32 -2
View File
@@ -13,6 +13,7 @@ pub(crate) async fn handle_via_middle_proxy<R, W>(
mut route_rx: watch::Receiver<RouteCutoverState>, mut route_rx: watch::Receiver<RouteCutoverState>,
route_snapshot: RouteCutoverState, route_snapshot: RouteCutoverState,
session_id: u64, session_id: u64,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>, shared: Arc<ProxySharedState>,
) -> Result<()> ) -> Result<()>
where where
@@ -20,6 +21,10 @@ where
W: AsyncWrite + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static,
{ {
let user = success.user.clone(); let user = success.user.clone();
if session_cancel.is_cancelled() {
return Err(ProxyError::UserDisabled { user });
}
let quota_limit = config.access.user_data_quota.get(&user).copied(); let quota_limit = config.access.user_data_quota.get(&user).copied();
let quota_user_stats = quota_limit.map(|_| stats.get_or_create_user_stats_handle(&user)); let quota_user_stats = quota_limit.map(|_| stats.get_or_create_user_stats_handle(&user));
let peer = success.peer; let peer = success.peer;
@@ -486,12 +491,18 @@ where
d2c_flush_policy.max_bytes, d2c_flush_policy.max_bytes,
max_delay_fired, max_delay_fired,
); );
let flush_started_at = if stats_clone.telemetry_policy().me_level.allows_debug() { let physical_flush =
me_d2c_flush_reason_requires_client_flush(flush_reason);
let flush_started_at = if physical_flush
&& stats_clone.telemetry_policy().me_level.allows_debug()
{
Some(Instant::now()) Some(Instant::now())
} else { } else {
None None
}; };
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?; if physical_flush {
flush_client_or_cancel(&mut writer, &flow_cancel_me_writer).await?;
}
let flush_duration_us = flush_started_at.map(|started| { let flush_duration_us = flush_started_at.map(|started| {
started started
.elapsed() .elapsed()
@@ -590,6 +601,25 @@ where
} }
tokio::select! { tokio::select! {
_ = session_cancel.cancelled() => {
warn!(
user = %user,
conn_id,
"Disabled user middle session cancelled"
);
let _ = enqueue_c2me_command_in(
shared.as_ref(),
&c2me_tx,
C2MeCommand::Close,
c2me_send_timeout,
stats.as_ref(),
)
.await;
main_result = Err(ProxyError::UserDisabled {
user: user.clone(),
});
break;
}
changed = route_rx.changed(), if route_watch_open => { changed = route_rx.changed(), if route_watch_open => {
if changed.is_err() { if changed.is_err() {
route_watch_open = false; route_watch_open = false;
+119 -8
View File
@@ -55,11 +55,13 @@ use crate::error::{ProxyError, Result};
use crate::proxy::traffic_limiter::TrafficLease; use crate::proxy::traffic_limiter::TrafficLease;
use crate::stats::Stats; use crate::stats::Stats;
use crate::stream::BufferPool; use crate::stream::BufferPool;
use std::future::pending;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, copy_bidirectional_with_sizes}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, copy_bidirectional_with_sizes};
use tokio::time::Instant; use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn}; use tracing::{debug, warn};
// ============= Constants ============= // ============= Constants =============
@@ -191,6 +193,84 @@ pub async fn relay_bidirectional_with_activity_timeout_and_lease<CR, CW, SR, SW>
traffic_lease: Option<Arc<TrafficLease>>, traffic_lease: Option<Arc<TrafficLease>>,
activity_timeout: Duration, activity_timeout: Duration,
) -> Result<()> ) -> Result<()>
where
CR: AsyncRead + Unpin + Send + 'static,
CW: AsyncWrite + Unpin + Send + 'static,
SR: AsyncRead + Unpin + Send + 'static,
SW: AsyncWrite + Unpin + Send + 'static,
{
relay_bidirectional_with_activity_timeout_lease_cancel_inner(
client_reader,
client_writer,
server_reader,
server_writer,
c2s_buf_size,
s2c_buf_size,
user,
stats,
quota_limit,
_buffer_pool,
traffic_lease,
activity_timeout,
None,
)
.await
}
pub async fn relay_bidirectional_with_activity_timeout_lease_and_cancel<CR, CW, SR, SW>(
client_reader: CR,
client_writer: CW,
server_reader: SR,
server_writer: SW,
c2s_buf_size: usize,
s2c_buf_size: usize,
user: &str,
stats: Arc<Stats>,
quota_limit: Option<u64>,
_buffer_pool: Arc<BufferPool>,
traffic_lease: Option<Arc<TrafficLease>>,
activity_timeout: Duration,
session_cancel: CancellationToken,
) -> Result<()>
where
CR: AsyncRead + Unpin + Send + 'static,
CW: AsyncWrite + Unpin + Send + 'static,
SR: AsyncRead + Unpin + Send + 'static,
SW: AsyncWrite + Unpin + Send + 'static,
{
relay_bidirectional_with_activity_timeout_lease_cancel_inner(
client_reader,
client_writer,
server_reader,
server_writer,
c2s_buf_size,
s2c_buf_size,
user,
stats,
quota_limit,
_buffer_pool,
traffic_lease,
activity_timeout,
Some(session_cancel),
)
.await
}
async fn relay_bidirectional_with_activity_timeout_lease_cancel_inner<CR, CW, SR, SW>(
client_reader: CR,
client_writer: CW,
server_reader: SR,
server_writer: SW,
c2s_buf_size: usize,
s2c_buf_size: usize,
user: &str,
stats: Arc<Stats>,
quota_limit: Option<u64>,
_buffer_pool: Arc<BufferPool>,
traffic_lease: Option<Arc<TrafficLease>>,
activity_timeout: Duration,
session_cancel: Option<CancellationToken>,
) -> Result<()>
where where
CR: AsyncRead + Unpin + Send + 'static, CR: AsyncRead + Unpin + Send + 'static,
CW: AsyncWrite + Unpin + Send + 'static, CW: AsyncWrite + Unpin + Send + 'static,
@@ -287,14 +367,29 @@ where
// //
// When the watchdog fires, select! drops the copy future, // When the watchdog fires, select! drops the copy future,
// releasing the &mut borrows on client and server. // releasing the &mut borrows on client and server.
let copy_result = tokio::select! { enum RelayOutcome {
Copy(std::io::Result<(u64, u64)>),
ActivityTimeout,
UserDisabled,
}
let cancel_wait = async move {
match session_cancel {
Some(token) => token.cancelled().await,
None => pending::<()>().await,
}
};
tokio::pin!(cancel_wait);
let relay_outcome = tokio::select! {
result = copy_bidirectional_with_sizes( result = copy_bidirectional_with_sizes(
&mut client, &mut client,
&mut server, &mut server,
c2s_buf_size.max(1), c2s_buf_size.max(1),
s2c_buf_size.max(1), s2c_buf_size.max(1),
) => Some(result), ) => RelayOutcome::Copy(result),
_ = watchdog => None, // Activity timeout — cancel relay _ = watchdog => RelayOutcome::ActivityTimeout,
_ = &mut cancel_wait => RelayOutcome::UserDisabled,
}; };
// ── Clean shutdown ────────────────────────────────────────────── // ── Clean shutdown ──────────────────────────────────────────────
@@ -308,8 +403,8 @@ where
let s2c_ops = counters.s2c_ops.load(Ordering::Relaxed); let s2c_ops = counters.s2c_ops.load(Ordering::Relaxed);
let duration = epoch.elapsed(); let duration = epoch.elapsed();
match copy_result { match relay_outcome {
Some(Ok((c2s, s2c))) => { RelayOutcome::Copy(Ok((c2s, s2c))) => {
// Normal completion — one side closed the connection // Normal completion — one side closed the connection
debug!( debug!(
user = %user_owned, user = %user_owned,
@@ -322,7 +417,7 @@ where
); );
Ok(()) Ok(())
} }
Some(Err(e)) if is_quota_io_error(&e) => { RelayOutcome::Copy(Err(e)) if is_quota_io_error(&e) => {
let c2s = counters.c2s_bytes.load(Ordering::Relaxed); let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed); let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
warn!( warn!(
@@ -338,7 +433,7 @@ where
user: user_owned.clone(), user: user_owned.clone(),
}) })
} }
Some(Err(e)) => { RelayOutcome::Copy(Err(e)) => {
// I/O error in one of the directions // I/O error in one of the directions
let c2s = counters.c2s_bytes.load(Ordering::Relaxed); let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed); let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
@@ -354,7 +449,7 @@ where
); );
Err(e.into()) Err(e.into())
} }
None => { RelayOutcome::ActivityTimeout => {
// Activity timeout (watchdog fired) // Activity timeout (watchdog fired)
let c2s = counters.c2s_bytes.load(Ordering::Relaxed); let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed); let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
@@ -369,6 +464,22 @@ where
); );
Ok(()) Ok(())
} }
RelayOutcome::UserDisabled => {
let c2s = counters.c2s_bytes.load(Ordering::Relaxed);
let s2c = counters.s2c_bytes.load(Ordering::Relaxed);
debug!(
user = %user_owned,
c2s_bytes = c2s,
s2c_bytes = s2c,
c2s_msgs = c2s_ops,
s2c_msgs = s2c_ops,
duration_secs = duration.as_secs(),
"Relay finished (user disabled)"
);
Err(ProxyError::UserDisabled {
user: user_owned.clone(),
})
}
} }
} }
+157 -4
View File
@@ -1,18 +1,20 @@
use std::collections::HashSet;
use std::collections::hash_map::RandomState; use std::collections::hash_map::RandomState;
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use dashmap::DashMap; use dashmap::DashMap;
use tokio::sync::mpsc; use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
use tokio_util::sync::CancellationToken;
use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState}; use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState};
use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry}; use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry};
use crate::proxy::traffic_limiter::TrafficLimiter; use crate::proxy::traffic_limiter::TrafficLimiter;
const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64; const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64;
const MASKING_FALLBACK_MAX_CONCURRENT: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConntrackCloseReason { pub(crate) enum ConntrackCloseReason {
@@ -59,7 +61,7 @@ pub(crate) struct MiddleRelaySharedState {
pub(crate) desync_hasher: RandomState, pub(crate) desync_hasher: RandomState,
pub(crate) desync_full_cache_last_emit_at: Mutex<Option<Instant>>, pub(crate) desync_full_cache_last_emit_at: Mutex<Option<Instant>>,
pub(crate) desync_dedup_rotation_state: Mutex<DesyncDedupRotationState>, pub(crate) desync_dedup_rotation_state: Mutex<DesyncDedupRotationState>,
pub(crate) relay_idle_registry: Mutex<RelayIdleCandidateRegistry>, pub(crate) relay_idle_registry: RelayIdleCandidateRegistry,
pub(crate) relay_idle_mark_seq: AtomicU64, pub(crate) relay_idle_mark_seq: AtomicU64,
} }
@@ -67,8 +69,34 @@ pub(crate) struct ProxySharedState {
pub(crate) handshake: HandshakeSharedState, pub(crate) handshake: HandshakeSharedState,
pub(crate) middle_relay: MiddleRelaySharedState, pub(crate) middle_relay: MiddleRelaySharedState,
pub(crate) traffic_limiter: Arc<TrafficLimiter>, pub(crate) traffic_limiter: Arc<TrafficLimiter>,
disabled_users: DashMap<String, ()>,
active_user_sessions: DashMap<(String, u64), CancellationToken>,
pub(crate) conntrack_pressure_active: AtomicBool, pub(crate) conntrack_pressure_active: AtomicBool,
pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>, pub(crate) conntrack_close_tx: Mutex<Option<mpsc::Sender<ConntrackCloseEvent>>>,
masking_fallback_permits: Arc<Semaphore>,
}
#[must_use = "registered user sessions must be kept alive until relay completion"]
pub(crate) struct UserSessionRegistration {
token: CancellationToken,
_guard: UserSessionGuard,
}
impl UserSessionRegistration {
pub(crate) fn token(&self) -> CancellationToken {
self.token.clone()
}
}
struct UserSessionGuard {
shared: Arc<ProxySharedState>,
key: (String, u64),
}
impl Drop for UserSessionGuard {
fn drop(&mut self) {
self.shared.active_user_sessions.remove(&self.key);
}
} }
impl ProxySharedState { impl ProxySharedState {
@@ -97,15 +125,95 @@ impl ProxySharedState {
desync_hasher: RandomState::new(), desync_hasher: RandomState::new(),
desync_full_cache_last_emit_at: Mutex::new(None), desync_full_cache_last_emit_at: Mutex::new(None),
desync_dedup_rotation_state: Mutex::new(DesyncDedupRotationState::default()), desync_dedup_rotation_state: Mutex::new(DesyncDedupRotationState::default()),
relay_idle_registry: Mutex::new(RelayIdleCandidateRegistry::default()), relay_idle_registry: RelayIdleCandidateRegistry::default(),
relay_idle_mark_seq: AtomicU64::new(0), relay_idle_mark_seq: AtomicU64::new(0),
}, },
traffic_limiter: TrafficLimiter::new(), traffic_limiter: TrafficLimiter::new(),
disabled_users: DashMap::new(),
active_user_sessions: DashMap::new(),
conntrack_pressure_active: AtomicBool::new(false), conntrack_pressure_active: AtomicBool::new(false),
conntrack_close_tx: Mutex::new(None), conntrack_close_tx: Mutex::new(None),
masking_fallback_permits: Arc::new(Semaphore::new(MASKING_FALLBACK_MAX_CONCURRENT)),
}) })
} }
/// Attempts to reserve one masking fallback slot for a pre-auth connection.
pub(crate) fn try_acquire_masking_fallback_permit(&self) -> Option<OwnedSemaphorePermit> {
self.masking_fallback_permits
.clone()
.try_acquire_owned()
.ok()
}
pub(crate) fn is_user_enabled(&self, user: &str) -> bool {
!self.disabled_users.contains_key(user)
}
pub(crate) fn set_user_enabled(&self, user: &str, enabled: bool) -> bool {
if enabled {
self.disabled_users.remove(user);
false
} else {
self.disabled_users.insert(user.to_string(), ()).is_none()
}
}
pub(crate) fn apply_user_enabled_config(
&self,
user_enabled: &HashMap<String, bool>,
) -> Vec<String> {
let desired_disabled = user_enabled
.iter()
.filter_map(|(user, enabled)| (!*enabled).then_some(user.clone()))
.collect::<HashSet<_>>();
let current_disabled = self
.disabled_users
.iter()
.map(|entry| entry.key().clone())
.collect::<HashSet<_>>();
for user in current_disabled.difference(&desired_disabled) {
self.disabled_users.remove(user);
}
let newly_disabled = desired_disabled
.difference(&current_disabled)
.cloned()
.collect::<Vec<_>>();
for user in desired_disabled {
self.disabled_users.insert(user, ());
}
newly_disabled
}
pub(crate) fn register_user_session(
self: &Arc<Self>,
user: &str,
session_id: u64,
) -> UserSessionRegistration {
let token = CancellationToken::new();
let key = (user.to_string(), session_id);
self.active_user_sessions.insert(key.clone(), token.clone());
UserSessionRegistration {
token,
_guard: UserSessionGuard {
shared: Arc::clone(self),
key,
},
}
}
pub(crate) fn cancel_user_sessions(&self, user: &str) -> usize {
let tokens = self
.active_user_sessions
.iter()
.filter_map(|entry| (entry.key().0 == user).then(|| entry.value().clone()))
.collect::<Vec<_>>();
for token in &tokens {
token.cancel();
}
tokens.len()
}
pub(crate) fn set_conntrack_close_sender(&self, tx: mpsc::Sender<ConntrackCloseEvent>) { pub(crate) fn set_conntrack_close_sender(&self, tx: mpsc::Sender<ConntrackCloseEvent>) {
match self.conntrack_close_tx.lock() { match self.conntrack_close_tx.lock() {
Ok(mut guard) => { Ok(mut guard) => {
@@ -166,3 +274,48 @@ impl ProxySharedState {
self.conntrack_pressure_active.load(Ordering::Relaxed) self.conntrack_pressure_active.load(Ordering::Relaxed)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_enabled_config_sync_tracks_disabled_overrides() {
let shared = ProxySharedState::new();
assert!(shared.is_user_enabled("alice"));
let mut user_enabled = HashMap::new();
user_enabled.insert("alice".to_string(), false);
user_enabled.insert("bob".to_string(), true);
let mut newly_disabled = shared.apply_user_enabled_config(&user_enabled);
newly_disabled.sort();
assert_eq!(newly_disabled, vec!["alice".to_string()]);
assert!(!shared.is_user_enabled("alice"));
assert!(shared.is_user_enabled("bob"));
assert!(shared.apply_user_enabled_config(&user_enabled).is_empty());
user_enabled.clear();
assert!(shared.apply_user_enabled_config(&user_enabled).is_empty());
assert!(shared.is_user_enabled("alice"));
}
#[test]
fn cancel_user_sessions_cancels_only_registered_matching_user() {
let shared = ProxySharedState::new();
let alice_1 = shared.register_user_session("alice", 1);
let alice_2 = shared.register_user_session("alice", 2);
let bob = shared.register_user_session("bob", 1);
let alice_1_token = alice_1.token();
let alice_2_token = alice_2.token();
let bob_token = bob.token();
drop(alice_1);
assert_eq!(shared.cancel_user_sessions("alice"), 1);
assert!(!alice_1_token.is_cancelled());
assert!(alice_2_token.is_cancelled());
assert!(!bob_token.is_cancelled());
}
}
@@ -39,6 +39,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -85,17 +86,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![fill; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[0] = 0x16; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -182,10 +238,11 @@ async fn run_tls_success_mtproto_fail_capture(
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await; read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side.write_all(&bad_mtproto_record).await.unwrap(); let mut client_payload = bad_mtproto_record;
for record in trailing_records { for record in trailing_records {
client_side.write_all(&record).await.unwrap(); client_payload.extend_from_slice(&record);
} }
client_side.write_all(&client_payload).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(4), accept_task) let got = tokio::time::timeout(Duration::from_secs(4), accept_task)
.await .await
@@ -434,11 +491,9 @@ async fn blackhat_campaign_06_replayed_tls_hello_is_masked_without_serverhello()
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16); assert_eq!(head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, head).await; read_and_discard_tls_record_body(&mut client_side, head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&first_tail);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&first_tail).await.unwrap();
} else { } else {
let mut one = [0u8; 1]; let mut one = [0u8; 1];
let no_server_hello = tokio::time::timeout( let no_server_hello = tokio::time::timeout(
@@ -740,8 +795,9 @@ async fn blackhat_campaign_12_parallel_tls_success_mtproto_fail_sessions_keep_is
let mut head = [0u8; 5]; let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
read_and_discard_tls_record_body(&mut client_side, head).await; read_and_discard_tls_record_body(&mut client_side, head).await;
client_side.write_all(&bad).await.unwrap(); let mut client_payload = bad;
client_side.write_all(&tail).await.unwrap(); client_payload.extend_from_slice(&tail);
client_side.write_all(&client_payload).await.unwrap();
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), handler) let result = tokio::time::timeout(Duration::from_secs(5), handler)
@@ -35,6 +35,7 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -64,17 +65,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![fill; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[0] = 0x16; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -239,11 +295,9 @@ async fn tls_mtproto_bad_client_does_not_reinject_clienthello_into_mask_backend(
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await; read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -33,6 +33,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -79,17 +80,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![fill; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[0] = 0x16; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -172,13 +228,11 @@ async fn run_tls_success_mtproto_fail_capture(
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
read_tls_record_body(&mut client_side, tls_response_head).await; read_tls_record_body(&mut client_side, tls_response_head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record)
.await
.unwrap();
for record in trailing_records { for record in trailing_records {
client_side.write_all(&record).await.unwrap(); client_payload.extend_from_slice(&record);
} }
client_side.write_all(&client_payload).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(3), accept_task) let got = tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -343,11 +397,9 @@ async fn replayed_tls_hello_gets_no_serverhello_and_is_masked() {
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16); assert_eq!(head[0], 0x16);
read_tls_record_body(&mut client_side, head).await; read_tls_record_body(&mut client_side, head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&first_tail);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&first_tail).await.unwrap();
} else { } else {
let mut one = [0u8; 1]; let mut one = [0u8; 1];
let no_server_hello = tokio::time::timeout( let no_server_hello = tokio::time::timeout(
@@ -418,11 +470,9 @@ async fn connects_bad_increments_once_per_invalid_mtproto() {
let mut head = [0u8; 5]; let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
read_tls_record_body(&mut client_side, head).await; read_tls_record_body(&mut client_side, head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&tail);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&tail).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -675,8 +725,9 @@ async fn concurrent_tls_mtproto_fail_sessions_are_isolated() {
let mut head = [0u8; 5]; let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
read_tls_record_body(&mut client_side, head).await; read_tls_record_body(&mut client_side, head).await;
client_side.write_all(&invalid_mtproto).await.unwrap(); let mut client_payload = invalid_mtproto;
client_side.write_all(&trailing).await.unwrap(); client_payload.extend_from_slice(&trailing);
client_side.write_all(&client_payload).await.unwrap();
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(3), handler) let _ = tokio::time::timeout(Duration::from_secs(3), handler)
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -46,6 +46,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -70,17 +71,77 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
} }
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> { fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
let total_len = 5 + tls_len; assert!(
let mut handshake = vec![fill; total_len]; tls_len <= u16::MAX as usize,
"TLS length must fit into record header"
handshake[0] = 0x16; );
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -249,11 +310,9 @@ async fn blackhat_integration_empty_initial_data_path_is_byte_exact_and_eof_clea
assert_eq!(head[0], 0x16); assert_eq!(head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, head).await; read_and_discard_tls_record_body(&mut client_side, head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
@@ -24,6 +24,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -47,6 +47,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -76,17 +77,73 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![fill; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
handshake[0] = 0x16; const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[1] = 0x03; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
let ts = timestamp.to_le_bytes(); let ts = timestamp.to_le_bytes();
@@ -155,14 +212,9 @@ async fn run_tls_success_mtproto_fail_session(
let mut body = vec![0u8; body_len]; let mut body = vec![0u8; body_len];
client_side.read_exact(&mut body).await.unwrap(); client_side.read_exact(&mut body).await.unwrap();
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&wrap_tls_application_data(&tail));
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side
.write_all(&wrap_tls_application_data(&tail))
.await
.unwrap();
let forwarded = tokio::time::timeout(Duration::from_secs(3), accept_task) let forwarded = tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -240,6 +292,7 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -484,6 +537,7 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -561,6 +615,7 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -21,6 +21,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -33,17 +34,77 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
} }
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> { fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
let total_len = 5 + tls_len; assert!(
let mut handshake = vec![fill; total_len]; tls_len <= u16::MAX as usize,
"TLS length must fit into record header"
handshake[0] = 0x16; );
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -118,14 +179,9 @@ async fn run_replay_candidate_session(
invalid_mtproto_record.extend_from_slice(&TLS_VERSION); invalid_mtproto_record.extend_from_slice(&TLS_VERSION);
invalid_mtproto_record.extend_from_slice(&(HANDSHAKE_LEN as u16).to_be_bytes()); invalid_mtproto_record.extend_from_slice(&(HANDSHAKE_LEN as u16).to_be_bytes());
invalid_mtproto_record.extend_from_slice(&vec![0u8; HANDSHAKE_LEN]); invalid_mtproto_record.extend_from_slice(&vec![0u8; HANDSHAKE_LEN]);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(b"GET /replay-fallback HTTP/1.1\r\nHost: x\r\n\r\n");
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side
.write_all(b"GET /replay-fallback HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
} }
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -19,6 +19,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -33,6 +33,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -79,17 +80,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![fill; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[0] = 0x16; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -204,8 +260,13 @@ async fn run_parallel_tail_fallback_case(
assert_eq!(server_hello_head[0], 0x16); assert_eq!(server_hello_head[0], 0x16);
read_tls_record_body(&mut client_side, server_hello_head).await; read_tls_record_body(&mut client_side, server_hello_head).await;
client_side.write_all(&invalid_mtproto).await.unwrap(); let mut chunks = trailing.chunks(write_chunk.max(1));
for chunk in trailing.chunks(write_chunk.max(1)) { let mut client_payload = invalid_mtproto;
if let Some(first_chunk) = chunks.next() {
client_payload.extend_from_slice(first_chunk);
}
client_side.write_all(&client_payload).await.unwrap();
for chunk in chunks {
client_side.write_all(chunk).await.unwrap(); client_side.write_all(chunk).await.unwrap();
} }
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
+115 -14
View File
@@ -3,7 +3,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
use crate::crypto::{AesCtr, sha256, sha256_hmac}; use crate::crypto::{AesCtr, sha256, sha256_hmac};
use crate::protocol::constants::{ use crate::protocol::constants::{
DC_IDX_POS, HANDSHAKE_LEN, IV_LEN, PREKEY_LEN, PROTO_TAG_POS, ProtoTag, SKIP_LEN, DC_IDX_POS, HANDSHAKE_LEN, IV_LEN, PREKEY_LEN, PROTO_TAG_POS, ProtoTag, SKIP_LEN,
TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_CHANGE_CIPHER, TLS_VERSION,
}; };
use crate::protocol::tls; use crate::protocol::tls;
use crate::proxy::handshake::HandshakeSuccess; use crate::proxy::handshake::HandshakeSuccess;
@@ -341,6 +341,7 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -459,6 +460,7 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -586,6 +588,7 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -759,6 +762,7 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -839,6 +843,7 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load(
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1032,6 +1037,7 @@ async fn short_tls_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1123,6 +1129,7 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1212,6 +1219,7 @@ async fn handle_client_stream_increments_connects_all_exactly_once() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1308,6 +1316,7 @@ async fn running_client_handler_increments_connects_all_exactly_once() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1401,6 +1410,7 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1475,6 +1485,7 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1564,6 +1575,7 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1618,17 +1630,73 @@ fn make_valid_tls_client_hello_with_len(secret: &[u8], timestamp: u32, tls_len:
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![0x42u8; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[0] = 0x16; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[1] = 0x03; let fill = 0x42u8;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -1651,6 +1719,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32, timestamp: u32,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -1662,6 +1733,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0); body.push(0);
let mut ext_blob = Vec::new(); let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
@@ -1892,6 +1976,7 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -2004,6 +2089,7 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -2048,8 +2134,9 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side.write_all(&tls_app_record).await.unwrap(); let mut client_payload = tls_app_record;
client_side.write_all(&trailing_tls_record).await.unwrap(); client_payload.extend_from_slice(&trailing_tls_record);
client_side.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -2114,6 +2201,7 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -2173,8 +2261,9 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
client.read_exact(&mut tls_response_head).await.unwrap(); client.read_exact(&mut tls_response_head).await.unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client.write_all(&tls_app_record).await.unwrap(); let mut client_payload = tls_app_record;
client.write_all(&trailing_tls_record).await.unwrap(); client_payload.extend_from_slice(&trailing_tls_record);
client.write_all(&client_payload).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), mask_accept_task) tokio::time::timeout(Duration::from_secs(3), mask_accept_task)
.await .await
@@ -2239,6 +2328,7 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -2335,6 +2425,7 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -2437,6 +2528,7 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -3395,6 +3487,7 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -3963,6 +4056,7 @@ async fn untrusted_proxy_header_source_is_rejected() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4036,6 +4130,7 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4136,6 +4231,7 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4242,6 +4338,7 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4362,6 +4459,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4468,6 +4566,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4577,6 +4676,7 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -4681,6 +4781,7 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -32,6 +32,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -74,12 +75,17 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend_addr = listener.local_addr().unwrap(); let backend_addr = listener.local_addr().unwrap();
let backend_reply = REPLY_404.to_vec(); let backend_reply = REPLY_404.to_vec();
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let accept_task = tokio::spawn({ let accept_task = tokio::spawn({
let backend_reply = backend_reply.clone(); let backend_reply = backend_reply.clone();
let expected_probe_len = probe.len();
async move { async move {
let (mut stream, _) = listener.accept().await.unwrap(); let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 5]; let mut buf = vec![0u8; expected_probe_len];
stream.read_exact(&mut buf).await.unwrap(); stream.read_exact(&mut buf).await.unwrap();
stream.write_all(&backend_reply).await.unwrap(); stream.write_all(&backend_reply).await.unwrap();
} }
@@ -93,6 +99,7 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
cfg.censorship.mask_host = Some("127.0.0.1".to_string()); cfg.censorship.mask_host = Some("127.0.0.1".to_string());
cfg.censorship.mask_port = backend_addr.port(); cfg.censorship.mask_port = backend_addr.port();
cfg.censorship.mask_proxy_protocol = 0; cfg.censorship.mask_proxy_protocol = 0;
cfg.censorship.mask_shape_hardening = false;
if matches!(class, ProbeClass::PlainWebBaseline) { if matches!(class, ProbeClass::PlainWebBaseline) {
cfg.general.modes.classic = false; cfg.general.modes.classic = false;
@@ -129,11 +136,6 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
false, false,
)); ));
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let started = Instant::now(); let started = Instant::now();
client_side.write_all(&probe).await.unwrap(); client_side.write_all(&probe).await.unwrap();
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
@@ -169,11 +171,16 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
let front_addr = front_listener.local_addr().unwrap(); let front_addr = front_listener.local_addr().unwrap();
let backend_reply = REPLY_404.to_vec(); let backend_reply = REPLY_404.to_vec();
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let mask_accept_task = tokio::spawn({ let mask_accept_task = tokio::spawn({
let backend_reply = backend_reply.clone(); let backend_reply = backend_reply.clone();
let expected_probe_len = probe.len();
async move { async move {
let (mut stream, _) = mask_listener.accept().await.unwrap(); let (mut stream, _) = mask_listener.accept().await.unwrap();
let mut buf = [0u8; 5]; let mut buf = vec![0u8; expected_probe_len];
stream.read_exact(&mut buf).await.unwrap(); stream.read_exact(&mut buf).await.unwrap();
stream.write_all(&backend_reply).await.unwrap(); stream.write_all(&backend_reply).await.unwrap();
} }
@@ -187,6 +194,7 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
cfg.censorship.mask_host = Some("127.0.0.1".to_string()); cfg.censorship.mask_host = Some("127.0.0.1".to_string());
cfg.censorship.mask_port = backend_addr.port(); cfg.censorship.mask_port = backend_addr.port();
cfg.censorship.mask_proxy_protocol = 0; cfg.censorship.mask_proxy_protocol = 0;
cfg.censorship.mask_shape_hardening = false;
if matches!(class, ProbeClass::PlainWebBaseline) { if matches!(class, ProbeClass::PlainWebBaseline) {
cfg.general.modes.classic = false; cfg.general.modes.classic = false;
@@ -239,11 +247,6 @@ async fn run_client_handler_once(class: ProbeClass) -> u128 {
}) })
}; };
let probe = match class {
ProbeClass::MalformedTlsTruncation => malformed_tls_probe(),
ProbeClass::PlainWebBaseline => plain_web_probe(),
};
let mut client = TcpStream::connect(front_addr).await.unwrap(); let mut client = TcpStream::connect(front_addr).await.unwrap();
let started = Instant::now(); let started = Instant::now();
client.write_all(&probe).await.unwrap(); client.write_all(&probe).await.unwrap();
@@ -34,6 +34,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -35,6 +35,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -49,6 +49,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -78,17 +79,72 @@ fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fi
"TLS length must fit into record header" "TLS length must fit into record header"
); );
let total_len = 5 + tls_len; const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
let mut handshake = vec![fill; total_len]; const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const TLS_EXTENSION_PADDING: u16 = 0x0015;
handshake[0] = 0x16; const X25519_KEY_SHARE_LEN: usize = 32;
handshake[1] = 0x03;
handshake[2] = 0x01;
handshake[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
let session_id_len: usize = 32; let session_id_len: usize = 32;
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let base_tls_len = 4
+ 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
assert!(
tls_len == base_tls_len || tls_len >= base_tls_len + 4,
"TLS length must leave room for a complete padding extension"
);
if tls_len > base_tls_len {
let padding_len = tls_len - base_tls_len - 4;
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
extensions.resize(extensions.len() + padding_len, fill);
}
let body_len = tls_len - 4;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + tls_len);
handshake.push(0x16);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
let mut digest = computed; let mut digest = computed;
@@ -190,11 +246,9 @@ async fn tls_bad_mtproto_fallback_preserves_wire_and_backend_response() {
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await; read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -260,11 +314,9 @@ async fn tls_bad_mtproto_fallback_keeps_connects_bad_accounting() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -334,11 +386,9 @@ async fn tls_bad_mtproto_fallback_forwards_zero_length_tls_record_verbatim() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -402,11 +452,9 @@ async fn tls_bad_mtproto_fallback_forwards_max_tls_record_verbatim() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -480,11 +528,9 @@ async fn tls_bad_mtproto_fallback_light_fuzz_tls_record_lengths_verbatim() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -585,11 +631,9 @@ async fn tls_bad_mtproto_fallback_concurrent_sessions_are_isolated() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
drop(client_side); drop(client_side);
let _ = tokio::time::timeout(Duration::from_secs(3), handler) let _ = tokio::time::timeout(Duration::from_secs(3), handler)
@@ -659,12 +703,14 @@ async fn tls_bad_mtproto_fallback_forwards_fragmented_client_writes_verbatim() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut chunks = trailing_record.chunks(3);
.write_all(&invalid_mtproto_record) let mut client_payload = invalid_mtproto_record;
.await if let Some(first_chunk) = chunks.next() {
.unwrap(); client_payload.extend_from_slice(first_chunk);
}
client_side.write_all(&client_payload).await.unwrap();
for chunk in trailing_record.chunks(3) { for chunk in chunks {
client_side.write_all(chunk).await.unwrap(); client_side.write_all(chunk).await.unwrap();
} }
@@ -728,11 +774,13 @@ async fn tls_bad_mtproto_fallback_header_fragmentation_bytewise_is_verbatim() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut bytes = trailing_record.iter().copied();
.write_all(&invalid_mtproto_record) let mut client_payload = invalid_mtproto_record;
.await if let Some(first_byte) = bytes.next() {
.unwrap(); client_payload.push(first_byte);
for b in trailing_record.iter().copied() { }
client_side.write_all(&client_payload).await.unwrap();
for b in bytes {
client_side.write_all(&[b]).await.unwrap(); client_side.write_all(&[b]).await.unwrap();
} }
@@ -801,14 +849,16 @@ async fn tls_bad_mtproto_fallback_record_splitting_chaos_is_verbatim() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let chaos = [7usize, 1, 19, 3, 5, 31, 2, 11, 13, 17]; let chaos = [7usize, 1, 19, 3, 5, 31, 2, 11, 13, 17];
let mut pos = 0usize; let mut pos = 0usize;
let mut idx = 0usize; let mut idx = 0usize;
let mut client_payload = invalid_mtproto_record;
let first_step = chaos[idx % chaos.len()];
let first_end = first_step.min(trailing_record.len());
client_payload.extend_from_slice(&trailing_record[..first_end]);
client_side.write_all(&client_payload).await.unwrap();
pos = first_end;
idx += 1;
while pos < trailing_record.len() { while pos < trailing_record.len() {
let step = chaos[idx % chaos.len()]; let step = chaos[idx % chaos.len()];
let end = (pos + step).min(trailing_record.len()); let end = (pos + step).min(trailing_record.len());
@@ -883,11 +933,9 @@ async fn tls_bad_mtproto_fallback_multiple_tls_records_are_forwarded_in_order()
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&r1);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&r1).await.unwrap();
client_side.write_all(&r2).await.unwrap(); client_side.write_all(&r2).await.unwrap();
client_side.write_all(&r3).await.unwrap(); client_side.write_all(&r3).await.unwrap();
@@ -957,11 +1005,9 @@ async fn tls_bad_mtproto_fallback_client_half_close_propagates_eof_to_backend()
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
client_side.shutdown().await.unwrap(); client_side.shutdown().await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
@@ -1028,11 +1074,9 @@ async fn tls_bad_mtproto_fallback_backend_half_close_after_response_is_tolerated
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await; read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(3), accept_task) tokio::time::timeout(Duration::from_secs(3), accept_task)
.await .await
@@ -1089,11 +1133,9 @@ async fn tls_bad_mtproto_fallback_backend_reset_after_clienthello_is_handled() {
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await let write_res = client_side.write_all(&client_payload).await;
.unwrap();
let write_res = client_side.write_all(&trailing_record).await;
assert!( assert!(
write_res.is_ok() || write_res.is_err(), write_res.is_ok() || write_res.is_err(),
"write completion is environment dependent under backend reset" "write completion is environment dependent under backend reset"
@@ -1169,11 +1211,9 @@ async fn tls_bad_mtproto_fallback_backend_slow_reader_preserves_byte_identity()
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
tokio::time::timeout(Duration::from_secs(5), accept_task) tokio::time::timeout(Duration::from_secs(5), accept_task)
.await .await
@@ -1253,11 +1293,9 @@ async fn tls_bad_mtproto_fallback_replay_pressure_masks_replay_without_serverhel
let mut head = [0u8; 5]; let mut head = [0u8; 5];
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16); assert_eq!(head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&trailing_record);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&trailing_record).await.unwrap();
} else { } else {
let mut one = [0u8; 1]; let mut one = [0u8; 1];
let no_server_hello = tokio::time::timeout( let no_server_hello = tokio::time::timeout(
@@ -1351,13 +1389,29 @@ async fn tls_bad_mtproto_fallback_large_multi_record_chaos_under_backpressure()
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side
.write_all(&invalid_mtproto_record)
.await
.unwrap();
let chaos = [5usize, 23, 11, 47, 3, 19, 29, 13, 7, 31]; let chaos = [5usize, 23, 11, 47, 3, 19, 29, 13, 7, 31];
for record in [&a, &b, &c] { let records = [&a, &b, &c];
let mut records_iter = records.iter().copied();
let mut client_payload = invalid_mtproto_record;
if let Some(first_record) = records_iter.next() {
let first_step = chaos[0].min(first_record.len());
client_payload.extend_from_slice(&first_record[..first_step]);
client_side.write_all(&client_payload).await.unwrap();
let mut pos = first_step;
let mut idx = 1usize;
while pos < first_record.len() {
let step = chaos[idx % chaos.len()];
let end = (pos + step).min(first_record.len());
client_side
.write_all(&first_record[pos..end])
.await
.unwrap();
pos = end;
idx += 1;
}
}
for record in records_iter {
let mut pos = 0usize; let mut pos = 0usize;
let mut idx = 0usize; let mut idx = 0usize;
while pos < record.len() { while pos < record.len() {
@@ -1432,11 +1486,9 @@ async fn tls_bad_mtproto_fallback_interleaved_control_and_application_records_ve
.unwrap(); .unwrap();
assert_eq!(tls_response_head[0], 0x16); assert_eq!(tls_response_head[0], 0x16);
client_side let mut client_payload = invalid_mtproto_record;
.write_all(&invalid_mtproto_record) client_payload.extend_from_slice(&ccs);
.await client_side.write_all(&client_payload).await.unwrap();
.unwrap();
client_side.write_all(&ccs).await.unwrap();
client_side.write_all(&app).await.unwrap(); client_side.write_all(&app).await.unwrap();
client_side.write_all(&alert).await.unwrap(); client_side.write_all(&alert).await.unwrap();
@@ -1532,11 +1584,13 @@ async fn tls_bad_mtproto_fallback_many_short_sessions_with_chaos_no_cross_leak()
client_side.read_exact(&mut head).await.unwrap(); client_side.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x16); assert_eq!(head[0], 0x16);
client_side let mut chunks = record.chunks((idx % 9) + 1);
.write_all(&invalid_mtproto_record) let mut client_payload = invalid_mtproto_record;
.await if let Some(first_chunk) = chunks.next() {
.unwrap(); client_payload.extend_from_slice(first_chunk);
for chunk in record.chunks((idx % 9) + 1) { }
client_side.write_all(&client_payload).await.unwrap();
for chunk in chunks {
client_side.write_all(chunk).await.unwrap(); client_side.write_all(chunk).await.unwrap();
} }
@@ -1338,6 +1338,7 @@ async fn direct_relay_abort_midflight_releases_route_gauge() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1448,6 +1449,7 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1570,6 +1572,7 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
1, 1,
@@ -1803,6 +1806,7 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
100, 100,
@@ -1897,6 +1901,7 @@ async fn adversarial_direct_relay_cutover_integrity() {
selected_scope: String::new(), selected_scope: String::new(),
ipv4: None, ipv4: None,
ipv6: None, ipv6: None,
prefer: None,
}], }],
1, 1,
100, 100,
@@ -21,11 +21,59 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
} }
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> { fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32; let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len; let fill = 0x42u8;
let mut handshake = vec![0x42u8; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8; let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
@@ -85,6 +133,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32, timestamp: u32,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -96,6 +147,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0); body.push(0);
let mut ext_blob = Vec::new(); let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
@@ -150,13 +214,7 @@ async fn tls_minimum_viable_length_boundary() {
let rng = SecureRandom::new(); let rng = SecureRandom::new();
let peer: SocketAddr = "192.0.2.1:12345".parse().unwrap(); let peer: SocketAddr = "192.0.2.1:12345".parse().unwrap();
let min_len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1; let exact_min_handshake = make_valid_tls_handshake(&secret, 0);
let mut exact_min_handshake = vec![0x42u8; min_len];
exact_min_handshake[min_len - 1] = 0;
exact_min_handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let digest = sha256_hmac(&secret, &exact_min_handshake);
exact_min_handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.copy_from_slice(&digest);
let res = handle_tls_handshake( let res = handle_tls_handshake(
&exact_min_handshake, &exact_min_handshake,
@@ -171,12 +229,12 @@ async fn tls_minimum_viable_length_boundary() {
.await; .await;
assert!( assert!(
matches!(res, HandshakeResult::Success(_)), matches!(res, HandshakeResult::Success(_)),
"Exact minimum length TLS handshake must succeed" "Minimum valid TLS ClientHello must succeed"
); );
let short_handshake = vec![0x42u8; min_len - 1]; let short_handshake = &exact_min_handshake[..exact_min_handshake.len() - 1];
let res_short = handle_tls_handshake( let res_short = handle_tls_handshake(
&short_handshake, short_handshake,
tokio::io::empty(), tokio::io::empty(),
tokio::io::sink(), tokio::io::sink(),
peer, peer,
@@ -188,7 +246,7 @@ async fn tls_minimum_viable_length_boundary() {
.await; .await;
assert!( assert!(
matches!(res_short, HandshakeResult::BadClient { .. }), matches!(res_short, HandshakeResult::BadClient { .. }),
"Handshake 1 byte shorter than minimum must fail closed" "Handshake 1 byte shorter than minimum valid ClientHello must fail closed"
); );
} }
@@ -1,5 +1,6 @@
use super::*; use super::*;
use crate::crypto::sha256_hmac; use crate::crypto::sha256_hmac;
use crate::protocol::constants::{TLS_RECORD_HANDSHAKE, TLS_VERSION};
use crate::stats::ReplayChecker; use crate::stats::ReplayChecker;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -17,11 +18,59 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
} }
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> { fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32; let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len; let fill = 0x42u8;
let mut handshake = vec![0x42u8; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8; let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
+67 -3
View File
@@ -25,11 +25,59 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
} }
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> { fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32; let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len; let fill = 0x42u8;
let mut handshake = vec![0x42u8; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8; let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
@@ -90,6 +138,9 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
sni_host: &str, sni_host: &str,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -112,6 +163,19 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes()); ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_payload); ext_blob.extend_from_slice(&sni_payload);
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
@@ -24,6 +24,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32, timestamp: u32,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -35,6 +38,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0); body.push(0);
let mut ext_blob = Vec::new(); let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
+94 -25
View File
@@ -10,11 +10,62 @@ use std::time::{Duration, Instant};
use tokio::sync::Barrier; use tokio::sync::Barrier;
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> { fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
let session_id_len: usize = 32; make_valid_tls_handshake_with_fill(secret, timestamp, 0x42)
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len; }
let mut handshake = vec![0x42u8; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8; fn make_valid_tls_handshake_with_fill(secret: &[u8], timestamp: u32, fill: u8) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32;
let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
@@ -34,6 +85,9 @@ fn make_valid_tls_client_hello_with_alpn(
timestamp: u32, timestamp: u32,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -45,6 +99,19 @@ fn make_valid_tls_client_hello_with_alpn(
body.push(0); body.push(0);
let mut ext_blob = Vec::new(); let mut ext_blob = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
@@ -92,6 +159,9 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
sni_host: &str, sni_host: &str,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -114,6 +184,19 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes()); ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_payload); ext_blob.extend_from_slice(&sni_payload);
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
@@ -549,25 +632,6 @@ async fn adversarial_tls_replay_churn_allows_only_unique_digests() {
let replay_checker = Arc::new(ReplayChecker::new(8192, Duration::from_secs(60))); let replay_checker = Arc::new(ReplayChecker::new(8192, Duration::from_secs(60)));
let rng = Arc::new(SecureRandom::new()); let rng = Arc::new(SecureRandom::new());
let make_tagged_handshake = |timestamp: u32, tag: u8| {
let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len;
let mut handshake = vec![tag; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8;
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(&secret, &handshake);
let mut digest = computed;
let ts = timestamp.to_le_bytes();
for i in 0..4 {
digest[28 + i] ^= ts[i];
}
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
.copy_from_slice(&digest);
handshake
};
let mut tasks = Vec::new(); let mut tasks = Vec::new();
// 128 exact duplicates: only one should pass. // 128 exact duplicates: only one should pass.
@@ -596,12 +660,17 @@ async fn adversarial_tls_replay_churn_allows_only_unique_digests() {
})); }));
} }
// 128 unique timestamps: all should pass because HMAC digest differs. // 128 unique ClientHello bodies: all should pass because replay tracks the
// first digest half, while timestamp skew is encoded in the last bytes.
for i in 0..128u16 { for i in 0..128u16 {
let config = Arc::clone(&config); let config = Arc::clone(&config);
let replay_checker = Arc::clone(&replay_checker); let replay_checker = Arc::clone(&replay_checker);
let rng = Arc::clone(&rng); let rng = Arc::clone(&rng);
let handshake = make_tagged_handshake(10_000 + i as u32, (i as u8).wrapping_add(0x80)); let handshake = make_valid_tls_handshake_with_fill(
&secret,
10_000 + i as u32,
(i as u8).wrapping_add(0x80),
);
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(async move {
let peer = SocketAddr::new( let peer = SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(198, 18, 0, ((i % 250) + 1) as u8)), IpAddr::V4(Ipv4Addr::new(198, 18, 0, ((i % 250) + 1) as u8)),
@@ -47,11 +47,59 @@ fn make_valid_mtproto_handshake(
} }
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> { fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let session_id_len: usize = 32; let session_id_len: usize = 32;
let len = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + session_id_len; let fill = 0x42u8;
let mut handshake = vec![0x42u8; len];
handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = session_id_len as u8; let mut extensions = Vec::new();
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
extensions.extend_from_slice(&key_share_extension);
let body_len = 2
+ 32
+ 1
+ session_id_len
+ 2
+ TLS_AES_128_GCM_SHA256.len()
+ 1
+ 1
+ 2
+ extensions.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[fill; 32]);
body.push(session_id_len as u8);
body.extend_from_slice(&[fill; 32]);
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
body.push(1);
body.push(0);
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
body.extend_from_slice(&extensions);
assert_eq!(body.len(), body_len);
let mut handshake = Vec::with_capacity(5 + 4 + body_len);
handshake.push(TLS_RECORD_HANDSHAKE);
handshake.extend_from_slice(&[0x03, 0x01]);
handshake.extend_from_slice(&((4 + body_len) as u16).to_be_bytes());
handshake.push(0x01);
let body_len_bytes = (body_len as u32).to_be_bytes();
handshake.extend_from_slice(&body_len_bytes[1..4]);
handshake.extend_from_slice(&body);
// The proxy authenticates TLS-fronted clients through the random field.
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0); handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
let computed = sha256_hmac(secret, &handshake); let computed = sha256_hmac(secret, &handshake);
@@ -72,6 +120,9 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
sni_host: &str, sni_host: &str,
alpn_protocols: &[&[u8]], alpn_protocols: &[&[u8]],
) -> Vec<u8> { ) -> Vec<u8> {
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
const X25519_KEY_SHARE_LEN: usize = 32;
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&TLS_VERSION);
body.extend_from_slice(&[0u8; 32]); body.extend_from_slice(&[0u8; 32]);
@@ -93,6 +144,19 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes()); ext_blob.extend_from_slice(&(sni_payload.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&sni_payload); ext_blob.extend_from_slice(&sni_payload);
let mut key_share = Vec::new();
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
key_share.push(9);
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
let mut key_share_extension = Vec::new();
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
key_share_extension.extend_from_slice(&key_share);
ext_blob.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
ext_blob.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
ext_blob.extend_from_slice(&key_share_extension);
if !alpn_protocols.is_empty() { if !alpn_protocols.is_empty() {
let mut alpn_list = Vec::new(); let mut alpn_list = Vec::new();
for proto in alpn_protocols { for proto in alpn_protocols {
@@ -34,7 +34,7 @@ fn loop_guard_unspecified_bind_uses_interface_inventory() {
"mask.example", "mask.example",
443, 443,
local, local,
Some(resolved), &[resolved],
&interfaces, &interfaces,
)); ));
} }
@@ -25,7 +25,7 @@ async fn adversarial_parallel_cold_miss_performs_single_interface_refresh() {
let barrier = std::sync::Arc::clone(&barrier); let barrier = std::sync::Arc::clone(&barrier);
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(async move {
barrier.wait().await; barrier.wait().await;
is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await
})); }));
} }
@@ -17,8 +17,8 @@ async fn tdd_repeated_local_listener_checks_do_not_repeat_interface_enumeration_
let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr"); let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr");
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await; let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await;
let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, None).await; let _ = is_mask_target_local_listener_async("127.0.0.1", 443, local_addr, &[]).await;
assert_eq!( assert_eq!(
local_interface_enumerations_for_tests(), local_interface_enumerations_for_tests(),
@@ -35,7 +35,7 @@ async fn tdd_non_local_port_short_circuit_does_not_enumerate_interfaces() {
reset_local_interface_enumerations_for_tests(); reset_local_interface_enumerations_for_tests();
let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr"); let local_addr: SocketAddr = "0.0.0.0:443".parse().expect("valid local addr");
let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, None).await; let is_local = is_mask_target_local_listener_async("127.0.0.1", 8443, local_addr, &[]).await;
assert!( assert!(
!is_local, !is_local,
@@ -0,0 +1,111 @@
use super::*;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::time::{Duration, Instant};
const PERF_TOTAL_BYTES: usize = 64 * 1024 * 1024;
struct PatternReader {
remaining: usize,
chunk: usize,
read_calls: AtomicUsize,
}
impl PatternReader {
fn new(total: usize, chunk: usize) -> Self {
Self {
remaining: total,
chunk,
read_calls: AtomicUsize::new(0),
}
}
fn read_calls(&self) -> usize {
self.read_calls.load(Ordering::Relaxed)
}
}
impl AsyncRead for PatternReader {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
self.read_calls.fetch_add(1, Ordering::Relaxed);
if self.remaining == 0 {
return Poll::Ready(Ok(()));
}
let take = self.remaining.min(self.chunk).min(buf.remaining());
if take == 0 {
return Poll::Ready(Ok(()));
}
static PATTERN: [u8; MASK_BUFFER_MAX_SIZE] = [0xA5; MASK_BUFFER_MAX_SIZE];
buf.put_slice(&PATTERN[..take]);
self.remaining -= take;
Poll::Ready(Ok(()))
}
}
#[derive(Default)]
struct CountingWriter {
written: usize,
}
impl AsyncWrite for CountingWriter {
fn poll_write(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
self.written = self.written.saturating_add(buf.len());
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
#[ignore = "manual benchmark: throughput-sensitive and host-dependent"]
async fn masking_copy_with_idle_timeout_manual_throughput() {
let mut reader = PatternReader::new(PERF_TOTAL_BYTES, MASK_BUFFER_MAX_SIZE);
let mut writer = CountingWriter::default();
let started = Instant::now();
let outcome = copy_with_idle_timeout(
&mut reader,
&mut writer,
PERF_TOTAL_BYTES,
true,
Duration::from_secs(30),
)
.await;
let elapsed = started.elapsed();
let mb = PERF_TOTAL_BYTES as f64 / (1024.0 * 1024.0);
let mbps = mb / elapsed.as_secs_f64();
assert_eq!(outcome.total, PERF_TOTAL_BYTES);
assert_eq!(writer.written, PERF_TOTAL_BYTES);
assert!(
!outcome.ended_by_eof,
"manual throughput run should terminate at byte cap"
);
eprintln!(
"masking manual throughput: bytes={} elapsed_ms={} mib_per_sec={:.2} read_calls={}",
PERF_TOTAL_BYTES,
elapsed.as_millis(),
mbps,
reader.read_calls()
);
}

Some files were not shown because too many files have changed in this diff Show More