Compare commits

...

204 Commits

Author SHA1 Message Date
Alexey 8dbd24b11b Merge pull request #897 from telemt/bump
Bump -> 3.5.0
2026-08-22 16:39:13 +03:00
Alexey 0b1cba1042 Bump -> 3.5.0 2026-08-22 16:38:41 +03:00
Alexey ca3063005c Merge pull request #896 from telemt/flow/3.5.0
Flow/3.5.0
2026-08-22 16:36:48 +03:00
Alexey fb47ad149c Rustfmt 2026-08-22 16:31:49 +03:00
Alexey bb0d3ba927 Manifest Revision + Listener Reload Semantics + MSS/SYN-limit Safety 2026-08-22 16:20:55 +03:00
Alexey 7f4b87bea4 Redesign runtime w/ include-aware config + Module Split + Listener Lifecycle + Atomic Reload 2026-08-22 16:13:25 +03:00
Alexey f9910fb29e Manifest Revision + Include Ownership + Atomic Reload Reservation in Docs 2026-08-22 13:50:34 +03:00
Alexey 189e10800a Hardened listener reload + config persistence + SYN Limit startup safety 2026-08-22 13:45:57 +03:00
Alexey 96d467a400 Merge pull request #895 from telemt/flow-api-patch-server-listeners
Allow PATCH/GET of server.listeners via config API
2026-08-22 10:10:34 +03:00
Alexey 32ff9e16c1 Merge pull request #878 from l0nelynx/feat/config-api-server-listeners
feat(api): allow GET/PATCH of server.listeners via config API
2026-08-22 10:09:17 +03:00
Alexey 5e70455ca2 Merge pull request #891 from telemt/flow-synlimit-mss-24
Raised synlimit_burst -> 24 by default
2026-08-12 21:27:11 +03:00
Alexey c488970569 Merge pull request #877 from anton-vinogradov/fix/synlimit-default-burst
fix(synlimit): raise default synlimit_burst from 1 to 24
2026-08-12 21:26:16 +03:00
Alexey 08127f318d Merge pull request #890 from telemt/flow-pf
BSD PF-backend for Synlimit
2026-08-12 21:25:13 +03:00
Alexey 25e76a3afb Merge pull request #884 from vgrebenschikov/feature/synlimit-pf-support
Add PF synlimit backend
2026-08-12 21:17:40 +03:00
Alexey 3668190100 Merge pull request #889 from telemt/flow-bulk-mss
Fragmentation only for first TLS-F
2026-08-12 21:05:42 +03:00
Alexey 064cd0da61 Merge branch 'flow/3.5.0' into flow-bulk-mss 2026-08-12 21:05:14 +03:00
Alexey 7289621458 Update listeners.rs 2026-08-12 21:03:51 +03:00
Dmitry 723da8c34c Merge remote-tracking branch 'upstream/main' into feat/config-api-server-listeners 2026-07-20 11:57:05 +00:00
Vladimir Grebenshchikov e2eec67a33 Add PF synlimit backend
Implement synlimit = "pf" for PF-based firewalls using per-listener source tracking rules in a Telemt-owned anchor.

PF evaluates max-src-conn-rate through source tracking when creating
tates for completed TCP connections. Telemt installs a rate-limited pass
rule followed by a reject fallback rule in the listener anchor:

  - under-limit new connections create PF state and pass;
  - over-limit new connections fall through to block return-rst;
  - access resumes automatically when the source rate drops below the configured window.

Keep Linux iptables/nftables behavior unchanged.
PF maps synlimit_hitcount / synlimit_seconds to max-src-conn-rate and intentionally has
no direct equivalent for Linux-only burst/hashlimit knobs.
2026-07-20 09:43:46 +02:00
Alexey d851200e47 Merge pull request #883 from telemt/flow/3.4.25
Flow/3.4.25
2026-07-20 10:36:20 +03:00
Alexey a5216d77fb Rustfmt 2026-07-19 18:41:23 +03:00
Alexey 5b5cd952c8 Bump -> 3.4.25 2026-07-19 18:36:04 +03:00
Alexey a40578b278 Merge pull request #882 from telemt/flow-runtime-reload
In-Runtime Reload
2026-07-19 17:26:43 +03:00
Alexey bd99493622 Merge pull request #881 from astronaut808/feature/handshake-stage-accounting
add handshake failure stage accounting
2026-07-19 17:20:28 +03:00
Alexey d862deebb2 Fix handshake stage attribution and bound counters
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-19 17:20:18 +03:00
Alexey 0d869d716c Merge branch 'flow-runtime-reload' into feature/handshake-stage-accounting 2026-07-19 17:18:18 +03:00
Alexey 1e85b91ad3 LCOV artifact for pull request + manual start
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-19 16:11:31 +03:00
Alexey 4679bdcfd5 Atomic Maestro Sessions + Shutdown gate
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-19 16:10:42 +03:00
astronaut808 7291c3192c docs handshake failure stage diagnostics 2026-07-18 21:25:40 +05:00
astronaut808 4a5dc0b21b add handshake failure stage accounting 2026-07-18 20:46:00 +05:00
Alexey fabd98ce89 Cfg Test for handle_bad_client 2026-07-18 14:31:43 +03:00
Alexey 7df3dab5e8 Update API.md
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-18 14:30:03 +03:00
Alexey c6f40e3717 Harden Maestro reload lifecycle and readiness barriers
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-18 14:27:24 +03:00
Alexey 61ec46c2db Merge pull request #871 from xaer981/fix/client-mss-bulk
fix(server): client_mss_bulk — fragment only ServerHello, keep bulk MSS from start
2026-07-18 14:02:03 +03:00
Alexey 91e05265be Docs for API In-Proccess Reload
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-17 23:56:42 +03:00
Alexey 991d5b2c38 Maestro: add in-process runtime generation reload
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-17 23:56:00 +03:00
l0nelynx 9248076572 style: rustfmt config_edit test helper chain 2026-07-17 22:49:18 +03:00
l0nelynx 60c0242238 ci: trigger fork workflows 2026-07-17 22:39:38 +03:00
l0nelynx f1d08c4ae6 feat(api): allow PATCH/GET of server.listeners via config API
Add a nested field-level allowlist under [server] so listeners can be managed remotely without exposing server.api auth_header or other bind-identity fields.
2026-07-16 22:44:42 +03:00
Anton Vinogradov f770756cba fix(synlimit): raise default synlimit_burst from 1 to 24
The generic-path SYN limiter default of burst=1 admits only one new
connection per source IP, then refills at 48/minute (0.8/s). Telegram
clients open several connections at once on startup/reconnect, so the
extra SYNs are rejected with TCP RST until a token accrues — clients
intermittently fail to connect until the app is restarted.

The iOS fast-path (meta length 64) does not match real-world traffic
behind carrier NAT/NAT64 (SYNs arrive as 60 bytes), so the generic path
governs all clients, iPhone included. Raise its default burst to 24 —
the value already used by the iOS path — so a legitimate startup fan-out
passes. Steady-state rate (48/minute) is unchanged, preserving the
anti-TSPU posture.

Updates the default constant, the default-assertion test, and the EN/RU/DE
CONFIG_PARAMS docs.
2026-07-16 13:04:23 +03:00
Alexey 1f9c82c924 Merge pull request #875 from telemt/flow-aescr
Bound ME writer queues by resident payload bytes + Enable expanded AES key schedule zeroization
2026-07-14 22:31:27 +03:00
Alexey 73afeccae1 Rustfmt 2026-07-13 12:20:24 +03:00
Yaroslav Petrovskikh cd38e8ca80 chore: fix rustfmt import order 2026-07-13 11:27:24 +03:00
Yaroslav Petrovskikh 5e9d99bb4d fix(server): fragment only the initial FakeTLS response 2026-07-13 11:26:48 +03:00
Alexey feb51cbf57 Fix RustCrypto zeroize feature wiring 2026-07-12 09:27:15 +03:00
Alexey 8c65cd868c Enable expanded AES key schedule zeroization
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-11 21:43:01 +03:00
Alexey ea296bbdc8 Replace per-session pool trimming with pressure hysteresis
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-11 21:36:01 +03:00
Alexey fb042f826e Optimize crypto and Fake-TLS buffer residency
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-11 21:22:17 +03:00
Alexey 96425f15c8 Bound Direct relay buffers with an adaptive global memory envelope
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-11 20:56:54 +03:00
Alexey d4c4980e5a Bound ME writer queues by resident payload bytes
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-11 18:43:42 +03:00
Alexey 893ce0cf36 Hold C2ME byte permits through ME writer completion
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-10 16:35:39 +03:00
Alexey 2ac93c6d49 Update Cargo.lock 2026-07-10 11:47:09 +03:00
Alexey a51e58009b Update Cargo.toml 2026-07-10 11:46:33 +03:00
Alexey d523406c0a Merge pull request #869 from telemt/flow
Regression coverage + Synlimit netfilter rules per-target + CidrRateLimitKey with IpNetwork + Telemt-Referenz der Konfigurationsparameter
2026-07-10 11:39:22 +03:00
Alexey 5b3ad0096b Create CONFIG_PARAMS.de.md 2026-07-09 22:42:07 +03:00
Alexey b587fdbf94 Update CONFIG_PARAMS.ru.md 2026-07-08 20:52:26 +03:00
Alexey 77a45e509a Update CONFIG_PARAMS.en.md 2026-07-07 22:46:03 +03:00
Alexey b8be805aed Rustfmt 2026-07-06 20:12:36 +03:00
Alexey a1ebd44cee Update load_basic_tests.rs 2026-07-05 19:58:37 +03:00
Alexey 25d02a8e0e Update traffic_limiter.rs 2026-07-04 15:45:51 +03:00
Alexey 3375017460 Add the new key to the hot-reload snapshot type 2026-07-04 13:11:08 +03:00
Alexey 25e0abae8a Validate duplicate normalized auto-templates 2026-07-04 12:35:22 +03:00
Alexey 50538d234e CidrRateLimitKey with IpNetwork parsing and serialization added 2026-07-04 10:54:55 +03:00
Alexey e3a7be6786 Update CONTRIBUTING.md 2026-07-03 23:39:46 +03:00
Alexey 3fc2877205 Reset ports configuration in merge docker-compose file: merge pull request #866 from dvrass/patch-1
Reset ports configuration in merge docker-compose file
2026-07-03 23:34:04 +03:00
Alexey cd1dc2f4c9 Update docker-compose.host-netfilter.yml 2026-07-03 23:32:30 +03:00
Alexey 451227da60 Namespace synlimit netfilter rules per target set
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-07-01 01:47:14 +03:00
dvrass f55d8479e3 Reset ports configuration in merge docker-compose file
Reset ports configuration in docker-compose file for network_mode:host
2026-06-30 13:23:56 +03:00
Alexey 81ae483201 Add regression coverage for ME routing, D2C padding, synlimit, and MSS bulk validation
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-30 13:13:11 +03:00
Alexey ed1895d6df Merge pull request #865 from telemt/flow
Secure + VersionD Outbound Paddings Fix
2026-06-29 17:37:13 +03:00
Alexey 88d161a5e9 Bump -> 3.4.22 2026-06-29 16:38:09 +03:00
Alexey a0ac108807 Secure + VersionD Outbound Paddings Fix 2026-06-29 13:56:16 +03:00
Alexey 809352fac5 Merge pull request #864 from telemt/flow
Restore ME writer source IP for initial proxy request binding
2026-06-29 12:51:04 +03:00
Alexey 22627b498d Bump -> 3.4.21 2026-06-29 12:44:03 +03:00
Alexey b9c5c71dbc Restore ME writer source IP for initial proxy request binding 2026-06-29 12:37:31 +03:00
Alexey 7aee991416 Synlimit V2 + Split config loader helpers into modules + Restore ME D2C queue-drain flushing: merge pull request #863 from telemt/flow
Synlimit V2 + Split config loader helpers into modules + Restore ME D2C queue-drain flushing
2026-06-29 11:44:29 +03:00
Alexey 9a9fd3f55d Update d2c.rs 2026-06-29 11:37:30 +03:00
Alexey 3a5fe31262 Rustfmt 2026-06-29 11:01:36 +03:00
Alexey 82f63d0d8a Split config loader helpers into focused modules 2026-06-28 17:25:03 +03:00
Alexey fce75163b0 Rustfmt 2026-06-28 15:45:53 +03:00
Alexey fe56621a83 Delete synlimit_control.rs 2026-06-28 15:43:26 +03:00
Alexey 1f2910f5bc Update crypto_bench.rs 2026-06-28 15:42:53 +03:00
Alexey d67e7c5a6f Update mod.rs 2026-06-28 15:29:54 +03:00
Alexey 558f352a57 Synlimit V2 2026-06-28 12:53:28 +03:00
Alexey 1ee9a234d7 Merge pull request #859 from CryZFix/main
fixed a docker build bug for CI or docker-compose build variations
2026-06-25 19:24:08 +03:00
CryZFix 2e13f89f6d fixed a docker build bug for CI or docker-compose build variations 2026-06-25 16:24:42 +04:00
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
248 changed files with 39898 additions and 16284 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Coverage
on:
pull_request:
branches: [ "*" ]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
coverage:
name: LLVM coverage report
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-llvm-cov-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-llvm-cov-
${{ runner.os }}-cargo-
- name: Install cargo-llvm-cov
run: cargo install --locked cargo-llvm-cov || true
- name: Generate LCOV report
run: cargo llvm-cov --locked --lcov --output-path lcov.info
- name: Upload LCOV report
uses: actions/upload-artifact@v4
with:
name: telemt-lcov
path: lcov.info
+13 -11
View File
@@ -1,18 +1,20 @@
# Issues
## Warnung
### Warnung
Before opening Issue, if it is more question than problem or bug - ask about that [in our chat](https://t.me/telemtrs)
## What it is not
- NOT Question and Answer
- NOT Helpdesk
***Each of your Issues triggers attempts to reproduce problems and analyze them, which are done manually by people***
Issues is **NOT** about:
- Question and Answer
- Helpdesk
- Configuration or Intergraion Support
---
# Pull Requests
## General
### General
- ONLY signed and verified commits
- ONLY from your name
- DO NOT commit with `codex`, `claude`, or other AI tools as author/committer
@@ -20,7 +22,7 @@ Before opening Issue, if it is more question than problem or bug - ask about tha
---
## Definition of Ready (MANDATORY)
### Definition of Ready (MANDATORY)
A Pull Request WILL be ignored or closed if:
@@ -32,14 +34,14 @@ A Pull Request WILL be ignored or closed if:
---
## Blessed Principles
### Blessed Principles
- PR must build
- PR must pass tests
- PR must be understood by author
---
## AI Usage Policy
### AI Usage Policy
AI tools (Claude, ChatGPT, Codex, DeepSeek, etc.) are allowed as **assistants**, NOT as decision-makers.
@@ -60,7 +62,7 @@ PRs that look like unverified AI dumps WILL be closed
---
## Maintainer Policy
### Maintainer Policy
Maintainers reserve the right to:
@@ -72,7 +74,7 @@ Respect the reviewers time
---
## Enforcement
### Enforcement
Pull Requests that violate project standards may be closed without review.
Generated
+370 -483
View File
File diff suppressed because it is too large Load Diff
+59 -57
View File
@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.4.12"
version = "3.5.0"
edition = "2024"
[features]
@@ -8,88 +8,90 @@ redteam_offline_expected_fail = []
[dependencies]
# C
libc = "0.2"
libc = "0.2.186"
# Async runtime
tokio = { version = "1.42", features = ["full", "tracing"] }
tokio-util = { version = "0.7", features = ["full"] }
tokio = { version = "1.52.3", features = ["full", "tracing"] }
tokio-util = { version = "0.7.18", features = ["full"] }
# Crypto
aes = "0.8"
ctr = "0.9"
cbc = "0.1"
sha2 = "0.10"
sha1 = "0.10"
md-5 = "0.10"
hmac = "0.12"
crc32fast = "1.4"
crc32c = "0.6"
zeroize = { version = "1.8", features = ["derive"] }
subtle = "2.6"
static_assertions = "1.1"
aes = { version = "0.8.4", features = ["zeroize"] }
ctr = { version = "0.9.2", features = ["zeroize"] }
cbc = "0.1.2"
sha2 = "0.10.9"
sha1 = "0.10.6"
md-5 = "0.10.6"
hmac = "0.12.1"
crc32fast = "1.5.0"
crc32c = "0.6.8"
zeroize = { version = "1.9.0", features = ["derive"] }
subtle = "2.6.1"
static_assertions = "1.1.0"
ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] }
# Network
socket2 = { version = "0.6", features = ["all"] }
nix = { version = "0.31", default-features = false, features = [
socket2 = { version = "0.6.4", features = ["all"] }
nix = { version = "0.31.3", default-features = false, features = [
"net",
"user",
"process",
"fs",
"signal",
] }
shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] }
shadowsocks = { version = "1.24.0", features = ["aead-cipher-2022"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "1.0"
x509-parser = "0.18"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
toml = "1.1"
x509-parser = "0.18.1"
# Utils
bytes = "1.9"
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
parking_lot = "0.12"
dashmap = "6.1"
arc-swap = "1.7"
lru = "0.16"
rand = "0.10"
chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
base64 = "0.22"
url = "2.5"
regex = "1.11"
crossbeam-queue = "0.3"
num-bigint = "0.4"
num-traits = "0.2"
x25519-dalek = "2"
anyhow = "1.0"
bytes = "1.12.0"
thiserror = "2.0.18"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-appender = "0.2.5"
parking_lot = "0.12.5"
dashmap = "6.2.1"
arc-swap = "1.9.1"
lru = "0.16.4"
rand = "0.10.1"
chrono = { version = "0.4.45", features = ["serde"] }
hex = "0.4.3"
base64 = "0.22.1"
url = "2.5.8"
regex = "1.12.4"
crossbeam-queue = "0.3.12"
num-bigint = "0.4.6"
num-traits = "0.2.19"
x25519-dalek = "2.0.1"
anyhow = "1.0.102"
# HTTP
reqwest = { version = "0.13", features = ["rustls"], default-features = false }
notify = "8.2"
ipnetwork = { version = "0.21", features = ["serde"] }
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] }
http-body-util = "0.1"
httpdate = "1.0"
tokio-rustls = { version = "0.26", default-features = false, features = [
reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
notify = "8.2.0"
ipnetwork = { version = "0.21.1", features = ["serde"] }
hyper = { version = "1.10.1", features = ["server", "http1"] }
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
http-body-util = "0.1.3"
httpdate = "1.0.3"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"tls12",
] }
rustls = { version = "0.23", default-features = false, features = [
rustls = { version = "0.23.41", default-features = false, features = [
"std",
"tls12",
"ring",
] }
webpki-roots = "1.0"
webpki-roots = "1.0.8"
[dev-dependencies]
tokio-test = "0.4"
criterion = "0.8"
proptest = "1.4"
futures = "0.3"
tokio-test = "0.4.5"
criterion = "0.8.2"
proptest = "1.11.0"
futures = "0.3.32"
tempfile = "3.27.0"
[[bench]]
name = "crypto_bench"
+13 -3
View File
@@ -55,6 +55,16 @@ RUN set -eux; \
strip --strip-unneeded /telemt || true; \
rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt
RUN --mount=type=bind,target=/tmp \
mkdir -p /app && \
if [ -f /tmp/config.toml ]; then \
cp /tmp/config.toml /app/config.toml; \
elif [ -f /tmp/config/config.toml ]; then \
cp /tmp/config/config.toml /app/config.toml; \
else \
echo "Config file not found" && exit 1; \
fi
# ==========================
# Debug Image
# ==========================
@@ -73,7 +83,7 @@ RUN set -eux; \
WORKDIR /app
COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml
COPY ./config/config.toml /app/config.toml
EXPOSE 443 9090 9091
@@ -99,7 +109,7 @@ RUN set -eux; \
WORKDIR /app
COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml
COPY --from=minimal /app/config.toml /app/config.toml
EXPOSE 443 9090 9091
@@ -116,7 +126,7 @@ FROM gcr.io/distroless/static-debian12 AS prod
WORKDIR /app
COPY --from=minimal /telemt /app/telemt
COPY config.toml /app/config.toml
COPY --from=minimal /app/config.toml /app/config.toml
USER nonroot:nonroot
+4 -4
View File
@@ -4,13 +4,13 @@
[🇷🇺 README на русском](https://github.com/telemt/telemt/blob/main/README.ru.md)
***Löst Probleme, bevor andere überhaupt wissen, dass sie existieren*** / ***It solves problems before others even realize they exist***
> [!NOTE]
>
> Fixed TLS ClientHello is now available in official clients for Desktop / Android / iOS
> From June 5th, 2026: we are already analyzing the causes of a new wave of "malfunctions"
>
> To work with EE-MTProxy, please update your client!
> Telegram Clients TLS ClientHello has been banned by JA4/JA4+ Fingerprint: we are already looking for ways to solve this problem
>
> You can try build your client with our Telegram Devlibrary - [tdlib-obf](https://github.com/telemt/tdlib-obf)
<p align="center">
<a href="https://t.me/telemtrs">
+28 -38
View File
@@ -1,57 +1,52 @@
# 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]
>
> Исправленный TLS ClientHello доступен в Telegram для настольных ПК, Android и iOS.
> Клиенты Telegram подвергаются блокировке по JA3-отпечатку; мы ищем варианты решения этой проблемы
>
> Пожалуйста, обновите клиентское приложение для работы с EE-MTProxy.
> Вы можете попробовать собрать свой клиент с нашей Telegram Devlibrary — [tdlib-obf](https://github.com/telemt/tdlib-obf)
<p align="center">
<a href="https://t.me/telemtrs">
<img src="/docs/assets/telegram_button.svg" width="150"/>
<img src="https://github.com/user-attachments/assets/30b7e7b9-974a-4e3d-aab6-b58a85de4507" width="240"/>
</a>
</p>
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust. Он полностью реализует официальный алгоритм прокси Telegram и добавляет множество улучшений для продакшена:
**Telemt** — это быстрый, безопасный и функциональный сервер, написанный на Rust: он полностью реализует официальный алгоритм Telegram прокси и добавляет множество различных улучшений
## Установка и обновление одной командой
```bash
curl -fsSL https://raw.githubusercontent.com/telemt/telemt/main/install.sh | sh
```
- [Инструкция по быстрому запуску](docs/Quick_start/QUICK_START_GUIDE.ru.md)
- [Quick Start Guide](docs/Quick_start/QUICK_START_GUIDE.en.md)
Реализация **TLS-fronting** максимально приближена к поведению реального HTTPS-трафика (подробнее - [FAQ](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров)).
## Функционал
Наша реализация **TLS-fronting** одна из наиболее глубоко отлаженных, продвинутых и почти поведенчески неотличима от настоящего: мы уверены, что сделали это правильно - [см. доказательства в нашей проверке](docs/FAQ.ru.md#распознаваемость-для-dpi-и-сканеров).
***Middle-End Pool*** оптимизирован для высокой производительности.
Наша архитектура ***Middle-End Pool*** в стандартных сценариях самая производительная, по сравнению с другими реализациями подключения к Middle-End прокси: не кардинально, но достаточно
- Поддержка всех режимов MTProto proxy:
- Полная поддержа всех официальных режимов MTProto proxy:
- Classic;
- Secure (префикс `dd`);
- Fake TLS (префикс `ee` + SNI fronting);
- Secure — с префиксом `dd`;
- Fake TLS — с префиксом `ee` + SNI fronting;
- Защита от replay-атак;
- Маскировка трафика (перенаправление неизвестных подключений на реальные сайты);
- Настраиваемые keepalive, таймауты, IPv6 и «быстрый режим»;
- Опциональная маскировка трафика: перенаправление неизвестных подключений на реальные сайты;
- Настраиваемые keepalive, таймауты, IPv6 и "быстрый режим";
- Корректное завершение работы (Ctrl+C);
- Подробное логирование через `trace` и `debug`.
- Подробное логирование через `trace` и `debug` с помощью `RUST_LOG`.
# Подробнее о Telemt
- [FAQ](#faq)
- [Архитектура](docs/Architecture)
- [Параметры конфигурационного файла](docs/Config_params)
- [Сборка](#build)
- [Установка на BSD](#%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D0%BD%D0%B0-bsd)
- [Почему Rust?](#why-rust)
## ЧаВо
- [Часто задаваемые вопросы](docs/FAQ.ru.md)
## FAQ
- [FAQ RU](docs/FAQ.ru.md)
- [FAQ EN](docs/FAQ.en.md)
# Узнайте больше о Telemt
- [Наша архитектура](docs/Architecture)
- [Все конфигурационные параметры](docs/Config_params)
- [Как собрать Telemt самостоятельно?](#сборка)
- [Установка на BSD](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md)
- [Почему Rust?](#почему-rust)
## Сборка
```bash
@@ -63,7 +58,7 @@ cd telemt
cargo build --release
# В текущем release-профиле используется lto = "fat" для максимальной оптимизации (см. Cargo.toml).
# На системах с малым объёмом RAM (~1 ГБ) можно переопределить это значение на "thin".
# На системах с малым объёмом ОЗУ (~1 ГБ) можно переопределить это значение на "thin".
# Перейдите в каталог /bin
mv ./target/release/telemt /bin
@@ -73,24 +68,19 @@ chmod +x /bin/telemt
telemt config.toml
```
## Установка на BSD
- Руководство по сборке и настройке на английском языке [OpenBSD Guide (EN)](docs/Quick_start/OPENBSD_QUICK_START_GUIDE.en.md);
- Пример rc.d скрипта: [contrib/openbsd/telemt.rcd](contrib/openbsd/telemt.rcd);
- Поддержка sandbox с `pledge(2)` и `unveil(2)` пока не реализована.
## Почему Rust?
- Надёжность для долгоживущих процессов;
- Детерминированное управление ресурсами (RAII);
- Надёжность при длительной работе и идемпотентное поведение;
- Детерминированное управление ресурсами — RAII;
- Отсутствие сборщика мусора;
- Безопасность памяти;
- Безопасность памяти и меньше поверхность атаки;
- Асинхронная архитектура Tokio.
## Поддержать Telemt
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разработанное в свободное время.
Telemt — это бесплатное программное обеспечение с открытым исходным кодом, разрабатываемое в свободное время.
Если оно оказалось вам полезным, вы можете поддержать дальнейшую разработку.
Принимаемые криптовалюты (BTC, ETH, USDT, 350+ и другие):
Любая криптовалюта (BTC, ETH, USDT и 350+ других):
<p align="center">
<a href="https://nowpayments.io/donation?api_key=2bf1afd2-abc2-49f9-a012-f1e715b37223" target="_blank" rel="noreferrer noopener">
+15 -3
View File
@@ -1,12 +1,24 @@
// Cryptobench
use criterion::{Criterion, black_box, criterion_group};
use criterion::{Criterion, criterion_group, criterion_main};
use std::hint::black_box;
#[allow(unused_imports)]
#[path = "../src/crypto/aes.rs"]
mod aes_impl;
#[allow(unused_imports)]
#[path = "../src/error.rs"]
mod error;
use aes_impl::AesCtr;
fn bench_aes_ctr(c: &mut Criterion) {
c.bench_function("aes_ctr_encrypt_64kb", |b| {
let data = vec![0u8; 65536];
b.iter(|| {
let mut enc = AesCtr::new(&[0u8; 32], 0);
black_box(enc.encrypt(&data))
black_box(enc.encrypt(black_box(data.as_slice())))
})
});
}
criterion_group!(benches, bench_aes_ctr);
criterion_main!(benches);
+1 -2
View File
@@ -4,7 +4,6 @@ services:
context: .
target: prod-netfilter
network_mode: host
ports: []
ports: !reset []
cap_add:
- NET_BIND_SERVICE
- NET_ADMIN
+12 -6
View File
@@ -10,12 +10,15 @@ services:
- "443:443"
- "127.0.0.1:9090:9090"
- "127.0.0.1:9091:9091"
# Allow caching 'proxy-secret' in read-only container
working_dir: /etc/telemt
# Working dir uses tmpfs for caching 'proxy-secret' at runtime.
# Config is mounted as a directory (not a single file) so the API can
# atomically update config.toml via write-temp → rename within the same FS.
working_dir: /run/telemt
command: ["/etc/telemt/config.toml"]
volumes:
- ./config.toml:/etc/telemt/config.toml:ro
- ./config:/etc/telemt:rw
tmpfs:
- /etc/telemt:rw,mode=1777,size=4m
- /run/telemt:rw,mode=1777,size=4m
environment:
- RUST_LOG=info
healthcheck:
@@ -24,8 +27,6 @@ services:
timeout: 5s
retries: 3
start_period: 20s
# Uncomment this line if you want to use host network for IPv6, but bridge is default and usually better
# network_mode: host
cap_drop:
- ALL
cap_add:
@@ -37,3 +38,8 @@ services:
nofile:
soft: 65536
hard: 262144
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
+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]].enabled` | alle Upstreams | `bool` | nein | `true` | Deaktivierte Einträge werden beim Start ignoriert. |
| `[[upstreams]].scopes` | alle Upstreams | `String` | nein | `""` | Komma-separierte Scope-Tags für Request-Routing. |
| `[[upstreams]].ipv4` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv4-DC-Ziele für diesen Upstream. |
| `[[upstreams]].ipv6` | alle Upstreams | `Option<bool>` | nein | `auto` | Erlaubt IPv6-DC-Ziele für diesen Upstream, inklusive Proxy-Egress unabhängig vom Host-IPv6. |
| `[[upstreams]].prefer` | alle Upstreams | `Option<4 \| 6>` | nein | effective `[network].prefer` | Pro-Upstream-Präferenz für die DC-Ziel-Adressfamilie. |
| `interface` | `direct` | `Option<String>` | nein | `null` | Interface-Name (z. B. `eth0`) oder lokale Literal-IP. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | nein | `null` | Explizite Source-IP-Kandidaten (strikter Vorrang vor `interface`). |
| `address` | `socks4` | `String` | ja | n/a | SOCKS4-Server (`ip:port` oder `host:port`). |
+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]].enabled` | all upstreams | `bool` | no | `true` | Disabled entries are ignored at startup. |
| `[[upstreams]].scopes` | all upstreams | `String` | no | `""` | Comma-separated scope tags for request-level routing. |
| `[[upstreams]].ipv4` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv4 DC targets for this upstream. |
| `[[upstreams]].ipv6` | all upstreams | `Option<bool>` | no | `auto` | Allow IPv6 DC targets for this upstream, including proxy egress independent of host IPv6. |
| `[[upstreams]].prefer` | all upstreams | `Option<4 \| 6>` | no | effective `[network].prefer` | Per-upstream DC target family preference. |
| `interface` | `direct` | `Option<String>` | no | `null` | Interface name (e.g. `eth0`) or literal local IP for bind selection. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | no | `null` | Explicit local source IP candidates (strict priority over `interface`). |
| `address` | `socks4` | `String` | yes | n/a | SOCKS4 server endpoint (`ip:port` or `host:port`). |
+3
View File
@@ -86,6 +86,9 @@
| `[[upstreams]].weight` | все upstream | `u16` | нет | `1` | Базовый вес в weighted-random выборе. |
| `[[upstreams]].enabled` | все upstream | `bool` | нет | `true` | Выключенные записи игнорируются на старте. |
| `[[upstreams]].scopes` | все upstream | `String` | нет | `""` | Список scope-токенов через запятую для маршрутизации. |
| `[[upstreams]].ipv4` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv4 DC-targets для этого upstream. |
| `[[upstreams]].ipv6` | все upstream | `Option<bool>` | нет | `auto` | Разрешает IPv6 DC-targets для этого upstream, включая proxy egress независимо от IPv6 на хосте. |
| `[[upstreams]].prefer` | все upstream | `Option<4 \| 6>` | нет | эффективный `[network].prefer` | Предпочтительное семейство DC-target для конкретного upstream. |
| `interface` | `direct` | `Option<String>` | нет | `null` | Имя интерфейса (например `eth0`) или literal локальный IP. |
| `bind_addresses` | `direct` | `Option<Vec<IpAddr>>` | нет | `null` | Явные кандидаты source IP (имеют приоритет над `interface`). |
| `address` | `socks4` | `String` | да | n/a | Адрес SOCKS4 сервера (`ip:port` или `host:port`). |
+300 -6
View File
@@ -103,14 +103,21 @@ Notes:
| `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` |
| `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` |
| `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` |
| `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` |
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
| `GET` | `/v1/config` | none | `200` | `ConfigData` |
| `PATCH` | `/v1/config` | sparse JSON object; optional reload query | `200` or `202` | `PatchConfigResponse` |
| `POST` | `/v1/system/reload` | `ReloadRequest` or empty body | `202` | `ReloadAccepted` |
| `GET` | `/v1/system/reload/{id}` | none | `200` | `ReloadStatus` |
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` or `202` | `UserInfo` |
| `DELETE` | `/v1/users/{username}` | none | `200` or `202` | `DeleteUserResponse` |
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `200` or `202` | `CreateUserResponse` |
| `POST` | `/v1/users/{username}/enable` | empty body | `200` or `202` | `UserInfo` |
| `POST` | `/v1/users/{username}/disable` | empty body | `200` or `202` | `UserInfo` |
| `POST` | `/v1/users/{username}/reset-quota` | empty body | `200` | `ResetUserQuotaResponse` |
## Endpoint Behavior
@@ -140,12 +147,18 @@ Notes:
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
| `GET /v1/config` | Returns the current editable config sections as JSON (no `access.*`) plus the revision. |
| `PATCH /v1/config` | Applies a sparse patch and optionally submits an in-process runtime reload to Maestro. |
| `POST /v1/system/reload` | Loads and validates the current on-disk config, then asks Maestro to prepare and activate a new runtime generation. |
| `GET /v1/system/reload/{id}` | Returns one retained reload status; the coordinator retains the most recent 32 operations. |
| `GET /v1/users` | Returns disk-first user views sorted by username. |
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
| `PATCH /v1/users/{username}` | Updates selected per-user fields with JSON Merge Patch semantics. |
| `DELETE /v1/users/{username}` | Deletes one user and related per-user access-map entries. |
| `POST /v1/users/{username}/rotate-secret` | Rotates one user's secret and returns the effective secret. |
| `POST /v1/users/{username}/enable` | Enables one user, removing any disabled override from config. |
| `POST /v1/users/{username}/disable` | Disables one user and closes active runtime sessions for that user. |
| `POST /v1/users/{username}/reset-quota` | Resets one user's runtime quota counter and persists quota state. |
## Common Error Codes
@@ -153,17 +166,22 @@ Notes:
| HTTP | `error.code` | Trigger |
| --- | --- | --- |
| `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 `network` or an unknown top-level key. |
| `400` | `field_not_editable` | `PATCH /v1/config` body contains a forbidden nested field under a partially editable section (e.g. `server.api`, `server.port`). |
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
| `404` | `not_found` | Unknown route, unknown user, or unsupported sub-route. |
| `405` | `method_not_allowed` | Unsupported method for `/v1/users/{username}` route shape. |
| `409` | `revision_conflict` | `If-Match` revision mismatch. |
| `409` | `reload_in_progress` | Another reload operation is non-terminal. |
| `409` | `user_exists` | User already exists on create. |
| `409` | `last_user_forbidden` | Attempt to delete last configured user. |
| `413` | `payload_too_large` | Body exceeds `request_body_limit_bytes`. |
| `500` | `internal_error` | Internal error (I/O, serialization, config load/save). |
| `503` | `api_disabled` | API disabled in config. |
| `503` | `maestro_unavailable` | Maestro's reload command channel is unavailable. |
## Routing and Method Edge Cases
@@ -172,9 +190,12 @@ Notes:
| 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`. |
| 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`. |
| `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}/enable/` | Trailing slash is trimmed and the route matches `enable`. |
| `POST /v1/users/{username}/disable/` | Trailing slash is trimmed and the route matches `disable`. |
| `POST /v1/users/{username}/reset-quota/` | Trailing slash is trimmed and the route matches `reset-quota`. |
## Body and JSON Semantics
@@ -205,9 +226,10 @@ Notes:
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
| `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bytes per second. |
| `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bytes per second. |
| `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 bits per second. |
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
| `enabled` | `bool` | no | User enable flag. Missing means enabled. `false` persists a disabled override. |
### `PatchUserRequest`
| Field | Type | Required | Description |
@@ -217,9 +239,10 @@ Notes:
| `max_tcp_conns` | `usize|null` | no | Per-user concurrent TCP limit; `null` removes the per-user override. |
| `expiration_rfc3339` | `string|null` | no | RFC3339 expiration timestamp; `null` removes the expiration. |
| `data_quota_bytes` | `u64|null` | no | Per-user traffic quota; `null` removes the per-user quota. |
| `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bytes per second; `null` removes the upload direction limit. |
| `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bytes per second; `null` removes the download direction limit. |
| `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 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. |
| `enabled` | `bool|null` | no | `false` disables the user. `true` or `null` removes the disabled override, so the user is enabled. |
### `access.user_source_deny` via API
- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`.
@@ -236,6 +259,26 @@ alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
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`, `dc_overrides`) or the partially editable `server` object (only `listeners` is allowed under `server`; see below). 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`).
- `network`, or any unknown top-level key → `400 section_not_editable`.
- `server` with any key other than `listeners` (e.g. `port`, `api`, `admin_api`) → `400 field_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"}}
```
Example — replace `[[server.listeners]]` (other `[server]` fields including `[server.api]` are preserved):
```json
{"server": {"listeners": [{"ip": "0.0.0.0", "port": 443, "client_mss": "92"}]}}
```
### `RotateSecretRequest`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
@@ -245,6 +288,35 @@ An empty request body is accepted and generates a new secret automatically.
## 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. |
| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. |
| `server` | `object?` | Partial `[server]` view when editable nested fields are present. Currently only `listeners` may appear; `api`/`admin_api`, `port`, unix sockets, and other bind-identity fields are never returned. |
Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets) and `network` (per-node addresses) are always excluded. Under `server`, only the nested field-level allowlist (`listeners`) is exposed.
### `PatchConfigResponse`
Returned by `PATCH /v1/config` on success (`200`, or `202` when a reload was accepted).
| Field | Type | Description |
| --- | --- | --- |
| `revision` | `string` | SHA-256 hex of the config file after the patch was written. |
| `restart_required` | `bool` | Legacy classifier result: `true` when the old file watcher alone cannot apply every changed field. Use `runtime_reload_required` and `process_restart_required` for new integrations. |
| `runtime_reload_required` | `bool` | `true` when full effect requires a Maestro runtime-generation reload rather than the legacy hot-field overlay. |
| `process_restart_required` | `bool` | `true` when process-owned sockets or paths changed and remain deferred after an in-process reload. |
| `deferred_process_fields` | `string[]` | Process-owned fields that the active process cannot rebind during generation activation. |
| `changed` | `string[]` | Top-level section names that differed between the old and new config (e.g. `["censorship"]`). |
| `reload` | `ReloadAccepted?` | Present only when the patch included a valid reload query and Maestro accepted the operation. |
### `HealthData`
| Field | Type | Description |
| --- | --- | --- |
@@ -269,6 +341,7 @@ An empty request body is accepted and generates a new secret automatically.
| `connections_bad_total` | `u64` | Failed/invalid client connections. |
| `connections_bad_by_class` | `ClassCount[]` | Failed/invalid connections grouped by class. |
| `handshake_failures_by_class` | `ClassCount[]` | Handshake failures grouped by class. |
| `handshake_failures_by_stage` | `StageCount[]` | Handshake failures grouped by state-machine stage. |
| `handshake_timeouts_total` | `u64` | Handshake timeout count. |
| `configured_users` | `usize` | Number of configured users in config. |
@@ -278,6 +351,38 @@ An empty request body is accepted and generates a new secret automatically.
| `class` | `string` | Failure class label. |
| `total` | `u64` | Counter value for this class. |
#### `StageCount`
| Field | Type | Description |
| --- | --- | --- |
| `stage` | `string` | State-machine stage label. |
| `total` | `u64` | Counter value for this stage. |
#### Handshake failure stage diagnostics
`handshake_failures_by_class` and `telemt_handshake_failures_by_class_total` describe the error kind. `handshake_failures_by_stage` and `telemt_handshake_failures_by_stage_total` describe where the same failure happened in the handshake state machine.
This does not add a DPI verdict or any protocol decision. The stage is derived from the existing Telemt handshake control flow and is counted only when the existing handshake failure or timeout accounting path is reached.
Fixed stage labels:
| Stage | Meaning |
| --- | --- |
| `first_packet_prelude` | Reading the first 5 bytes before selecting the TLS or direct branch. |
| `tls_clienthello_body` | Reading the TLS ClientHello body after the TLS record header. |
| `tls_core` | Running the TLS-F handshake/auth flow. |
| `tls_post_serverhello_mtproto` | Waiting for the 64-byte MTProto handshake after TLS ServerHello. |
| `direct_mtproto` | Reading the direct classic/secure 64-byte MTProto handshake. |
Example:
```text
telemt_handshake_failures_by_class_total{class="expected_64_got_0_unexpected_eof"} 3
telemt_handshake_failures_by_stage_total{stage="direct_mtproto"} 1
telemt_handshake_failures_by_stage_total{stage="tls_post_serverhello_mtproto"} 2
```
This means the same EOF-while-reading-64-bytes failure happened once in the direct MTProto path and twice after TLS ServerHello.
### `SystemInfoData`
| Field | Type | Description |
| --- | --- | --- |
@@ -807,6 +912,43 @@ An empty request body is accepted and generates a new secret automatically.
| `event_type` | `string` | Event kind identifier. |
| `context` | `string` | Context text (truncated to implementation-defined max length). |
### `RuntimeEdgeTlsFingerprintsData`
| Field | Type | Description |
| --- | --- | --- |
| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. |
| `reason` | `string?` | `feature_disabled` when endpoint is disabled. |
| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. |
| `data` | `RuntimeEdgeTlsFingerprintsPayload?` | Null when unavailable. |
#### `RuntimeEdgeTlsFingerprintsPayload`
| Field | Type | Description |
| --- | --- | --- |
| `limit` | `usize` | Effective Top-N row count. |
| `retention_secs` | `u64` | In-memory retention window, derived from `general.beobachten_minutes`. |
| `capacity` | `usize` | Maximum retained fingerprint buckets. |
| `dropped_total` | `u64` | Buckets dropped because the collector was full. |
| `parse_error_total` | `u64` | Complete ClientHello records that could not be fingerprinted. |
| `by_fingerprint` | `RuntimeEdgeTlsFingerprintRow[]` | Global JA3/JA4 leaderboard. |
| `by_ip` | `RuntimeEdgeTlsFingerprintRow[]` | Source-IP scoped leaderboard. |
| `by_cidr` | `RuntimeEdgeTlsFingerprintRow[]` | Source CIDR scoped leaderboard (`/24` for IPv4, `/56` for IPv6). |
| `by_user` | `RuntimeEdgeTlsFingerprintRow[]` | Authenticated user scoped leaderboard. |
#### `RuntimeEdgeTlsFingerprintRow`
| Field | Type | Description |
| --- | --- | --- |
| `scope` | `string?` | IP, CIDR, or username; absent in `by_fingerprint`. |
| `ja3` | `string` | JA3 MD5 hash. |
| `ja3_raw` | `string` | Raw JA3 field string. |
| `ja4` | `string` | JA4 TLS client fingerprint. |
| `ja4_raw` | `string` | Raw JA4 material used for the hashed parts. |
| `total` | `u64` | Complete ClientHello observations for this bucket. |
| `auth_success` | `u64` | TLS-authenticated observations for this bucket. |
| `bad_or_probe` | `u64` | Complete ClientHello observations later classified as bad/probe. |
| `first_seen_epoch_secs` | `u64` | First observation timestamp. |
| `last_seen_epoch_secs` | `u64` | Last observation timestamp. |
JA3 follows the Salesforce ClientHello field order. JA4 follows the FoxIO TLS-client `a_b_c` format; GREASE values are excluded and no high-cardinality Prometheus labels are emitted for fingerprints.
### `ZeroAllData`
| Field | Type | Description |
| --- | --- | --- |
@@ -825,6 +967,7 @@ An empty request body is accepted and generates a new secret automatically.
| `connections_bad_total` | `u64` | Failed/invalid connections. |
| `connections_bad_by_class` | `ClassCount[]` | Failed/invalid connections grouped by class. |
| `handshake_failures_by_class` | `ClassCount[]` | Handshake failures grouped by class. |
| `handshake_failures_by_stage` | `StageCount[]` | Handshake failures grouped by state-machine stage. |
| `handshake_timeouts_total` | `u64` | Handshake timeouts. |
| `accept_permit_timeout_total` | `u64` | Listener admission permit acquisition timeouts. |
| `configured_users` | `usize` | Configured user count. |
@@ -1165,13 +1308,14 @@ An empty request body is accepted and generates a new secret automatically.
| Field | Type | Description |
| --- | --- | --- |
| `username` | `string` | Username. |
| `enabled` | `bool` | Effective user enable flag. Missing config entry is reported as `true`. |
| `in_runtime` | `bool` | Whether current runtime config already contains this user. |
| `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). |
| `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. |
| `expiration_rfc3339` | `string?` | Optional expiration timestamp. |
| `data_quota_bytes` | `u64?` | Optional data quota. |
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bytes per second. |
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bytes per second. |
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bits per second. |
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bits per second. |
| `max_unique_ips` | `usize?` | Optional unique IP limit. |
| `current_connections` | `u64` | Current live connections. |
| `active_unique_ips` | `usize` | Current active unique source IPs. |
@@ -1232,13 +1376,162 @@ Link generation uses active config and enabled modes:
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
| `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"}
}
```
The response is built from the validated, include-expanded configuration and may therefore contain normalized defaults or synthesized listeners that are absent from the root file. 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`. It covers the complete recursive include graph. If supplied and it does not match the current source manifest, returns `409 revision_conflict`. If omitted, the patch applies unconditionally. |
**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `dc_overrides`, plus partially editable `server` (only nested `listeners`).
**Rejected keys and their error codes:**
| Key | HTTP | `error.code` |
| --- | --- | --- |
| `access` | `400` | `access_not_editable` |
| `network`, or any unknown top-level key | `400` | `section_not_editable` |
| `server` with keys other than `listeners` | `400` | `field_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. A mutation is written to the single source file that owns every touched semantic section. File comments, the root file when it is not the owner, and all other include files are preserved. A target split across sources, a patch spanning multiple owners, or an include directive nested inside a TOML table returns `409 config_patch_not_atomic` without writing any file.
**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`.
**Optional in-process reload query:**
| Query | Required | Description |
| --- | --- | --- |
| `reload=instant` | no | Activates a new generation and cancels sessions owned by the previous generation. |
| `reload=drain` | no | Activates a new generation and lets old sessions finish until `timeout_secs`. |
| `timeout_secs=1..3600` | for `reload=drain` | Bounded old-generation drain interval. Invalid with `reload=instant`. |
| `failure_policy=keep_new\|rollback` | no | Defaults to `keep_new`. `rollback` applies only through the activation barrier, before old-generation teardown. |
Without a `reload` query parameter, the endpoint writes the patch and the file watcher applies only supported hot fields. With a reload query, coordinator capacity and status are reserved before the source file is replaced. Runtime-owned changes enqueue the validated immutable snapshot after the atomic write. A process-only patch is persisted with `200`, reports its deferred fields, and does not create a reload operation.
**Success `200` or `202` response body** (`data` field of the standard envelope):
```json
{
"revision": "<new-sha256-hex>",
"restart_required": true,
"runtime_reload_required": true,
"process_restart_required": false,
"deferred_process_fields": [],
"changed": ["censorship"],
"reload": {
"reload_id": 7,
"target_generation": 2,
"config_revision": "<new-sha256-hex>",
"state": "accepted",
"mode": "instant",
"failure_policy": "keep_new"
}
}
```
- `revision` — SHA-256 hex of the canonical source manifest after the write, including every recursive include path and its raw bytes.
- `restart_required` — legacy file-watcher classification retained for compatibility.
- `runtime_reload_required` — reports whether a full Maestro generation reload is needed for runtime effect.
- `process_restart_required` and `deferred_process_fields` — report socket policies or process-owned paths that remain unchanged by an in-process reload. A pure listener endpoint move is reloadable only when every retained endpoint keeps identical bind policy and neither the active nor desired listener set uses SYN limiting; same-address MSS, PROXY protocol, backlog, reuse, or SYN-limit changes remain deferred.
- `changed` — list of top-level section names that differed.
- `reload` — accepted operation metadata; omitted without a reload query and for process-only patches that cannot change the active generation.
**Status codes:**
| HTTP | `error.code` | Condition |
| --- | --- | --- |
| `200` | — | Patch applied successfully. |
| `202` | — | Patch applied and runtime reload accepted. |
| `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 `network` or an unknown top-level key. |
| `400` | `field_not_editable` | Patch contains a forbidden nested `server.*` field (anything other than `listeners`). |
| `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. |
| `409` | `reload_in_progress` | Another runtime reload is active; the patch is not written. |
| `409` | `config_patch_not_atomic` | Touched semantic sections have multiple source owners or cannot be mutated as one source-file transaction. |
| `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?reload=instant'
```
## Runtime Reload Endpoints
### `POST /v1/system/reload`
Loads the current on-disk config under the API mutation lock and submits an immutable config snapshot to Maestro. `If-Match` is optional and uses the same revision contract as `PATCH /v1/config`. An empty body defaults to `{"mode":"instant","failure_policy":"keep_new"}`.
```json
{
"mode": "drain",
"timeout_secs": 30,
"failure_policy": "rollback"
}
```
The endpoint returns `202` with `ReloadAccepted`. A concurrent non-terminal reload returns `409 reload_in_progress`. Config parsing or validation failure is reported before a command is submitted.
### `GET /v1/system/reload/{id}`
Returns `ReloadStatus` with `state` equal to `accepted`, `preparing`, `activating`, `draining`, `succeeded`, `rolled_back`, or `failed`. Terminal statuses include `finished_at_epoch_secs`; failures include `error`. Successful activation may include `warnings` for old-generation cleanup failures and `deferred_process_fields` for process-owned settings.
Runtime generation activation rebuilds statistics, upstream routing, replay and buffer state, TLS-front cache, IP tracking, admission/route state, and Middle-End orchestration. Per-user quota accounting is process-scoped and remains continuous across generations. A process-owned listener manager can prepare new endpoint sockets without calling `listen(2)`, stop removed acceptors before the runtime swap, and start new acceptors only after the swap. Inbound listener planning depends on normalized config and explicit IPv4/IPv6 policy, never transient outbound connectivity-probe results. Retained endpoints are not rebound, and unsupported same-address policy changes are overlaid with active values and reported as deferred. API, metrics, Unix listeners, PID ownership, and logging remain process-scoped. Maestro does not invoke systemd, containerd, or another process supervisor.
Reload preparation requires every configured TLS-front domain to have a non-default cached profile and requires a ready Middle-End pool when direct fallback is disabled. A candidate that does not satisfy either readiness condition fails without replacing the active generation.
The revision is verified again after preparation. With `failure_policy=rollback`, a changed revision or revision read failure rolls the candidate back; with `failure_policy=keep_new`, the condition is reported in `warnings` and activation continues.
## Mutation Semantics
| Endpoint | Notes |
| --- | --- |
| `PATCH /v1/config` | Deep-merges and validates the patch, writes touched sections via atomic `tmp + rename`, and optionally submits the exact written revision for an in-process Maestro reload. |
| `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. |
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |
| `POST /v1/users/{username}/enable` | Enables the user idempotently by removing the `access.user_enabled[username]` override and updating the runtime admission state immediately. |
| `POST /v1/users/{username}/disable` | Disables the user idempotently by writing `access.user_enabled[username] = false`, updating runtime admission immediately, and cancelling active sessions for that username. |
| `POST /v1/users/{username}/reset-quota` | Resets the runtime quota counter for the route username, persists quota state to `general.quota_state_path`, and does not modify user config. |
| `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. |
@@ -1282,6 +1575,7 @@ Additional runtime endpoint behavior:
| `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload |
| `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload |
| `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
| `/v1/runtime/tls-fingerprints` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload |
## ME Fallback Behavior Exposed Via API
@@ -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
File diff suppressed because it is too large Load Diff
+277 -182
View File
@@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`.
# Table of contents
- [Top-level keys](#top-level-keys)
- [logging](#logging)
- [general](#general)
- [general.modes](#generalmodes)
- [general.links](#generallinks)
@@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`.
| --- | ---- | ------- | ---------- |
| [`include`](#include) | `String` (special directive) | — | `✔` |
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
| [`logging`](#logging) | Table | default values | `✘` |
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` |
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
@@ -83,147 +85,87 @@ This document lists all configuration keys accepted by `config.toml`.
default_dc = 2
```
# [logging]
| Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- |
| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `✘` |
| [`path`](#loggingpath) | `String` | — | `✘` |
| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `✘` |
| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `✘` |
| [`max_files`](#loggingmax_files) | `usize` | `0` | `✘` |
| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `✘` |
## logging.destination
- **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`.
- **Description**: Selects the runtime log destination. CLI flags override this value.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
```
## logging.path
- **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty.
- **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
```
## logging.rotation
- **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`.
- **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
```
## logging.max_size_bytes
- **Constraints / validation**: `0` disables size rotation.
- **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
max_size_bytes = 104857600
```
## logging.max_files
- **Constraints / validation**: `0` disables count-based retention.
- **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
max_files = 14
```
## logging.max_age_secs
- **Constraints / validation**: `0` disables age-based retention.
- **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup.
- **Example**:
```toml
[logging]
destination = "file"
path = "/var/log/telemt.log"
rotation = "daily"
max_age_secs = 1209600
```
# [general]
| 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 |
| --- | ---- | ------- | ---------- |
| [`data_path`](#data_path) | `String` | — | `✘` |
@@ -770,7 +712,7 @@ This document lists all configuration keys accepted by `config.toml`.
```
## beobachten
- **Constraints / validation**: `bool`.
- **Description**: Enables per-IP forensic observation buckets.
- **Description**: Enables per-IP forensic observation buckets and appends TLS JA3/JA4 fingerprint snapshots to Beobachten output when available.
- **Example**:
```toml
@@ -779,7 +721,7 @@ This document lists all configuration keys accepted by `config.toml`.
```
## beobachten_minutes
- **Constraints / validation**: Must be `> 0` (minutes).
- **Description**: Retention window (minutes) for per-IP observation buckets.
- **Description**: Retention window (minutes) for per-IP observation buckets and in-memory TLS fingerprint buckets.
- **Example**:
```toml
@@ -1943,6 +1885,8 @@ This document lists all configuration keys accepted by `config.toml`.
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `✘` |
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` |
| [`client_mss`](#client_mss) | `String` | `""` | `✘` |
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `✘` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `✘` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `✘` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `✘` |
@@ -2025,6 +1969,27 @@ This document lists all configuration keys accepted by `config.toml`.
listen_unix_sock = "/run/telemt.sock"
listen_tcp = true
```
## client_mss
- **Constraints / validation**: `String`. Empty or omitted means do not change kernel MSS. Presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Custom decimal strings must be within `88..=4096`.
- **Description**: Controls the segment size used for client connections. By default, this value is applied to the TCP listener and remains active for the whole connection. When `client_mss_bulk` is also set on Linux, the accepted socket is temporarily clamped to `client_mss` while Telemt sends the initial authenticated FakeTLS response (`ServerHello`), then restored to the connection's bulk MSS on success, write error, or cancellation. This setting does not affect API, metrics, Unix sockets, Telegram upstreams, ME sockets, or mask backend connections. Changes require a listener restart/rebind.
- **Operator note**: The two-tier `synlimit` profile does not require Telemt to disable MSS automatically. Operators that follow external host-tuning recipes should decide explicitly whether to leave MSS shaping enabled for handshake fragmentation or disable it for higher media throughput.
- **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**: Linux-only `String`. Same grammar as [`client_mss`](#client_mss) (empty/omitted, presets `"extreme-low"`/`"tspu"`/`"2in8"`, or a decimal in `88..=4096`). A non-empty value requires at least one listener with an effective `client_mss`, and it must be greater than every participating listener's handshake value. A listener may use `client_mss = ""` as an explicit opt-out.
- **Description**: Enables an experimental two-size profile. The listener uses `client_mss_bulk` from the start of the connection, including when the client sends `ClientHello`. Telemt temporarily clamps the accepted socket's `TCP_MAXSEG` to `client_mss` and sends the initial authenticated FakeTLS response (`ServerHello`) in writes no larger than that value, then restores the prior socket MSS before normal MTProto traffic. Restoration is attempted on success, write error, and task cancellation. TCP is a byte stream: `MSG_EOR`, TCP offloads, loss, and retransmission can still change observable capture boundaries, so the release packet-capture gate remains authoritative. When this option is empty or omitted, `client_mss` remains the kernel MSS for the whole connection. Changes require listener restart/rebind.
- **Example**:
```toml
[server]
client_mss = "tspu"
client_mss_bulk = "1400"
```
## proxy_protocol
- **Constraints / validation**: `bool`.
- **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header.
@@ -2311,7 +2276,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
```
## runtime_edge_top_n
- **Constraints / validation**: `1..=1000`.
- **Description**: Top-N size for edge connection leaderboard.
- **Description**: Top-N size for edge connection and TLS fingerprint leaderboard snapshots.
- **Example**:
```toml
@@ -2345,6 +2310,16 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
| --- | ---- | ------- | ---------- |
| [`ip`](#ip) | `IpAddr` | — | `✘` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, `"nftables"`, or `"pf"` | `false` | `✘` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✘` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✘` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `24` | `✘` |
| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✘` |
| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✘` |
| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✘` |
| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✘` |
| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✘` |
| [`announce`](#announce) | `String` | — | `✘` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
@@ -2369,6 +2344,135 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
ip = "0.0.0.0"
port = 443
```
## client_mss (server.listeners)
- **Constraints / validation**: `String` (optional). Same values as `[server].client_mss`.
- **Description**: Per-listener MSS override. When omitted, inherits `[server].client_mss`; when set to an empty string, disables MSS shaping for this listener even if the global value is set. Changes require listener restart/rebind.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
client_mss = "256"
```
## synlimit (server.listeners)
- **Constraints / validation**: `false`, `"iptables"`, `"nftables"`, or `"pf"`. Omitted or `false` disables SYN limiting for this listener.
- **Description**: Installs startup-owned per-listener firewall rules for the listener port. Linux accepts only `"iptables"` or `"nftables"`; FreeBSD accepts only `"pf"`; other platforms accept only `false`. Linux netfilter rules accept under-limit SYN packets and reject excess packets with TCP RST. PF renders explicit `inet`/`inet6` rules and uses native `max-src-conn-rate`; excess state-creating packets are silently dropped. Startup fails before accept loops if privileges, backend validation, stale-candidate cleanup, or rule application fails. Every `synlimit*` change requires process restart. Combining enabled SYN limiting with `--run-as-user` or `--run-as-group` is rejected until a separate privileged firewall helper exists. Rules are removed during graceful shutdown; `SIGKILL` cannot be cleaned up by the process. Linux requires CAP_NET_ADMIN. FreeBSD requires root and a main PF ruleset hook such as `anchor "telemt_synlimit/*"`.
- **Operator note**: Telemt does not persist rules with `iptables-persistent`, write `/etc/sysctl.d`, edit systemd limits, or modify `client_mss`. Apply host-level tuning manually if your deployment policy requires it.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
[[server.listeners]]
ip = "::"
port = 443
synlimit = "nftables"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "pf"
```
## synlimit_seconds (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60`.
- **Description**: Generic SYN-fix token-bucket interval. For Linux backends, the rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). This bucket handles SYN packets that do not match the iOS-like TTL/length classifier. For PF, the same pair is rendered as `max-src-conn-rate hitcount/seconds`.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_seconds = 60
```
## synlimit_hitcount (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `48`.
- **Description**: Generic SYN-fix token-bucket rate amount. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate. Linux netfilter rejects excess SYN packets with TCP RST; PF silently drops excess state-creating packets.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hitcount = 48
```
## synlimit_burst (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `24`.
- **Description**: Generic SYN-fix token-bucket burst size. 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 = 24
```
## synlimit_ios_seconds (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `1`.
- **Description**: Token-bucket interval for SYN packets matching the iOS-like classifier. IPv4 matches packet length `64` and TTL `< 65`; IPv6 matches packet length `84` and hop limit `< 65`.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_ios_seconds = 1
```
## synlimit_ios_hitcount (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `12`.
- **Description**: Token-bucket rate amount for the iOS-like SYN classifier.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_ios_hitcount = 12
```
## synlimit_ios_burst (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `24`.
- **Description**: Token-bucket burst size for the iOS-like SYN classifier.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_ios_burst = 24
```
## synlimit_hashlimit_expire_ms (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `60000`.
- **Description**: Entry expiration in milliseconds for iptables/ip6tables hashlimit buckets. nftables meters and PF source tracking use kernel-managed state and do not expose this exact knob.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hashlimit_expire_ms = 60000
```
## synlimit_hashlimit_size (server.listeners)
- **Constraints / validation**: `u32`, must be `> 0`. Default is `32768`.
- **Description**: Hash table size for iptables/ip6tables hashlimit buckets. nftables meters and PF source tracking use kernel-managed state and do not expose this exact knob.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hashlimit_size = 32768
```
## announce
- **Constraints / validation**: `String` (optional). Must not be empty when set.
- **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`.
@@ -2525,41 +2629,6 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
# [censorship]
| Key | Type | Default |
| --- | ---- | ------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` |
| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` |
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
| [`mask`](#mask) | `bool` | `true` |
| [`mask_host`](#mask_host) | `String` | — |
| [`mask_port`](#mask_port) | `u16` | `443` |
| [`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 |
| --- | ---- | ------- | ---------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `✘` |
@@ -3107,6 +3176,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
| Key | Type | Default | Hot-Reload |
| --- | ---- | ------- | ---------- |
| [`users`](#users) | `Map<String, String>` | `{"default": "000…000"}` | `✔` |
| [`user_enabled`](#user_enabled-1) | `Map<String, bool>` | `{}` | `✔` |
| [`user_ad_tags`](#user_ad_tags) | `Map<String, String>` | `{}` | `✔` |
| [`user_max_tcp_conns`](#user_max_tcp_conns) | `Map<String, usize>` | `{}` | `✔` |
| [`user_max_tcp_conns_global_each`](#user_max_tcp_conns_global_each) | `usize` | `0` | `✔` |
@@ -3121,7 +3191,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | `✘` |
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | `✘` |
| [`user_rate_limits`](#user_rate_limits) | `Map<String, RateLimitBps>` | `{}` | `✔` |
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<IpNetwork, RateLimitBps>` | `{}` | `✔` |
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<CidrRateLimitKey, RateLimitBps>` | `{}` | `✔` |
## users
- **Constraints / validation**: Must not be empty (at least one user must exist). Each value must be **exactly 32 hex characters**.
@@ -3133,6 +3203,16 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
alice = "00112233445566778899aabbccddeeff"
bob = "0123456789abcdef0123456789abcdef"
```
## user_enabled
- **Constraints / validation**: `Map<String, bool>`.
- **Description**: Optional per-user enable overrides. Missing users are enabled by default. A value of `false` disables new sessions for that user; setting the value to `true` is accepted but equivalent to removing the override. API enable operations remove the override, while disable operations write `false`.
- **Runtime behavior**: Hot reload applies this map immediately. Users disabled through API or config reload are rejected after successful authentication and active runtime sessions for that username are cancelled.
- **Example**:
```toml
[access.user_enabled]
alice = false
```
## user_ad_tags
- **Constraints / validation**: Each value must be **exactly 32 hex characters** (same format as `general.ad_tag`). An all-zero tag is allowed but logs a warning.
- **Description**: Per-user sponsored-channel ad tag override. When a user has an entry here, it takes precedence over `general.ad_tag`.
@@ -3266,7 +3346,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
## user_rate_limits
- **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**:
```toml
@@ -3274,13 +3354,15 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
alice = { up_bps = 1048576, down_bps = 2097152 }
```
## cidr_rate_limits
- **Constraints / validation**: Table `CIDR -> { up_bps, down_bps }`. CIDR must parse as `IpNetwork`; at least one direction must be non-zero.
- **Description**: Source-subnet bandwidth caps applied alongside per-user limits.
- **Constraints / validation**: Table `CIDR or auto-template -> { up_bps, down_bps }`. Explicit CIDR keys must parse as `IpNetwork`; auto-template keys must be `*4/N` (`N=0..32`), `*6/N` (`N=0..128`), or `*/N` (`N=0..32`). At least one direction must be non-zero. Duplicate normalized auto-templates are rejected.
- **Description**: Source-subnet bandwidth caps applied alongside per-user limits. Explicit CIDR rules use longest-prefix-wins and take priority over auto-templates. Auto-templates create buckets lazily per matched source subnet: `*4/N` for IPv4, `*6/N` for IPv6, and `*/N` as a dual-stack shorthand where IPv4 uses `/N` and IPv6 uses `/(N * 4)`.
- **Example**:
```toml
[access.cidr_rate_limits]
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
"*4/32" = { up_bps = 262144, down_bps = 1048576 }
"*6/64" = { up_bps = 262144, down_bps = 1048576 }
```
# [[upstreams]]
@@ -3293,6 +3375,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
| [`scopes`](#scopes) | `String` | `""` | `✘` |
| [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `✘` |
| [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `✘` |
| [`prefer`](#prefer-upstreams) | `4` or `6` | effective `[network].prefer` | `✘` |
| [`interface`](#interface) | `String` | — | `✘` |
| [`bind_addresses`](#bind_addresses) | `String[]` | — | `✘` |
| [`bindtodevice`](#bindtodevice) | `String` | — | `✘` |
@@ -3364,7 +3447,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
```
## ipv6 (upstreams)
- **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**:
```toml
@@ -3372,6 +3455,18 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p
type = "direct"
ipv6 = false
```
## prefer (upstreams)
- **Constraints / validation**: Optional integer. Must be `4` or `6`.
- **Description**: Overrides the IP family preference for Telegram DC targets selected through this upstream. When omitted, the upstream inherits the effective global `[network].prefer` decision. Use `prefer = 6` together with `ipv6 = true` for a SOCKS or Shadowsocks upstream that can egress over IPv6 even when the local Telemt host is IPv4-only.
- **Example**:
```toml
[[upstreams]]
type = "socks5"
address = "192.0.2.10:1080"
ipv6 = true
prefer = 6
```
## interface
- **Constraints / validation**: `String` (optional).
- For `"direct"`: may be an IP address (used as explicit local bind) or an OS interface name (resolved to an IP at runtime; Unix only).
+186 -182
View File
@@ -85,145 +85,7 @@
# [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 |
| --- | ---- | ------- | ---------- |
| [`data_path`](#data_path) | `String` | — | `✘` |
@@ -770,7 +632,7 @@
```
## beobachten
- **Ограничения / валидация**: `bool`.
- **Описание**: Включает "криминалистическое" наблюдения для каждого IP-адреса. Анализирует поведение всех подключений и записывает возможные типы клиентов, которые посылают active-probing запросы.
- **Описание**: Включает "криминалистическое" наблюдения для каждого IP-адреса. Анализирует поведение всех подключений, записывает возможные типы клиентов, которые посылают active-probing запросы, и добавляет snapshot’ы TLS JA3/JA4 fingerprint’ов в Beobachten output, когда есть данные.
- **Пример**:
```toml
@@ -779,7 +641,7 @@
```
## beobachten_minutes
- **Ограничения / валидация**: Должно быть `> 0` (минут).
- **Описание**: Время хранения (минуты) для сегментов наблюдения по каждому IP-адресу.
- **Описание**: Время хранения (минуты) для сегментов наблюдения по каждому IP-адресу и in-memory bucket’ов TLS fingerprint’ов.
- **Пример**:
```toml
@@ -1945,6 +1807,8 @@
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `✘` |
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `✘` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `✘` |
| [`client_mss`](#client_mss) | `String` | `""` | `✘` |
| [`client_mss_bulk`](#client_mss_bulk) | `String` | `""` | `✘` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `✘` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `✘` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `✘` |
@@ -2027,6 +1891,27 @@
listen_unix_sock = "/run/telemt.sock"
listen_tcp = true
```
## client_mss
- **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`.
- **Описание**: Управляет размером сегментов в клиентских соединениях. По умолчанию значение применяется к TCP listener и действует в течение всего соединения. Если в Linux также задан `client_mss_bulk`, Telemt временно ограничивает `TCP_MAXSEG` принятого socket значением `client_mss` на время отправки начального аутентифицированного FakeTLS-ответа (`ServerHello`), а затем восстанавливает bulk MSS при успехе, ошибке записи или отмене задачи. Настройка не влияет на API, metrics, Unix sockets, Telegram upstreams, ME sockets и mask backend connections. Изменение требует перезапуска или повторного создания listener.
- **Operator note**: Two-tier `synlimit` profile больше не требует автоматического отключения MSS внутри Telemt. Оператор должен сам решить, оставлять MSS shaping для handshake fragmentation или отключать его ради более высокой скорости media.
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
- **Пример**:
```toml
[server]
client_mss = "tspu"
```
## client_mss_bulk
- **Ограничения / валидация**: Только Linux, тип `String`. Грамматика совпадает с [`client_mss`](#client_mss): пустое/отсутствующее значение, пресеты `"extreme-low"`/`"tspu"`/`"2in8"` или десятичное число в диапазоне `88..=4096`. Непустое значение требует хотя бы одного listener с эффективным `client_mss` и должно быть больше handshake-значения каждого участвующего listener. Listener может задать `client_mss = ""` для явного отказа от профиля.
- **Описание**: Включает экспериментальный профиль с двумя размерами. Listener использует `client_mss_bulk` с самого начала соединения, включая получение `ClientHello`. При отправке начального аутентифицированного FakeTLS-ответа (`ServerHello`) Telemt временно ограничивает `TCP_MAXSEG` принятого socket значением `client_mss` и использует записи не больше этого значения, затем восстанавливает прежний MSS до обычного MTProto-трафика. Восстановление выполняется при успехе, ошибке записи и отмене задачи. TCP является потоком байтов: `MSG_EOR`, TCP offloads, потери и ретрансляции могут менять наблюдаемые границы capture, поэтому packet-capture release gate остаётся определяющим. Если параметр пуст или отсутствует, `client_mss` остаётся kernel MSS для всего соединения. Изменение требует restart/rebind listener.
- **Пример**:
```toml
[server]
client_mss = "tspu"
client_mss_bulk = "1400"
```
## proxy_protocol
- **Ограничения / валидация**: `bool`.
- **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка.
@@ -2317,7 +2202,7 @@
```
## runtime_edge_top_n
- **Ограничения / валидация**: `1..=1000`.
- **Описание**: Размер выборки Top-N для рейтинга (leaderboard) edge-соединений.
- **Описание**: Размер выборки Top-N для snapshot’ов рейтинга edge-соединений и TLS fingerprint’ов.
- **Пример**:
```toml
@@ -2351,6 +2236,16 @@
| --- | ---- | ------- | ---------- |
| [`ip`](#ip) | `IpAddr` | — | `✘` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, `"nftables"` или `"pf"` | `false` | `✘` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✘` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✘` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `24` | `✘` |
| [`synlimit_ios_seconds`](#synlimit_ios_seconds-serverlisteners) | `u32` | `1` | `✘` |
| [`synlimit_ios_hitcount`](#synlimit_ios_hitcount-serverlisteners) | `u32` | `12` | `✘` |
| [`synlimit_ios_burst`](#synlimit_ios_burst-serverlisteners) | `u32` | `24` | `✘` |
| [`synlimit_hashlimit_expire_ms`](#synlimit_hashlimit_expire_ms-serverlisteners) | `u32` | `60000` | `✘` |
| [`synlimit_hashlimit_size`](#synlimit_hashlimit_size-serverlisteners) | `u32` | `32768` | `✘` |
| [`announce`](#announce) | `String` | — | `✘` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
@@ -2375,6 +2270,135 @@
ip = "0.0.0.0"
port = 443
```
## client_mss (server.listeners)
- **Ограничения / валидация**: `String` (необязательный параметр). Допустимые значения совпадают с `[server].client_mss`.
- **Описание**: Per-listener override для MSS. Если параметр не задан, listener наследует `[server].client_mss`; если задана пустая строка, MSS shaping отключается только для этого listener’а, даже когда глобальный параметр задан. Изменение требует restart/rebind listener’а.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
client_mss = "256"
```
## synlimit (server.listeners)
- **Ограничения / валидация**: `false`, `"iptables"`, `"nftables"` или `"pf"`. Если параметр отсутствует или равен `false`, SYN limiting для этого listener отключён.
- **Описание**: Устанавливает принадлежащие startup-процессу per-listener firewall rules для порта listener. Linux принимает только `"iptables"` или `"nftables"`, FreeBSD — только `"pf"`, остальные платформы — только `false`. Linux netfilter rules принимают SYN packets в пределах лимита и отклоняют превышение с TCP RST. PF формирует отдельные `inet`/`inet6` rules и использует native `max-src-conn-rate`; превышающие лимит state-creating packets молча отбрасываются. При отсутствии privileges, ошибке backend validation, очистки stale candidate или применения rules startup завершается до запуска accept loops. Любое изменение `synlimit*` требует перезапуска процесса. Комбинация включённого SYN limiting с `--run-as-user` или `--run-as-group` отклоняется до появления отдельного privileged firewall helper. Rules удаляются при graceful shutdown; после `SIGKILL` процесс не может их очистить. Linux требует CAP_NET_ADMIN. FreeBSD требует root и hook в основном PF ruleset, например `anchor "telemt_synlimit/*"`.
- **Operator note**: Telemt не сохраняет rules через `iptables-persistent`, не пишет `/etc/sysctl.d`, не меняет systemd limits и не модифицирует `client_mss`. Host-level tuning применяется оператором вручную.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
[[server.listeners]]
ip = "::"
port = 443
synlimit = "nftables"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "pf"
```
## synlimit_seconds (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60`.
- **Описание**: Generic SYN-fix token-bucket interval. В Linux rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`). Этот bucket обрабатывает SYN packets, которые не совпали с iOS-like TTL/length classifier. PF рендерит ту же пару как `max-src-conn-rate hitcount/seconds`.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_seconds = 60
```
## synlimit_hitcount (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `48`.
- **Описание**: Generic SYN-fix token-bucket rate amount. Вместе с `synlimit_seconds` задаёт разрешённый source-IP SYN rate. Linux netfilter отклоняет превышение с TCP RST; PF молча отбрасывает превышающие лимит state-creating packets.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hitcount = 48
```
## synlimit_burst (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `24`.
- **Описание**: Generic SYN-fix token-bucket burst size. Более высокие значения разрешают 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 = 24
```
## synlimit_ios_seconds (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`.
- **Описание**: Token-bucket interval для SYN packets, совпавших с iOS-like classifier. IPv4 match: packet length `64` и TTL `< 65`; IPv6 match: packet length `84` и hop limit `< 65`.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_ios_seconds = 1
```
## synlimit_ios_hitcount (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `12`.
- **Описание**: Token-bucket rate amount для iOS-like SYN classifier.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_ios_hitcount = 12
```
## synlimit_ios_burst (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `24`.
- **Описание**: Token-bucket burst size для iOS-like SYN classifier.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_ios_burst = 24
```
## synlimit_hashlimit_expire_ms (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `60000`.
- **Описание**: Entry expiration в миллисекундах для iptables/ip6tables hashlimit buckets. nftables meters и PF source tracking используют kernel-managed state и не имеют точного аналога этого knob.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hashlimit_expire_ms = 60000
```
## synlimit_hashlimit_size (server.listeners)
- **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `32768`.
- **Описание**: Hash table size для iptables/ip6tables hashlimit buckets. nftables meters и PF source tracking используют kernel-managed state и не имеют точного аналога этого knob.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
synlimit_hashlimit_size = 32768
```
## announce
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
- **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`.
@@ -2531,41 +2555,6 @@
# [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 |
| --- | ---- | ------- | ---------- |
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | `✘` |
@@ -3128,7 +3117,7 @@
| [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | `✘` |
| [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | `✘` |
| [`user_rate_limits`](#user_rate_limits) | `Map<String, RateLimitBps>` | `{}` | `✔` |
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<IpNetwork, RateLimitBps>` | `{}` | `✔` |
| [`cidr_rate_limits`](#cidr_rate_limits) | `Map<CidrRateLimitKey, RateLimitBps>` | `{}` | `✔` |
## users
- **Ограничения / валидация**: Не должно быть пустым (должен существовать хотя бы один пользователь). Каждое значение должно состоять **ровно из 32 шестнадцатеричных символов**.
@@ -3273,7 +3262,7 @@
## user_rate_limits
- **Ограничения / валидация**: Таблица `username -> { up_bps, down_bps }`. Должно быть ненулевое значение хотя бы в одном направлении.
- **Описание**: Персональные лимиты скорости по пользователям в байтах/сек для отправки (`up_bps`) и получения (`down_bps`).
- **Описание**: Персональные лимиты скорости по пользователям в битах/сек для отправки (`up_bps`) и получения (`down_bps`).
- **Example**:
```toml
@@ -3281,13 +3270,15 @@
alice = { up_bps = 1048576, down_bps = 2097152 }
```
## cidr_rate_limits
- **Ограничения / валидация**: Таблица `CIDR -> { up_bps, down_bps }`. CIDR должен корректно разбираться как `IpNetwork`; хотя бы одно направление должно быть ненулевым.
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений.
- **Ограничения / валидация**: Таблица `CIDR или auto-template -> { up_bps, down_bps }`. Explicit CIDR-ключи должны корректно разбираться как `IpNetwork`; auto-template ключи должны иметь вид `*4/N` (`N=0..32`), `*6/N` (`N=0..128`) или `*/N` (`N=0..32`). Хотя бы одно направление должно быть ненулевым. Дублирующиеся нормализованные auto-template отклоняются.
- **Описание**: Лимиты скорости для подсетей источников, применяются поверх пользовательских ограничений. Explicit CIDR-правила используют longest-prefix-wins и имеют приоритет над auto-template. Auto-template создают bucket’ы лениво по matched source subnet: `*4/N` для IPv4, `*6/N` для IPv6, а `*/N` является dual-stack shorthand, где IPv4 использует `/N`, а IPv6 — `/(N * 4)`.
- **Example**:
```toml
[access.cidr_rate_limits]
"203.0.113.0/24" = { up_bps = 0, down_bps = 1048576 }
"*4/32" = { up_bps = 262144, down_bps = 1048576 }
"*6/64" = { up_bps = 262144, down_bps = 1048576 }
```
# [[upstreams]]
@@ -3300,6 +3291,7 @@
| [`scopes`](#scopes) | `String` | `""` | `✘` |
| [`ipv4`](#ipv4-upstreams) | `bool` | — (auto) | `✘` |
| [`ipv6`](#ipv6-upstreams) | `bool` | — (auto) | `✘` |
| [`prefer`](#prefer-upstreams) | `4` или `6` | эффективный `[network].prefer` | `✘` |
| [`interface`](#interface) | `String` | — | `✘` |
| [`bind_addresses`](#bind_addresses) | `String[]` | — | `✘` |
| [`bindtodevice`](#bindtodevice) | `String` | — | `✘` |
@@ -3371,7 +3363,7 @@
```
## ipv6 (upstreams)
- **Ограничения / валидация**: `bool` (необязательный параметр).
- **Описание**: Разрешает IPv6 DC-targets для этого upstream. Если не задан, Telemt определяет поддержку автоматически по runtime-состоянию connectivity.
- **Описание**: Разрешает IPv6 DC-targets для этого upstream. Если не задан, Telemt определяет поддержку автоматически по runtime-состоянию connectivity. Установите `true`, если upstream proxy доступен с локального хоста по IPv4, но сам proxy умеет подключаться к Telegram DC по IPv6.
- **Пример**:
```toml
@@ -3379,6 +3371,18 @@
type = "direct"
ipv6 = false
```
## prefer (upstreams)
- **Ограничения / валидация**: Необязательное число. Должно быть `4` или `6`.
- **Описание**: Переопределяет предпочтительное IP-семейство для Telegram DC-targets, выбранных через этот upstream. Если параметр не задан, upstream наследует эффективное глобальное решение `[network].prefer`. Используйте `prefer = 6` вместе с `ipv6 = true` для SOCKS или Shadowsocks upstream, который умеет выходить в IPv6, даже если локальный хост с Telemt работает только по IPv4.
- **Пример**:
```toml
[[upstreams]]
type = "socks5"
address = "192.0.2.10:1080"
ipv6 = true
prefer = 6
```
## interface
- **Ограничения / валидация**: `String` (необязательный параметр).
- для `"direct"`: может быть IP-адресом (используется как явный local bind) или именем сетевого интерфейса ОС (резолвится в IP во время выполнения; только Unix).
+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.
> The client has no valid session to route the request through.
This is also why an MTProxy only needs to reach Telegram's DC infrastructure as a whole.
This is also why it is required for MTProxy to reach Telegram's DC infrastructure as a whole.
The proxy itself doesn't care which DC your account lives on. The client negotiates the correct DC through the proxy after connecting.
### How many people can use one link
+4 -2
View File
@@ -40,6 +40,8 @@ hello2 = "ad_tag2"
> Проблема с TLS отпечатком исправлена в последних версиях клиентов Telegram для Desktop / Android / iOS.
> Обновите свой клиент для корректной работы с MTProxy Fake-TLS!
- Для расследования блокировок на базе JA4 ClientHello используйте отдельную инструкцию: [`JA3 и JA4 анализ в Telemt`](Architecture/Fronting-splitting/TLS_JA3_JA4_ANALYSIS.ru.md).
- Мы считаем это прорывом, которому на сегодняшний день нет стабильных аналогов;
- Исходя из этого: если `telemt` настроен правильно, **режим TLS полностью идентичен реальному «рукопожатию» + обмену данными** с указанным хостом;
- Вот наши доказательства:
@@ -157,7 +159,7 @@ https://github.com/telemt/telemt/discussions/167
## Как клиенты взаимодействуют с дата-центрами Telegram
При регистрации аккаунта Telegram он навсегда привязывается к одному из дата-центров (DC).
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относиться номер телефона.
Telegram заранее определяет к какому DC привязать аккаунт исходя из региона, к которому относится номер телефона.
Этот DC становится вашим **домашним**: именно там хранится весь контент, который вы загружаете (фото, видео, файлы, сообщения).
И именно на нем клиент авторизуется при каждом подключении.
@@ -170,7 +172,7 @@ Telegram заранее определяет к какому DC привязат
> Если ваш домашний DC — DC2, и DC2 лежит, вы **не сможете** достучаться и до DC5, даже если сам DC5 полностью исправен.
> У клиента просто нет валидной сессии, через которую можно было бы направить запрос.
По той же причине MTProxy достаточно иметь доступ к инфраструктуре Telegram в целом.
По той же причине MTProxy необходимо иметь доступ к инфраструктуре Telegram целиком, а не частично.
Cамому MTProxy всё равно, на каком DC живёт ваш аккаунт. Клиент cам договаривается о нужном DC через прокси уже после подключения.
## Что такое dd и ee в контексте MTProxy?
+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
**1. Отредактируйте `config.toml` в корневом каталоге репозитория (как минимум: порт, пользовательские секреты, tls_domain)**
**1. Создайте директорию `config/` и поместите в неё отрдеактированный `config.toml` (указав как минимум: порт, пользовательские секреты, tls_domain):**
```bash
mkdir config && mv config.toml config/
```
**2. Запустите контейнер:**
```bash
docker compose up -d --build
@@ -249,7 +252,7 @@ docker compose logs -f telemt
docker compose down
```
> [!NOTE]
> - В `docker-compose.yml` файл `./config.toml` монтируется в `/app/config.toml` (доступно только для чтения)
> - Директория `./config/` монтируется в `/etc/telemt/` (read-write), что позволяет API атомарно обновлять config.toml
> - По умолчанию публикуются порты 443:443, а контейнер запускается со сброшенными привилегиями (добавлена только `NET_BIND_SERVICE`)
> - Если вам действительно нужна сеть хоста (обычно это требуется только для некоторых конфигураций IPv6), раскомментируйте `network_mode: host`
+1 -1
View File
@@ -206,7 +206,7 @@ File content:
"publicKey": "<SERVER_B_PUBLIC_KEY>",
"shortId": "<SHORT_ID>",
"spiderX": "/",
"fingerprint": "chrome"
"fingerprint": "firefox"
},
"xhttpSettings": {
"path": "/<YOUR_RANDOM_PATH>"
+1 -1
View File
@@ -206,7 +206,7 @@ nano /usr/local/etc/xray/config.json
"publicKey": "<SERVER_B_PUBLIC_KEY>",
"shortId": "<SHORT_ID>",
"spiderX": "/",
"fingerprint": "chrome"
"fingerprint": "firefox"
},
"xhttpSettings": {
"path": "/<YOUR_RANDOM_PATH>"
+58 -25
View File
@@ -84,21 +84,22 @@ set_language() {
L_INFO_KEEP_CONF="Примечание: Конфигурация сохранена. Используйте 'purge' для очистки."
L_INFO_I_START="Начинается установка"
L_I_STAGE_1=">>> Этап 1: Проверка окружения и зависимостей"
L_I_STAGE_1_5=">>> Этап 1.5: Интерактивная настройка"
L_I_STAGE_2=">>> Этап 2: Интерактивная настройка"
L_I_PROMPT_DOM="\nПожалуйста, укажите домен TLS\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
L_I_PROMPT_PORT="\nПожалуйста, укажите порт сервера\nНажмите Enter, чтобы оставить по умолчанию [%s]: "
L_WARN_NO_TTY="Интерактивный режим недоступен (нет TTY). Используется:"
L_I_STAGE_2=">>> Этап 2: Загрузка архива"
L_I_STAGE_3=">>> Этап 3: Загрузка архива"
L_ERR_TMP_DIR="Не удалось создать временную директорию"
L_ERR_TMP_INV="Временная директория недействительна"
L_INFO_FALLBACK="Сборка x86_64-v3 не найдена, откат к стандартной x86_64..."
L_ERR_DL_FAIL="Ошибка загрузки архива"
L_I_STAGE_3=">>> Этап 3: Распаковка архива"
L_I_STAGE_4=">>> Этап 4: Распаковка архива"
L_ERR_EXTRACT="Ошибка распаковки архива."
L_ERR_BIN_NOT_FOUND="Бинарный файл не найден в архиве"
L_I_STAGE_4=">>> Этап 4: Настройка окружения (Юзер, Группа, Папки)"
L_I_STAGE_5=">>> Этап 5: Установка бинарного файла"
L_I_STAGE_6=">>> Этап 6: Генерация/Обновление конфигурации"
L_I_STAGE_7=">>> Этап 7: Установка и запуск службы"
L_I_STAGE_5=">>> Этап 5: Настройка окружения (Юзер, Группа, Папки)"
L_I_STAGE_6=">>> Этап 6: Установка бинарного файла"
L_I_STAGE_7=">>> Этап 7: Генерация/Обновление конфигурации"
L_I_STAGE_8=">>> Этап 8: Установка и запуск службы"
L_OUT_WARN_H="УСТАНОВКА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ"
L_OUT_WARN_D="Служба установлена, но не запустилась.\nПожалуйста, проверьте логи.\n"
L_OUT_SUCC_H="УСТАНОВКА УСПЕШНО ЗАВЕРШЕНА"
@@ -160,21 +161,22 @@ set_language() {
L_INFO_KEEP_CONF="Note: Configuration kept. Run with 'purge' to remove completely."
L_INFO_I_START="Starting installation of"
L_I_STAGE_1=">>> Stage 1: Verifying environment and dependencies"
L_I_STAGE_1_5=">>> Stage 1.5: Interactive Setup"
L_I_STAGE_2=">>> Stage 2: Interactive Setup"
L_I_PROMPT_DOM="\nPlease specify the TLS Domain\nPress Enter to keep default [%s]: "
L_I_PROMPT_PORT="\nPlease specify the Server Port\nPress Enter to keep default [%s]: "
L_WARN_NO_TTY="Interactive mode unavailable (no TTY). Using:"
L_I_STAGE_2=">>> Stage 2: Downloading archive"
L_I_STAGE_3=">>> Stage 3: Downloading archive"
L_ERR_TMP_DIR="Temp directory creation failed"
L_ERR_TMP_INV="Temp directory is invalid or was not created"
L_INFO_FALLBACK="x86_64-v3 build not found, falling back to standard x86_64..."
L_ERR_DL_FAIL="Download failed"
L_I_STAGE_3=">>> Stage 3: Extracting archive"
L_I_STAGE_4=">>> Stage 4: Extracting archive"
L_ERR_EXTRACT="Extraction failed."
L_ERR_BIN_NOT_FOUND="Binary not found in archive"
L_I_STAGE_4=">>> Stage 4: Setting up environment (User, Group, Directories)"
L_I_STAGE_5=">>> Stage 5: Installing binary"
L_I_STAGE_6=">>> Stage 6: Generating/Updating configuration"
L_I_STAGE_7=">>> Stage 7: Installing and starting service"
L_I_STAGE_5=">>> Stage 5: Setting up environment (User, Group, Directories)"
L_I_STAGE_6=">>> Stage 6: Installing binary"
L_I_STAGE_7=">>> Stage 7: Generating/Updating configuration"
L_I_STAGE_8=">>> Stage 8: Installing and starting service"
L_OUT_WARN_H="INSTALLATION COMPLETED WITH WARNINGS"
L_OUT_WARN_D="The service was installed but failed to start.\nPlease check the logs to determine the issue.\n"
L_OUT_SUCC_H="INSTALLATION SUCCESS"
@@ -269,7 +271,10 @@ say() {
if [ "$#" -eq 0 ] || [ -z "${1:-}" ]; then
printf '\n'
else
printf '[INFO] %s\n' "$*"
case "$*" in
\[*\]*) printf '%s\n' "$*" ;;
*) printf '[INFO] %s\n' "$*" ;;
esac
fi
}
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
@@ -527,9 +532,9 @@ setup_dirs() {
stop_service() {
svc="$(get_svc_mgr)"
if [ "$svc" = "systemd" ] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
if [ "$svc" = "systemd" ] && $SUDO systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
$SUDO systemctl stop "$SERVICE_NAME" 2>/dev/null || true
elif [ "$svc" = "openrc" ] && rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
elif [ "$svc" = "openrc" ] && $SUDO rc-service "$SERVICE_NAME" status >/dev/null 2>&1; then
$SUDO rc-service "$SERVICE_NAME" stop 2>/dev/null || true
fi
}
@@ -832,10 +837,36 @@ case "$ACTION" in
fi
fi
check_port_availability
if [ "$PORT_PROVIDED" -eq 0 ] || [ "$DOMAIN_PROVIDED" -eq 0 ]; then
say "$L_I_STAGE_2"
fi
if [ "$PORT_PROVIDED" -eq 0 ]; then
if [ -t 0 ] || [ -c /dev/tty ]; then
while true; do
printf "$L_I_PROMPT_PORT" "$SERVER_PORT"
read -r input_port </dev/tty || input_port=""
if [ -z "$input_port" ]; then
break
fi
case "$input_port" in
*[!0-9]*) printf '[ERROR] %s\n' "$L_ERR_PORT_NUM" >&2; continue ;;
esac
port_num="$(printf '%s\n' "$input_port" | sed 's/^0*//')"
[ -z "$port_num" ] && port_num="0"
if [ "${#port_num}" -gt 5 ] || [ "$port_num" -lt 1 ] || [ "$port_num" -gt 65535 ]; then
printf '[ERROR] %s\n' "$L_ERR_PORT_RANGE" >&2; continue
fi
SERVER_PORT="$port_num"
break
done
else
say "[WARNING] $L_WARN_NO_TTY $SERVER_PORT"
fi
PORT_PROVIDED=1
fi
if [ "$DOMAIN_PROVIDED" -eq 0 ]; then
say "$L_I_STAGE_1_5"
if [ -t 0 ] || [ -c /dev/tty ]; then
printf "$L_I_PROMPT_DOM" "$TLS_DOMAIN"
read -r input_domain </dev/tty || input_domain=""
@@ -848,6 +879,8 @@ case "$ACTION" in
DOMAIN_PROVIDED=1
fi
check_port_availability
if [ "$TARGET_VERSION" != "latest" ]; then
TARGET_VERSION="${TARGET_VERSION#v}"
fi
@@ -861,7 +894,7 @@ case "$ACTION" in
DL_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}/${FILE_NAME}"
fi
say "$L_I_STAGE_2"
say "$L_I_STAGE_3"
TEMP_DIR="$(mktemp -d)" || die "$L_ERR_TMP_DIR"
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
die "$L_ERR_TMP_INV"
@@ -883,7 +916,7 @@ case "$ACTION" in
fi
fi
say "$L_I_STAGE_3"
say "$L_I_STAGE_4"
if ! gzip -dc "${TEMP_DIR}/${FILE_NAME}" | tar -xf - -C "$TEMP_DIR" 2>/dev/null; then
die "$L_ERR_EXTRACT"
fi
@@ -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)"
[ -n "$EXTRACTED_BIN" ] || die "$L_ERR_BIN_NOT_FOUND"
say "$L_I_STAGE_4"
say "$L_I_STAGE_5"
ensure_user_group; setup_dirs; stop_service
say "$L_I_STAGE_5"
say "$L_I_STAGE_6"
install_binary "$EXTRACTED_BIN" "${INSTALL_DIR}/${BIN_NAME}"
say "$L_I_STAGE_6"
say "$L_I_STAGE_7"
install_config
say "$L_I_STAGE_7"
say "$L_I_STAGE_8"
install_service
if [ "${SERVICE_START_FAILED:-0}" -eq 1 ]; then
+387
View File
@@ -0,0 +1,387 @@
//! Config-editing API: read managed sections and apply sparse field patches.
//! `access.*` is intentionally not editable here (owned by the users API).
//! `[server]` is only partially editable — see [`EDITABLE_SERVER_FIELDS`].
use serde_json::Value as Json;
use toml::Value as Toml;
use super::ApiShared;
use super::config_store::{
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section,
load_candidate_snapshot, load_config_snapshot, render_server_listeners,
render_top_level_section, resolve_single_source_owner, upsert_toml_table, write_atomic,
};
use super::model::ApiFailure;
use crate::config::ProxyConfig;
use crate::config::hot_reload::classify_config_changes;
use crate::maestro::reload::{ReloadAccepted, ReloadRequest, ReloadSubmitError};
use crate::maestro::runtime_build::{deferred_process_fields, resolve_reload_config};
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Serialize)]
pub(super) struct PatchConfigResponse {
pub revision: String,
pub restart_required: bool,
pub runtime_reload_required: bool,
pub process_restart_required: bool,
pub deferred_process_fields: Vec<String>,
pub changed: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reload: Option<ReloadAccepted>,
}
struct PreparedConfigPatch {
owner_path: PathBuf,
owner_contents: String,
desired_config: Arc<ProxyConfig>,
response: PatchConfigResponse,
}
/// Serializes config mutations behind `mutation_lock`, commits them, and records
/// a runtime event. The route handler calls this shared-state wrapper.
pub(super) async fn patch_config(
patch_json: Json,
expected_revision: Option<String>,
reload_request: Option<ReloadRequest>,
shared: &ApiShared,
) -> Result<PatchConfigResponse, ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let active_config = shared.active_runtime.load_full().config();
let mut prepared =
prepare_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
let resolved = resolve_reload_config(&active_config, &prepared.desired_config);
prepared.response.runtime_reload_required = resolved.runtime_changed;
prepared.response.process_restart_required = !resolved.deferred_process_fields.is_empty();
prepared.response.deferred_process_fields = resolved.deferred_process_fields;
let reservation = if let Some(request) = reload_request.filter(|_| resolved.runtime_changed) {
Some(
shared
.reload_control
.reserve(prepared.response.revision.clone(), request)
.await
.map_err(reload_submit_failure)?,
)
} else {
None
};
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
if let Some(reservation) = reservation {
prepared.response.reload = Some(reservation.enqueue(prepared.desired_config));
}
let resp = prepared.response;
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.
#[cfg(test)]
pub(super) async fn apply_patch_to_path(
config_path: &Path,
patch_json: &Json,
expected_revision: Option<String>,
) -> Result<PatchConfigResponse, ApiFailure> {
let prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
Ok(prepared.response)
}
async fn prepare_patch_to_path(
config_path: &Path,
patch_json: &Json,
expected_revision: Option<String>,
) -> Result<PreparedConfigPatch, ApiFailure> {
// 1. optimistic concurrency
let loaded = load_config_snapshot(config_path, false).await?;
let current = compute_snapshot_revision(&loaded);
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 / forbidden server fields
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, value) in patch_table {
if !is_editable_section(key.as_str()) {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"section_not_editable",
format!("section not editable: {}", key),
));
}
if key == "server" {
validate_server_patch(value)?;
}
}
let touched: Vec<&str> = patch_table
.keys()
.map(|k| k.as_str())
.filter(|k| is_editable_section(k))
.collect();
if touched.is_empty() {
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
}
// 3. Merge against the fully expanded and normalized desired config. The
// source owner is resolved separately so included sections stay in their
// original file and unrelated source files remain byte-identical.
let old_cfg = loaded.config.clone();
let mut merged = Toml::try_from(&old_cfg)
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
deep_merge(&mut merged, &patch_toml);
let requested_cfg: ProxyConfig = merged
.clone()
.try_into()
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
requested_cfg
.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let ownership_targets: Vec<&str> = touched
.iter()
.map(|section| {
if *section == "server" {
"server.listeners"
} else {
*section
}
})
.collect();
let owner_path = resolve_single_source_owner(&loaded, config_path, &ownership_targets)?;
let mut owner_contents = loaded
.source_contents
.get(&owner_path)
.cloned()
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
for section in &touched {
if *section == "server" {
let rendered = render_server_listeners(&requested_cfg)?;
owner_contents = upsert_toml_table(&owner_contents, "server.listeners", &rendered);
} else {
let rendered = render_top_level_section(&requested_cfg, section)?;
owner_contents = upsert_toml_table(&owner_contents, section, &rendered);
}
}
let candidate = load_candidate_snapshot(
config_path,
&loaded.source_contents,
owner_path.clone(),
owner_contents.clone(),
)
.await?;
if touched.contains(&"server")
&& serde_json::to_value(&candidate.config.server.listeners).ok()
!= serde_json::to_value(&requested_cfg.server.listeners).ok()
{
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"ambiguous_listeners",
"server.listeners normalizes to a different effective listener set",
));
}
// 4. classify the validated, normalized candidate.
let revision = compute_snapshot_revision(&candidate);
let new_cfg = candidate.config;
let class = classify_config_changes(&old_cfg, &new_cfg);
let deferred_process_fields = deferred_process_fields(&old_cfg, &new_cfg);
Ok(PreparedConfigPatch {
owner_path,
owner_contents,
desired_config: Arc::new(new_cfg),
response: PatchConfigResponse {
revision,
restart_required: class.restart_required,
runtime_reload_required: class.restart_required,
process_restart_required: !deferred_process_fields.is_empty(),
deferred_process_fields,
changed: class.changed,
reload: None,
},
})
}
fn reload_submit_failure(error: ReloadSubmitError) -> ApiFailure {
match error {
ReloadSubmitError::InProgress(reload_id) => ApiFailure::new(
hyper::StatusCode::CONFLICT,
"reload_in_progress",
format!("Reload {} is already in progress", reload_id),
),
ReloadSubmitError::MaestroUnavailable => ApiFailure::new(
hyper::StatusCode::SERVICE_UNAVAILABLE,
"maestro_unavailable",
"Maestro reload coordinator is unavailable",
),
}
}
/// Return only the editable config sections + current revision.
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let revision = compute_snapshot_revision(&loaded);
let parsed = Toml::try_from(&loaded.config)
.map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?;
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.api` (auth_header) and `network` (per-node
// addresses). Mirror the PATCH contract, including the nested server
// field-level allowlist.
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());
}
}
if let Some(server) = parsed_table.get("server") {
if let Some(filtered) = filter_server_for_read(server) {
table.insert("server".to_string(), filtered);
}
}
Ok((Toml::Table(table), revision))
}
/// Keep only [`EDITABLE_SERVER_FIELDS`] from a `[server]` table for GET.
fn filter_server_for_read(server: &Toml) -> Option<Toml> {
let Some(src) = server.as_table() else {
return None;
};
let mut out = toml::value::Table::new();
for field in EDITABLE_SERVER_FIELDS {
if let Some(value) = src.get(*field) {
// Skip empty listeners arrays so absent-vs-empty stays consistent
// with other optional sections.
if *field == "listeners" {
if let Some(arr) = value.as_array() {
if arr.is_empty() {
continue;
}
}
}
out.insert((*field).to_string(), value.clone());
}
}
if out.is_empty() {
None
} else {
Some(Toml::Table(out))
}
}
/// Reject any `[server]` patch keys outside [`EDITABLE_SERVER_FIELDS`].
fn validate_server_patch(server: &Toml) -> Result<(), ApiFailure> {
let Some(table) = server.as_table() else {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"section_not_editable",
"server patch must be a JSON object",
));
};
if table.is_empty() {
return Err(ApiFailure::bad_request(
"empty server patch: provide at least one editable field \
(currently: listeners)",
));
}
for key in table.keys() {
if !EDITABLE_SERVER_FIELDS.contains(&key.as_str()) {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"field_not_editable",
format!(
"server.{} is not editable via the config API; allowed server fields: {}",
key,
EDITABLE_SERVER_FIELDS.join(", ")
),
));
}
}
Ok(())
}
/// 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() {
// TOML has no null value, so sparse object nulls are omitted.
continue;
}
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)]
#[path = "config_edit/tests.rs"]
mod tests;
+368
View File
@@ -0,0 +1,368 @@
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"));
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100));
}
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.runtime_reload_required);
assert!(!resp.process_restart_required);
assert!(resp.deferred_process_fields.is_empty());
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::current_revision(&path)
.await
.unwrap()
);
}
#[tokio::test]
async fn read_managed_config_strips_access() {
let (path, _d) = temp_config(
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"00000000000000000000000000000000\"\n",
);
let (value, revision) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
// Secrets never leave the box through this endpoint.
assert!(!table.contains_key("access"));
assert_eq!(
revision,
crate::api::config_store::current_revision(&path)
.await
.unwrap()
);
}
#[tokio::test]
async fn read_managed_config_returns_only_editable_sections() {
// Full server (api/port) and network must not leak. Listeners-only server
// is returned via the nested allowlist (covered in a dedicated test).
let (path, _d) = temp_config(concat!(
"[censorship]\ntls_domain = \"a\"\n",
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
"[network]\nipv4 = true\n",
"[access.users]\nbob = \"00000000000000000000000000000000\"\n",
));
let (value, _rev) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
let server = table["server"].as_table().unwrap();
assert!(server.contains_key("listeners"));
assert!(!server.contains_key("api"));
assert!(!server.contains_key("port"));
assert!(!table.contains_key("network"));
assert!(!table.contains_key("access"));
}
#[tokio::test]
async fn read_managed_config_returns_server_listeners_only() {
let (path, _d) = temp_config(concat!(
"[censorship]\ntls_domain = \"a\"\n",
"[server]\nport = 443\n",
"[server.api]\nauth_header = \"SECRET\"\n",
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
));
let (value, _rev) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
let server = table
.get("server")
.expect("server.listeners present")
.as_table()
.unwrap();
assert!(server.contains_key("listeners"));
assert!(!server.contains_key("api"));
assert!(!server.contains_key("port"));
let listeners = server["listeners"].as_array().unwrap();
assert_eq!(listeners.len(), 1);
assert_eq!(listeners[0]["port"].as_integer(), Some(443));
}
#[tokio::test]
async fn patch_rejects_forbidden_server_fields() {
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, "field_not_editable");
}
#[tokio::test]
async fn patch_rejects_server_api_field() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"server": {"api": {"enabled": false}}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "field_not_editable");
}
#[tokio::test]
async fn patch_server_listeners_preserves_api() {
let (path, _d) = temp_config(concat!(
"[censorship]\ntls_domain = \"a\"\n",
"[server]\nport = 443\n",
"[server.api]\nenabled = true\nauth_header = \"SECRET\"\n",
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
));
let patch: Json = serde_json::json!({
"server": {
"listeners": [
{"ip": "0.0.0.0", "port": 8443, "client_mss": "92"}
]
}
});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(resp.changed.iter().any(|c| c == "server"));
let written = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(
parsed["server"]["api"]["auth_header"].as_str(),
Some("SECRET"),
"{written}"
);
let listeners = parsed["server"]["listeners"].as_array().unwrap();
assert_eq!(listeners.len(), 1, "{written}");
assert_eq!(listeners[0]["port"].as_integer(), Some(8443), "{written}");
assert_eq!(listeners[0]["client_mss"].as_str(), Some("92"), "{written}");
}
#[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_writes_the_included_section_owner_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
let included = dir.path().join("censorship.toml");
let root_body = "include = \"censorship.toml\"\n[server]\nport = 443\n";
let included_body = "[censorship]\ntls_domain = \"old.example\"\n";
tokio::fs::write(&root, root_body).await.unwrap();
tokio::fs::write(&included, included_body).await.unwrap();
let patch: Json = serde_json::json!({
"censorship": {"tls_domain": "new.example"}
});
let response = apply_patch_to_path(&root, &patch, None).await.unwrap();
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
let written = tokio::fs::read_to_string(&included).await.unwrap();
assert!(written.contains("tls_domain = \"new.example\""));
assert_eq!(
response.revision,
crate::api::config_store::current_revision(&root)
.await
.unwrap()
);
}
#[tokio::test]
async fn patch_rejects_multiple_source_owners_without_writing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
let included = dir.path().join("censorship.toml");
let root_body = concat!(
"include = \"censorship.toml\"\n",
"[general]\nprefer_ipv6 = false\n"
);
let included_body = "[censorship]\ntls_domain = \"old.example\"\n";
tokio::fs::write(&root, root_body).await.unwrap();
tokio::fs::write(&included, included_body).await.unwrap();
let patch: Json = serde_json::json!({
"general": {"prefer_ipv6": true},
"censorship": {"tls_domain": "new.example"}
});
let error = apply_patch_to_path(&root, &patch, None).await.unwrap_err();
assert_eq!(error.code, "config_patch_not_atomic");
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
assert_eq!(
tokio::fs::read_to_string(&included).await.unwrap(),
included_body
);
}
#[tokio::test]
async fn unavailable_reload_coordinator_is_detected_before_config_write() {
let (path, _dir) = temp_config("[censorship]\ntls_domain = \"old.example\"\n");
let original = tokio::fs::read_to_string(&path).await.unwrap();
let patch: Json = serde_json::json!({
"censorship": {"tls_domain": "new.example"}
});
let prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
let (control, receiver) = crate::maestro::reload::ReloadControl::channel(1);
drop(receiver);
let error = match control
.reserve(prepared.response.revision.clone(), ReloadRequest::default())
.await
{
Ok(_) => panic!("closed reload coordinator must reject the reservation"),
Err(error) => error,
};
assert_eq!(error, ReloadSubmitError::MaestroUnavailable);
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), original);
}
#[tokio::test]
async fn failed_config_write_releases_reload_reservation() {
let dir = tempfile::tempdir().unwrap();
let (control, _receiver) = crate::maestro::reload::ReloadControl::channel(1);
let reservation = control
.reserve("candidate-revision".to_string(), ReloadRequest::default())
.await
.unwrap();
let result = write_atomic(dir.path().to_path_buf(), "invalid target".to_string()).await;
drop(reservation);
assert!(result.is_err());
assert_eq!(control.in_progress().await, None);
}
#[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.runtime_reload_required);
assert!(!resp.process_restart_required);
assert!(resp.changed.iter().any(|c| c == "general"));
}
+234 -250
View File
@@ -1,19 +1,27 @@
use std::collections::BTreeMap;
use std::io::Write;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use hyper::header::IF_MATCH;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::config::{ProxyConfig, RateLimitBps};
use crate::config::{ConfigSourceGraph, LoadedConfig, ProxyConfig};
use super::model::ApiFailure;
// Source-preserving TOML rendering and atomic persistence helpers.
mod persistence;
#[cfg(test)]
use persistence::{find_toml_table_bounds, render_access_section, save_sections_to_disk};
pub(in crate::api) use persistence::{
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
upsert_toml_table, write_atomic,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AccessSection {
Users,
UserEnabled,
UserAdTags,
UserMaxTcpConns,
UserExpirations,
@@ -26,6 +34,7 @@ impl AccessSection {
fn table_name(self) -> &'static str {
match self {
Self::Users => "access.users",
Self::UserEnabled => "access.user_enabled",
Self::UserAdTags => "access.user_ad_tags",
Self::UserMaxTcpConns => "access.user_max_tcp_conns",
Self::UserExpirations => "access.user_expirations",
@@ -64,10 +73,21 @@ pub(super) async fn ensure_expected_revision(
}
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
let content = tokio::fs::read_to_string(config_path)
let config_path = config_path.to_path_buf();
let graph = tokio::task::spawn_blocking(move || ProxyConfig::read_source_graph(config_path))
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
Ok(compute_revision(&content))
.map_err(|error| ApiFailure::internal(format!("failed to join config reader: {error}")))?
.map_err(|error| ApiFailure::internal(format!("failed to read config graph: {error}")))?;
Ok(compute_source_revision(&graph))
}
pub(crate) async fn current_revision_for_maestro(config_path: &Path) -> Result<String, String> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::read_source_graph(config_path))
.await
.map_err(|error| format!("failed to join config reader: {error}"))?
.map(|graph| compute_source_revision(&graph))
.map_err(|error| error.to_string())
}
pub(super) fn compute_revision(content: &str) -> String {
@@ -76,6 +96,164 @@ pub(super) fn compute_revision(content: &str) -> String {
hex::encode(hasher.finalize())
}
pub(super) fn compute_snapshot_revision(loaded: &LoadedConfig) -> String {
compute_source_revision(&ConfigSourceGraph {
source_contents: loaded.source_contents.clone(),
rendered: String::new(),
})
}
pub(super) fn compute_source_revision(graph: &ConfigSourceGraph) -> String {
let mut hasher = Sha256::new();
hasher.update(b"telemt-config-manifest-v1\0");
for (path, content) in &graph.source_contents {
let path = path.as_os_str().as_encoded_bytes();
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path);
hasher.update((content.len() as u64).to_le_bytes());
hasher.update(content.as_bytes());
}
hex::encode(hasher.finalize())
}
pub(super) async fn load_config_snapshot(
config_path: &Path,
invalid_is_bad_request: bool,
) -> Result<LoadedConfig, ApiFailure> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::load_with_metadata(config_path))
.await
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
.map_err(|error| {
if invalid_is_bad_request {
ApiFailure::bad_request(format!("invalid runtime config: {error}"))
} else {
ApiFailure::internal(format!("failed to load config: {error}"))
}
})
}
pub(super) fn resolve_single_source_owner(
loaded: &LoadedConfig,
config_path: &Path,
targets: &[&str],
) -> Result<PathBuf, ApiFailure> {
let root = normalize_source_path(config_path);
let mut mutation_owners = BTreeSet::new();
if loaded
.source_contents
.values()
.any(|content| has_include_inside_table(content))
{
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"config_patch_not_atomic",
"config includes nested inside a TOML table cannot be mutated atomically",
));
}
for target in targets {
let mut owners = BTreeSet::new();
for (path, content) in &loaded.source_contents {
let parsed: toml::Value = toml::from_str(content).map_err(|error| {
ApiFailure::new(
hyper::StatusCode::CONFLICT,
"config_patch_not_atomic",
format!(
"config source {} is not independently writable: {error}",
path.display()
),
)
})?;
if toml_path_exists(&parsed, target) {
owners.insert(path.clone());
}
}
match owners.len() {
0 => {
mutation_owners.insert(root.clone());
}
1 => {
mutation_owners.extend(owners);
}
_ => {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"config_patch_not_atomic",
format!("config section {target} is owned by multiple source files"),
));
}
}
}
if mutation_owners.len() != 1 {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"config_patch_not_atomic",
"one mutation may update only one config source file",
));
}
mutation_owners
.into_iter()
.next()
.ok_or_else(|| ApiFailure::bad_request("empty mutation: no owned config sections"))
}
fn toml_path_exists(value: &toml::Value, target: &str) -> bool {
target
.split('.')
.try_fold(value, |current, part| current.get(part))
.is_some()
}
fn has_include_inside_table(content: &str) -> bool {
let mut inside_table = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
inside_table = true;
}
if inside_table
&& trimmed
.strip_prefix("include")
.is_some_and(|rest| rest.trim_start().starts_with('='))
{
return true;
}
}
false
}
pub(super) async fn load_candidate_snapshot(
config_path: &Path,
base_sources: &BTreeMap<PathBuf, String>,
owner_path: PathBuf,
owner_contents: String,
) -> Result<LoadedConfig, ApiFailure> {
let config_path = config_path.to_path_buf();
let mut overrides = base_sources.clone();
overrides.insert(owner_path, owner_contents);
tokio::task::spawn_blocking(move || {
ProxyConfig::load_with_source_overrides(config_path, &overrides)
})
.await
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))
}
fn normalize_source_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
})
}
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
@@ -84,6 +262,14 @@ pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyCon
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
}
pub(super) async fn load_config_for_reload(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
.await
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {}", error)))?
.map_err(|error| ApiFailure::bad_request(format!("invalid runtime config: {}", error)))
}
#[allow(dead_code)]
pub(super) async fn save_config_to_disk(
config_path: &Path,
@@ -95,250 +281,48 @@ pub(super) async fn save_config_to_disk(
Ok(compute_revision(&serialized))
}
pub(super) async fn save_access_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
sections: &[AccessSection],
) -> 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)))?;
/// 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.
/// - `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`.
///
/// `server` is partially editable: only the nested fields listed in
/// [`EDITABLE_SERVER_FIELDS`] (currently `listeners`) may appear in GET/PATCH.
/// Secrets and bind identity (`api`/`admin_api`, `port`, unix sockets, …) stay
/// blocked. See also the field-level allowlist note below for `network.*`.
///
/// 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",
];
let mut applied = Vec::new();
for section in sections {
if applied.contains(section) {
continue;
}
if find_toml_table_bounds(&content, section.table_name()).is_none()
&& access_section_is_empty(cfg, *section)
{
applied.push(*section);
continue;
}
let rendered = render_access_section(cfg, *section)?;
content = upsert_toml_table(&content, section.table_name(), &rendered);
applied.push(*section);
}
/// Nested fields under `[server]` that may be read/patched via the config API.
///
/// Arrays (e.g. `listeners`) replace wholesale on PATCH, matching the existing
/// merge semantics for non-table values.
pub(super) const EDITABLE_SERVER_FIELDS: &[&str] = &["listeners"];
write_atomic(config_path.to_path_buf(), content.clone()).await?;
Ok(compute_revision(&content))
}
fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<String, ApiFailure> {
let body = match section {
AccessSection::Users => {
let rows: BTreeMap<String, String> = cfg
.access
.users
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserAdTags => {
let rows: BTreeMap<String, String> = cfg
.access
.user_ad_tags
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserMaxTcpConns => {
let rows: BTreeMap<String, usize> = cfg
.access
.user_max_tcp_conns
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserExpirations => {
let rows: BTreeMap<String, DateTime<Utc>> = cfg
.access
.user_expirations
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserDataQuota => {
let rows: BTreeMap<String, u64> = cfg
.access
.user_data_quota
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserRateLimits => {
let rows: BTreeMap<String, RateLimitBps> = cfg
.access
.user_rate_limits
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_rate_limit_body(&rows)?
}
AccessSection::UserMaxUniqueIps => {
let rows: BTreeMap<String, usize> = cfg
.access
.user_max_unique_ips
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
};
let mut out = format!("[{}]\n", section.table_name());
if !body.is_empty() {
out.push_str(&body);
}
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
match section {
AccessSection::Users => cfg.access.users.is_empty(),
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
}
}
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
toml::to_string(value)
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
}
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
let mut out = String::new();
for (key, value) in rows {
let key = serialize_toml_key(key)?;
out.push_str(&format!(
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
value.up_bps, value.down_bps
));
}
Ok(out)
}
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
let mut row = BTreeMap::new();
row.insert(key.to_string(), 0_u8);
let rendered = serialize_table_body(&row)?;
rendered
.split_once(" = ")
.map(|(key, _)| key.to_string())
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
}
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
if let Some((start, end)) = find_toml_table_bounds(source, table_name) {
let mut out = String::with_capacity(source.len() + replacement.len());
out.push_str(&source[..start]);
out.push_str(replacement);
out.push_str(&source[end..]);
return out;
}
let mut out = source.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(replacement);
out
}
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
let target = format!("[{}]", table_name);
let mut offset = 0usize;
let mut start = None;
for line in source.split_inclusive('\n') {
let trimmed = line.trim();
if let Some(start_offset) = start {
if trimmed.starts_with('[') {
return Some((start_offset, offset));
}
} else if trimmed == target {
start = Some(offset);
}
offset = offset.saturating_add(line.len());
}
start.map(|start_offset| (start_offset, source.len()))
}
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
.await
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
}
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let tmp_name = format!(
".{}.tmp-{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config.toml"),
rand::random::<u64>()
);
let tmp_path = parent.join(tmp_name);
let write_result = (|| {
let mut file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)?;
file.write_all(contents.as_bytes())?;
file.sync_all()?;
std::fs::rename(&tmp_path, path)?;
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
write_result
/// Whether `key` is an allowed top-level PATCH/GET section name.
///
/// Fully editable sections from [`EDITABLE_SECTIONS`], plus `server` which is
/// further restricted by [`EDITABLE_SERVER_FIELDS`].
pub(super) fn is_editable_section(key: &str) -> bool {
EDITABLE_SECTIONS.contains(&key) || key == "server"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_user_rate_limits_section() {
let mut cfg = ProxyConfig::default();
cfg.access.user_rate_limits.insert(
"alice".to_string(),
RateLimitBps {
up_bps: 1024,
down_bps: 2048,
},
);
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
.expect("section must render");
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
}
}
#[path = "config_store/tests.rs"]
mod tests;
+418
View File
@@ -0,0 +1,418 @@
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::config::{ProxyConfig, RateLimitBps};
#[cfg(test)]
use super::compute_revision;
use super::{
AccessSection, compute_snapshot_revision, load_candidate_snapshot, load_config_snapshot,
resolve_single_source_owner, toml_path_exists,
};
use crate::api::model::ApiFailure;
/// Re-render the given top-level tables from `cfg` and upsert each into the
/// on-disk file, preserving every untouched section (and its comments).
#[cfg(test)]
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.
pub(in crate::api) 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)
}
/// Renders normalized listener entries as nested array-of-table blocks.
pub(in crate::api) fn render_server_listeners(cfg: &ProxyConfig) -> Result<String, ApiFailure> {
let mut out = String::new();
for listener in &cfg.server.listeners {
out.push_str("[[server.listeners]]\n");
out.push_str(&toml::to_string(listener).map_err(|error| {
ApiFailure::internal(format!("failed to serialize server.listeners: {error}"))
})?);
if !out.ends_with('\n') {
out.push('\n');
}
}
Ok(out)
}
/// Validates and atomically writes access tables to their single source owner.
pub(in crate::api) async fn save_access_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
sections: &[AccessSection],
) -> Result<String, ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let mut applied = Vec::new();
for section in sections {
if applied.contains(section) {
continue;
}
applied.push(*section);
}
applied.retain(|section| {
!access_section_is_empty(cfg, *section)
|| loaded.source_contents.values().any(|contents| {
toml::from_str::<toml::Value>(contents)
.ok()
.is_some_and(|value| toml_path_exists(&value, section.table_name()))
})
});
if applied.is_empty() {
return Ok(compute_snapshot_revision(&loaded));
}
let targets = applied
.iter()
.map(|section| section.table_name())
.collect::<Vec<_>>();
let owner_path = resolve_single_source_owner(&loaded, config_path, &targets)?;
let mut owner_contents = loaded
.source_contents
.get(&owner_path)
.cloned()
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
for section in applied {
let rendered = render_access_section(cfg, section)?;
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
}
let candidate = load_candidate_snapshot(
config_path,
&loaded.source_contents,
owner_path.clone(),
owner_contents.clone(),
)
.await?;
let revision = compute_snapshot_revision(&candidate);
write_atomic(owner_path, owner_contents).await?;
Ok(revision)
}
/// Renders one access-control table for persistence tests and user mutations.
pub(super) fn render_access_section(
cfg: &ProxyConfig,
section: AccessSection,
) -> Result<String, ApiFailure> {
let body = match section {
AccessSection::Users => {
let rows: BTreeMap<String, String> = cfg
.access
.users
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserEnabled => {
let rows: BTreeMap<String, bool> = cfg
.access
.user_enabled
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserAdTags => {
let rows: BTreeMap<String, String> = cfg
.access
.user_ad_tags
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserMaxTcpConns => {
let rows: BTreeMap<String, usize> = cfg
.access
.user_max_tcp_conns
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserExpirations => {
let rows: BTreeMap<String, DateTime<Utc>> = cfg
.access
.user_expirations
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserDataQuota => {
let rows: BTreeMap<String, u64> = cfg
.access
.user_data_quota
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
AccessSection::UserRateLimits => {
let rows: BTreeMap<String, RateLimitBps> = cfg
.access
.user_rate_limits
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_rate_limit_body(&rows)?
}
AccessSection::UserMaxUniqueIps => {
let rows: BTreeMap<String, usize> = cfg
.access
.user_max_unique_ips
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect();
serialize_table_body(&rows)?
}
};
let mut out = format!("[{}]\n", section.table_name());
if !body.is_empty() {
out.push_str(&body);
}
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
match section {
AccessSection::Users => cfg.access.users.is_empty(),
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
}
}
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
toml::to_string(value)
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
}
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
let mut out = String::new();
for (key, value) in rows {
let key = serialize_toml_key(key)?;
out.push_str(&format!(
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
value.up_bps, value.down_bps
));
}
Ok(out)
}
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
let mut row = BTreeMap::new();
row.insert(key.to_string(), 0_u8);
let rendered = serialize_table_body(&row)?;
rendered
.split_once(" = ")
.map(|(key, _)| key.to_string())
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
}
/// Replaces all blocks owned by one semantic TOML table with one rendering.
pub(in crate::api) fn upsert_toml_table(
source: &str,
table_name: &str,
replacement: &str,
) -> String {
let blocks = find_all_table_blocks(source, table_name);
if let Some(&(first_start, first_end)) = blocks.first() {
// Replace the first block in place and delete any further blocks that
// also belong to this table. Telemt writes a section's sub-tables
// contiguously, but a hand-edited config may scatter them; dropping the
// extras here prevents the duplicate-table corruption that would
// otherwise break config load.
let mut out = String::with_capacity(source.len() + replacement.len());
out.push_str(&source[..first_start]);
out.push_str(replacement);
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;
}
let mut out = source.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(replacement);
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.
/// Locates one complete TOML table block in source text.
#[cfg(test)]
pub(super) fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
find_all_table_blocks(source, table_name).into_iter().next()
}
/// Locate every byte range that belongs to `table_name`: the table header and
/// its nested sub-tables. Returns one range per contiguous run, so a config
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
/// ranges — letting the caller collapse them into a single rendered block.
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
let mut blocks = Vec::new();
let mut offset = 0usize;
let mut start: Option<usize> = None;
for line in source.split_inclusive('\n') {
// Drop any inline comment so a hand-edited header like
// `[censorship] # note` still matches. Section names never contain `#`.
let header = line.trim().split('#').next().unwrap_or("").trim();
let is_header = header.starts_with('[');
if let Some(start_offset) = start {
if is_header && !header_belongs_to(header, table_name) {
blocks.push((start_offset, offset));
start = None;
}
}
if start.is_none() && header_belongs_to(header, table_name) {
start = Some(offset);
}
offset = offset.saturating_add(line.len());
}
if let Some(start_offset) = start {
blocks.push((start_offset, source.len()));
}
blocks
}
/// Replaces one config source through a durable same-directory rename.
pub(in crate::api) async fn write_atomic(
path: PathBuf,
contents: String,
) -> Result<(), ApiFailure> {
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
.await
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
}
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let tmp_name = format!(
".{}.tmp-{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config.toml"),
rand::random::<u64>()
);
let tmp_path = parent.join(tmp_name);
let write_result = (|| {
let mut file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)?;
file.write_all(contents.as_bytes())?;
file.sync_all()?;
std::fs::rename(&tmp_path, path)?;
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
write_result
}
+309
View File
@@ -0,0 +1,309 @@
use super::*;
use crate::config::RateLimitBps;
#[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\""));
// Untouched comments and tables remain byte content.
assert!(written.contains("# top comment"));
assert!(written.contains("[server]\nport = 443"));
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]]"));
// The bound spans through the last upstream block.
assert!(slice.contains("kind = \"b\""));
}
#[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"));
// The bound terminates at the next header.
assert!(!slice.contains("[server]"));
}
#[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}"));
// The unrelated table remains untouched.
assert!(written.contains("[server]\nport = 443"));
}
#[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]"));
// Nested sub-tables belong to the parent table bound.
assert!(slice.contains("[general.modes]"));
assert!(slice.contains("[general.links]"));
// The bound terminates before an unrelated header.
assert!(!slice.contains("[server]"));
}
#[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]"));
}
#[test]
fn nested_include_detection_does_not_reject_similar_access_keys() {
assert!(has_include_inside_table(
"[access.users]\ninclude = \"users.toml\"\n"
));
assert!(!has_include_inside_table(
"[access.users]\ninclude_user = \"00000000000000000000000000000000\"\n"
));
}
#[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}"));
// The unrelated section remains present.
assert!(written.contains("[server]"));
}
#[tokio::test]
async fn manifest_revision_changes_when_an_included_source_changes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
let included = dir.path().join("included.toml");
tokio::fs::write(&root, "include = \"included.toml\"\n")
.await
.unwrap();
tokio::fs::write(&included, "[censorship]\ntls_domain = \"one.example\"\n")
.await
.unwrap();
let first = current_revision(&root).await.unwrap();
tokio::fs::write(&included, "[censorship]\ntls_domain = \"two.example\"\n")
.await
.unwrap();
let second = current_revision(&root).await.unwrap();
assert_ne!(first, second);
}
#[tokio::test]
async fn manifest_revision_does_not_require_typed_config_validation() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
tokio::fs::write(&root, "[server]\nport = \"invalid\"\n")
.await
.unwrap();
let revision = current_revision(&root).await.unwrap();
assert_eq!(revision.len(), 64);
assert!(load_config_snapshot(&root, false).await.is_err());
}
#[tokio::test]
async fn access_mutation_writes_only_the_single_included_owner() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
let included = dir.path().join("users.toml");
let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"example.com\"\n";
let included_body = "[access.users]\nalice = \"00000000000000000000000000000000\"\n";
tokio::fs::write(&root, root_body).await.unwrap();
tokio::fs::write(&included, included_body).await.unwrap();
let mut cfg = load_config_from_disk(&root).await.unwrap();
cfg.access.users.insert(
"bob".to_string(),
"11111111111111111111111111111111".to_string(),
);
let revision = save_access_sections_to_disk(&root, &cfg, &[AccessSection::Users])
.await
.unwrap();
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
let written = tokio::fs::read_to_string(&included).await.unwrap();
assert!(written.contains("bob = \"11111111111111111111111111111111\""));
assert_eq!(revision, current_revision(&root).await.unwrap());
}
#[tokio::test]
async fn access_mutation_rejects_sections_with_different_source_owners() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
let included = dir.path().join("enabled.toml");
let root_body = concat!(
"include = \"enabled.toml\"\n",
"[access.users]\nalice = \"00000000000000000000000000000000\"\n"
);
let included_body = "[access.user_enabled]\nalice = false\n";
tokio::fs::write(&root, root_body).await.unwrap();
tokio::fs::write(&included, included_body).await.unwrap();
let cfg = load_config_from_disk(&root).await.unwrap();
let error = save_access_sections_to_disk(
&root,
&cfg,
&[AccessSection::Users, AccessSection::UserEnabled],
)
.await
.unwrap_err();
assert_eq!(error.code, "config_patch_not_atomic");
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
assert_eq!(
tokio::fs::read_to_string(&included).await.unwrap(),
included_body
);
}
#[test]
fn render_user_rate_limits_section() {
let mut cfg = ProxyConfig::default();
cfg.access.user_rate_limits.insert(
"alice".to_string(),
RateLimitBps {
up_bps: 1024,
down_bps: 2048,
},
);
let rendered =
render_access_section(&cfg, AccessSection::UserRateLimits).expect("section must render");
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
}
+10 -5
View File
@@ -1,6 +1,7 @@
use http_body_util::{BodyExt, Full};
use hyper::StatusCode;
use hyper::body::{Bytes, Incoming};
use hyper::header::ALLOW;
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -25,6 +26,8 @@ pub(super) fn success_response<T: Serialize>(
}
pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Response<Full<Bytes>> {
let status = failure.status;
let allow = failure.allow;
let payload = ErrorResponse {
ok: false,
error: ErrorBody {
@@ -40,11 +43,13 @@ pub(super) fn error_response(request_id: u64, failure: ApiFailure) -> hyper::Res
)
.into_bytes()
});
hyper::Response::builder()
.status(failure.status)
.header("content-type", "application/json; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.unwrap()
let mut builder = hyper::Response::builder()
.status(status)
.header("content-type", "application/json; charset=utf-8");
if let Some(allow) = allow {
builder = builder.header(ALLOW, allow);
}
builder.body(Full::new(Bytes::from(body))).unwrap()
}
pub(super) async fn read_json<T: DeserializeOwned>(
+442 -27
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use arc_swap::ArcSwap;
use http_body_util::Full;
use hyper::body::{Bytes, Incoming};
use hyper::header::AUTHORIZATION;
@@ -19,19 +20,25 @@ use tokio::sync::{Mutex, RwLock, Semaphore, watch};
use tokio::time::timeout;
use tracing::{debug, info, warn};
use crate::config::{ApiGrayAction, ProxyConfig};
use crate::config::ApiGrayAction;
use crate::ip_tracker::UserIpTracker;
use crate::maestro::generation::{RuntimeGeneration, RuntimeWatchState};
use crate::maestro::reload::{ReloadAccepted, ReloadControl, ReloadRequest, ReloadSubmitError};
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::startup::StartupTracker;
use crate::stats::Stats;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
mod config_store;
mod config_edit;
pub(crate) mod config_store;
mod events;
mod http_utils;
mod model;
mod patch;
#[cfg(test)]
mod reload_tests;
mod runtime_edge;
mod runtime_init;
mod runtime_min;
@@ -41,7 +48,10 @@ mod runtime_watch;
mod runtime_zero;
mod users;
use config_store::{current_revision, load_config_from_disk, parse_if_match};
use config_store::{
current_revision, ensure_expected_revision, load_config_for_reload, load_config_from_disk,
parse_if_match,
};
use events::ApiEventStore;
use http_utils::{error_response, read_json, read_optional_json, success_response};
use model::{
@@ -49,9 +59,10 @@ use model::{
PatchUserRequest, ResetUserQuotaResponse, RotateSecretRequest, SummaryData, UserActiveIps,
is_valid_username,
};
use patch::Patch;
use runtime_edge::{
EdgeConnectionsCacheEntry, build_runtime_connections_summary_data,
build_runtime_events_recent_data,
build_runtime_events_recent_data, build_runtime_tls_fingerprints_data,
};
use runtime_init::build_runtime_initialization_data;
use runtime_min::{
@@ -69,12 +80,18 @@ use runtime_zero::{
build_system_info_data,
};
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_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
const ROUTE_USERNAME_ERROR: &str = "username must match [A-Za-z0-9_.-] and be 1..64 chars";
const ALLOW_GET: &str = "GET";
const ALLOW_POST: &str = "POST";
const ALLOW_GET_POST: &str = "GET, POST";
const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE";
const ALLOW_GET_PATCH: &str = "GET, PATCH";
pub(super) struct ApiRuntimeState {
pub(super) process_started_at_epoch_secs: u64,
@@ -96,11 +113,15 @@ pub(super) struct ApiShared {
pub(super) minimal_cache: Arc<Mutex<Option<MinimalCacheEntry>>>,
pub(super) runtime_edge_connections_cache: Arc<Mutex<Option<EdgeConnectionsCacheEntry>>>,
pub(super) runtime_edge_recompute_lock: Arc<Mutex<()>>,
pub(super) cache_generation: Arc<AtomicU64>,
pub(super) runtime_events: Arc<ApiEventStore>,
pub(super) request_id: Arc<AtomicU64>,
pub(super) runtime_state: Arc<ApiRuntimeState>,
pub(super) startup_tracker: Arc<StartupTracker>,
pub(super) route_runtime: Arc<RouteRuntimeController>,
pub(super) proxy_shared: Arc<ProxySharedState>,
pub(super) reload_control: ReloadControl,
pub(super) active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
}
impl ApiShared {
@@ -111,6 +132,31 @@ impl ApiShared {
fn detected_link_ips(&self) -> (Option<IpAddr>, Option<IpAddr>) {
*self.detected_ips_rx.borrow()
}
fn for_runtime(&self, runtime: &RuntimeGeneration) -> Self {
Self {
stats: runtime.stats.clone(),
ip_tracker: runtime.ip_tracker.clone(),
me_pool: runtime.me_pool_runtime.clone(),
upstream_manager: runtime.upstream_manager.clone(),
config_path: self.config_path.clone(),
quota_state_path: self.quota_state_path.clone(),
detected_ips_rx: self.detected_ips_rx.clone(),
mutation_lock: self.mutation_lock.clone(),
minimal_cache: self.minimal_cache.clone(),
runtime_edge_connections_cache: self.runtime_edge_connections_cache.clone(),
runtime_edge_recompute_lock: self.runtime_edge_recompute_lock.clone(),
cache_generation: self.cache_generation.clone(),
runtime_events: self.runtime_events.clone(),
request_id: self.request_id.clone(),
runtime_state: self.runtime_state.clone(),
startup_tracker: self.startup_tracker.clone(),
route_runtime: runtime.route_runtime.clone(),
proxy_shared: runtime.proxy_shared.clone(),
reload_control: self.reload_control.clone(),
active_runtime: self.active_runtime.clone(),
}
}
}
fn auth_header_matches(actual: &str, expected: &str) -> bool {
@@ -125,21 +171,135 @@ 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 reload_status_route_id(path: &str) -> Option<u64> {
path.strip_prefix("/v1/system/reload/")
.filter(|id| !id.is_empty() && !id.contains('/'))
.and_then(|id| id.parse().ok())
}
async fn submit_reload_from_disk(
config_path: &std::path::Path,
mutation_lock: &Mutex<()>,
reload_control: &ReloadControl,
expected_revision: Option<&str>,
request: ReloadRequest,
) -> Result<(ReloadAccepted, String), ApiFailure> {
let _guard = mutation_lock.lock().await;
ensure_expected_revision(config_path, expected_revision).await?;
let revision = current_revision(config_path).await?;
let config = Arc::new(load_config_for_reload(config_path).await?);
let accepted = reload_control
.submit(config, revision.clone(), request)
.await
.map_err(|error| match error {
ReloadSubmitError::InProgress(reload_id) => ApiFailure::new(
StatusCode::CONFLICT,
"reload_in_progress",
format!("Reload {} is already in progress", reload_id),
),
ReloadSubmitError::MaestroUnavailable => ApiFailure::new(
StatusCode::SERVICE_UNAVAILABLE,
"maestro_unavailable",
"Maestro reload coordinator is unavailable",
),
})?;
Ok((accepted, revision))
}
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/system/reload" => Some(ALLOW_POST),
"/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 reload_status_route_id(path).is_some() => Some(ALLOW_GET),
_ if path
.strip_prefix("/v1/users/")
.map(|user| !user.is_empty() && !user.contains('/'))
.unwrap_or(false) =>
{
Some(ALLOW_GET_PATCH_DELETE)
}
_ => None,
}
}
pub async fn serve(
listen: SocketAddr,
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
route_runtime: Arc<RouteRuntimeController>,
proxy_shared: Arc<ProxySharedState>,
upstream_manager: Arc<UpstreamManager>,
config_rx: watch::Receiver<Arc<ProxyConfig>>,
admission_rx: watch::Receiver<bool>,
config_path: PathBuf,
quota_state_path: PathBuf,
detected_ips_rx: watch::Receiver<(Option<IpAddr>, Option<IpAddr>)>,
process_started_at_epoch_secs: u64,
startup_tracker: Arc<StartupTracker>,
reload_control: ReloadControl,
mut active_runtime_rx: watch::Receiver<Option<Arc<ArcSwap<RuntimeGeneration>>>>,
mut runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
) {
let active_runtime = loop {
if let Some(active_runtime) = active_runtime_rx.borrow().clone() {
break active_runtime;
}
if active_runtime_rx.changed().await.is_err() {
warn!("Runtime generation channel closed before API bootstrap");
return;
}
};
let initial_watch_state = loop {
if let Some(watch_state) = runtime_watch_rx.borrow().clone() {
break watch_state;
}
if runtime_watch_rx.changed().await.is_err() {
warn!("Runtime watch channel closed before API bootstrap");
return;
}
};
let config_rx = initial_watch_state.config_rx.clone();
let admission_rx = initial_watch_state.admission_rx.clone();
let listener = match TcpListener::bind(listen).await {
Ok(listener) => listener,
Err(error) => {
@@ -173,6 +333,7 @@ pub async fn serve(
minimal_cache: Arc::new(Mutex::new(None)),
runtime_edge_connections_cache: Arc::new(Mutex::new(None)),
runtime_edge_recompute_lock: Arc::new(Mutex::new(())),
cache_generation: Arc::new(AtomicU64::new(1)),
runtime_events: Arc::new(ApiEventStore::new(
config_rx.borrow().server.api.runtime_edge_events_capacity,
)),
@@ -180,11 +341,13 @@ pub async fn serve(
runtime_state: runtime_state.clone(),
startup_tracker,
route_runtime,
proxy_shared,
reload_control,
active_runtime,
});
spawn_runtime_watchers(
config_rx.clone(),
admission_rx.clone(),
runtime_watch_rx,
runtime_state.clone(),
shared.runtime_events.clone(),
);
@@ -213,13 +376,11 @@ pub async fn serve(
};
let shared_conn = shared.clone();
let config_rx_conn = config_rx.clone();
tokio::spawn(async move {
let _connection_permit = connection_permit;
let svc = service_fn(move |req: Request<Incoming>| {
let shared_req = shared_conn.clone();
let config_rx_req = config_rx_conn.clone();
async move { handle(req, peer, shared_req, config_rx_req).await }
async move { handle(req, peer, shared_req).await }
});
match timeout(
API_HTTP_CONNECTION_TIMEOUT,
@@ -249,8 +410,19 @@ async fn handle(
req: Request<Incoming>,
peer: SocketAddr,
shared: Arc<ApiShared>,
config_rx: watch::Receiver<Arc<ProxyConfig>>,
) -> Result<Response<Full<Bytes>>, IoError> {
let runtime = shared.active_runtime.load_full();
let previous_cache_generation = shared.cache_generation.swap(runtime.id, Ordering::AcqRel);
if previous_cache_generation != runtime.id {
*shared.minimal_cache.lock().await = None;
*shared.runtime_edge_connections_cache.lock().await = None;
}
let shared = Arc::new(shared.for_runtime(runtime.as_ref()));
let config_rx = runtime.config_rx.clone();
shared
.runtime_state
.admission_open
.store(*runtime.admission_rx.borrow(), Ordering::Relaxed);
let request_id = shared.next_request_id();
let cfg = config_rx.borrow().clone();
let api_cfg = &cfg.server.api;
@@ -435,22 +607,22 @@ async fn handle(
let data = build_dcs_data(shared.as_ref(), api_cfg).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/me_pool_state") => {
("GET", "/v1/runtime/me-pool-state") | ("GET", "/v1/runtime/me_pool_state") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_me_pool_state_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/me_quality") => {
("GET", "/v1/runtime/me-quality") | ("GET", "/v1/runtime/me_quality") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_me_quality_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/upstream_quality") => {
("GET", "/v1/runtime/upstream-quality") | ("GET", "/v1/runtime/upstream_quality") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_upstream_quality_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/nat_stun") => {
("GET", "/v1/runtime/nat-stun") | ("GET", "/v1/runtime/nat_stun") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_nat_stun_data(shared.as_ref()).await;
Ok(success_response(StatusCode::OK, data, revision))
@@ -475,6 +647,15 @@ async fn handle(
);
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/runtime/tls-fingerprints") => {
let revision = current_revision(&shared.config_path).await?;
let data = build_runtime_tls_fingerprints_data(
shared.as_ref(),
cfg.as_ref(),
query.as_deref(),
);
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", "/v1/stats/users/active-ips") => {
let revision = current_revision(&shared.config_path).await?;
let usernames: Vec<_> = cfg.access.users.keys().cloned().collect();
@@ -506,7 +687,7 @@ async fn handle(
.await;
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 disk_cfg = load_config_from_disk(&shared.config_path).await?;
let data = build_user_quota_list(&disk_cfg, shared.stats.as_ref());
@@ -525,6 +706,7 @@ async fn handle(
}
let expected_revision = parse_if_match(req.headers());
let body = read_json::<CreateUserRequest>(req.into_body(), body_limit).await?;
let requested_enabled = body.enabled;
let result = create_user(body, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
@@ -537,6 +719,25 @@ async fn handle(
};
let runtime_cfg = config_rx.borrow().clone();
data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username);
if let Some(enabled) = requested_enabled {
shared
.proxy_shared
.set_user_enabled(&data.user.username, enabled);
if !enabled {
let cancelled = shared
.proxy_shared
.cancel_user_sessions(&data.user.username);
if cancelled > 0 {
shared.runtime_events.record(
"api.user.disable.runtime",
format!(
"username={} cancelled_sessions={}",
data.user.username, cancelled
),
);
}
}
}
shared.runtime_events.record(
"api.user.create.ok",
format!("username={}", data.user.username),
@@ -548,7 +749,185 @@ async fn handle(
};
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))
}
("POST", "/v1/system/reload") => {
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 request = read_optional_json::<ReloadRequest>(req.into_body(), body_limit)
.await?
.unwrap_or_default();
request.validate().map_err(ApiFailure::bad_request)?;
let (accepted, revision) = submit_reload_from_disk(
&shared.config_path,
shared.mutation_lock.as_ref(),
&shared.reload_control,
expected_revision.as_deref(),
request,
)
.await?;
Ok(success_response(StatusCode::ACCEPTED, accepted, 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 reload_request =
ReloadRequest::from_query(query.as_deref()).map_err(ApiFailure::bad_request)?;
let body = read_json::<serde_json::Value>(req.into_body(), body_limit).await?;
match config_edit::patch_config(body, expected_revision, reload_request, &shared)
.await
{
Ok(resp) => {
let revision = resp.revision.clone();
let status = if resp.reload.is_some() {
StatusCode::ACCEPTED
} else {
StatusCode::OK
};
Ok(success_response(status, resp, revision))
}
Err(error) => {
shared
.runtime_events
.record("api.config.patch.failed", error.code);
Err(error)
}
}
}
_ => {
if method == Method::GET
&& let Some(reload_id) = reload_status_route_id(normalized_path)
{
let revision = current_revision(&shared.config_path).await?;
let status =
shared
.reload_control
.status(reload_id)
.await
.ok_or_else(|| {
ApiFailure::new(
StatusCode::NOT_FOUND,
"reload_not_found",
format!("Reload {} was not found", reload_id),
)
})?;
return Ok(success_response(StatusCode::OK, status, revision));
}
if method == Method::POST
&& let Some(base_user) = normalized_path
.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix("/enable"))
&& !base_user.is_empty()
&& !base_user.contains('/')
{
let base_user = parse_route_username(base_user)?;
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let result =
set_user_enabled(base_user, true, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
Err(error) => {
shared.runtime_events.record(
"api.user.enable.failed",
format!("username={} code={}", base_user, error.code),
);
return Err(error);
}
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
shared.proxy_shared.set_user_enabled(base_user, true);
shared
.runtime_events
.record("api.user.enable.ok", format!("username={}", base_user));
let status = if data.in_runtime {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok(success_response(status, data, revision));
}
if method == Method::POST
&& let Some(base_user) = normalized_path
.strip_prefix("/v1/users/")
.and_then(|path| path.strip_suffix("/disable"))
&& !base_user.is_empty()
&& !base_user.contains('/')
{
let base_user = parse_route_username(base_user)?;
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let result =
set_user_enabled(base_user, false, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
Err(error) => {
shared.runtime_events.record(
"api.user.disable.failed",
format!("username={} code={}", base_user, error.code),
);
return Err(error);
}
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
let newly_disabled = shared.proxy_shared.set_user_enabled(base_user, false);
let cancelled = shared.proxy_shared.cancel_user_sessions(base_user);
shared.runtime_events.record(
"api.user.disable.ok",
format!(
"username={} newly_disabled={} cancelled_sessions={}",
base_user, newly_disabled, cancelled
),
);
let status = if data.in_runtime {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok(success_response(status, data, revision));
}
if method == Method::POST
&& let Some(user) = normalized_path
.strip_prefix("/v1/users/")
@@ -567,6 +946,16 @@ async fn handle(
),
));
}
let expected_revision = parse_if_match(req.headers());
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref())
.await?;
if !disk_cfg.access.users.contains_key(user) {
return Ok(error_response(
request_id,
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "User not found"),
));
}
let snapshot = match crate::quota_state::reset_user_quota(
&shared.quota_state_path,
shared.stats.as_ref(),
@@ -696,6 +1085,11 @@ async fn handle(
let expected_revision = parse_if_match(req.headers());
let body =
read_json::<PatchUserRequest>(req.into_body(), body_limit).await?;
let enabled_update = match &body.enabled {
Patch::Unchanged => None,
Patch::Remove => Some(true),
Patch::Set(enabled) => Some(*enabled),
};
let result = patch_user(user, body, expected_revision, &shared).await;
let (mut data, revision) = match result {
Ok(ok) => ok,
@@ -709,6 +1103,22 @@ async fn handle(
};
let runtime_cfg = config_rx.borrow().clone();
data.in_runtime = runtime_cfg.access.users.contains_key(&data.username);
if let Some(enabled) = enabled_update {
shared
.proxy_shared
.set_user_enabled(&data.username, enabled);
if !enabled {
let cancelled =
shared.proxy_shared.cancel_user_sessions(&data.username);
shared.runtime_events.record(
"api.user.disable.runtime",
format!(
"username={} cancelled_sessions={}",
data.username, cancelled
),
);
}
}
shared
.runtime_events
.record("api.user.patch.ok", format!("username={}", data.username));
@@ -742,9 +1152,12 @@ async fn handle(
return Err(error);
}
};
shared
.runtime_events
.record("api.user.delete.ok", format!("username={}", deleted_user));
shared.proxy_shared.set_user_enabled(&deleted_user, true);
let cancelled = shared.proxy_shared.cancel_user_sessions(&deleted_user);
shared.runtime_events.record(
"api.user.delete.ok",
format!("username={} cancelled_sessions={}", deleted_user, cancelled),
);
let runtime_cfg = config_rx.borrow().clone();
let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user);
let response = DeleteUserResponse {
@@ -761,16 +1174,18 @@ async fn handle(
if method == Method::POST {
return Ok(error_response(
request_id,
ApiFailure::new(StatusCode::NOT_FOUND, "not_found", "Route not found"),
ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
));
}
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::METHOD_NOT_ALLOWED,
"method_not_allowed",
"Unsupported HTTP method for this route",
),
ApiFailure::method_not_allowed(ALLOW_GET_PATCH_DELETE),
));
}
if let Some(allow) = allowed_methods_for_path(normalized_path) {
return Ok(error_response(
request_id,
ApiFailure::method_not_allowed(allow),
));
}
debug!(
+15
View File
@@ -15,6 +15,7 @@ pub(super) struct ApiFailure {
pub(super) status: StatusCode,
pub(super) code: &'static str,
pub(super) message: String,
pub(super) allow: Option<&'static str>,
}
impl ApiFailure {
@@ -23,6 +24,7 @@ impl ApiFailure {
status,
code,
message: message.into(),
allow: None,
}
}
@@ -33,6 +35,15 @@ impl ApiFailure {
pub(super) fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
pub(super) fn method_not_allowed(allow: &'static str) -> Self {
Self {
status: StatusCode::METHOD_NOT_ALLOWED,
code: "method_not_allowed",
message: "Unsupported HTTP method for this route".to_string(),
allow: Some(allow),
}
}
}
#[derive(Serialize)]
@@ -468,6 +479,7 @@ pub(super) struct TlsDomainLink {
#[derive(Serialize)]
pub(super) struct UserInfo {
pub(super) username: String,
pub(super) enabled: bool,
pub(super) in_runtime: bool,
pub(super) user_ad_tag: Option<String>,
pub(super) max_tcp_conns: Option<usize>,
@@ -534,6 +546,7 @@ pub(super) struct CreateUserRequest {
pub(super) rate_limit_up_bps: Option<u64>,
pub(super) rate_limit_down_bps: Option<u64>,
pub(super) max_unique_ips: Option<usize>,
pub(super) enabled: Option<bool>,
}
#[derive(Deserialize)]
@@ -553,6 +566,8 @@ pub(super) struct PatchUserRequest {
pub(super) rate_limit_down_bps: Patch<u64>,
#[serde(default, deserialize_with = "patch_field")]
pub(super) max_unique_ips: Patch<usize>,
#[serde(default, deserialize_with = "patch_field")]
pub(super) enabled: Patch<bool>,
}
#[derive(Default, Deserialize)]
+119
View File
@@ -0,0 +1,119 @@
use super::*;
use crate::config::ProxyConfig;
async fn config_file() -> (tempfile::TempDir, PathBuf, String) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
let mut config = ProxyConfig::default();
config.server.max_connections = 4_242;
let body = toml::to_string_pretty(&config).unwrap();
tokio::fs::write(&path, &body).await.unwrap();
let revision = config_store::current_revision(&path).await.unwrap();
(directory, path, revision)
}
#[tokio::test]
async fn reload_submission_uses_matching_disk_revision_and_snapshot() {
let (_directory, path, revision) = config_file().await;
let mutation_lock = Mutex::new(());
let (control, mut commands) = ReloadControl::channel(1);
let request = ReloadRequest::default();
let (accepted, response_revision) = submit_reload_from_disk(
&path,
&mutation_lock,
&control,
Some(&revision),
request.clone(),
)
.await
.unwrap();
let command = commands.recv().await.unwrap();
assert_eq!(response_revision, revision);
assert_eq!(accepted.config_revision, revision);
assert_eq!(command.config_revision, revision);
assert_eq!(command.request, request);
assert_eq!(command.config.server.max_connections, 4_242);
}
#[tokio::test]
async fn revision_conflict_rejects_without_enqueuing_reload() {
let (_directory, path, _revision) = config_file().await;
let mutation_lock = Mutex::new(());
let (control, _commands) = ReloadControl::channel(1);
let error = submit_reload_from_disk(
&path,
&mutation_lock,
&control,
Some("stale-revision"),
ReloadRequest::default(),
)
.await
.unwrap_err();
assert_eq!(error.status, StatusCode::CONFLICT);
assert_eq!(error.code, "revision_conflict");
assert_eq!(control.in_progress().await, None);
}
#[tokio::test]
async fn reload_conflict_and_closed_coordinator_map_to_http_contract() {
let (_directory, path, _revision) = config_file().await;
let mutation_lock = Mutex::new(());
let (control, mut commands) = ReloadControl::channel(1);
let _accepted = submit_reload_from_disk(
&path,
&mutation_lock,
&control,
None,
ReloadRequest::default(),
)
.await
.unwrap();
let _command = commands.recv().await.unwrap();
let conflict = submit_reload_from_disk(
&path,
&mutation_lock,
&control,
None,
ReloadRequest::default(),
)
.await
.unwrap_err();
assert_eq!(conflict.status, StatusCode::CONFLICT);
assert_eq!(conflict.code, "reload_in_progress");
control.fail(1, "test cleanup").await;
drop(commands);
let unavailable = submit_reload_from_disk(
&path,
&mutation_lock,
&control,
None,
ReloadRequest::default(),
)
.await
.unwrap_err();
assert_eq!(unavailable.status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(unavailable.code, "maestro_unavailable");
}
#[test]
fn reload_routes_expose_only_documented_methods_and_ids() {
assert_eq!(
allowed_methods_for_path("/v1/system/reload"),
Some(ALLOW_POST)
);
assert_eq!(
allowed_methods_for_path("/v1/system/reload/42"),
Some(ALLOW_GET)
);
assert_eq!(reload_status_route_id("/v1/system/reload/42"), Some(42));
assert_eq!(
reload_status_route_id("/v1/system/reload/not-a-number"),
None
);
}
+128
View File
@@ -12,6 +12,8 @@ const FEATURE_DISABLED_REASON: &str = "feature_disabled";
const SOURCE_UNAVAILABLE_REASON: &str = "source_unavailable";
const EVENTS_DEFAULT_LIMIT: usize = 50;
const EVENTS_MAX_LIMIT: usize = 1000;
const TLS_FINGERPRINTS_MAX_LIMIT: usize = 1000;
const RUNTIME_EDGE_RETENTION_MAX_MINUTES: u64 = 24 * 60;
#[derive(Clone, Serialize)]
pub(super) struct RuntimeEdgeConnectionUserData {
@@ -90,6 +92,44 @@ pub(super) struct RuntimeEdgeEventsData {
pub(super) data: Option<RuntimeEdgeEventsPayload>,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintRow {
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) scope: Option<String>,
pub(super) ja3: String,
pub(super) ja3_raw: String,
pub(super) ja4: String,
pub(super) ja4_raw: String,
pub(super) total: u64,
pub(super) auth_success: u64,
pub(super) bad_or_probe: u64,
pub(super) first_seen_epoch_secs: u64,
pub(super) last_seen_epoch_secs: u64,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintsPayload {
pub(super) limit: usize,
pub(super) retention_secs: u64,
pub(super) capacity: usize,
pub(super) dropped_total: u64,
pub(super) parse_error_total: u64,
pub(super) by_fingerprint: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_ip: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_cidr: Vec<RuntimeEdgeTlsFingerprintRow>,
pub(super) by_user: Vec<RuntimeEdgeTlsFingerprintRow>,
}
#[derive(Serialize)]
pub(super) struct RuntimeEdgeTlsFingerprintsData {
pub(super) enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) reason: Option<&'static str>,
pub(super) generated_at_epoch_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) data: Option<RuntimeEdgeTlsFingerprintsPayload>,
}
pub(super) async fn build_runtime_connections_summary_data(
shared: &ApiShared,
cfg: &ProxyConfig,
@@ -162,6 +202,65 @@ pub(super) fn build_runtime_events_recent_data(
}
}
pub(super) fn build_runtime_tls_fingerprints_data(
shared: &ApiShared,
cfg: &ProxyConfig,
query: Option<&str>,
) -> RuntimeEdgeTlsFingerprintsData {
let now_epoch_secs = now_epoch_secs();
let api_cfg = &cfg.server.api;
if !api_cfg.runtime_edge_enabled {
return RuntimeEdgeTlsFingerprintsData {
enabled: false,
reason: Some(FEATURE_DISABLED_REASON),
generated_at_epoch_secs: now_epoch_secs,
data: None,
};
}
let limit = parse_recent_events_limit(
query,
api_cfg.runtime_edge_top_n.max(1),
TLS_FINGERPRINTS_MAX_LIMIT,
);
let snapshot = shared
.stats
.tls_fingerprint_snapshot(runtime_edge_retention(cfg), limit);
RuntimeEdgeTlsFingerprintsData {
enabled: true,
reason: None,
generated_at_epoch_secs: now_epoch_secs,
data: Some(RuntimeEdgeTlsFingerprintsPayload {
limit,
retention_secs: snapshot.retention_secs,
capacity: snapshot.capacity,
dropped_total: snapshot.dropped_total,
parse_error_total: snapshot.parse_error_total,
by_fingerprint: snapshot
.by_fingerprint
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_ip: snapshot
.by_ip
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_cidr: snapshot
.by_cidr
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
by_user: snapshot
.by_user
.into_iter()
.map(runtime_tls_fingerprint_row)
.collect(),
}),
}
}
async fn get_connections_payload_cached(
shared: &ApiShared,
cache_ttl_ms: u64,
@@ -286,6 +385,35 @@ fn parse_recent_events_limit(query: Option<&str>, default_limit: usize, max_limi
default_limit
}
fn runtime_edge_retention(cfg: &ProxyConfig) -> Duration {
let minutes = cfg
.general
.beobachten_minutes
.clamp(1, RUNTIME_EDGE_RETENTION_MAX_MINUTES);
Duration::from_secs(minutes.saturating_mul(60))
}
fn runtime_tls_fingerprint_row(
row: crate::stats::TlsFingerprintSnapshotRow,
) -> RuntimeEdgeTlsFingerprintRow {
RuntimeEdgeTlsFingerprintRow {
scope: if row.scope_key.is_empty() {
None
} else {
Some(row.scope_key)
},
ja3: row.ja3,
ja3_raw: row.ja3_raw,
ja4: row.ja4,
ja4_raw: row.ja4_raw,
total: row.total,
auth_success: row.auth_success,
bad_or_probe: row.bad_or_probe,
first_seen_epoch_secs: row.first_seen_epoch_secs,
last_seen_epoch_secs: row.last_seen_epoch_secs,
}
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
+292 -39
View File
@@ -4,58 +4,184 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::watch;
use crate::config::ProxyConfig;
use crate::maestro::generation::RuntimeWatchState;
use super::ApiRuntimeState;
use super::events::ApiEventStore;
pub(super) fn spawn_runtime_watchers(
config_rx: watch::Receiver<Arc<ProxyConfig>>,
admission_rx: watch::Receiver<bool>,
runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
runtime_state: Arc<ApiRuntimeState>,
runtime_events: Arc<ApiEventStore>,
) {
let mut config_rx_reload = config_rx;
let runtime_state_reload = runtime_state.clone();
let runtime_events_reload = runtime_events.clone();
tokio::spawn(async move {
loop {
if config_rx_reload.changed().await.is_err() {
break;
}
runtime_state_reload
.config_reload_count
.fetch_add(1, Ordering::Relaxed);
runtime_state_reload
.last_config_reload_epoch_secs
.store(now_epoch_secs(), Ordering::Relaxed);
runtime_events_reload.record("config.reload.applied", "config receiver updated");
}
});
let _config_watcher = spawn_config_watcher(
runtime_watch_rx.clone(),
runtime_state.clone(),
runtime_events.clone(),
);
let _admission_watcher =
spawn_admission_watcher(runtime_watch_rx, runtime_state, runtime_events);
}
let mut admission_rx_watch = admission_rx;
fn spawn_config_watcher(
mut runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
runtime_state: Arc<ApiRuntimeState>,
runtime_events: Arc<ApiEventStore>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
runtime_state
.admission_open
.store(*admission_rx_watch.borrow(), Ordering::Relaxed);
runtime_events.record(
"admission.state",
format!("accepting_new_connections={}", *admission_rx_watch.borrow()),
);
let Some(mut current) = runtime_watch_rx.borrow().clone() else {
return;
};
loop {
if admission_rx_watch.changed().await.is_err() {
break;
tokio::select! {
biased;
changed = runtime_watch_rx.changed() => {
if changed.is_err() {
break;
}
let Some(next) = runtime_watch_rx.borrow().clone() else {
continue;
};
if next.generation_id != current.generation_id {
current = next;
record_config_reload(
&runtime_state,
&runtime_events,
format!("runtime generation {} activated", current.generation_id),
);
}
}
changed = current.config_rx.changed() => {
if changed.is_err() {
let Some(next) = wait_for_new_generation(
&mut runtime_watch_rx,
current.generation_id,
).await else {
break;
};
current = next;
record_config_reload(
&runtime_state,
&runtime_events,
format!("runtime generation {} activated", current.generation_id),
);
continue;
}
if active_generation_id(&runtime_watch_rx) != Some(current.generation_id) {
continue;
}
record_config_reload(
&runtime_state,
&runtime_events,
format!("generation {} config receiver updated", current.generation_id),
);
}
}
let admission_open = *admission_rx_watch.borrow();
runtime_state
.admission_open
.store(admission_open, Ordering::Relaxed);
runtime_events.record(
"admission.state",
format!("accepting_new_connections={}", admission_open),
);
}
});
})
}
fn spawn_admission_watcher(
mut runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
runtime_state: Arc<ApiRuntimeState>,
runtime_events: Arc<ApiEventStore>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let Some(mut current) = runtime_watch_rx.borrow().clone() else {
return;
};
record_admission_state(&runtime_state, &runtime_events, &current);
loop {
tokio::select! {
biased;
changed = runtime_watch_rx.changed() => {
if changed.is_err() {
break;
}
let Some(next) = runtime_watch_rx.borrow().clone() else {
continue;
};
if next.generation_id != current.generation_id {
current = next;
record_admission_state(&runtime_state, &runtime_events, &current);
}
}
changed = current.admission_rx.changed() => {
if changed.is_err() {
let Some(next) = wait_for_new_generation(
&mut runtime_watch_rx,
current.generation_id,
).await else {
break;
};
current = next;
record_admission_state(&runtime_state, &runtime_events, &current);
continue;
}
if active_generation_id(&runtime_watch_rx) == Some(current.generation_id) {
record_admission_state(&runtime_state, &runtime_events, &current);
}
}
}
}
})
}
fn active_generation_id(
runtime_watch_rx: &watch::Receiver<Option<RuntimeWatchState>>,
) -> Option<u64> {
runtime_watch_rx
.borrow()
.as_ref()
.map(|state| state.generation_id)
}
async fn wait_for_new_generation(
runtime_watch_rx: &mut watch::Receiver<Option<RuntimeWatchState>>,
previous_generation_id: u64,
) -> Option<RuntimeWatchState> {
loop {
if let Some(state) = runtime_watch_rx.borrow().clone()
&& state.generation_id != previous_generation_id
{
return Some(state);
}
if runtime_watch_rx.changed().await.is_err() {
return None;
}
}
}
fn record_config_reload(
runtime_state: &ApiRuntimeState,
runtime_events: &ApiEventStore,
context: String,
) {
runtime_state
.config_reload_count
.fetch_add(1, Ordering::Relaxed);
runtime_state
.last_config_reload_epoch_secs
.store(now_epoch_secs(), Ordering::Relaxed);
runtime_events.record("config.reload.applied", context);
}
fn record_admission_state(
runtime_state: &ApiRuntimeState,
runtime_events: &ApiEventStore,
current: &RuntimeWatchState,
) {
let admission_open = *current.admission_rx.borrow();
runtime_state
.admission_open
.store(admission_open, Ordering::Relaxed);
runtime_events.record(
"admission.state",
format!(
"generation={} accepting_new_connections={}",
current.generation_id, admission_open
),
);
}
fn now_epoch_secs() -> u64 {
@@ -64,3 +190,130 @@ fn now_epoch_secs() -> u64 {
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ProxyConfig;
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::time::Duration;
fn state(
generation_id: u64,
) -> (
RuntimeWatchState,
watch::Sender<Arc<ProxyConfig>>,
watch::Sender<bool>,
) {
let (config_tx, config_rx) = watch::channel(Arc::new(ProxyConfig::default()));
let (admission_tx, admission_rx) = watch::channel(true);
(
RuntimeWatchState {
generation_id,
config_rx,
admission_rx,
},
config_tx,
admission_tx,
)
}
fn runtime_state() -> Arc<ApiRuntimeState> {
Arc::new(ApiRuntimeState {
process_started_at_epoch_secs: 1,
config_reload_count: AtomicU64::new(0),
last_config_reload_epoch_secs: AtomicU64::new(0),
admission_open: AtomicBool::new(false),
})
}
async fn wait_for_count(runtime_state: &ApiRuntimeState, expected: u64) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if runtime_state.config_reload_count.load(Ordering::Relaxed) == expected {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
#[tokio::test]
async fn watchers_follow_only_the_active_generation() {
let (initial, initial_config_tx, initial_admission_tx) = state(1);
let (runtime_watch_tx, runtime_watch_rx) = watch::channel(Some(initial));
let runtime_state = runtime_state();
let events = Arc::new(ApiEventStore::new(16));
spawn_runtime_watchers(runtime_watch_rx, runtime_state.clone(), events.clone());
tokio::task::yield_now().await;
assert_eq!(runtime_state.config_reload_count.load(Ordering::Relaxed), 0);
initial_config_tx.send_replace(Arc::new(ProxyConfig::default()));
wait_for_count(&runtime_state, 1).await;
let (next, next_config_tx, next_admission_tx) = state(2);
runtime_watch_tx.send_replace(Some(next));
wait_for_count(&runtime_state, 2).await;
initial_config_tx.send_replace(Arc::new(ProxyConfig::default()));
initial_admission_tx.send_replace(false);
tokio::task::yield_now().await;
assert_eq!(runtime_state.config_reload_count.load(Ordering::Relaxed), 2);
assert!(runtime_state.admission_open.load(Ordering::Relaxed));
next_config_tx.send_replace(Arc::new(ProxyConfig::default()));
next_admission_tx.send_replace(false);
wait_for_count(&runtime_state, 3).await;
tokio::time::timeout(Duration::from_secs(1), async {
while runtime_state.admission_open.load(Ordering::Relaxed) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let snapshot = events.snapshot(16);
assert_eq!(
snapshot
.events
.iter()
.filter(|event| event.event_type == "config.reload.applied")
.count(),
3
);
}
#[tokio::test]
async fn watcher_recovers_from_closed_generation_and_exits_with_process_channel() {
let (initial, initial_config_tx, _initial_admission_tx) = state(1);
let (runtime_watch_tx, runtime_watch_rx) = watch::channel(Some(initial));
let runtime_state = runtime_state();
let events = Arc::new(ApiEventStore::new(16));
let watcher = spawn_config_watcher(runtime_watch_rx, runtime_state.clone(), events.clone());
drop(initial_config_tx);
tokio::task::yield_now().await;
let (next, next_config_tx, _next_admission_tx) = state(2);
runtime_watch_tx.send_replace(Some(next));
wait_for_count(&runtime_state, 1).await;
next_config_tx.send_replace(Arc::new(ProxyConfig::default()));
wait_for_count(&runtime_state, 2).await;
drop(runtime_watch_tx);
tokio::time::timeout(Duration::from_secs(1), watcher)
.await
.unwrap()
.unwrap();
assert_eq!(
events
.snapshot(16)
.events
.iter()
.filter(|event| event.event_type == "config.reload.applied")
.count(),
2
);
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
use std::sync::atomic::Ordering;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
@@ -162,7 +163,7 @@ pub(super) fn build_system_info_data(
build_time_utc,
rustc_version,
process_started_at_epoch_secs: shared.runtime_state.process_started_at_epoch_secs,
uptime_seconds: shared.stats.uptime_secs(),
uptime_seconds: process_uptime_seconds(shared.runtime_state.process_started_at_epoch_secs),
config_path: shared.config_path.display().to_string(),
config_hash: revision.to_string(),
config_reload_count: shared
@@ -173,6 +174,18 @@ pub(super) fn build_system_info_data(
}
}
fn process_uptime_seconds(process_started_at_epoch_secs: u64) -> f64 {
let now_epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
process_uptime_seconds_at(process_started_at_epoch_secs, now_epoch_secs)
}
fn process_uptime_seconds_at(process_started_at_epoch_secs: u64, now_epoch_secs: u64) -> f64 {
now_epoch_secs.saturating_sub(process_started_at_epoch_secs) as f64
}
pub(super) async fn build_runtime_gates_data(
shared: &ApiShared,
cfg: &ProxyConfig,
@@ -339,3 +352,14 @@ fn me_writer_pick_mode_label(mode: MeWriterPickMode) -> &'static str {
MeWriterPickMode::P2c => "p2c",
}
}
#[cfg(test)]
mod tests {
use super::process_uptime_seconds_at;
#[test]
fn process_uptime_is_monotonic_and_saturating() {
assert_eq!(process_uptime_seconds_at(100, 135), 35.0);
assert_eq!(process_uptime_seconds_at(135, 100), 0.0);
}
}
+11 -1029
View File
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
use super::*;
pub(in crate::api) async fn create_user(
body: CreateUserRequest,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<(CreateUserResponse, String), ApiFailure> {
let touches_user_ad_tags = body.user_ad_tag.is_some();
let touches_user_max_tcp_conns = body.max_tcp_conns.is_some();
let touches_user_expirations = body.expiration_rfc3339.is_some();
let touches_user_data_quota = body.data_quota_bytes.is_some();
let touches_user_rate_limits =
body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some();
let touches_user_max_unique_ips = body.max_unique_ips.is_some();
let touches_user_enabled = matches!(body.enabled, Some(false));
if !is_valid_username(&body.username) {
return Err(ApiFailure::bad_request(
"username must match [A-Za-z0-9_.-] and be 1..64 chars",
));
}
let secret = match body.secret {
Some(secret) => {
if !is_valid_user_secret(&secret) {
return Err(ApiFailure::bad_request(
"secret must be exactly 32 hex characters",
));
}
secret
}
None => random_user_secret(),
};
if let Some(ad_tag) = body.user_ad_tag.as_ref()
&& !is_valid_ad_tag(ad_tag)
{
return Err(ApiFailure::bad_request(
"user_ad_tag must be exactly 32 hex characters",
));
}
let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?;
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(&body.username) {
return Err(ApiFailure::new(
StatusCode::CONFLICT,
"user_exists",
"User already exists",
));
}
cfg.access
.users
.insert(body.username.clone(), secret.clone());
if let Some(ad_tag) = body.user_ad_tag {
cfg.access
.user_ad_tags
.insert(body.username.clone(), ad_tag);
}
if let Some(limit) = body.max_tcp_conns {
cfg.access
.user_max_tcp_conns
.insert(body.username.clone(), limit);
}
if let Some(expiration) = expiration {
cfg.access
.user_expirations
.insert(body.username.clone(), expiration);
}
if let Some(quota) = body.data_quota_bytes {
cfg.access
.user_data_quota
.insert(body.username.clone(), quota);
}
if touches_user_rate_limits {
cfg.access.user_rate_limits.insert(
body.username.clone(),
RateLimitBps {
up_bps: body.rate_limit_up_bps.unwrap_or(0),
down_bps: body.rate_limit_down_bps.unwrap_or(0),
},
);
}
let updated_limit = body.max_unique_ips;
if let Some(limit) = updated_limit {
cfg.access
.user_max_unique_ips
.insert(body.username.clone(), limit);
}
if matches!(body.enabled, Some(false)) {
cfg.access.user_enabled.insert(body.username.clone(), false);
}
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let mut touched_sections = vec![AccessSection::Users];
if touches_user_ad_tags {
touched_sections.push(AccessSection::UserAdTags);
}
if touches_user_max_tcp_conns {
touched_sections.push(AccessSection::UserMaxTcpConns);
}
if touches_user_expirations {
touched_sections.push(AccessSection::UserExpirations);
}
if touches_user_data_quota {
touched_sections.push(AccessSection::UserDataQuota);
}
if touches_user_rate_limits {
touched_sections.push(AccessSection::UserRateLimits);
}
if touches_user_max_unique_ips {
touched_sections.push(AccessSection::UserMaxUniqueIps);
}
if touches_user_enabled {
touched_sections.push(AccessSection::UserEnabled);
}
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
drop(_guard);
if let Some(limit) = updated_limit {
shared
.ip_tracker
.set_user_limit(&body.username, limit)
.await;
}
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 = users
.into_iter()
.find(|entry| entry.username == body.username)
.unwrap_or(UserInfo {
username: body.username.clone(),
enabled: cfg.access.is_user_enabled(&body.username),
in_runtime: false,
user_ad_tag: None,
max_tcp_conns: cfg
.access
.user_max_tcp_conns
.get(&body.username)
.copied()
.filter(|limit| *limit > 0)
.or((cfg.access.user_max_tcp_conns_global_each > 0)
.then_some(cfg.access.user_max_tcp_conns_global_each)),
expiration_rfc3339: None,
data_quota_bytes: None,
rate_limit_up_bps: body.rate_limit_up_bps.filter(|limit| *limit > 0),
rate_limit_down_bps: body.rate_limit_down_bps.filter(|limit| *limit > 0),
max_unique_ips: updated_limit,
current_connections: 0,
active_unique_ips: 0,
active_unique_ips_list: Vec::new(),
recent_unique_ips: 0,
recent_unique_ips_list: Vec::new(),
total_octets: 0,
links: build_user_links(&cfg, &secret, detected_ip_v4, detected_ip_v6),
});
Ok((CreateUserResponse { user, secret }, revision))
}
+116
View File
@@ -0,0 +1,116 @@
use super::*;
pub(in crate::api) async fn rotate_secret(
user: &str,
body: RotateSecretRequest,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<(CreateUserResponse, String), ApiFailure> {
let secret = body.secret.unwrap_or_else(random_user_secret);
if !is_valid_user_secret(&secret) {
return Err(ApiFailure::bad_request(
"secret must be exactly 32 hex characters",
));
}
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",
));
}
cfg.access.users.insert(user.to_string(), secret.clone());
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::Users]).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((
CreateUserResponse {
user: user_info,
secret,
},
revision,
))
}
pub(in crate::api) async fn delete_user(
user: &str,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<(String, 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 cfg.access.users.len() <= 1 {
return Err(ApiFailure::new(
StatusCode::CONFLICT,
"last_user_forbidden",
"Cannot delete the last configured user",
));
}
let mut touched_sections = vec![AccessSection::Users];
cfg.access.users.remove(user);
if cfg.access.user_enabled.remove(user).is_some() {
touched_sections.push(AccessSection::UserEnabled);
}
if cfg.access.user_ad_tags.remove(user).is_some() {
touched_sections.push(AccessSection::UserAdTags);
}
if cfg.access.user_max_tcp_conns.remove(user).is_some() {
touched_sections.push(AccessSection::UserMaxTcpConns);
}
if cfg.access.user_expirations.remove(user).is_some() {
touched_sections.push(AccessSection::UserExpirations);
}
if cfg.access.user_data_quota.remove(user).is_some() {
touched_sections.push(AccessSection::UserDataQuota);
}
if cfg.access.user_rate_limits.remove(user).is_some() {
touched_sections.push(AccessSection::UserRateLimits);
}
if cfg.access.user_max_unique_ips.remove(user).is_some() {
touched_sections.push(AccessSection::UserMaxUniqueIps);
}
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
drop(_guard);
shared.ip_tracker.remove_user_limit(user).await;
shared.ip_tracker.clear_user_ips(user).await;
Ok((user.to_string(), revision))
}
+199
View File
@@ -0,0 +1,199 @@
use super::*;
pub(in crate::api::users) fn empty_user_links() -> UserLinks {
UserLinks {
classic: Vec::new(),
secure: Vec::new(),
tls: Vec::new(),
tls_domains: Vec::new(),
}
}
pub(in crate::api::users) fn build_user_links(
cfg: &ProxyConfig,
secret: &str,
startup_detected_ip_v4: Option<IpAddr>,
startup_detected_ip_v6: Option<IpAddr>,
) -> UserLinks {
let hosts = resolve_link_hosts(cfg, startup_detected_ip_v4, startup_detected_ip_v6);
let port = cfg
.general
.links
.public_port
.unwrap_or(resolve_default_link_port(cfg));
let tls_domains = resolve_tls_domains(cfg);
let extra_tls_domains = resolve_extra_tls_domains(cfg);
let mut classic = Vec::new();
let mut secure = Vec::new();
let mut tls = Vec::new();
let mut tls_domain_links = Vec::new();
for host in &hosts {
if cfg.general.modes.classic {
classic.push(format!(
"tg://proxy?server={}&port={}&secret={}",
host, port, secret
));
}
if cfg.general.modes.secure {
secure.push(format!(
"tg://proxy?server={}&port={}&secret=dd{}",
host, port, secret
));
}
if cfg.general.modes.tls {
for domain in &tls_domains {
let domain_hex = hex::encode(domain);
tls.push(format!(
"tg://proxy?server={}&port={}&secret=ee{}{}",
host, port, secret, domain_hex
));
}
for domain in &extra_tls_domains {
let domain_hex = hex::encode(domain);
let link = format!(
"tg://proxy?server={}&port={}&secret=ee{}{}",
host, port, secret, domain_hex
);
tls_domain_links.push(TlsDomainLink {
domain: (*domain).to_string(),
link,
});
}
}
}
UserLinks {
classic,
secure,
tls,
tls_domains: tls_domain_links,
}
}
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
cfg.server
.listeners
.first()
.and_then(|listener| listener.port)
.unwrap_or(cfg.server.port)
}
fn resolve_link_hosts(
cfg: &ProxyConfig,
startup_detected_ip_v4: Option<IpAddr>,
startup_detected_ip_v6: Option<IpAddr>,
) -> Vec<String> {
if let Some(host) = cfg
.general
.links
.public_host
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return vec![host.to_string()];
}
let mut hosts = Vec::new();
for listener in &cfg.server.listeners {
if let Some(host) = listener
.announce
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
push_unique_host(&mut hosts, host);
continue;
}
if let Some(ip) = listener.announce_ip
&& !ip.is_unspecified()
{
push_unique_host(&mut hosts, &ip.to_string());
continue;
}
if listener.ip.is_unspecified() {
let detected_ip = if listener.ip.is_ipv4() {
startup_detected_ip_v4
} else {
startup_detected_ip_v6
};
if let Some(ip) = detected_ip {
push_unique_host(&mut hosts, &ip.to_string());
} else {
push_unique_host(&mut hosts, &listener.ip.to_string());
}
continue;
}
push_unique_host(&mut hosts, &listener.ip.to_string());
}
if !hosts.is_empty() {
return hosts;
}
if let Some(ip) = startup_detected_ip_v4.or(startup_detected_ip_v6) {
return vec![ip.to_string()];
}
if let Some(host) = cfg.server.listen_addr_ipv4.as_deref() {
push_host_from_legacy_listen(&mut hosts, host);
}
if let Some(host) = cfg.server.listen_addr_ipv6.as_deref() {
push_host_from_legacy_listen(&mut hosts, host);
}
if !hosts.is_empty() {
return hosts;
}
vec!["UNKNOWN".to_string()]
}
fn push_host_from_legacy_listen(hosts: &mut Vec<String>, raw: &str) {
let candidate = raw.trim();
if candidate.is_empty() {
return;
}
match candidate.parse::<IpAddr>() {
Ok(ip) if ip.is_unspecified() => {}
Ok(ip) => push_unique_host(hosts, &ip.to_string()),
Err(_) => push_unique_host(hosts, candidate),
}
}
fn push_unique_host(hosts: &mut Vec<String>, candidate: &str) {
if !hosts.iter().any(|existing| existing == candidate) {
hosts.push(candidate.to_string());
}
}
fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
let mut domains = Vec::with_capacity(1 + cfg.censorship.tls_domains.len());
let primary = cfg.censorship.tls_domain.as_str();
if !primary.is_empty() {
domains.push(primary);
}
for domain in &cfg.censorship.tls_domains {
let value = domain.as_str();
if value.is_empty() || domains.contains(&value) {
continue;
}
domains.push(value);
}
domains
}
fn resolve_extra_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
let mut domains = Vec::with_capacity(cfg.censorship.tls_domains.len());
let primary = cfg.censorship.tls_domain.as_str();
for domain in &cfg.censorship.tls_domains {
let value = domain.as_str();
if value.is_empty() || value == primary || domains.contains(&value) {
continue;
}
domains.push(value);
}
domains
}
+292
View File
@@ -0,0 +1,292 @@
use super::*;
use crate::ip_tracker::UserIpTracker;
use crate::stats::Stats;
#[tokio::test]
async fn users_from_config_reports_effective_tcp_limit_with_global_fallback() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.user_max_tcp_conns_global_each = 7;
let stats = Stats::new();
let tracker = UserIpTracker::new();
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
assert!(!alice.in_runtime);
assert_eq!(alice.max_tcp_conns, Some(7));
cfg.access.user_max_tcp_conns.insert("alice".to_string(), 5);
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
assert!(!alice.in_runtime);
assert_eq!(alice.max_tcp_conns, Some(5));
cfg.access.user_max_tcp_conns.insert("alice".to_string(), 0);
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
assert!(!alice.in_runtime);
assert_eq!(alice.max_tcp_conns, Some(7));
cfg.access.user_max_tcp_conns_global_each = 0;
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
assert!(!alice.in_runtime);
assert_eq!(alice.max_tcp_conns, None);
}
#[tokio::test]
async fn users_from_config_reports_user_rate_limits() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.user_rate_limits.insert(
"alice".to_string(),
RateLimitBps {
up_bps: 1024,
down_bps: 0,
},
);
let stats = Stats::new();
let tracker = UserIpTracker::new();
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
assert_eq!(alice.rate_limit_up_bps, Some(1024));
assert_eq!(alice.rate_limit_down_bps, None);
}
#[tokio::test]
async fn users_from_config_reports_user_enabled_default_and_override() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.users.insert(
"bob".to_string(),
"fedcba9876543210fedcba9876543210".to_string(),
);
cfg.access.user_enabled.insert("bob".to_string(), false);
let stats = Stats::new();
let tracker = UserIpTracker::new();
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
let bob = users
.iter()
.find(|entry| entry.username == "bob")
.expect("bob must be present");
assert!(alice.enabled);
assert!(!bob.enabled);
cfg.access.user_enabled.insert("bob".to_string(), true);
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let bob = users
.iter()
.find(|entry| entry.username == "bob")
.expect("bob must be present");
assert!(bob.enabled);
}
#[tokio::test]
async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() {
let mut disk_cfg = ProxyConfig::default();
disk_cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
disk_cfg.access.users.insert(
"bob".to_string(),
"fedcba9876543210fedcba9876543210".to_string(),
);
let mut runtime_cfg = ProxyConfig::default();
runtime_cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
let stats = Stats::new();
let tracker = UserIpTracker::new();
let users =
users_from_config(&disk_cfg, &stats, &tracker, None, None, Some(&runtime_cfg)).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.in_runtime);
assert!(!bob.in_runtime);
}
#[tokio::test]
async fn users_from_config_returns_tls_link_for_each_tls_domain() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.general.modes.classic = false;
cfg.general.modes.secure = false;
cfg.general.modes.tls = true;
cfg.general.links.public_host = Some("proxy.example.net".to_string());
cfg.general.links.public_port = Some(443);
cfg.censorship.tls_domain = "front-a.example.com".to_string();
cfg.censorship.tls_domains = vec![
"front-b.example.com".to_string(),
"front-c.example.com".to_string(),
"front-b.example.com".to_string(),
"front-a.example.com".to_string(),
];
let stats = Stats::new();
let tracker = UserIpTracker::new();
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
let alice = users
.iter()
.find(|entry| entry.username == "alice")
.expect("alice must be present");
assert_eq!(alice.links.tls.len(), 3);
assert!(
alice
.links
.tls
.iter()
.any(|link| link.ends_with(&hex::encode("front-a.example.com")))
);
assert!(
alice
.links
.tls
.iter()
.any(|link| link.ends_with(&hex::encode("front-b.example.com")))
);
assert!(
alice
.links
.tls
.iter()
.any(|link| link.ends_with(&hex::encode("front-c.example.com")))
);
assert_eq!(alice.links.tls_domains.len(), 2);
assert!(
alice
.links
.tls_domains
.iter()
.any(|entry| entry.domain == "front-b.example.com"
&& entry.link.ends_with(&hex::encode("front-b.example.com")))
);
assert!(
alice
.links
.tls_domains
.iter()
.any(|entry| entry.domain == "front-c.example.com"
&& entry.link.ends_with(&hex::encode("front-c.example.com")))
);
assert!(
!alice
.links
.tls_domains
.iter()
.any(|entry| entry.domain == "front-a.example.com")
);
}
#[test]
fn build_user_quota_list_skips_users_without_positive_quota_and_sorts_by_username() {
let mut cfg = ProxyConfig::default();
cfg.access.users.insert(
"alice".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.users.insert(
"bob".to_string(),
"fedcba9876543210fedcba9876543210".to_string(),
);
cfg.access.users.insert(
"carol".to_string(),
"aaaabbbbccccddddeeeeffff00001111".to_string(),
);
// alice has a positive quota and should be listed.
cfg.access
.user_data_quota
.insert("alice".to_string(), 1 << 20);
// bob has no quota entry at all (None) — should be skipped.
// carol has an explicit zero quota — should be skipped.
cfg.access.user_data_quota.insert("carol".to_string(), 0);
let stats = Stats::new();
// Charge some traffic against alice; carol gets traffic too but should
// still be filtered out by the quota check.
let alice_stats = stats.get_or_create_user_stats_handle("alice");
stats.quota_charge_post_write(&alice_stats, 4096);
let carol_stats = stats.get_or_create_user_stats_handle("carol");
stats.quota_charge_post_write(&carol_stats, 99);
let data = build_user_quota_list(&cfg, &stats);
assert_eq!(data.users.len(), 1);
let entry = &data.users[0];
assert_eq!(entry.username, "alice");
assert_eq!(entry.data_quota_bytes, 1 << 20);
assert_eq!(entry.used_bytes, 4096);
assert_eq!(entry.last_reset_epoch_secs, 0);
}
#[test]
fn build_user_quota_list_orders_multiple_users_by_username_ascending() {
let mut cfg = ProxyConfig::default();
for name in ["charlie", "alice", "bob"] {
cfg.access.users.insert(
name.to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
);
cfg.access.user_data_quota.insert(name.to_string(), 1 << 30);
}
let stats = Stats::new();
let data = build_user_quota_list(&cfg, &stats);
let names: Vec<&str> = data.users.iter().map(|e| e.username.as_str()).collect();
assert_eq!(names, vec!["alice", "bob", "charlie"]);
for entry in &data.users {
assert_eq!(entry.used_bytes, 0);
assert_eq!(entry.last_reset_epoch_secs, 0);
assert_eq!(entry.data_quota_bytes, 1 << 30);
}
}
+244
View File
@@ -0,0 +1,244 @@
use super::*;
pub(in crate::api) async fn patch_user(
user: &str,
body: PatchUserRequest,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<(UserInfo, String), ApiFailure> {
let touches_users = body.secret.is_some();
let touches_user_ad_tags = !matches!(&body.user_ad_tag, Patch::Unchanged);
let touches_user_max_tcp_conns = !matches!(&body.max_tcp_conns, Patch::Unchanged);
let touches_user_expirations = !matches!(&body.expiration_rfc3339, Patch::Unchanged);
let touches_user_data_quota = !matches!(&body.data_quota_bytes, Patch::Unchanged);
let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged)
|| !matches!(&body.rate_limit_down_bps, Patch::Unchanged);
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged);
if let Some(secret) = body.secret.as_ref()
&& !is_valid_user_secret(secret)
{
return Err(ApiFailure::bad_request(
"secret must be exactly 32 hex characters",
));
}
if let Patch::Set(ad_tag) = &body.user_ad_tag
&& !is_valid_ad_tag(ad_tag)
{
return Err(ApiFailure::bad_request(
"user_ad_tag must be exactly 32 hex characters",
));
}
let expiration = parse_patch_expiration(&body.expiration_rfc3339)?;
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 let Some(secret) = body.secret {
cfg.access.users.insert(user.to_string(), secret);
}
match body.user_ad_tag {
Patch::Unchanged => {}
Patch::Remove => {
cfg.access.user_ad_tags.remove(user);
}
Patch::Set(ad_tag) => {
cfg.access.user_ad_tags.insert(user.to_string(), ad_tag);
}
}
match body.max_tcp_conns {
Patch::Unchanged => {}
Patch::Remove => {
cfg.access.user_max_tcp_conns.remove(user);
}
Patch::Set(limit) => {
cfg.access
.user_max_tcp_conns
.insert(user.to_string(), limit);
}
}
match expiration {
Patch::Unchanged => {}
Patch::Remove => {
cfg.access.user_expirations.remove(user);
}
Patch::Set(expiration) => {
cfg.access
.user_expirations
.insert(user.to_string(), expiration);
}
}
match body.data_quota_bytes {
Patch::Unchanged => {}
Patch::Remove => {
cfg.access.user_data_quota.remove(user);
}
Patch::Set(quota) => {
cfg.access.user_data_quota.insert(user.to_string(), quota);
}
}
if touches_user_rate_limits {
let mut rate_limit = cfg
.access
.user_rate_limits
.get(user)
.copied()
.unwrap_or_default();
match body.rate_limit_up_bps {
Patch::Unchanged => {}
Patch::Remove => rate_limit.up_bps = 0,
Patch::Set(limit) => rate_limit.up_bps = limit,
}
match body.rate_limit_down_bps {
Patch::Unchanged => {}
Patch::Remove => rate_limit.down_bps = 0,
Patch::Set(limit) => rate_limit.down_bps = limit,
}
if rate_limit.up_bps == 0 && rate_limit.down_bps == 0 {
cfg.access.user_rate_limits.remove(user);
} else {
cfg.access
.user_rate_limits
.insert(user.to_string(), rate_limit);
}
}
// Capture how the per-user IP limit changed, so the in-memory ip_tracker
// can be synced (set or removed) after the config is persisted.
let max_unique_ips_change = match body.max_unique_ips {
Patch::Unchanged => None,
Patch::Remove => {
cfg.access.user_max_unique_ips.remove(user);
Some(None)
}
Patch::Set(limit) => {
cfg.access
.user_max_unique_ips
.insert(user.to_string(), 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()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let mut touched_sections = Vec::new();
if touches_users {
touched_sections.push(AccessSection::Users);
}
if touches_user_ad_tags {
touched_sections.push(AccessSection::UserAdTags);
}
if touches_user_max_tcp_conns {
touched_sections.push(AccessSection::UserMaxTcpConns);
}
if touches_user_expirations {
touched_sections.push(AccessSection::UserExpirations);
}
if touches_user_data_quota {
touched_sections.push(AccessSection::UserDataQuota);
}
if touches_user_rate_limits {
touched_sections.push(AccessSection::UserRateLimits);
}
if touches_user_max_unique_ips {
touched_sections.push(AccessSection::UserMaxUniqueIps);
}
if touches_user_enabled {
touched_sections.push(AccessSection::UserEnabled);
}
let revision = if touched_sections.is_empty() {
current_revision(&shared.config_path).await?
} else {
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?
};
drop(_guard);
match max_unique_ips_change {
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
Some(None) => shared.ip_tracker.remove_user_limit(user).await,
None => {}
}
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(in crate::api) 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))
}
+112
View File
@@ -0,0 +1,112 @@
use super::*;
pub(in crate::api) async fn users_from_config(
cfg: &ProxyConfig,
stats: &Stats,
ip_tracker: &UserIpTracker,
startup_detected_ip_v4: Option<IpAddr>,
startup_detected_ip_v6: Option<IpAddr>,
runtime_cfg: Option<&ProxyConfig>,
) -> Vec<UserInfo> {
let mut names = cfg.access.users.keys().cloned().collect::<Vec<_>>();
names.sort();
let active_ip_lists = ip_tracker.get_active_ips_for_users(&names).await;
let recent_ip_lists = ip_tracker.get_recent_ips_for_users(&names).await;
let mut users = Vec::with_capacity(names.len());
for username in names {
let active_ip_list = active_ip_lists
.get(&username)
.cloned()
.unwrap_or_else(Vec::new);
let recent_ip_list = recent_ip_lists
.get(&username)
.cloned()
.unwrap_or_else(Vec::new);
let links = cfg
.access
.users
.get(&username)
.map(|secret| {
build_user_links(cfg, secret, startup_detected_ip_v4, startup_detected_ip_v6)
})
.unwrap_or_else(empty_user_links);
users.push(UserInfo {
enabled: cfg.access.is_user_enabled(&username),
in_runtime: runtime_cfg
.map(|runtime| runtime.access.users.contains_key(&username))
.unwrap_or(false),
user_ad_tag: cfg.access.user_ad_tags.get(&username).cloned(),
max_tcp_conns: cfg
.access
.user_max_tcp_conns
.get(&username)
.copied()
.filter(|limit| *limit > 0)
.or((cfg.access.user_max_tcp_conns_global_each > 0)
.then_some(cfg.access.user_max_tcp_conns_global_each)),
expiration_rfc3339: cfg
.access
.user_expirations
.get(&username)
.map(chrono::DateTime::<chrono::Utc>::to_rfc3339),
data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(),
rate_limit_up_bps: cfg
.access
.user_rate_limits
.get(&username)
.map(|limit| limit.up_bps)
.filter(|limit| *limit > 0),
rate_limit_down_bps: cfg
.access
.user_rate_limits
.get(&username)
.map(|limit| limit.down_bps)
.filter(|limit| *limit > 0),
max_unique_ips: cfg
.access
.user_max_unique_ips
.get(&username)
.copied()
.filter(|limit| *limit > 0)
.or((cfg.access.user_max_unique_ips_global_each > 0)
.then_some(cfg.access.user_max_unique_ips_global_each)),
current_connections: stats.get_user_curr_connects(&username),
active_unique_ips: active_ip_list.len(),
active_unique_ips_list: active_ip_list,
recent_unique_ips: recent_ip_list.len(),
recent_unique_ips_list: recent_ip_list,
total_octets: stats.get_user_total_octets(&username),
links,
username,
});
}
users
}
pub(in crate::api) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData {
let mut names = cfg.access.users.keys().cloned().collect::<Vec<_>>();
names.sort();
let snapshot = stats.user_quota_snapshot();
let mut users = Vec::with_capacity(names.len());
for username in names {
let Some(&data_quota_bytes) = cfg.access.user_data_quota.get(&username) else {
continue;
};
if data_quota_bytes == 0 {
continue;
}
let (used_bytes, last_reset_epoch_secs) = snapshot
.get(&username)
.map(|entry| (entry.used_bytes, entry.last_reset_epoch_secs))
.unwrap_or((0, 0));
users.push(UserQuotaEntry {
username,
data_quota_bytes,
used_bytes,
last_reset_epoch_secs,
});
}
UserQuotaListData { users }
}
+3
View File
@@ -705,6 +705,9 @@ ignore_time_skew = false
type = "direct"
enabled = true
weight = 10
# Optional per-upstream DC family policy:
# ipv6 = true
# prefer = 6
"#,
username = username,
secret = secret,
+62
View File
@@ -24,6 +24,10 @@ const DEFAULT_ME_ADAPTIVE_FLOOR_MAX_WARM_WRITERS_GLOBAL: u32 = 256;
const DEFAULT_ME_ROUTE_BACKPRESSURE_ENABLED: bool = false;
const DEFAULT_ME_ROUTE_FAIRSHARE_ENABLED: bool = false;
const DEFAULT_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 4096;
pub(crate) const ME_WRITER_BYTE_PERMIT_UNIT_BYTES: usize = 16 * 1024;
pub(crate) const ME_WRITER_FRAME_OVERHEAD_RESERVE_BYTES: usize = 256;
const DEFAULT_ME_WRITER_BYTE_BUDGET_BYTES: usize =
32 * 1024 * 1024 + ME_WRITER_BYTE_PERMIT_UNIT_BYTES;
const DEFAULT_ME_ROUTE_CHANNEL_CAPACITY: usize = 768;
const DEFAULT_ME_C2ME_CHANNEL_CAPACITY: usize = 1024;
const DEFAULT_ME_READER_ROUTE_DATA_WAIT_MS: u64 = 2;
@@ -35,6 +39,8 @@ const DEFAULT_ME_QUOTA_SOFT_OVERSHOOT_BYTES: u64 = 64 * 1024;
const DEFAULT_ME_D2C_FRAME_BUF_SHRINK_THRESHOLD_BYTES: usize = 256 * 1024;
const DEFAULT_DIRECT_RELAY_COPY_BUF_C2S_BYTES: usize = 64 * 1024;
const DEFAULT_DIRECT_RELAY_COPY_BUF_S2C_BYTES: usize = 256 * 1024;
pub(crate) const DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES: usize = 4 * 1024;
const DEFAULT_DIRECT_RELAY_BUFFER_BUDGET_MAX_BYTES: usize = 0;
const DEFAULT_ME_WRITER_PICK_SAMPLE_SIZE: u8 = 3;
const DEFAULT_ME_HEALTH_INTERVAL_MS_UNHEALTHY: u64 = 1000;
const DEFAULT_ME_HEALTH_INTERVAL_MS_HEALTHY: u64 = 3000;
@@ -54,6 +60,14 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true;
const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85;
const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
const DEFAULT_SYNLIMIT_SECONDS: u32 = 60;
const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 48;
const DEFAULT_SYNLIMIT_BURST: u32 = 24;
const DEFAULT_SYNLIMIT_IOS_SECONDS: u32 = 1;
const DEFAULT_SYNLIMIT_IOS_HITCOUNT: u32 = 12;
const DEFAULT_SYNLIMIT_IOS_BURST: u32 = 24;
const DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS: u32 = 60_000;
const DEFAULT_SYNLIMIT_HASHLIMIT_SIZE: u32 = 32_768;
const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2;
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;
@@ -243,6 +257,38 @@ pub(crate) fn default_conntrack_delete_budget_per_sec() -> u64 {
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_synlimit_ios_seconds() -> u32 {
DEFAULT_SYNLIMIT_IOS_SECONDS
}
pub(crate) fn default_synlimit_ios_hitcount() -> u32 {
DEFAULT_SYNLIMIT_IOS_HITCOUNT
}
pub(crate) fn default_synlimit_ios_burst() -> u32 {
DEFAULT_SYNLIMIT_IOS_BURST
}
pub(crate) fn default_synlimit_hashlimit_expire_ms() -> u32 {
DEFAULT_SYNLIMIT_HASHLIMIT_EXPIRE_MS
}
pub(crate) fn default_synlimit_hashlimit_size() -> u32 {
DEFAULT_SYNLIMIT_HASHLIMIT_SIZE
}
pub(crate) fn default_prefer_4() -> u8 {
4
}
@@ -415,6 +461,18 @@ pub(crate) fn default_me_writer_cmd_channel_capacity() -> usize {
DEFAULT_ME_WRITER_CMD_CHANNEL_CAPACITY
}
pub(crate) fn default_me_writer_byte_budget_bytes() -> usize {
DEFAULT_ME_WRITER_BYTE_BUDGET_BYTES
}
pub(crate) fn minimum_me_writer_byte_budget_bytes(max_client_frame: usize) -> usize {
max_client_frame
.saturating_mul(2)
.saturating_add(ME_WRITER_FRAME_OVERHEAD_RESERVE_BYTES)
.div_ceil(ME_WRITER_BYTE_PERMIT_UNIT_BYTES)
.saturating_mul(ME_WRITER_BYTE_PERMIT_UNIT_BYTES)
}
pub(crate) fn default_me_route_channel_capacity() -> usize {
DEFAULT_ME_ROUTE_CHANNEL_CAPACITY
}
@@ -459,6 +517,10 @@ pub(crate) fn default_direct_relay_copy_buf_s2c_bytes() -> usize {
DEFAULT_DIRECT_RELAY_COPY_BUF_S2C_BYTES
}
pub(crate) fn default_direct_relay_buffer_budget_max_bytes() -> usize {
DEFAULT_DIRECT_RELAY_BUFFER_BUDGET_MAX_BYTES
}
pub(crate) fn default_me_writer_pick_sample_size() -> u8 {
DEFAULT_ME_WRITER_PICK_SAMPLE_SIZE
}
+23 -1640
View File
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
use super::*;
fn canonicalize_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
let mut pairs: Vec<(String, serde_json::Value)> =
std::mem::take(map).into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (_, item) in pairs.iter_mut() {
canonicalize_json(item);
}
for (key, item) in pairs {
map.insert(key, item);
}
}
serde_json::Value::Array(items) => {
for item in items {
canonicalize_json(item);
}
}
_ => {}
}
}
pub(super) fn config_equal(lhs: &ProxyConfig, rhs: &ProxyConfig) -> bool {
let mut left = match serde_json::to_value(lhs) {
Ok(value) => value,
Err(_) => return false,
};
let mut right = match serde_json::to_value(rhs) {
Ok(value) => value,
Err(_) => return false,
};
canonicalize_json(&mut left);
canonicalize_json(&mut right);
left == right
}
fn listeners_equal(
lhs: &[crate::config::ListenerConfig],
rhs: &[crate::config::ListenerConfig],
) -> bool {
serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok()
}
/// Warns when the requested snapshot contains fields that require restart.
pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
let mut warned = false;
if old.server.port != new.server.port {
warned = true;
warn!(
"config reload: server.port changed ({} → {}); restart required",
old.server.port, new.server.port
);
}
if old.server.api.enabled != new.server.api.enabled
|| old.server.api.listen != new.server.api.listen
|| old.server.api.whitelist != new.server.api.whitelist
|| old.server.api.gray_action != new.server.api.gray_action
|| old.server.api.auth_header != new.server.api.auth_header
|| old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes
|| old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled
|| old.server.api.minimal_runtime_cache_ttl_ms
!= new.server.api.minimal_runtime_cache_ttl_ms
|| old.server.api.runtime_edge_enabled != new.server.api.runtime_edge_enabled
|| old.server.api.runtime_edge_cache_ttl_ms != new.server.api.runtime_edge_cache_ttl_ms
|| old.server.api.runtime_edge_top_n != new.server.api.runtime_edge_top_n
|| old.server.api.runtime_edge_events_capacity
!= new.server.api.runtime_edge_events_capacity
|| old.server.api.read_only != new.server.api.read_only
{
warned = true;
warn!("config reload: server.api changed; restart required");
}
if old.server.proxy_protocol != new.server.proxy_protocol
|| !listeners_equal(&old.server.listeners, &new.server.listeners)
|| old.server.listen_backlog != new.server.listen_backlog
|| old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4
|| old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6
|| old.server.listen_tcp != new.server.listen_tcp
|| old.server.client_mss != new.server.client_mss
|| old.server.listen_unix_sock != new.server.listen_unix_sock
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
{
warned = true;
warn!("config reload: server listener settings changed; restart required");
}
if old.censorship.tls_domain != new.censorship.tls_domain
|| old.censorship.tls_domains != new.censorship.tls_domains
|| old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope
|| 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_port != new.censorship.mask_port
|| old.censorship.exclusive_mask != new.censorship.exclusive_mask
|| old.censorship.mask_unix_sock != new.censorship.mask_unix_sock
|| old.censorship.fake_cert_len != new.censorship.fake_cert_len
|| old.censorship.tls_emulation != new.censorship.tls_emulation
|| old.censorship.tls_front_dir != new.censorship.tls_front_dir
|| old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms
|| old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms
|| old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets
|| old.censorship.serverhello_compact != new.censorship.serverhello_compact
|| old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs
|| old.censorship.alpn_enforce != new.censorship.alpn_enforce
|| old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol
|| old.censorship.mask_shape_hardening != new.censorship.mask_shape_hardening
|| old.censorship.mask_shape_bucket_floor_bytes
!= new.censorship.mask_shape_bucket_floor_bytes
|| old.censorship.mask_shape_bucket_cap_bytes != new.censorship.mask_shape_bucket_cap_bytes
|| old.censorship.mask_shape_above_cap_blur != new.censorship.mask_shape_above_cap_blur
|| old.censorship.mask_shape_above_cap_blur_max_bytes
!= new.censorship.mask_shape_above_cap_blur_max_bytes
|| old.censorship.mask_relay_max_bytes != new.censorship.mask_relay_max_bytes
|| old.censorship.mask_relay_timeout_ms != new.censorship.mask_relay_timeout_ms
|| old.censorship.mask_relay_idle_timeout_ms != new.censorship.mask_relay_idle_timeout_ms
|| old.censorship.mask_classifier_prefetch_timeout_ms
!= new.censorship.mask_classifier_prefetch_timeout_ms
|| old.censorship.mask_timing_normalization_enabled
!= new.censorship.mask_timing_normalization_enabled
|| old.censorship.mask_timing_normalization_floor_ms
!= new.censorship.mask_timing_normalization_floor_ms
|| old.censorship.mask_timing_normalization_ceiling_ms
!= new.censorship.mask_timing_normalization_ceiling_ms
{
warned = true;
warn!("config reload: censorship settings changed; restart required");
}
if old.censorship.tls_domain != new.censorship.tls_domain {
warned = true;
warn!(
"config reload: censorship.tls_domain changed ('{}' → '{}'); restart required",
old.censorship.tls_domain, new.censorship.tls_domain
);
}
if old.network.ipv4 != new.network.ipv4 || old.network.ipv6 != new.network.ipv6 {
warned = true;
warn!("config reload: network.ipv4/ipv6 changed; restart required");
}
if old.network.prefer != new.network.prefer
|| old.network.multipath != new.network.multipath
|| old.network.stun_use != new.network.stun_use
|| old.network.stun_servers != new.network.stun_servers
|| old.network.stun_tcp_fallback != new.network.stun_tcp_fallback
|| old.network.http_ip_detect_urls != new.network.http_ip_detect_urls
|| old.network.cache_public_ip_path != new.network.cache_public_ip_path
{
warned = true;
warn!("config reload: non-hot network settings changed; restart required");
}
if old.general.use_middle_proxy != new.general.use_middle_proxy {
warned = true;
warn!("config reload: use_middle_proxy changed; restart required");
}
if old.general.stun_nat_probe_concurrency != new.general.stun_nat_probe_concurrency {
warned = true;
warn!("config reload: general.stun_nat_probe_concurrency changed; restart required");
}
if old.general.middle_proxy_pool_size != new.general.middle_proxy_pool_size {
warned = true;
warn!("config reload: general.middle_proxy_pool_size changed; restart required");
}
if old.general.me_route_no_writer_mode != new.general.me_route_no_writer_mode
|| old.general.me_route_no_writer_wait_ms != new.general.me_route_no_writer_wait_ms
|| old.general.me_route_hybrid_max_wait_ms != new.general.me_route_hybrid_max_wait_ms
|| old.general.me_route_blocking_send_timeout_ms
!= new.general.me_route_blocking_send_timeout_ms
|| old.general.me_route_inline_recovery_attempts
!= new.general.me_route_inline_recovery_attempts
|| old.general.me_route_inline_recovery_wait_ms
!= new.general.me_route_inline_recovery_wait_ms
{
warned = true;
warn!("config reload: general.me_route_no_writer_* changed; restart required");
}
if old.general.unknown_dc_log_path != new.general.unknown_dc_log_path
|| old.general.unknown_dc_file_log_enabled != new.general.unknown_dc_file_log_enabled
{
warned = true;
warn!("config reload: general.unknown_dc_* changed; restart required");
}
if old.general.me_init_retry_attempts != new.general.me_init_retry_attempts {
warned = true;
warn!("config reload: general.me_init_retry_attempts changed; restart required");
}
if old.general.me2dc_fallback != new.general.me2dc_fallback
|| old.general.me2dc_fast != new.general.me2dc_fast
{
warned = true;
warn!("config reload: general.me2dc_fallback/me2dc_fast changed; restart required");
}
if old.general.proxy_config_v4_cache_path != new.general.proxy_config_v4_cache_path
|| old.general.proxy_config_v6_cache_path != new.general.proxy_config_v6_cache_path
{
warned = true;
warn!("config reload: general.proxy_config_*_cache_path changed; restart required");
}
if old.general.me_keepalive_enabled != new.general.me_keepalive_enabled
|| old.general.me_keepalive_interval_secs != new.general.me_keepalive_interval_secs
|| old.general.me_keepalive_jitter_secs != new.general.me_keepalive_jitter_secs
|| old.general.me_keepalive_payload_random != new.general.me_keepalive_payload_random
{
warned = true;
warn!("config reload: general.me_keepalive_* changed; restart required");
}
if old.general.upstream_connect_retry_attempts != new.general.upstream_connect_retry_attempts
|| old.general.upstream_connect_retry_backoff_ms
!= new.general.upstream_connect_retry_backoff_ms
|| old.general.tg_connect != new.general.tg_connect
|| old.general.upstream_unhealthy_fail_threshold
!= new.general.upstream_unhealthy_fail_threshold
|| old.general.upstream_connect_failfast_hard_errors
!= new.general.upstream_connect_failfast_hard_errors
|| old.general.rpc_proxy_req_every != new.general.rpc_proxy_req_every
{
warned = true;
warn!("config reload: general.upstream_* changed; restart required");
}
if non_hot_changed && !warned {
warn!("config reload: one or more non-hot fields changed; restart required");
}
}
/// Resolve the public host for link generation — mirrors the logic in main.rs.
///
/// Priority:
/// 1. `[general.links] public_host` — explicit override in config
/// 2. `detected_ip_v4` — from STUN/interface probe at startup
/// 3. `detected_ip_v6` — fallback
/// 4. `"UNKNOWN"` — warn the user to set `public_host`
/// 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
}
+345
View File
@@ -0,0 +1,345 @@
use super::*;
/// Fields that are safe to swap without restarting listeners.
#[derive(Debug, Clone, PartialEq)]
pub struct HotFields {
pub log_level: LogLevel,
pub ad_tag: Option<String>,
pub dns_overrides: Vec<String>,
pub desync_all_full: bool,
pub update_every_secs: u64,
pub me_reinit_every_secs: u64,
pub me_reinit_singleflight: bool,
pub me_reinit_coalesce_window_ms: u64,
pub hardswap: bool,
pub me_pool_drain_ttl_secs: u64,
pub me_instadrain: bool,
pub me_pool_drain_threshold: u64,
pub me_pool_min_fresh_ratio: f32,
pub me_reinit_drain_timeout_secs: u64,
pub me_hardswap_warmup_delay_min_ms: u64,
pub me_hardswap_warmup_delay_max_ms: u64,
pub me_hardswap_warmup_extra_passes: u8,
pub me_hardswap_warmup_pass_backoff_base_ms: u64,
pub me_bind_stale_mode: MeBindStaleMode,
pub me_bind_stale_ttl_secs: u64,
pub me_secret_atomic_snapshot: bool,
pub me_deterministic_writer_sort: bool,
pub me_writer_pick_mode: MeWriterPickMode,
pub me_writer_pick_sample_size: u8,
pub me_single_endpoint_shadow_writers: u8,
pub me_single_endpoint_outage_mode_enabled: bool,
pub me_single_endpoint_outage_disable_quarantine: bool,
pub me_single_endpoint_outage_backoff_min_ms: u64,
pub me_single_endpoint_outage_backoff_max_ms: u64,
pub me_single_endpoint_shadow_rotate_every_secs: u64,
pub me_config_stable_snapshots: u8,
pub me_config_apply_cooldown_secs: u64,
pub me_snapshot_require_http_2xx: bool,
pub me_snapshot_reject_empty_map: bool,
pub me_snapshot_min_proxy_for_lines: u32,
pub proxy_secret_stable_snapshots: u8,
pub proxy_secret_rotate_runtime: bool,
pub proxy_secret_len_max: usize,
pub telemetry_core_enabled: bool,
pub telemetry_user_enabled: bool,
pub telemetry_me_level: MeTelemetryLevel,
pub me_socks_kdf_policy: MeSocksKdfPolicy,
pub me_route_backpressure_enabled: bool,
pub me_route_fairshare_enabled: bool,
pub me_floor_mode: MeFloorMode,
pub me_adaptive_floor_idle_secs: u64,
pub me_adaptive_floor_min_writers_single_endpoint: u8,
pub me_adaptive_floor_min_writers_multi_endpoint: u8,
pub me_adaptive_floor_recover_grace_secs: u64,
pub me_adaptive_floor_writers_per_core_total: u16,
pub me_adaptive_floor_cpu_cores_override: u16,
pub me_adaptive_floor_max_extra_writers_single_per_core: u16,
pub me_adaptive_floor_max_extra_writers_multi_per_core: u16,
pub me_adaptive_floor_max_active_writers_per_core: u16,
pub me_adaptive_floor_max_warm_writers_per_core: u16,
pub me_adaptive_floor_max_active_writers_global: u32,
pub me_adaptive_floor_max_warm_writers_global: u32,
pub me_route_backpressure_base_timeout_ms: u64,
pub me_route_backpressure_high_timeout_ms: u64,
pub me_route_backpressure_high_watermark_pct: u8,
pub me_reader_route_data_wait_ms: u64,
pub me_d2c_flush_batch_max_frames: usize,
pub me_d2c_flush_batch_max_bytes: usize,
pub me_d2c_flush_batch_max_delay_us: u64,
pub me_d2c_ack_flush_immediate: bool,
pub me_quota_soft_overshoot_bytes: u64,
pub me_d2c_frame_buf_shrink_threshold_bytes: usize,
pub direct_relay_copy_buf_c2s_bytes: usize,
pub direct_relay_copy_buf_s2c_bytes: usize,
pub me_health_interval_ms_unhealthy: u64,
pub me_health_interval_ms_healthy: u64,
pub me_admission_poll_ms: u64,
pub me_warn_rate_limit_ms: u64,
pub users: std::collections::HashMap<String, String>,
pub user_enabled: std::collections::HashMap<String, bool>,
pub user_ad_tags: std::collections::HashMap<String, String>,
pub user_max_tcp_conns: std::collections::HashMap<String, usize>,
pub user_max_tcp_conns_global_each: usize,
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
pub user_data_quota: std::collections::HashMap<String, u64>,
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
pub cidr_rate_limits: std::collections::HashMap<CidrRateLimitKey, crate::config::RateLimitBps>,
pub user_max_unique_ips: std::collections::HashMap<String, usize>,
pub user_max_unique_ips_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64,
}
impl HotFields {
pub fn from_config(cfg: &ProxyConfig) -> Self {
Self {
log_level: cfg.general.log_level.clone(),
ad_tag: cfg.general.ad_tag.clone(),
dns_overrides: cfg.network.dns_overrides.clone(),
desync_all_full: cfg.general.desync_all_full,
update_every_secs: cfg.general.effective_update_every_secs(),
me_reinit_every_secs: cfg.general.me_reinit_every_secs,
me_reinit_singleflight: cfg.general.me_reinit_singleflight,
me_reinit_coalesce_window_ms: cfg.general.me_reinit_coalesce_window_ms,
hardswap: cfg.general.hardswap,
me_pool_drain_ttl_secs: cfg.general.me_pool_drain_ttl_secs,
me_instadrain: cfg.general.me_instadrain,
me_pool_drain_threshold: cfg.general.me_pool_drain_threshold,
me_pool_min_fresh_ratio: cfg.general.me_pool_min_fresh_ratio,
me_reinit_drain_timeout_secs: cfg.general.me_reinit_drain_timeout_secs,
me_hardswap_warmup_delay_min_ms: cfg.general.me_hardswap_warmup_delay_min_ms,
me_hardswap_warmup_delay_max_ms: cfg.general.me_hardswap_warmup_delay_max_ms,
me_hardswap_warmup_extra_passes: cfg.general.me_hardswap_warmup_extra_passes,
me_hardswap_warmup_pass_backoff_base_ms: cfg
.general
.me_hardswap_warmup_pass_backoff_base_ms,
me_bind_stale_mode: cfg.general.me_bind_stale_mode,
me_bind_stale_ttl_secs: cfg.general.me_bind_stale_ttl_secs,
me_secret_atomic_snapshot: cfg.general.me_secret_atomic_snapshot,
me_deterministic_writer_sort: cfg.general.me_deterministic_writer_sort,
me_writer_pick_mode: cfg.general.me_writer_pick_mode,
me_writer_pick_sample_size: cfg.general.me_writer_pick_sample_size,
me_single_endpoint_shadow_writers: cfg.general.me_single_endpoint_shadow_writers,
me_single_endpoint_outage_mode_enabled: cfg
.general
.me_single_endpoint_outage_mode_enabled,
me_single_endpoint_outage_disable_quarantine: cfg
.general
.me_single_endpoint_outage_disable_quarantine,
me_single_endpoint_outage_backoff_min_ms: cfg
.general
.me_single_endpoint_outage_backoff_min_ms,
me_single_endpoint_outage_backoff_max_ms: cfg
.general
.me_single_endpoint_outage_backoff_max_ms,
me_single_endpoint_shadow_rotate_every_secs: cfg
.general
.me_single_endpoint_shadow_rotate_every_secs,
me_config_stable_snapshots: cfg.general.me_config_stable_snapshots,
me_config_apply_cooldown_secs: cfg.general.me_config_apply_cooldown_secs,
me_snapshot_require_http_2xx: cfg.general.me_snapshot_require_http_2xx,
me_snapshot_reject_empty_map: cfg.general.me_snapshot_reject_empty_map,
me_snapshot_min_proxy_for_lines: cfg.general.me_snapshot_min_proxy_for_lines,
proxy_secret_stable_snapshots: cfg.general.proxy_secret_stable_snapshots,
proxy_secret_rotate_runtime: cfg.general.proxy_secret_rotate_runtime,
proxy_secret_len_max: cfg.general.proxy_secret_len_max,
telemetry_core_enabled: cfg.general.telemetry.core_enabled,
telemetry_user_enabled: cfg.general.telemetry.user_enabled,
telemetry_me_level: cfg.general.telemetry.me_level,
me_socks_kdf_policy: cfg.general.me_socks_kdf_policy,
me_route_backpressure_enabled: cfg.general.me_route_backpressure_enabled,
me_route_fairshare_enabled: cfg.general.me_route_fairshare_enabled,
me_floor_mode: cfg.general.me_floor_mode,
me_adaptive_floor_idle_secs: cfg.general.me_adaptive_floor_idle_secs,
me_adaptive_floor_min_writers_single_endpoint: cfg
.general
.me_adaptive_floor_min_writers_single_endpoint,
me_adaptive_floor_min_writers_multi_endpoint: cfg
.general
.me_adaptive_floor_min_writers_multi_endpoint,
me_adaptive_floor_recover_grace_secs: cfg.general.me_adaptive_floor_recover_grace_secs,
me_adaptive_floor_writers_per_core_total: cfg
.general
.me_adaptive_floor_writers_per_core_total,
me_adaptive_floor_cpu_cores_override: cfg.general.me_adaptive_floor_cpu_cores_override,
me_adaptive_floor_max_extra_writers_single_per_core: cfg
.general
.me_adaptive_floor_max_extra_writers_single_per_core,
me_adaptive_floor_max_extra_writers_multi_per_core: cfg
.general
.me_adaptive_floor_max_extra_writers_multi_per_core,
me_adaptive_floor_max_active_writers_per_core: cfg
.general
.me_adaptive_floor_max_active_writers_per_core,
me_adaptive_floor_max_warm_writers_per_core: cfg
.general
.me_adaptive_floor_max_warm_writers_per_core,
me_adaptive_floor_max_active_writers_global: cfg
.general
.me_adaptive_floor_max_active_writers_global,
me_adaptive_floor_max_warm_writers_global: cfg
.general
.me_adaptive_floor_max_warm_writers_global,
me_route_backpressure_base_timeout_ms: cfg
.general
.me_route_backpressure_base_timeout_ms,
me_route_backpressure_high_timeout_ms: cfg
.general
.me_route_backpressure_high_timeout_ms,
me_route_backpressure_high_watermark_pct: cfg
.general
.me_route_backpressure_high_watermark_pct,
me_reader_route_data_wait_ms: cfg.general.me_reader_route_data_wait_ms,
me_d2c_flush_batch_max_frames: cfg.general.me_d2c_flush_batch_max_frames,
me_d2c_flush_batch_max_bytes: cfg.general.me_d2c_flush_batch_max_bytes,
me_d2c_flush_batch_max_delay_us: cfg.general.me_d2c_flush_batch_max_delay_us,
me_d2c_ack_flush_immediate: cfg.general.me_d2c_ack_flush_immediate,
me_quota_soft_overshoot_bytes: cfg.general.me_quota_soft_overshoot_bytes,
me_d2c_frame_buf_shrink_threshold_bytes: cfg
.general
.me_d2c_frame_buf_shrink_threshold_bytes,
direct_relay_copy_buf_c2s_bytes: cfg.general.direct_relay_copy_buf_c2s_bytes,
direct_relay_copy_buf_s2c_bytes: cfg.general.direct_relay_copy_buf_s2c_bytes,
me_health_interval_ms_unhealthy: cfg.general.me_health_interval_ms_unhealthy,
me_health_interval_ms_healthy: cfg.general.me_health_interval_ms_healthy,
me_admission_poll_ms: cfg.general.me_admission_poll_ms,
me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms,
users: cfg.access.users.clone(),
user_enabled: cfg.access.user_enabled.clone(),
user_ad_tags: cfg.access.user_ad_tags.clone(),
user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(),
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
user_expirations: cfg.access.user_expirations.clone(),
user_data_quota: cfg.access.user_data_quota.clone(),
user_rate_limits: cfg.access.user_rate_limits.clone(),
cidr_rate_limits: cfg.access.cidr_rate_limits.clone(),
user_max_unique_ips: cfg.access.user_max_unique_ips.clone(),
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_window_secs: cfg.access.user_max_unique_ips_window_secs,
}
}
}
pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
let mut cfg = old.clone();
cfg.general.log_level = new.general.log_level.clone();
cfg.general.ad_tag = new.general.ad_tag.clone();
cfg.network.dns_overrides = new.network.dns_overrides.clone();
cfg.general.desync_all_full = new.general.desync_all_full;
cfg.general.update_every = new.general.update_every;
cfg.general.proxy_secret_auto_reload_secs = new.general.proxy_secret_auto_reload_secs;
cfg.general.proxy_config_auto_reload_secs = new.general.proxy_config_auto_reload_secs;
cfg.general.me_reinit_every_secs = new.general.me_reinit_every_secs;
cfg.general.me_reinit_singleflight = new.general.me_reinit_singleflight;
cfg.general.me_reinit_coalesce_window_ms = new.general.me_reinit_coalesce_window_ms;
cfg.general.hardswap = new.general.hardswap;
cfg.general.me_pool_drain_ttl_secs = new.general.me_pool_drain_ttl_secs;
cfg.general.me_instadrain = new.general.me_instadrain;
cfg.general.me_pool_drain_threshold = new.general.me_pool_drain_threshold;
cfg.general.me_pool_min_fresh_ratio = new.general.me_pool_min_fresh_ratio;
cfg.general.me_reinit_drain_timeout_secs = new.general.me_reinit_drain_timeout_secs;
cfg.general.me_hardswap_warmup_delay_min_ms = new.general.me_hardswap_warmup_delay_min_ms;
cfg.general.me_hardswap_warmup_delay_max_ms = new.general.me_hardswap_warmup_delay_max_ms;
cfg.general.me_hardswap_warmup_extra_passes = new.general.me_hardswap_warmup_extra_passes;
cfg.general.me_hardswap_warmup_pass_backoff_base_ms =
new.general.me_hardswap_warmup_pass_backoff_base_ms;
cfg.general.me_bind_stale_mode = new.general.me_bind_stale_mode;
cfg.general.me_bind_stale_ttl_secs = new.general.me_bind_stale_ttl_secs;
cfg.general.me_secret_atomic_snapshot = new.general.me_secret_atomic_snapshot;
cfg.general.me_deterministic_writer_sort = new.general.me_deterministic_writer_sort;
cfg.general.me_writer_pick_mode = new.general.me_writer_pick_mode;
cfg.general.me_writer_pick_sample_size = new.general.me_writer_pick_sample_size;
cfg.general.me_single_endpoint_shadow_writers = new.general.me_single_endpoint_shadow_writers;
cfg.general.me_single_endpoint_outage_mode_enabled =
new.general.me_single_endpoint_outage_mode_enabled;
cfg.general.me_single_endpoint_outage_disable_quarantine =
new.general.me_single_endpoint_outage_disable_quarantine;
cfg.general.me_single_endpoint_outage_backoff_min_ms =
new.general.me_single_endpoint_outage_backoff_min_ms;
cfg.general.me_single_endpoint_outage_backoff_max_ms =
new.general.me_single_endpoint_outage_backoff_max_ms;
cfg.general.me_single_endpoint_shadow_rotate_every_secs =
new.general.me_single_endpoint_shadow_rotate_every_secs;
cfg.general.me_config_stable_snapshots = new.general.me_config_stable_snapshots;
cfg.general.me_config_apply_cooldown_secs = new.general.me_config_apply_cooldown_secs;
cfg.general.me_snapshot_require_http_2xx = new.general.me_snapshot_require_http_2xx;
cfg.general.me_snapshot_reject_empty_map = new.general.me_snapshot_reject_empty_map;
cfg.general.me_snapshot_min_proxy_for_lines = new.general.me_snapshot_min_proxy_for_lines;
cfg.general.proxy_secret_stable_snapshots = new.general.proxy_secret_stable_snapshots;
cfg.general.proxy_secret_rotate_runtime = new.general.proxy_secret_rotate_runtime;
cfg.general.proxy_secret_len_max = new.general.proxy_secret_len_max;
cfg.general.telemetry = new.general.telemetry.clone();
cfg.general.me_socks_kdf_policy = new.general.me_socks_kdf_policy;
cfg.general.me_floor_mode = new.general.me_floor_mode;
cfg.general.me_adaptive_floor_idle_secs = new.general.me_adaptive_floor_idle_secs;
cfg.general.me_adaptive_floor_min_writers_single_endpoint =
new.general.me_adaptive_floor_min_writers_single_endpoint;
cfg.general.me_adaptive_floor_min_writers_multi_endpoint =
new.general.me_adaptive_floor_min_writers_multi_endpoint;
cfg.general.me_adaptive_floor_recover_grace_secs =
new.general.me_adaptive_floor_recover_grace_secs;
cfg.general.me_adaptive_floor_writers_per_core_total =
new.general.me_adaptive_floor_writers_per_core_total;
cfg.general.me_adaptive_floor_cpu_cores_override =
new.general.me_adaptive_floor_cpu_cores_override;
cfg.general
.me_adaptive_floor_max_extra_writers_single_per_core = new
.general
.me_adaptive_floor_max_extra_writers_single_per_core;
cfg.general
.me_adaptive_floor_max_extra_writers_multi_per_core = new
.general
.me_adaptive_floor_max_extra_writers_multi_per_core;
cfg.general.me_adaptive_floor_max_active_writers_per_core =
new.general.me_adaptive_floor_max_active_writers_per_core;
cfg.general.me_adaptive_floor_max_warm_writers_per_core =
new.general.me_adaptive_floor_max_warm_writers_per_core;
cfg.general.me_adaptive_floor_max_active_writers_global =
new.general.me_adaptive_floor_max_active_writers_global;
cfg.general.me_adaptive_floor_max_warm_writers_global =
new.general.me_adaptive_floor_max_warm_writers_global;
cfg.general.me_route_backpressure_base_timeout_ms =
new.general.me_route_backpressure_base_timeout_ms;
cfg.general.me_route_backpressure_high_timeout_ms =
new.general.me_route_backpressure_high_timeout_ms;
cfg.general.me_route_backpressure_high_watermark_pct =
new.general.me_route_backpressure_high_watermark_pct;
cfg.general.me_route_backpressure_enabled = new.general.me_route_backpressure_enabled;
cfg.general.me_route_fairshare_enabled = new.general.me_route_fairshare_enabled;
cfg.general.me_reader_route_data_wait_ms = new.general.me_reader_route_data_wait_ms;
cfg.general.me_d2c_flush_batch_max_frames = new.general.me_d2c_flush_batch_max_frames;
cfg.general.me_d2c_flush_batch_max_bytes = new.general.me_d2c_flush_batch_max_bytes;
cfg.general.me_d2c_flush_batch_max_delay_us = new.general.me_d2c_flush_batch_max_delay_us;
cfg.general.me_d2c_ack_flush_immediate = new.general.me_d2c_ack_flush_immediate;
cfg.general.me_quota_soft_overshoot_bytes = new.general.me_quota_soft_overshoot_bytes;
cfg.general.me_d2c_frame_buf_shrink_threshold_bytes =
new.general.me_d2c_frame_buf_shrink_threshold_bytes;
cfg.general.direct_relay_copy_buf_c2s_bytes = new.general.direct_relay_copy_buf_c2s_bytes;
cfg.general.direct_relay_copy_buf_s2c_bytes = new.general.direct_relay_copy_buf_s2c_bytes;
cfg.general.me_health_interval_ms_unhealthy = new.general.me_health_interval_ms_unhealthy;
cfg.general.me_health_interval_ms_healthy = new.general.me_health_interval_ms_healthy;
cfg.general.me_admission_poll_ms = new.general.me_admission_poll_ms;
cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms;
cfg.access.users = new.access.users.clone();
cfg.access.user_enabled = new.access.user_enabled.clone();
cfg.access.user_ad_tags = new.access.user_ad_tags.clone();
cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone();
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
cfg.access.user_expirations = new.access.user_expirations.clone();
cfg.access.user_data_quota = new.access.user_data_quota.clone();
cfg.access.user_rate_limits = new.access.user_rate_limits.clone();
cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.clone();
cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone();
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_window_secs = new.access.user_max_unique_ips_window_secs;
if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None;
}
cfg
}
+499
View File
@@ -0,0 +1,499 @@
use super::*;
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
cfg.server
.listeners
.first()
.and_then(|listener| listener.port)
.unwrap_or(cfg.server.port)
}
fn resolve_link_host(
cfg: &ProxyConfig,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
) -> String {
if let Some(ref h) = cfg.general.links.public_host {
return h.clone();
}
detected_ip_v4
.or(detected_ip_v6)
.map(|ip| ip.to_string())
.unwrap_or_else(|| {
warn!(
"config reload: could not determine public IP for proxy links. \
Set [general.links] public_host in config."
);
"UNKNOWN".to_string()
})
}
/// Print TG proxy links for a single user — mirrors print_proxy_links() in main.rs.
fn print_user_links(user: &str, secret: &str, host: &str, port: u16, cfg: &ProxyConfig) {
info!(target: "telemt::links", "--- New user: {} ---", user);
if cfg.general.modes.classic {
info!(
target: "telemt::links",
" Classic: tg://proxy?server={}&port={}&secret={}",
host, port, secret
);
}
if cfg.general.modes.secure {
info!(
target: "telemt::links",
" DD: tg://proxy?server={}&port={}&secret=dd{}",
host, port, secret
);
}
if cfg.general.modes.tls {
let mut domains = vec![cfg.censorship.tls_domain.clone()];
for d in &cfg.censorship.tls_domains {
if !domains.contains(d) {
domains.push(d.clone());
}
}
for domain in &domains {
let domain_hex = hex::encode(domain.as_bytes());
info!(
target: "telemt::links",
" EE-TLS: tg://proxy?server={}&port={}&secret=ee{}{}",
host, port, secret, domain_hex
);
}
}
info!(target: "telemt::links", "--------------------");
}
/// Log all detected changes and emit TG links for new users.
pub(super) fn log_changes(
old_hot: &HotFields,
new_hot: &HotFields,
new_cfg: &ProxyConfig,
log_tx: &watch::Sender<LogLevel>,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
) {
if old_hot.log_level != new_hot.log_level {
info!(
"config reload: log_level: '{}' → '{}'",
old_hot.log_level, new_hot.log_level
);
log_tx.send(new_hot.log_level.clone()).ok();
}
if old_hot.user_ad_tags != new_hot.user_ad_tags {
info!(
"config reload: user_ad_tags updated ({} entries)",
new_hot.user_ad_tags.len(),
);
}
if old_hot.ad_tag != new_hot.ad_tag {
info!("config reload: general.ad_tag updated (applied on next connection)");
}
if old_hot.dns_overrides != new_hot.dns_overrides {
info!(
"config reload: network.dns_overrides updated ({} entries)",
new_hot.dns_overrides.len()
);
}
if old_hot.desync_all_full != new_hot.desync_all_full {
info!(
"config reload: desync_all_full: {} → {}",
old_hot.desync_all_full, new_hot.desync_all_full,
);
}
if old_hot.update_every_secs != new_hot.update_every_secs {
info!(
"config reload: update_every(effective): {}s → {}s",
old_hot.update_every_secs, new_hot.update_every_secs,
);
}
if old_hot.me_reinit_every_secs != new_hot.me_reinit_every_secs
|| old_hot.me_reinit_singleflight != new_hot.me_reinit_singleflight
|| old_hot.me_reinit_coalesce_window_ms != new_hot.me_reinit_coalesce_window_ms
{
info!(
"config reload: me_reinit: interval={}s singleflight={} coalesce={}ms",
new_hot.me_reinit_every_secs,
new_hot.me_reinit_singleflight,
new_hot.me_reinit_coalesce_window_ms
);
}
if old_hot.hardswap != new_hot.hardswap {
info!(
"config reload: hardswap: {} → {}",
old_hot.hardswap, new_hot.hardswap,
);
}
if old_hot.me_pool_drain_ttl_secs != new_hot.me_pool_drain_ttl_secs {
info!(
"config reload: me_pool_drain_ttl_secs: {}s → {}s",
old_hot.me_pool_drain_ttl_secs, new_hot.me_pool_drain_ttl_secs,
);
}
if old_hot.me_instadrain != new_hot.me_instadrain {
info!(
"config reload: me_instadrain: {} → {}",
old_hot.me_instadrain, new_hot.me_instadrain,
);
}
if old_hot.me_pool_drain_threshold != new_hot.me_pool_drain_threshold {
info!(
"config reload: me_pool_drain_threshold: {} → {}",
old_hot.me_pool_drain_threshold, new_hot.me_pool_drain_threshold,
);
}
if (old_hot.me_pool_min_fresh_ratio - new_hot.me_pool_min_fresh_ratio).abs() > f32::EPSILON {
info!(
"config reload: me_pool_min_fresh_ratio: {:.3} → {:.3}",
old_hot.me_pool_min_fresh_ratio, new_hot.me_pool_min_fresh_ratio,
);
}
if old_hot.me_reinit_drain_timeout_secs != new_hot.me_reinit_drain_timeout_secs {
info!(
"config reload: me_reinit_drain_timeout_secs: {}s → {}s",
old_hot.me_reinit_drain_timeout_secs, new_hot.me_reinit_drain_timeout_secs,
);
}
if old_hot.me_hardswap_warmup_delay_min_ms != new_hot.me_hardswap_warmup_delay_min_ms
|| old_hot.me_hardswap_warmup_delay_max_ms != new_hot.me_hardswap_warmup_delay_max_ms
|| old_hot.me_hardswap_warmup_extra_passes != new_hot.me_hardswap_warmup_extra_passes
|| old_hot.me_hardswap_warmup_pass_backoff_base_ms
!= new_hot.me_hardswap_warmup_pass_backoff_base_ms
{
info!(
"config reload: me_hardswap_warmup: min={}ms max={}ms extra_passes={} pass_backoff={}ms",
new_hot.me_hardswap_warmup_delay_min_ms,
new_hot.me_hardswap_warmup_delay_max_ms,
new_hot.me_hardswap_warmup_extra_passes,
new_hot.me_hardswap_warmup_pass_backoff_base_ms
);
}
if old_hot.me_bind_stale_mode != new_hot.me_bind_stale_mode
|| old_hot.me_bind_stale_ttl_secs != new_hot.me_bind_stale_ttl_secs
{
info!(
"config reload: me_bind_stale: mode={:?} ttl={}s",
new_hot.me_bind_stale_mode, new_hot.me_bind_stale_ttl_secs
);
}
if old_hot.me_secret_atomic_snapshot != new_hot.me_secret_atomic_snapshot
|| old_hot.me_deterministic_writer_sort != new_hot.me_deterministic_writer_sort
|| old_hot.me_writer_pick_mode != new_hot.me_writer_pick_mode
|| old_hot.me_writer_pick_sample_size != new_hot.me_writer_pick_sample_size
{
info!(
"config reload: me_runtime_flags: secret_atomic_snapshot={} deterministic_sort={} writer_pick_mode={:?} writer_pick_sample_size={}",
new_hot.me_secret_atomic_snapshot,
new_hot.me_deterministic_writer_sort,
new_hot.me_writer_pick_mode,
new_hot.me_writer_pick_sample_size,
);
}
if old_hot.me_single_endpoint_shadow_writers != new_hot.me_single_endpoint_shadow_writers
|| old_hot.me_single_endpoint_outage_mode_enabled
!= new_hot.me_single_endpoint_outage_mode_enabled
|| old_hot.me_single_endpoint_outage_disable_quarantine
!= new_hot.me_single_endpoint_outage_disable_quarantine
|| old_hot.me_single_endpoint_outage_backoff_min_ms
!= new_hot.me_single_endpoint_outage_backoff_min_ms
|| old_hot.me_single_endpoint_outage_backoff_max_ms
!= new_hot.me_single_endpoint_outage_backoff_max_ms
|| old_hot.me_single_endpoint_shadow_rotate_every_secs
!= new_hot.me_single_endpoint_shadow_rotate_every_secs
{
info!(
"config reload: me_single_endpoint: shadow={} outage_enabled={} disable_quarantine={} backoff=[{}..{}]ms rotate={}s",
new_hot.me_single_endpoint_shadow_writers,
new_hot.me_single_endpoint_outage_mode_enabled,
new_hot.me_single_endpoint_outage_disable_quarantine,
new_hot.me_single_endpoint_outage_backoff_min_ms,
new_hot.me_single_endpoint_outage_backoff_max_ms,
new_hot.me_single_endpoint_shadow_rotate_every_secs
);
}
if old_hot.me_config_stable_snapshots != new_hot.me_config_stable_snapshots
|| old_hot.me_config_apply_cooldown_secs != new_hot.me_config_apply_cooldown_secs
|| old_hot.me_snapshot_require_http_2xx != new_hot.me_snapshot_require_http_2xx
|| old_hot.me_snapshot_reject_empty_map != new_hot.me_snapshot_reject_empty_map
|| old_hot.me_snapshot_min_proxy_for_lines != new_hot.me_snapshot_min_proxy_for_lines
{
info!(
"config reload: me_snapshot_guard: stable={} cooldown={}s require_2xx={} reject_empty={} min_proxy_for={}",
new_hot.me_config_stable_snapshots,
new_hot.me_config_apply_cooldown_secs,
new_hot.me_snapshot_require_http_2xx,
new_hot.me_snapshot_reject_empty_map,
new_hot.me_snapshot_min_proxy_for_lines
);
}
if old_hot.proxy_secret_stable_snapshots != new_hot.proxy_secret_stable_snapshots
|| old_hot.proxy_secret_rotate_runtime != new_hot.proxy_secret_rotate_runtime
|| old_hot.proxy_secret_len_max != new_hot.proxy_secret_len_max
{
info!(
"config reload: proxy_secret_runtime: stable={} rotate={} len_max={}",
new_hot.proxy_secret_stable_snapshots,
new_hot.proxy_secret_rotate_runtime,
new_hot.proxy_secret_len_max
);
}
if old_hot.telemetry_core_enabled != new_hot.telemetry_core_enabled
|| old_hot.telemetry_user_enabled != new_hot.telemetry_user_enabled
|| old_hot.telemetry_me_level != new_hot.telemetry_me_level
{
info!(
"config reload: telemetry: core_enabled={} user_enabled={} me_level={}",
new_hot.telemetry_core_enabled,
new_hot.telemetry_user_enabled,
new_hot.telemetry_me_level,
);
}
if old_hot.me_socks_kdf_policy != new_hot.me_socks_kdf_policy {
info!(
"config reload: me_socks_kdf_policy: {:?} → {:?}",
old_hot.me_socks_kdf_policy, new_hot.me_socks_kdf_policy,
);
}
if old_hot.me_floor_mode != new_hot.me_floor_mode
|| old_hot.me_adaptive_floor_idle_secs != new_hot.me_adaptive_floor_idle_secs
|| old_hot.me_adaptive_floor_min_writers_single_endpoint
!= new_hot.me_adaptive_floor_min_writers_single_endpoint
|| old_hot.me_adaptive_floor_min_writers_multi_endpoint
!= new_hot.me_adaptive_floor_min_writers_multi_endpoint
|| old_hot.me_adaptive_floor_recover_grace_secs
!= new_hot.me_adaptive_floor_recover_grace_secs
|| old_hot.me_adaptive_floor_writers_per_core_total
!= new_hot.me_adaptive_floor_writers_per_core_total
|| old_hot.me_adaptive_floor_cpu_cores_override
!= new_hot.me_adaptive_floor_cpu_cores_override
|| old_hot.me_adaptive_floor_max_extra_writers_single_per_core
!= new_hot.me_adaptive_floor_max_extra_writers_single_per_core
|| old_hot.me_adaptive_floor_max_extra_writers_multi_per_core
!= new_hot.me_adaptive_floor_max_extra_writers_multi_per_core
|| old_hot.me_adaptive_floor_max_active_writers_per_core
!= new_hot.me_adaptive_floor_max_active_writers_per_core
|| old_hot.me_adaptive_floor_max_warm_writers_per_core
!= new_hot.me_adaptive_floor_max_warm_writers_per_core
|| old_hot.me_adaptive_floor_max_active_writers_global
!= new_hot.me_adaptive_floor_max_active_writers_global
|| old_hot.me_adaptive_floor_max_warm_writers_global
!= new_hot.me_adaptive_floor_max_warm_writers_global
{
info!(
"config reload: me_floor: mode={:?} idle={}s min_single={} min_multi={} recover_grace={}s per_core_total={} cores_override={} extra_single_per_core={} extra_multi_per_core={} max_active_per_core={} max_warm_per_core={} max_active_global={} max_warm_global={}",
new_hot.me_floor_mode,
new_hot.me_adaptive_floor_idle_secs,
new_hot.me_adaptive_floor_min_writers_single_endpoint,
new_hot.me_adaptive_floor_min_writers_multi_endpoint,
new_hot.me_adaptive_floor_recover_grace_secs,
new_hot.me_adaptive_floor_writers_per_core_total,
new_hot.me_adaptive_floor_cpu_cores_override,
new_hot.me_adaptive_floor_max_extra_writers_single_per_core,
new_hot.me_adaptive_floor_max_extra_writers_multi_per_core,
new_hot.me_adaptive_floor_max_active_writers_per_core,
new_hot.me_adaptive_floor_max_warm_writers_per_core,
new_hot.me_adaptive_floor_max_active_writers_global,
new_hot.me_adaptive_floor_max_warm_writers_global,
);
}
if old_hot.me_route_backpressure_base_timeout_ms
!= new_hot.me_route_backpressure_base_timeout_ms
|| old_hot.me_route_backpressure_high_timeout_ms
!= new_hot.me_route_backpressure_high_timeout_ms
|| old_hot.me_route_backpressure_high_watermark_pct
!= new_hot.me_route_backpressure_high_watermark_pct
|| old_hot.me_route_backpressure_enabled != new_hot.me_route_backpressure_enabled
|| old_hot.me_route_fairshare_enabled != new_hot.me_route_fairshare_enabled
|| old_hot.me_reader_route_data_wait_ms != new_hot.me_reader_route_data_wait_ms
|| old_hot.me_health_interval_ms_unhealthy != new_hot.me_health_interval_ms_unhealthy
|| old_hot.me_health_interval_ms_healthy != new_hot.me_health_interval_ms_healthy
|| old_hot.me_admission_poll_ms != new_hot.me_admission_poll_ms
|| old_hot.me_warn_rate_limit_ms != new_hot.me_warn_rate_limit_ms
{
info!(
"config reload: me_route_backpressure: enabled={} base={}ms high={}ms watermark={}%; me_route_fairshare_enabled={}; me_reader_route_data_wait_ms={}; me_health_interval: unhealthy={}ms healthy={}ms; me_admission_poll={}ms; me_warn_rate_limit={}ms",
new_hot.me_route_backpressure_enabled,
new_hot.me_route_backpressure_base_timeout_ms,
new_hot.me_route_backpressure_high_timeout_ms,
new_hot.me_route_backpressure_high_watermark_pct,
new_hot.me_route_fairshare_enabled,
new_hot.me_reader_route_data_wait_ms,
new_hot.me_health_interval_ms_unhealthy,
new_hot.me_health_interval_ms_healthy,
new_hot.me_admission_poll_ms,
new_hot.me_warn_rate_limit_ms,
);
}
if old_hot.me_d2c_flush_batch_max_frames != new_hot.me_d2c_flush_batch_max_frames
|| old_hot.me_d2c_flush_batch_max_bytes != new_hot.me_d2c_flush_batch_max_bytes
|| old_hot.me_d2c_flush_batch_max_delay_us != new_hot.me_d2c_flush_batch_max_delay_us
|| old_hot.me_d2c_ack_flush_immediate != new_hot.me_d2c_ack_flush_immediate
|| old_hot.me_quota_soft_overshoot_bytes != new_hot.me_quota_soft_overshoot_bytes
|| old_hot.me_d2c_frame_buf_shrink_threshold_bytes
!= new_hot.me_d2c_frame_buf_shrink_threshold_bytes
|| old_hot.direct_relay_copy_buf_c2s_bytes != new_hot.direct_relay_copy_buf_c2s_bytes
|| old_hot.direct_relay_copy_buf_s2c_bytes != new_hot.direct_relay_copy_buf_s2c_bytes
{
info!(
"config reload: relay_tuning: me_d2c_frames={} me_d2c_bytes={} me_d2c_delay_us={} me_ack_flush_immediate={} me_quota_soft_overshoot_bytes={} me_d2c_frame_buf_shrink_threshold_bytes={} direct_buf_c2s={} direct_buf_s2c={}",
new_hot.me_d2c_flush_batch_max_frames,
new_hot.me_d2c_flush_batch_max_bytes,
new_hot.me_d2c_flush_batch_max_delay_us,
new_hot.me_d2c_ack_flush_immediate,
new_hot.me_quota_soft_overshoot_bytes,
new_hot.me_d2c_frame_buf_shrink_threshold_bytes,
new_hot.direct_relay_copy_buf_c2s_bytes,
new_hot.direct_relay_copy_buf_s2c_bytes,
);
}
if old_hot.users != new_hot.users {
let mut added: Vec<&String> = new_hot
.users
.keys()
.filter(|u| !old_hot.users.contains_key(*u))
.collect();
added.sort();
let mut removed: Vec<&String> = old_hot
.users
.keys()
.filter(|u| !new_hot.users.contains_key(*u))
.collect();
removed.sort();
let mut changed: Vec<&String> = new_hot
.users
.keys()
.filter(|u| {
old_hot
.users
.get(*u)
.map(|s| s != &new_hot.users[*u])
.unwrap_or(false)
})
.collect();
changed.sort();
if !added.is_empty() {
info!(
"config reload: users added: [{}]",
added
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let host = resolve_link_host(new_cfg, detected_ip_v4, detected_ip_v6);
let port = new_cfg
.general
.links
.public_port
.unwrap_or(resolve_default_link_port(new_cfg));
for user in &added {
if let Some(secret) = new_hot.users.get(*user) {
print_user_links(user, secret, &host, port, new_cfg);
}
}
}
if !removed.is_empty() {
info!(
"config reload: users removed: [{}]",
removed
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
if !changed.is_empty() {
info!(
"config reload: users secret changed: [{}]",
changed
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}
if old_hot.user_enabled != new_hot.user_enabled {
info!(
"config reload: user_enabled updated ({} disabled overrides)",
new_hot
.user_enabled
.values()
.filter(|enabled| !**enabled)
.count()
);
}
if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns {
info!(
"config reload: user_max_tcp_conns updated ({} entries)",
new_hot.user_max_tcp_conns.len()
);
}
if old_hot.user_max_tcp_conns_global_each != new_hot.user_max_tcp_conns_global_each {
info!(
"config reload: user_max_tcp_conns policy global_each={}",
new_hot.user_max_tcp_conns_global_each
);
}
if old_hot.user_expirations != new_hot.user_expirations {
info!(
"config reload: user_expirations updated ({} entries)",
new_hot.user_expirations.len()
);
}
if old_hot.user_data_quota != new_hot.user_data_quota {
info!(
"config reload: user_data_quota updated ({} entries)",
new_hot.user_data_quota.len()
);
}
if old_hot.user_rate_limits != new_hot.user_rate_limits {
info!(
"config reload: user_rate_limits updated ({} entries)",
new_hot.user_rate_limits.len()
);
}
if old_hot.cidr_rate_limits != new_hot.cidr_rate_limits {
info!(
"config reload: cidr_rate_limits updated ({} entries)",
new_hot.cidr_rate_limits.len()
);
}
if old_hot.user_max_unique_ips != new_hot.user_max_unique_ips {
info!(
"config reload: user_max_unique_ips updated ({} entries)",
new_hot.user_max_unique_ips.len()
);
}
if old_hot.user_max_unique_ips_global_each != new_hot.user_max_unique_ips_global_each
|| old_hot.user_max_unique_ips_mode != new_hot.user_max_unique_ips_mode
|| old_hot.user_max_unique_ips_window_secs != new_hot.user_max_unique_ips_window_secs
{
info!(
"config reload: user_max_unique_ips policy global_each={} mode={:?} window={}s",
new_hot.user_max_unique_ips_global_each,
new_hot.user_max_unique_ips_mode,
new_hot.user_max_unique_ips_window_secs
);
}
}
+290
View File
@@ -0,0 +1,290 @@
use super::*;
fn sample_config() -> ProxyConfig {
ProxyConfig::default()
}
fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option<u16>) {
let mut config = String::from(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
if ad_tag.is_some() {
config.push_str("\n[general]\n");
if let Some(tag) = ad_tag {
config.push_str(&format!("ad_tag = \"{tag}\"\n"));
}
}
if let Some(port) = server_port {
config.push_str("\n[server]\n");
config.push_str(&format!("port = {port}\n"));
}
std::fs::write(path, config).unwrap();
}
fn temp_config_path(prefix: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{prefix}_{nonce}.toml"))
}
#[test]
fn overlay_applies_hot_and_preserves_non_hot() {
let old = sample_config();
let mut new = old.clone();
new.general.hardswap = !old.general.hardswap;
new.server.port = old.server.port.saturating_add(1);
let applied = overlay_hot_fields(&old, &new);
assert_eq!(applied.general.hardswap, new.general.hardswap);
assert_eq!(applied.server.port, old.server.port);
}
#[test]
fn non_hot_only_change_does_not_change_hot_snapshot() {
let old = sample_config();
let mut new = old.clone();
new.server.port = old.server.port.saturating_add(1);
let applied = overlay_hot_fields(&old, &new);
assert_eq!(
HotFields::from_config(&old),
HotFields::from_config(&applied)
);
assert_eq!(applied.server.port, old.server.port);
}
#[test]
fn bind_stale_mode_is_hot() {
let old = sample_config();
let mut new = old.clone();
new.general.me_bind_stale_mode = match old.general.me_bind_stale_mode {
MeBindStaleMode::Never => MeBindStaleMode::Ttl,
MeBindStaleMode::Ttl => MeBindStaleMode::Always,
MeBindStaleMode::Always => MeBindStaleMode::Never,
};
let applied = overlay_hot_fields(&old, &new);
assert_eq!(
applied.general.me_bind_stale_mode,
new.general.me_bind_stale_mode
);
assert_ne!(
HotFields::from_config(&old),
HotFields::from_config(&applied)
);
}
#[test]
fn keepalive_is_not_hot() {
let old = sample_config();
let mut new = old.clone();
new.general.me_keepalive_interval_secs = old.general.me_keepalive_interval_secs + 5;
let applied = overlay_hot_fields(&old, &new);
assert_eq!(
applied.general.me_keepalive_interval_secs,
old.general.me_keepalive_interval_secs
);
assert_eq!(
HotFields::from_config(&old),
HotFields::from_config(&applied)
);
}
#[test]
fn mixed_hot_and_non_hot_change_applies_only_hot_subset() {
let old = sample_config();
let mut new = old.clone();
new.general.hardswap = !old.general.hardswap;
new.general.use_middle_proxy = !old.general.use_middle_proxy;
let applied = overlay_hot_fields(&old, &new);
assert_eq!(applied.general.hardswap, new.general.hardswap);
assert_eq!(
applied.general.use_middle_proxy,
old.general.use_middle_proxy
);
assert!(!config_equal(&applied, &new));
}
#[test]
fn listener_synlimit_fields_are_process_owned() {
let mut old = sample_config();
old.server.listeners.push(ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
port: Some(443),
client_mss: None,
synlimit: SynLimitMode::Iptables,
synlimit_seconds: 60,
synlimit_hitcount: 48,
synlimit_burst: 1,
synlimit_ios_seconds: 1,
synlimit_ios_hitcount: 12,
synlimit_ios_burst: 24,
synlimit_hashlimit_expire_ms: 60_000,
synlimit_hashlimit_size: 32_768,
announce: None,
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
});
let mut new = old.clone();
new.server.port = 8443;
new.server.listeners[0].synlimit_seconds = 120;
new.server.listeners[0].synlimit_hitcount = 96;
new.server.listeners[0].synlimit_burst = 2;
new.server.listeners[0].synlimit_ios_seconds = 2;
new.server.listeners[0].synlimit_ios_hitcount = 18;
new.server.listeners[0].synlimit_ios_burst = 36;
new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000;
new.server.listeners[0].synlimit_hashlimit_size = 65_536;
let applied = overlay_hot_fields(&old, &new);
let listener = &applied.server.listeners[0];
assert_eq!(applied.server.port, old.server.port);
assert_eq!(
listener.synlimit_seconds,
old.server.listeners[0].synlimit_seconds
);
assert_eq!(
listener.synlimit_hitcount,
old.server.listeners[0].synlimit_hitcount
);
assert_eq!(
listener.synlimit_burst,
old.server.listeners[0].synlimit_burst
);
assert_eq!(
listener.synlimit_hashlimit_size,
old.server.listeners[0].synlimit_hashlimit_size
);
assert!(classify_config_changes(&old, &new).restart_required);
}
#[test]
fn reload_applies_hot_change_on_first_observed_snapshot() {
let initial_tag = "11111111111111111111111111111111";
let final_tag = "22222222222222222222222222222222";
let path = temp_config_path("telemt_hot_reload_stable");
write_reload_config(&path, Some(initial_tag), None);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_reload_config(&path, Some(final_tag), None);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
assert_eq!(
config_tx.borrow().general.ad_tag.as_deref(),
Some(final_tag)
);
let _ = std::fs::remove_file(path);
}
#[test]
fn reload_keeps_hot_apply_when_non_hot_fields_change() {
let initial_tag = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let final_tag = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let path = temp_config_path("telemt_hot_reload_mixed");
write_reload_config(&path, Some(initial_tag), None);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_reload_config(&path, Some(final_tag), Some(initial_cfg.server.port + 1));
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
let applied = config_tx.borrow().clone();
assert_eq!(applied.general.ad_tag.as_deref(), Some(final_tag));
assert_eq!(applied.server.port, initial_cfg.server.port);
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]
fn reload_recovers_after_parse_error_on_next_attempt() {
let initial_tag = "cccccccccccccccccccccccccccccccc";
let final_tag = "dddddddddddddddddddddddddddddddd";
let path = temp_config_path("telemt_hot_reload_parse_recovery");
write_reload_config(&path, Some(initial_tag), None);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
std::fs::write(&path, "[access.users\nuser = \"broken\"\n").unwrap();
assert!(reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).is_none());
assert_eq!(
config_tx.borrow().general.ad_tag.as_deref(),
Some(initial_tag)
);
write_reload_config(&path, Some(final_tag), None);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
assert_eq!(
config_tx.borrow().general.ad_tag.as_deref(),
Some(final_tag)
);
let _ = std::fs::remove_file(path);
}
+370
View File
@@ -0,0 +1,370 @@
use super::*;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct WatchManifest {
files: BTreeSet<PathBuf>,
dirs: BTreeSet<PathBuf>,
}
impl WatchManifest {
fn from_source_files(source_files: &[PathBuf]) -> Self {
let mut files = BTreeSet::new();
let mut dirs = BTreeSet::new();
for path in source_files {
let normalized = normalize_watch_path(path);
files.insert(normalized.clone());
if let Some(parent) = normalized.parent() {
dirs.insert(parent.to_path_buf());
}
}
Self { files, dirs }
}
fn matches_event_paths(&self, event_paths: &[PathBuf]) -> bool {
event_paths
.iter()
.map(|path| normalize_watch_path(path))
.any(|path| self.files.contains(&path))
}
}
#[derive(Debug, Default)]
pub(super) struct ReloadState {
applied_snapshot_hash: Option<u64>,
}
impl ReloadState {
pub(super) fn new(applied_snapshot_hash: Option<u64>) -> Self {
Self {
applied_snapshot_hash,
}
}
fn is_applied(&self, hash: u64) -> bool {
self.applied_snapshot_hash == Some(hash)
}
fn mark_applied(&mut self, hash: u64) {
self.applied_snapshot_hash = Some(hash);
}
}
fn normalize_watch_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
})
}
fn sync_watch_paths<W: Watcher>(
watcher: &mut W,
current: &BTreeSet<PathBuf>,
next: &BTreeSet<PathBuf>,
recursive_mode: RecursiveMode,
kind: &str,
) {
for path in current.difference(next) {
if let Err(e) = watcher.unwatch(path) {
warn!(path = %path.display(), error = %e, "config watcher: failed to unwatch {kind}");
}
}
for path in next.difference(current) {
if let Err(e) = watcher.watch(path, recursive_mode) {
warn!(path = %path.display(), error = %e, "config watcher: failed to watch {kind}");
}
}
}
fn apply_watch_manifest<W1: Watcher, W2: Watcher>(
notify_watcher: Option<&mut W1>,
poll_watcher: Option<&mut W2>,
manifest_state: &Arc<StdRwLock<WatchManifest>>,
next_manifest: WatchManifest,
) {
let current_manifest = manifest_state
.read()
.map(|manifest| manifest.clone())
.unwrap_or_default();
if current_manifest == next_manifest {
return;
}
if let Some(watcher) = notify_watcher {
sync_watch_paths(
watcher,
&current_manifest.dirs,
&next_manifest.dirs,
RecursiveMode::NonRecursive,
"config directory",
);
}
if let Some(watcher) = poll_watcher {
sync_watch_paths(
watcher,
&current_manifest.files,
&next_manifest.files,
RecursiveMode::NonRecursive,
"config file",
);
}
if let Ok(mut manifest) = manifest_state.write() {
*manifest = next_manifest;
}
}
/// Load config, validate, diff against current, and broadcast if changed.
pub(super) fn reload_config(
config_path: &PathBuf,
config_tx: &watch::Sender<Arc<ProxyConfig>>,
log_tx: &watch::Sender<LogLevel>,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
reload_state: &mut ReloadState,
) -> Option<WatchManifest> {
let loaded = match ProxyConfig::load_with_metadata(config_path) {
Ok(loaded) => loaded,
Err(e) => {
error!("config reload: failed to parse {:?}: {}", config_path, e);
return None;
}
};
let LoadedConfig {
config: new_cfg,
source_files,
source_contents: _,
rendered_hash,
} = loaded;
let next_manifest = WatchManifest::from_source_files(&source_files);
if let Err(e) = new_cfg.validate() {
error!(
"config reload: validation failed: {}; keeping old config",
e
);
return Some(next_manifest);
}
if reload_state.is_applied(rendered_hash) {
return Some(next_manifest);
}
let old_cfg = config_tx.borrow().clone();
let applied_cfg = overlay_hot_fields(&old_cfg, &new_cfg);
let old_hot = HotFields::from_config(&old_cfg);
let applied_hot = HotFields::from_config(&applied_cfg);
let non_hot_changed = !config_equal(&applied_cfg, &new_cfg);
let hot_changed = old_hot != applied_hot;
if non_hot_changed {
warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed);
}
if !hot_changed {
reload_state.mark_applied(rendered_hash);
return Some(next_manifest);
}
if old_hot.dns_overrides != applied_hot.dns_overrides
&& let Err(e) = crate::network::dns_overrides::install_entries(&applied_hot.dns_overrides)
{
error!(
"config reload: invalid network.dns_overrides: {}; keeping old config",
e
);
return Some(next_manifest);
}
log_changes(
&old_hot,
&applied_hot,
&applied_cfg,
log_tx,
detected_ip_v4,
detected_ip_v6,
);
config_tx.send(Arc::new(applied_cfg)).ok();
reload_state.mark_applied(rendered_hash);
Some(next_manifest)
}
/// Spawn the hot-reload watcher task.
///
/// Uses `notify` (inotify on Linux) to detect file changes instantly.
/// SIGHUP is also handled on Unix as an additional manual trigger.
///
/// `detected_ip_v4` / `detected_ip_v6` are the IPs discovered during the
/// startup probe — used when generating proxy links for newly added users,
/// matching the same logic as the startup output.
/// The watcher releases its notify and signal resources when `cancellation` fires.
pub fn spawn_config_watcher(
config_path: PathBuf,
initial: Arc<ProxyConfig>,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
cancellation: tokio_util::sync::CancellationToken,
) -> (watch::Receiver<Arc<ProxyConfig>>, watch::Receiver<LogLevel>) {
let initial_level = initial.general.log_level.clone();
let (config_tx, config_rx) = watch::channel(initial);
let (log_tx, log_rx) = watch::channel(initial_level);
let config_path = normalize_watch_path(&config_path);
let initial_loaded = ProxyConfig::load_with_metadata(&config_path).ok();
let initial_manifest = initial_loaded
.as_ref()
.map(|loaded| WatchManifest::from_source_files(&loaded.source_files))
.unwrap_or_else(|| WatchManifest::from_source_files(std::slice::from_ref(&config_path)));
let initial_snapshot_hash = initial_loaded.as_ref().map(|loaded| loaded.rendered_hash);
tokio::spawn(async move {
let (notify_tx, mut notify_rx) = mpsc::channel::<()>(4);
let manifest_state = Arc::new(StdRwLock::new(WatchManifest::default()));
let mut reload_state = ReloadState::new(initial_snapshot_hash);
let tx_inotify = notify_tx.clone();
let manifest_for_inotify = manifest_state.clone();
let mut inotify_watcher =
match recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
if !matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
return;
}
let is_our_file = manifest_for_inotify
.read()
.map(|manifest| manifest.matches_event_paths(&event.paths))
.unwrap_or(false);
if is_our_file {
let _ = tx_inotify.try_send(());
}
}) {
Ok(watcher) => Some(watcher),
Err(e) => {
warn!("config watcher: inotify unavailable: {}", e);
None
}
};
apply_watch_manifest(
inotify_watcher.as_mut(),
Option::<&mut notify::poll::PollWatcher>::None,
&manifest_state,
initial_manifest.clone(),
);
if inotify_watcher.is_some() {
info!("config watcher: inotify active on {:?}", config_path);
}
let tx_poll = notify_tx.clone();
let manifest_for_poll = manifest_state.clone();
let mut poll_watcher = match notify::poll::PollWatcher::new(
move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
if !matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
return;
}
let is_our_file = manifest_for_poll
.read()
.map(|manifest| manifest.matches_event_paths(&event.paths))
.unwrap_or(false);
if is_our_file {
let _ = tx_poll.try_send(());
}
},
notify::Config::default()
.with_poll_interval(Duration::from_secs(3))
.with_compare_contents(true),
) {
Ok(watcher) => Some(watcher),
Err(e) => {
warn!("config watcher: poll watcher unavailable: {}", e);
None
}
};
apply_watch_manifest(
Option::<&mut notify::RecommendedWatcher>::None,
poll_watcher.as_mut(),
&manifest_state,
initial_manifest.clone(),
);
if poll_watcher.is_some() {
info!("config watcher: poll watcher active (Docker/NFS safe)");
}
#[cfg(unix)]
let mut sighup = {
use tokio::signal::unix::{SignalKind, signal};
signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler")
};
loop {
#[cfg(unix)]
tokio::select! {
msg = notify_rx.recv() => {
if msg.is_none() { break; }
}
_ = sighup.recv() => {
info!("SIGHUP received — reloading {:?}", config_path);
}
_ = cancellation.cancelled() => break,
}
#[cfg(not(unix))]
tokio::select! {
msg = notify_rx.recv() => {
if msg.is_none() { break; }
}
_ = cancellation.cancelled() => break,
}
// Debounce: drain extra events that arrive within a short quiet window.
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
while notify_rx.try_recv().is_ok() {}
let mut next_manifest = reload_config(
&config_path,
&config_tx,
&log_tx,
detected_ip_v4,
detected_ip_v6,
&mut reload_state,
);
if next_manifest.is_none() {
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
while notify_rx.try_recv().is_ok() {}
next_manifest = reload_config(
&config_path,
&config_tx,
&log_tx,
detected_ip_v4,
detected_ip_v6,
&mut reload_state,
);
}
if let Some(next_manifest) = next_manifest {
apply_watch_manifest(
inotify_watcher.as_mut(),
poll_watcher.as_mut(),
&manifest_state,
next_manifest,
);
}
}
});
(config_rx, log_rx)
}
+116 -4179
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
use super::*;
pub(super) type DecodedSourceGraph = (
ProxyConfig,
BTreeSet<PathBuf>,
BTreeMap<PathBuf, String>,
String,
);
pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSourceGraph> {
let ConfigSourceGraph {
source_contents,
rendered: processed,
} = graph;
let source_files: BTreeSet<PathBuf> = source_contents.keys().cloned().collect();
let parsed_toml: toml::Value =
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
handle_unknown_config_keys(&parsed_toml)?;
let general_table = parsed_toml
.get("general")
.and_then(|value| value.as_table());
let network_table = parsed_toml
.get("network")
.and_then(|value| value.as_table());
let server_table = parsed_toml.get("server").and_then(|value| value.as_table());
let conntrack_control_table = server_table
.and_then(|table| table.get("conntrack_control"))
.and_then(|value| value.as_table());
let update_every_is_explicit = general_table
.map(|table| table.contains_key("update_every"))
.unwrap_or(false);
let beobachten_is_explicit = general_table
.map(|table| table.contains_key("beobachten"))
.unwrap_or(false);
let beobachten_minutes_is_explicit = general_table
.map(|table| table.contains_key("beobachten_minutes"))
.unwrap_or(false);
let beobachten_flush_secs_is_explicit = general_table
.map(|table| table.contains_key("beobachten_flush_secs"))
.unwrap_or(false);
let beobachten_file_is_explicit = general_table
.map(|table| table.contains_key("beobachten_file"))
.unwrap_or(false);
let legacy_secret_is_explicit = general_table
.map(|table| table.contains_key("proxy_secret_auto_reload_secs"))
.unwrap_or(false);
let legacy_config_is_explicit = general_table
.map(|table| table.contains_key("proxy_config_auto_reload_secs"))
.unwrap_or(false);
let legacy_top_level_beobachten = parsed_toml.get("beobachten").cloned();
let legacy_top_level_beobachten_minutes = parsed_toml.get("beobachten_minutes").cloned();
let legacy_top_level_beobachten_flush_secs = parsed_toml.get("beobachten_flush_secs").cloned();
let legacy_top_level_beobachten_file = parsed_toml.get("beobachten_file").cloned();
let stun_servers_is_explicit = network_table
.map(|table| table.contains_key("stun_servers"))
.unwrap_or(false);
let inline_conntrack_control_is_explicit = conntrack_control_table
.map(|table| table.contains_key("inline_conntrack_control"))
.unwrap_or(false);
let mut config: ProxyConfig = parsed_toml
.try_into()
.map_err(|e| ProxyError::Config(e.to_string()))?;
config
.server
.conntrack_control
.inline_conntrack_control_explicit = inline_conntrack_control_is_explicit;
if !update_every_is_explicit && (legacy_secret_is_explicit || legacy_config_is_explicit) {
config.general.update_every = None;
}
// Backward compatibility: legacy top-level beobachten* keys.
// Prefer `[general].*` when both are present.
let mut legacy_beobachten_applied = false;
if !beobachten_is_explicit && let Some(value) = legacy_top_level_beobachten.as_ref() {
let parsed = value.as_bool().ok_or_else(|| {
ProxyError::Config("beobachten (top-level) must be a boolean".to_string())
})?;
config.general.beobachten = parsed;
legacy_beobachten_applied = true;
}
if !beobachten_minutes_is_explicit
&& let Some(value) = legacy_top_level_beobachten_minutes.as_ref()
{
let raw = value.as_integer().ok_or_else(|| {
ProxyError::Config("beobachten_minutes (top-level) must be an integer".to_string())
})?;
let parsed = u64::try_from(raw).map_err(|_| {
ProxyError::Config(
"beobachten_minutes (top-level) must be within u64 range".to_string(),
)
})?;
config.general.beobachten_minutes = parsed;
legacy_beobachten_applied = true;
}
if !beobachten_flush_secs_is_explicit
&& let Some(value) = legacy_top_level_beobachten_flush_secs.as_ref()
{
let raw = value.as_integer().ok_or_else(|| {
ProxyError::Config("beobachten_flush_secs (top-level) must be an integer".to_string())
})?;
let parsed = u64::try_from(raw).map_err(|_| {
ProxyError::Config(
"beobachten_flush_secs (top-level) must be within u64 range".to_string(),
)
})?;
config.general.beobachten_flush_secs = parsed;
legacy_beobachten_applied = true;
}
if !beobachten_file_is_explicit && let Some(value) = legacy_top_level_beobachten_file.as_ref() {
let parsed = value.as_str().ok_or_else(|| {
ProxyError::Config("beobachten_file (top-level) must be a string".to_string())
})?;
config.general.beobachten_file = parsed.to_string();
legacy_beobachten_applied = true;
}
if legacy_beobachten_applied {
warn!("top-level beobachten* keys are deprecated; use general.beobachten* instead");
}
let legacy_nat_stun = config.general.middle_proxy_nat_stun.take();
let legacy_nat_stun_servers = std::mem::take(&mut config.general.middle_proxy_nat_stun_servers);
let legacy_nat_stun_used = legacy_nat_stun.is_some() || !legacy_nat_stun_servers.is_empty();
if stun_servers_is_explicit {
let mut explicit_stun_servers = Vec::new();
for stun in std::mem::take(&mut config.network.stun_servers) {
push_unique_nonempty(&mut explicit_stun_servers, stun);
}
config.network.stun_servers = explicit_stun_servers;
if legacy_nat_stun_used {
warn!(
"general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are ignored because network.stun_servers is explicitly set"
);
}
} else {
// Keep the default STUN pool unless network.stun_servers is explicitly overridden.
let mut unified_stun_servers = default_stun_servers();
if let Some(stun) = legacy_nat_stun {
push_unique_nonempty(&mut unified_stun_servers, stun);
}
for stun in legacy_nat_stun_servers {
push_unique_nonempty(&mut unified_stun_servers, stun);
}
config.network.stun_servers = unified_stun_servers;
if legacy_nat_stun_used {
warn!(
"general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are deprecated; use network.stun_servers"
);
}
}
sanitize_ad_tag(&mut config.general.ad_tag);
Ok((config, source_files, source_contents, processed))
}
+215
View File
@@ -0,0 +1,215 @@
use super::*;
pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
if config.censorship.tls_fetch.profiles.is_empty() {
config.censorship.tls_fetch.profiles = TlsFetchConfig::default().profiles;
} else {
let mut seen = HashSet::new();
config
.censorship
.tls_fetch
.profiles
.retain(|profile| seen.insert(*profile));
}
if config.censorship.tls_fetch.attempt_timeout_ms == 0 {
return Err(ProxyError::Config(
"censorship.tls_fetch.attempt_timeout_ms must be > 0".to_string(),
));
}
if config.censorship.tls_fetch.total_budget_ms == 0 {
return Err(ProxyError::Config(
"censorship.tls_fetch.total_budget_ms must be > 0".to_string(),
));
}
// Merge primary + extra TLS domains, deduplicate (primary always first).
if !config.censorship.tls_domains.is_empty() {
let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len());
all.push(config.censorship.tls_domain.clone());
for d in std::mem::take(&mut config.censorship.tls_domains) {
if !d.is_empty() {
let domain = normalize_domain_to_ascii(&d, "censorship.tls_domains entry")?;
if !all.contains(&domain) {
all.push(domain);
}
}
}
// keep primary as tls_domain; store remaining back to tls_domains
if all.len() > 1 {
config.censorship.tls_domains = all[1..].to_vec();
}
}
let mut exclusive_mask = HashMap::with_capacity(config.censorship.exclusive_mask.len());
let mut exclusive_mask_targets = HashMap::with_capacity(config.censorship.exclusive_mask.len());
for (domain, target) in std::mem::take(&mut config.censorship.exclusive_mask) {
let domain = normalize_domain_to_ascii(&domain, "censorship.exclusive_mask domain")?;
let target = normalize_exclusive_mask_target(&target, "censorship.exclusive_mask target")?;
let Some((host, port)) = parse_exclusive_mask_target(&target) else {
return Err(ProxyError::Config(format!(
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
domain, target
)));
};
exclusive_mask_targets.insert(
domain.clone(),
ExclusiveMaskTarget {
host: host.to_string(),
port,
},
);
exclusive_mask.insert(domain, target);
}
config.censorship.exclusive_mask = exclusive_mask;
config.censorship.exclusive_mask_targets = exclusive_mask_targets;
// Migration: prefer_ipv6 -> network.prefer.
if config.general.prefer_ipv6 {
if config.network.prefer == 4 {
config.network.prefer = 6;
}
warn!("prefer_ipv6 is deprecated, use [network].prefer = 6");
}
if config.general.use_middle_proxy && !config.general.me_secret_atomic_snapshot {
config.general.me_secret_atomic_snapshot = true;
warn!(
"Auto-enabled me_secret_atomic_snapshot for middle proxy mode to keep KDF key_selector/secret coherent"
);
}
validate_network_cfg(&mut config.network)?;
crate::network::dns_overrides::validate_entries(&config.network.dns_overrides)?;
if config.general.use_middle_proxy && config.network.ipv6 == Some(true) {
warn!(
"IPv6 with Middle Proxy is experimental and may cause KDF address mismatch; consider disabling IPv6 or ME"
);
}
// Random fake_cert_len only when default is in use.
if !config.censorship.tls_emulation
&& config.censorship.fake_cert_len == default_fake_cert_len()
{
config.censorship.fake_cert_len = rand::rng().random_range(1024..4096);
}
// Resolve listen_tcp: explicit value wins, otherwise auto-detect.
// If unix socket is set → TCP only when listen_addr_ipv4 or listeners are explicitly provided.
// If no unix socket → TCP always (backward compat).
let listen_tcp = config.server.listen_tcp.unwrap_or_else(|| {
if config.server.listen_unix_sock.is_some() {
// Unix socket present: TCP only if user explicitly set addresses or listeners.
config.server.listen_addr_ipv4.is_some() || !config.server.listeners.is_empty()
} else {
true
}
});
// Migration: Populate listeners if empty (skip when listen_tcp = false).
if config.server.listeners.is_empty() && listen_tcp {
let ipv4_str = config
.server
.listen_addr_ipv4
.as_deref()
.unwrap_or("0.0.0.0");
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
config.server.listeners.push(ListenerConfig {
ip: ipv4,
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(),
synlimit_ios_seconds: default_synlimit_ios_seconds(),
synlimit_ios_hitcount: default_synlimit_ios_hitcount(),
synlimit_ios_burst: default_synlimit_ios_burst(),
synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(),
synlimit_hashlimit_size: default_synlimit_hashlimit_size(),
announce: None,
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
});
}
if let Some(ipv6_str) = &config.server.listen_addr_ipv6
&& let Ok(ipv6) = ipv6_str.parse::<IpAddr>()
{
config.server.listeners.push(ListenerConfig {
ip: ipv6,
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(),
synlimit_ios_seconds: default_synlimit_ios_seconds(),
synlimit_ios_hitcount: default_synlimit_ios_hitcount(),
synlimit_ios_burst: default_synlimit_ios_burst(),
synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(),
synlimit_hashlimit_size: default_synlimit_hashlimit_size(),
announce: None,
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
});
}
}
// Migration: listeners[].port fallback to legacy server.port.
for listener in &mut config.server.listeners {
if listener.port.is_none() {
listener.port = Some(config.server.port);
}
}
// Migration: announce_ip → announce for each listener.
for listener in &mut config.server.listeners {
if listener.announce.is_none()
&& let Some(ip) = listener.announce_ip.take()
{
listener.announce = Some(ip.to_string());
}
}
validate_listener_runtime_profiles(config)?;
// Migration: show_link (top-level) → general.links.show.
if !config.show_link.is_empty() && config.general.links.show.is_empty() {
config.general.links.show = config.show_link.clone();
}
// Migration: Populate upstreams if empty (Default Direct).
if config.upstreams.is_empty() {
config.upstreams.push(UpstreamConfig {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
scopes: String::new(),
selected_scope: String::new(),
ipv4: None,
ipv6: None,
prefer: None,
});
}
normalize_upstream_family_policy(config);
// Ensure default DC203 override is present.
config
.dc_overrides
.entry("203".to_string())
.or_insert_with(|| vec!["91.105.192.100:443".to_string()]);
validate_logging_config(&config.logging)?;
validate_upstreams(config)?;
config.rebuild_runtime_user_auth()?;
Ok(())
}
+70
View File
@@ -0,0 +1,70 @@
use std::collections::{BTreeMap, BTreeSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use crate::error::{ProxyError, Result};
pub(super) fn normalize_config_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
})
}
pub(super) fn hash_rendered_snapshot(rendered: &str) -> u64 {
let mut hasher = DefaultHasher::new();
rendered.hash(&mut hasher);
hasher.finish()
}
pub(super) fn preprocess_includes(
content: &str,
base_dir: &Path,
depth: u8,
source_files: &mut BTreeSet<PathBuf>,
source_contents: &mut BTreeMap<PathBuf, String>,
source_overrides: &BTreeMap<PathBuf, String>,
) -> Result<String> {
if depth > 10 {
return Err(ProxyError::Config("Include depth > 10".into()));
}
let mut output = String::with_capacity(content.len());
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("include") {
let rest = rest.trim();
if let Some(rest) = rest.strip_prefix('=') {
let path_str = rest.trim().trim_matches('"');
let resolved = base_dir.join(path_str);
let normalized = normalize_config_path(&resolved);
source_files.insert(normalized.clone());
let included = source_overrides
.get(&normalized)
.cloned()
.map(Ok)
.unwrap_or_else(|| std::fs::read_to_string(&resolved))
.map_err(|e| ProxyError::Config(e.to_string()))?;
source_contents.insert(normalized, included.clone());
let included_dir = resolved.parent().unwrap_or(base_dir);
output.push_str(&preprocess_includes(
&included,
included_dir,
depth + 1,
source_files,
source_contents,
source_overrides,
)?);
output.push('\n');
continue;
}
}
output.push_str(line);
output.push('\n');
}
Ok(output)
}
+115
View File
@@ -0,0 +1,115 @@
use crate::error::{ProxyError, Result};
use tracing::warn;
pub(super) fn is_valid_tls_domain_name(domain: &str) -> bool {
!domain.is_empty()
&& !domain
.chars()
.any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\'))
}
pub(super) fn normalize_domain_to_ascii(domain: &str, field: &str) -> Result<String> {
let domain = domain.trim();
if !is_valid_tls_domain_name(domain) {
return Err(ProxyError::Config(format!(
"Invalid {field}: '{}'. Must be a valid domain name",
domain
)));
}
let parsed = url::Url::parse(&format!("https://{domain}/")).map_err(|error| {
ProxyError::Config(format!(
"Invalid {field}: '{}'. IDNA conversion failed: {error}",
domain
))
})?;
let host = parsed.host_str().ok_or_else(|| {
ProxyError::Config(format!("Invalid {field}: '{}'. Host is empty", domain))
})?;
Ok(host.to_ascii_lowercase())
}
pub(super) fn normalize_mask_host_to_ascii(host: &str, field: &str) -> Result<String> {
let host = host.trim();
if host.starts_with('[') && host.ends_with(']') {
let inner = &host[1..host.len() - 1];
let ip = inner.parse::<std::net::IpAddr>().map_err(|_| {
ProxyError::Config(format!(
"Invalid {field}: '{}'. IPv6 literal is invalid",
host
))
})?;
return match ip {
std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")),
std::net::IpAddr::V4(v4) => Ok(v4.to_string()),
};
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => Ok(v4.to_string()),
std::net::IpAddr::V6(v6) => Ok(format!("[{v6}]")),
};
}
normalize_domain_to_ascii(host, field)
}
pub(super) fn parse_exclusive_mask_target(target: &str) -> Option<(&str, u16)> {
let target = target.trim();
if target.is_empty() {
return None;
}
if target.starts_with('[') {
let end = target.find(']')?;
if target.get(end + 1..end + 2)? != ":" {
return None;
}
let host = &target[..=end];
let port = target[end + 2..].parse::<u16>().ok()?;
return (port > 0).then_some((host, port));
}
let (host, port) = target.rsplit_once(':')?;
if host.is_empty() || host.contains(':') {
return None;
}
let port = port.parse::<u16>().ok()?;
(port > 0).then_some((host, port))
}
pub(super) fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String> {
let (host, port) = parse_exclusive_mask_target(target).ok_or_else(|| {
ProxyError::Config(format!(
"Invalid {field}: '{}'. Expected host:port with port > 0",
target
))
})?;
let host = normalize_mask_host_to_ascii(host, field)?;
Ok(format!("{host}:{port}"))
}
pub(super) fn push_unique_nonempty(target: &mut Vec<String>, value: String) {
let trimmed = value.trim();
if trimmed.is_empty() {
return;
}
if !target.iter().any(|existing| existing == trimmed) {
target.push(trimmed.to_string());
}
}
pub(super) fn is_valid_ad_tag(tag: &str) -> bool {
tag.len() == 32 && tag.chars().all(|ch| ch.is_ascii_hexdigit())
}
pub(super) fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
let Some(tag) = ad_tag.as_ref() else {
return;
};
if !is_valid_ad_tag(tag) {
warn!("Invalid general.ad_tag value, expected exactly 32 hex chars; ad_tag is disabled");
*ad_tag = None;
}
}
+17
View File
@@ -0,0 +1,17 @@
use super::*;
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
let (mut config, source_files, source_contents, processed) =
decode::decode_source_graph(graph)?;
validate_core::validate(&mut config)?;
validate_runtime::validate(&mut config)?;
validate_me::validate(&mut config)?;
validate_server::validate(&mut config)?;
effective::apply(&mut config)?;
Ok(LoadedConfig {
config,
source_files: source_files.into_iter().collect(),
source_contents,
rendered_hash: hash_rendered_snapshot(&processed),
})
}
+112
View File
@@ -0,0 +1,112 @@
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use crate::error::{ProxyError, Result};
const ACCESS_SECRET_BYTES: usize = 16;
/// Precomputed, immutable user authentication data used by handshake hot paths.
#[derive(Debug, Clone, Default)]
pub(crate) struct UserAuthSnapshot {
entries: Vec<UserAuthEntry>,
by_name: HashMap<String, u32>,
sni_index: HashMap<u64, Vec<u32>>,
sni_initial_index: HashMap<u8, Vec<u32>>,
}
#[derive(Debug, Clone)]
pub(crate) struct UserAuthEntry {
pub(crate) user: String,
pub(crate) secret: [u8; ACCESS_SECRET_BYTES],
}
impl UserAuthSnapshot {
pub(super) fn from_users(users: &HashMap<String, String>) -> Result<Self> {
let mut entries = Vec::with_capacity(users.len());
let mut by_name = HashMap::with_capacity(users.len());
let mut sni_index = HashMap::with_capacity(users.len());
let mut sni_initial_index = HashMap::with_capacity(users.len());
for (user, secret_hex) in users {
let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret {
user: user.clone(),
reason: "Must be 32 hex characters".to_string(),
})?;
if decoded.len() != ACCESS_SECRET_BYTES {
return Err(ProxyError::InvalidSecret {
user: user.clone(),
reason: "Must be 32 hex characters".to_string(),
});
}
let user_id = u32::try_from(entries.len()).map_err(|_| {
ProxyError::Config("Too many users for runtime auth snapshot".to_string())
})?;
let mut secret = [0u8; ACCESS_SECRET_BYTES];
secret.copy_from_slice(&decoded);
entries.push(UserAuthEntry {
user: user.clone(),
secret,
});
by_name.insert(user.clone(), user_id);
sni_index
.entry(Self::sni_lookup_hash(user))
.or_insert_with(Vec::new)
.push(user_id);
if let Some(initial) = user
.as_bytes()
.first()
.map(|byte| byte.to_ascii_lowercase())
{
sni_initial_index
.entry(initial)
.or_insert_with(Vec::new)
.push(user_id);
}
}
Ok(Self {
entries,
by_name,
sni_index,
sni_initial_index,
})
}
pub(crate) fn entries(&self) -> &[UserAuthEntry] {
&self.entries
}
pub(crate) fn user_id_by_name(&self, user: &str) -> Option<u32> {
self.by_name.get(user).copied()
}
pub(crate) fn entry_by_id(&self, user_id: u32) -> Option<&UserAuthEntry> {
let idx = usize::try_from(user_id).ok()?;
self.entries.get(idx)
}
pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> {
self.sni_index
.get(&Self::sni_lookup_hash(sni))
.map(Vec::as_slice)
}
pub(crate) fn sni_initial_candidates(&self, sni: &str) -> Option<&[u32]> {
let initial = sni
.as_bytes()
.first()
.map(|byte| byte.to_ascii_lowercase())?;
self.sni_initial_index.get(&initial).map(Vec::as_slice)
}
fn sni_lookup_hash(value: &str) -> u64 {
let mut hasher = DefaultHasher::new();
for byte in value.bytes() {
hasher.write_u8(byte.to_ascii_lowercase());
}
hasher.finish()
}
}
+695
View File
@@ -0,0 +1,695 @@
use tracing::warn;
use crate::error::{ProxyError, Result};
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"general",
"logging",
"network",
"server",
"timeouts",
"censorship",
"access",
"upstreams",
"show_link",
"dc_overrides",
"default_dc",
"beobachten",
"beobachten_minutes",
"beobachten_flush_secs",
"beobachten_file",
"include",
];
const GENERAL_CONFIG_KEYS: &[&str] = &[
"data_path",
"quota_state_path",
"config_strict",
"modes",
"prefer_ipv6",
"fast_mode",
"use_middle_proxy",
"proxy_secret_path",
"proxy_secret_url",
"proxy_config_v4_cache_path",
"proxy_config_v4_url",
"proxy_config_v6_cache_path",
"proxy_config_v6_url",
"ad_tag",
"middle_proxy_nat_ip",
"middle_proxy_nat_probe",
"middle_proxy_nat_stun",
"middle_proxy_nat_stun_servers",
"stun_nat_probe_concurrency",
"middle_proxy_pool_size",
"middle_proxy_warm_standby",
"me_init_retry_attempts",
"me2dc_fallback",
"me2dc_fast",
"me_keepalive_enabled",
"me_keepalive_interval_secs",
"me_keepalive_jitter_secs",
"me_keepalive_payload_random",
"rpc_proxy_req_every",
"me_writer_cmd_channel_capacity",
"me_writer_byte_budget_bytes",
"me_route_channel_capacity",
"me_c2me_channel_capacity",
"me_c2me_send_timeout_ms",
"me_reader_route_data_wait_ms",
"me_d2c_flush_batch_max_frames",
"me_d2c_flush_batch_max_bytes",
"me_d2c_flush_batch_max_delay_us",
"me_d2c_ack_flush_immediate",
"me_quota_soft_overshoot_bytes",
"me_d2c_frame_buf_shrink_threshold_bytes",
"direct_relay_copy_buf_c2s_bytes",
"direct_relay_copy_buf_s2c_bytes",
"direct_relay_buffer_budget_max_bytes",
"crypto_pending_buffer",
"max_client_frame",
"desync_all_full",
"beobachten",
"beobachten_minutes",
"beobachten_flush_secs",
"beobachten_file",
"hardswap",
"me_warmup_stagger_enabled",
"me_warmup_step_delay_ms",
"me_warmup_step_jitter_ms",
"me_reconnect_max_concurrent_per_dc",
"me_reconnect_backoff_base_ms",
"me_reconnect_backoff_cap_ms",
"me_reconnect_fast_retry_count",
"me_single_endpoint_shadow_writers",
"me_single_endpoint_outage_mode_enabled",
"me_single_endpoint_outage_disable_quarantine",
"me_single_endpoint_outage_backoff_min_ms",
"me_single_endpoint_outage_backoff_max_ms",
"me_single_endpoint_shadow_rotate_every_secs",
"me_floor_mode",
"me_adaptive_floor_idle_secs",
"me_adaptive_floor_min_writers_single_endpoint",
"me_adaptive_floor_min_writers_multi_endpoint",
"me_adaptive_floor_recover_grace_secs",
"me_adaptive_floor_writers_per_core_total",
"me_adaptive_floor_cpu_cores_override",
"me_adaptive_floor_max_extra_writers_single_per_core",
"me_adaptive_floor_max_extra_writers_multi_per_core",
"me_adaptive_floor_max_active_writers_per_core",
"me_adaptive_floor_max_warm_writers_per_core",
"me_adaptive_floor_max_active_writers_global",
"me_adaptive_floor_max_warm_writers_global",
"upstream_connect_retry_attempts",
"upstream_connect_retry_backoff_ms",
"upstream_connect_budget_ms",
"tg_connect",
"upstream_unhealthy_fail_threshold",
"upstream_connect_failfast_hard_errors",
"stun_iface_mismatch_ignore",
"unknown_dc_log_path",
"unknown_dc_file_log_enabled",
"log_level",
"disable_colors",
"telemetry",
"me_socks_kdf_policy",
"me_route_backpressure_enabled",
"me_route_fairshare_enabled",
"me_route_backpressure_base_timeout_ms",
"me_route_backpressure_high_timeout_ms",
"me_route_backpressure_high_watermark_pct",
"me_health_interval_ms_unhealthy",
"me_health_interval_ms_healthy",
"me_admission_poll_ms",
"me_warn_rate_limit_ms",
"me_route_no_writer_mode",
"me_route_no_writer_wait_ms",
"me_route_hybrid_max_wait_ms",
"me_route_blocking_send_timeout_ms",
"me_route_inline_recovery_attempts",
"me_route_inline_recovery_wait_ms",
"links",
"fast_mode_min_tls_record",
"update_every",
"me_reinit_every_secs",
"me_hardswap_warmup_delay_min_ms",
"me_hardswap_warmup_delay_max_ms",
"me_hardswap_warmup_extra_passes",
"me_hardswap_warmup_pass_backoff_base_ms",
"me_config_stable_snapshots",
"me_config_apply_cooldown_secs",
"me_snapshot_require_http_2xx",
"me_snapshot_reject_empty_map",
"me_snapshot_min_proxy_for_lines",
"proxy_secret_stable_snapshots",
"proxy_secret_rotate_runtime",
"me_secret_atomic_snapshot",
"proxy_secret_len_max",
"me_pool_drain_ttl_secs",
"me_instadrain",
"me_pool_drain_threshold",
"me_pool_drain_soft_evict_enabled",
"me_pool_drain_soft_evict_grace_secs",
"me_pool_drain_soft_evict_per_writer",
"me_pool_drain_soft_evict_budget_per_core",
"me_pool_drain_soft_evict_cooldown_ms",
"me_bind_stale_mode",
"me_bind_stale_ttl_secs",
"me_pool_min_fresh_ratio",
"me_reinit_drain_timeout_secs",
"proxy_secret_auto_reload_secs",
"proxy_config_auto_reload_secs",
"me_reinit_singleflight",
"me_reinit_trigger_channel",
"me_reinit_coalesce_window_ms",
"me_deterministic_writer_sort",
"me_writer_pick_mode",
"me_writer_pick_sample_size",
"ntp_check",
"ntp_servers",
"auto_degradation_enabled",
"degradation_min_unavailable_dc_groups",
"rst_on_close",
];
const NETWORK_CONFIG_KEYS: &[&str] = &[
"ipv4",
"ipv6",
"prefer",
"multipath",
"stun_use",
"stun_servers",
"stun_tcp_fallback",
"http_ip_detect_urls",
"cache_public_ip_path",
"dns_overrides",
];
const SERVER_CONFIG_KEYS: &[&str] = &[
"port",
"listen_addr_ipv4",
"listen_addr_ipv6",
"listen_unix_sock",
"listen_unix_sock_perm",
"listen_tcp",
"client_mss",
"client_mss_bulk",
"proxy_protocol",
"proxy_protocol_header_timeout_ms",
"proxy_protocol_trusted_cidrs",
"metrics_port",
"metrics_listen",
"metrics_whitelist",
"api",
"admin_api",
"listeners",
"listen_backlog",
"max_connections",
"accept_permit_timeout_ms",
"conntrack_control",
];
const API_CONFIG_KEYS: &[&str] = &[
"enabled",
"listen",
"whitelist",
"gray_action",
"auth_header",
"request_body_limit_bytes",
"minimal_runtime_enabled",
"minimal_runtime_cache_ttl_ms",
"runtime_edge_enabled",
"runtime_edge_cache_ttl_ms",
"runtime_edge_top_n",
"runtime_edge_events_capacity",
"read_only",
];
const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
"inline_conntrack_control",
"mode",
"backend",
"profile",
"hybrid_listener_ips",
"pressure_high_watermark_pct",
"pressure_low_watermark_pct",
"delete_budget_per_sec",
];
const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip",
"port",
"client_mss",
"synlimit",
"synlimit_seconds",
"synlimit_hitcount",
"synlimit_burst",
"synlimit_ios_seconds",
"synlimit_ios_hitcount",
"synlimit_ios_burst",
"synlimit_hashlimit_expire_ms",
"synlimit_hashlimit_size",
"announce",
"announce_ip",
"proxy_protocol",
"reuse_allow",
];
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"client_first_byte_idle_secs",
"client_handshake",
"relay_idle_policy_v2_enabled",
"relay_client_idle_soft_secs",
"relay_client_idle_hard_secs",
"relay_idle_grace_after_downstream_activity_secs",
"client_keepalive",
"client_ack",
"me_one_retry",
"me_one_timeout_ms",
];
const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
"tls_domain",
"tls_domains",
"unknown_sni_action",
"tls_fetch_scope",
"tls_fetch",
"mask",
"mask_dynamic",
"mask_host",
"mask_port",
"exclusive_mask",
"mask_unix_sock",
"fake_cert_len",
"tls_emulation",
"tls_front_dir",
"server_hello_delay_min_ms",
"server_hello_delay_max_ms",
"tls_new_session_tickets",
"serverhello_compact",
"tls_full_cert_ttl_secs",
"alpn_enforce",
"mask_proxy_protocol",
"mask_shape_hardening",
"mask_shape_hardening_aggressive_mode",
"mask_shape_bucket_floor_bytes",
"mask_shape_bucket_cap_bytes",
"mask_shape_above_cap_blur",
"mask_shape_above_cap_blur_max_bytes",
"mask_relay_max_bytes",
"mask_relay_timeout_ms",
"mask_relay_idle_timeout_ms",
"mask_classifier_prefetch_timeout_ms",
"mask_timing_normalization_enabled",
"mask_timing_normalization_floor_ms",
"mask_timing_normalization_ceiling_ms",
];
const TLS_FETCH_CONFIG_KEYS: &[&str] = &[
"profiles",
"strict_route",
"attempt_timeout_ms",
"total_budget_ms",
"grease_enabled",
"deterministic",
"profile_cache_ttl_secs",
];
const ACCESS_CONFIG_KEYS: &[&str] = &[
"users",
"user_enabled",
"user_ad_tags",
"user_max_tcp_conns",
"user_max_tcp_conns_global_each",
"user_expirations",
"user_data_quota",
"user_rate_limits",
"cidr_rate_limits",
"user_max_unique_ips",
"user_max_unique_ips_global_each",
"user_max_unique_ips_mode",
"user_max_unique_ips_window_secs",
"replay_check_len",
"replay_window_secs",
"ignore_time_skew",
];
const RATE_LIMIT_BPS_CONFIG_KEYS: &[&str] = &["up_bps", "down_bps"];
const UPSTREAM_CONFIG_KEYS: &[&str] = &[
"type",
"interface",
"bind_addresses",
"bindtodevice",
"force_bind",
"address",
"user_id",
"username",
"password",
"url",
"weight",
"enabled",
"scopes",
"ipv4",
"ipv6",
];
const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"];
const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"];
const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"];
const LOGGING_CONFIG_KEYS: &[&str] = &[
"destination",
"path",
"rotation",
"max_size_bytes",
"max_files",
"max_age_secs",
];
#[derive(Debug)]
struct UnknownConfigKey {
path: String,
suggestion: Option<String>,
}
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_table()
}
fn is_strict_config(parsed_toml: &toml::Value) -> bool {
table_at(parsed_toml, &["general"])
.and_then(|table| table.get("config_strict"))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
}
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
let mut keys = Vec::new();
for group in [
TOP_LEVEL_CONFIG_KEYS,
GENERAL_CONFIG_KEYS,
NETWORK_CONFIG_KEYS,
SERVER_CONFIG_KEYS,
API_CONFIG_KEYS,
CONNTRACK_CONTROL_CONFIG_KEYS,
LISTENER_CONFIG_KEYS,
TIMEOUTS_CONFIG_KEYS,
CENSORSHIP_CONFIG_KEYS,
TLS_FETCH_CONFIG_KEYS,
ACCESS_CONFIG_KEYS,
RATE_LIMIT_BPS_CONFIG_KEYS,
UPSTREAM_CONFIG_KEYS,
PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] {
keys.extend_from_slice(group);
}
keys
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b_chars.iter().enumerate() {
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
let normalized = key.to_ascii_lowercase();
let mut best: Option<(&str, usize)> = None;
for known in known_keys {
let distance = levenshtein_distance(&normalized, known);
let is_better = match best {
Some((_, best_distance)) => distance < best_distance,
None => true,
};
if distance <= 4 && is_better {
best = Some((known, distance));
}
}
best.map(|(known, _)| known.to_string())
}
fn push_unknown_keys(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &str,
table: &toml::Table,
allowed: &[&str],
) {
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
let full_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
unknown.push(UnknownConfigKey {
path: full_path,
suggestion: unknown_key_suggestion(key, known_for_suggestion),
});
}
}
}
fn check_known_table(
parsed_toml: &toml::Value,
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &[&str],
allowed: &[&str],
) {
if let Some(table) = table_at(parsed_toml, path) {
push_unknown_keys(
unknown,
known_for_suggestion,
&path.join("."),
table,
allowed,
);
}
}
fn check_nested_table_value(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: String,
value: &toml::Value,
allowed: &[&str],
) {
if let Some(table) = value.as_table() {
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
}
}
fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
let known_for_suggestion = known_config_keys_for_suggestion();
let mut unknown = Vec::new();
if let Some(root) = parsed_toml.as_table() {
push_unknown_keys(
&mut unknown,
&known_for_suggestion,
"",
root,
TOP_LEVEL_CONFIG_KEYS,
);
}
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general"],
GENERAL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "modes"],
PROXY_MODES_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "telemetry"],
TELEMETRY_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "links"],
LINKS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["network"],
NETWORK_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server"],
SERVER_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "admin_api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "conntrack_control"],
CONNTRACK_CONTROL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["timeouts"],
TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship"],
CENSORSHIP_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship", "tls_fetch"],
TLS_FETCH_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["access"],
ACCESS_CONFIG_KEYS,
);
if let Some(listeners) = table_at(parsed_toml, &["server"])
.and_then(|table| table.get("listeners"))
.and_then(toml::Value::as_array)
{
for (idx, listener) in listeners.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("server.listeners[{idx}]"),
listener,
LISTENER_CONFIG_KEYS,
);
}
}
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
for (idx, upstream) in upstreams.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("upstreams[{idx}]"),
upstream,
UPSTREAM_CONFIG_KEYS,
);
}
}
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
if let Some(table) = table_at(parsed_toml, &["access"])
.and_then(|access| access.get(access_map))
.and_then(toml::Value::as_table)
{
for (entry_name, value) in table {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("access.{access_map}.{entry_name}"),
value,
RATE_LIMIT_BPS_CONFIG_KEYS,
);
}
}
}
unknown
}
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
let unknown = collect_unknown_config_keys(parsed_toml);
if unknown.is_empty() {
return Ok(());
}
for item in &unknown {
if let Some(suggestion) = item.suggestion.as_deref() {
warn!(
key = %item.path,
suggestion = %suggestion,
"Unknown config key ignored; did you mean the suggested key?"
);
} else {
warn!(key = %item.path, "Unknown config key ignored");
}
}
if is_strict_config(parsed_toml) {
let mut paths = Vec::with_capacity(unknown.len());
for item in unknown {
if let Some(suggestion) = item.suggestion {
paths.push(format!("{} (did you mean `{}`?)", item.path, suggestion));
} else {
paths.push(item.path);
}
}
return Err(ProxyError::Config(format!(
"unknown config keys are not allowed when general.config_strict=true: {}",
paths.join(", ")
)));
}
Ok(())
}
+228
View File
@@ -0,0 +1,228 @@
use super::*;
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
if let Some(path) = &config.general.proxy_config_v4_cache_path
&& path.trim().is_empty()
{
return Err(ProxyError::Config(
"general.proxy_config_v4_cache_path cannot be empty when provided".to_string(),
));
}
if let Some(path) = &config.general.proxy_config_v6_cache_path
&& path.trim().is_empty()
{
return Err(ProxyError::Config(
"general.proxy_config_v6_cache_path cannot be empty when provided".to_string(),
));
}
if let Some(update_every) = config.general.update_every {
if update_every == 0 {
return Err(ProxyError::Config(
"general.update_every must be > 0".to_string(),
));
}
} else {
let legacy_secret = config.general.proxy_secret_auto_reload_secs;
let legacy_config = config.general.proxy_config_auto_reload_secs;
let effective = legacy_secret.min(legacy_config);
if effective == 0 {
return Err(ProxyError::Config(
"legacy proxy_*_auto_reload_secs values must be > 0 when general.update_every is not set".to_string(),
));
}
if legacy_secret != default_proxy_secret_reload_secs()
|| legacy_config != default_proxy_config_reload_secs()
{
warn!(
proxy_secret_auto_reload_secs = legacy_secret,
proxy_config_auto_reload_secs = legacy_config,
effective_update_every_secs = effective,
"proxy_*_auto_reload_secs are deprecated; set general.update_every"
);
}
}
if config.general.stun_nat_probe_concurrency == 0 {
return Err(ProxyError::Config(
"general.stun_nat_probe_concurrency must be > 0".to_string(),
));
}
if config.general.me_init_retry_attempts > 1_000_000 {
return Err(ProxyError::Config(
"general.me_init_retry_attempts must be within [0, 1000000]".to_string(),
));
}
if config.general.upstream_connect_retry_attempts == 0 {
return Err(ProxyError::Config(
"general.upstream_connect_retry_attempts must be > 0".to_string(),
));
}
if config.general.upstream_connect_budget_ms == 0 {
return Err(ProxyError::Config(
"general.upstream_connect_budget_ms must be > 0".to_string(),
));
}
if config.general.tg_connect == 0 {
return Err(ProxyError::Config(
"general.tg_connect must be > 0".to_string(),
));
}
if config.general.upstream_unhealthy_fail_threshold == 0 {
return Err(ProxyError::Config(
"general.upstream_unhealthy_fail_threshold must be > 0".to_string(),
));
}
if config.general.rpc_proxy_req_every != 0
&& !(10..=300).contains(&config.general.rpc_proxy_req_every)
{
return Err(ProxyError::Config(
"general.rpc_proxy_req_every must be 0 or within [10, 300]".to_string(),
));
}
if config.timeouts.client_handshake == 0 {
return Err(ProxyError::Config(
"timeouts.client_handshake must be > 0".to_string(),
));
}
let handshake_timeout_ms = config
.timeouts
.client_handshake
.checked_mul(1000)
.ok_or_else(|| {
ProxyError::Config(
"timeouts.client_handshake is too large to validate milliseconds budget"
.to_string(),
)
})?;
if config.censorship.server_hello_delay_max_ms >= handshake_timeout_ms {
return Err(ProxyError::Config(
"censorship.server_hello_delay_max_ms must be < timeouts.client_handshake * 1000"
.to_string(),
));
}
if config.censorship.mask_shape_bucket_floor_bytes == 0 {
return Err(ProxyError::Config(
"censorship.mask_shape_bucket_floor_bytes must be > 0".to_string(),
));
}
if config.censorship.mask_shape_bucket_cap_bytes
< config.censorship.mask_shape_bucket_floor_bytes
{
return Err(ProxyError::Config(
"censorship.mask_shape_bucket_cap_bytes must be >= censorship.mask_shape_bucket_floor_bytes"
.to_string(),
));
}
if config.censorship.mask_shape_above_cap_blur && !config.censorship.mask_shape_hardening {
return Err(ProxyError::Config(
"censorship.mask_shape_above_cap_blur requires censorship.mask_shape_hardening = true"
.to_string(),
));
}
if config.censorship.mask_shape_hardening_aggressive_mode
&& !config.censorship.mask_shape_hardening
{
return Err(ProxyError::Config(
"censorship.mask_shape_hardening_aggressive_mode requires censorship.mask_shape_hardening = true"
.to_string(),
));
}
if config.censorship.mask_shape_above_cap_blur
&& config.censorship.mask_shape_above_cap_blur_max_bytes == 0
{
return Err(ProxyError::Config(
"censorship.mask_shape_above_cap_blur_max_bytes must be > 0 when censorship.mask_shape_above_cap_blur is enabled"
.to_string(),
));
}
if config.censorship.mask_shape_above_cap_blur_max_bytes > 1_048_576 {
return Err(ProxyError::Config(
"censorship.mask_shape_above_cap_blur_max_bytes must be <= 1048576".to_string(),
));
}
if config.censorship.mask_relay_max_bytes > 67_108_864 {
return Err(ProxyError::Config(
"censorship.mask_relay_max_bytes must be <= 67108864".to_string(),
));
}
if !(5..=50).contains(&config.censorship.mask_classifier_prefetch_timeout_ms) {
return Err(ProxyError::Config(
"censorship.mask_classifier_prefetch_timeout_ms must be within [5, 50]".to_string(),
));
}
if config.censorship.mask_timing_normalization_ceiling_ms
< config.censorship.mask_timing_normalization_floor_ms
{
return Err(ProxyError::Config(
"censorship.mask_timing_normalization_ceiling_ms must be >= censorship.mask_timing_normalization_floor_ms"
.to_string(),
));
}
if config.censorship.mask_timing_normalization_enabled
&& config.censorship.mask_timing_normalization_floor_ms == 0
{
return Err(ProxyError::Config(
"censorship.mask_timing_normalization_floor_ms must be > 0 when censorship.mask_timing_normalization_enabled is true"
.to_string(),
));
}
if config.censorship.mask_timing_normalization_ceiling_ms > 60_000 {
return Err(ProxyError::Config(
"censorship.mask_timing_normalization_ceiling_ms must be <= 60000".to_string(),
));
}
if config.timeouts.relay_client_idle_soft_secs == 0 {
return Err(ProxyError::Config(
"timeouts.relay_client_idle_soft_secs must be > 0".to_string(),
));
}
if config.timeouts.relay_client_idle_hard_secs == 0 {
return Err(ProxyError::Config(
"timeouts.relay_client_idle_hard_secs must be > 0".to_string(),
));
}
if config.timeouts.relay_client_idle_hard_secs < config.timeouts.relay_client_idle_soft_secs {
return Err(ProxyError::Config(
"timeouts.relay_client_idle_hard_secs must be >= timeouts.relay_client_idle_soft_secs"
.to_string(),
));
}
if config
.timeouts
.relay_idle_grace_after_downstream_activity_secs
> config.timeouts.relay_client_idle_hard_secs
{
return Err(ProxyError::Config(
"timeouts.relay_idle_grace_after_downstream_activity_secs must be <= timeouts.relay_client_idle_hard_secs"
.to_string(),
));
}
Ok(())
}
+230
View File
@@ -0,0 +1,230 @@
use super::*;
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
if config.general.me_reinit_every_secs == 0 {
return Err(ProxyError::Config(
"general.me_reinit_every_secs must be > 0".to_string(),
));
}
if config.general.me_single_endpoint_shadow_writers > 32 {
return Err(ProxyError::Config(
"general.me_single_endpoint_shadow_writers must be within [0, 32]".to_string(),
));
}
if config.general.me_adaptive_floor_min_writers_single_endpoint == 0
|| config.general.me_adaptive_floor_min_writers_single_endpoint > 32
{
return Err(ProxyError::Config(
"general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]"
.to_string(),
));
}
if config.general.me_adaptive_floor_min_writers_multi_endpoint == 0
|| config.general.me_adaptive_floor_min_writers_multi_endpoint > 32
{
return Err(ProxyError::Config(
"general.me_adaptive_floor_min_writers_multi_endpoint must be within [1, 32]"
.to_string(),
));
}
if config.general.me_adaptive_floor_writers_per_core_total == 0 {
return Err(ProxyError::Config(
"general.me_adaptive_floor_writers_per_core_total must be > 0".to_string(),
));
}
if config.general.me_adaptive_floor_max_active_writers_per_core == 0 {
return Err(ProxyError::Config(
"general.me_adaptive_floor_max_active_writers_per_core must be > 0".to_string(),
));
}
if config.general.me_adaptive_floor_max_warm_writers_per_core == 0 {
return Err(ProxyError::Config(
"general.me_adaptive_floor_max_warm_writers_per_core must be > 0".to_string(),
));
}
if config.general.me_adaptive_floor_max_active_writers_global == 0 {
return Err(ProxyError::Config(
"general.me_adaptive_floor_max_active_writers_global must be > 0".to_string(),
));
}
if config.general.me_adaptive_floor_max_warm_writers_global == 0 {
return Err(ProxyError::Config(
"general.me_adaptive_floor_max_warm_writers_global must be > 0".to_string(),
));
}
if config.general.me_single_endpoint_outage_backoff_min_ms == 0 {
return Err(ProxyError::Config(
"general.me_single_endpoint_outage_backoff_min_ms must be > 0".to_string(),
));
}
if config.general.me_single_endpoint_outage_backoff_max_ms == 0 {
return Err(ProxyError::Config(
"general.me_single_endpoint_outage_backoff_max_ms must be > 0".to_string(),
));
}
if config.general.me_single_endpoint_outage_backoff_min_ms
> config.general.me_single_endpoint_outage_backoff_max_ms
{
return Err(ProxyError::Config(
"general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms".to_string(),
));
}
if config.general.beobachten_minutes == 0 {
return Err(ProxyError::Config(
"general.beobachten_minutes must be > 0".to_string(),
));
}
if config.general.beobachten_flush_secs == 0 {
return Err(ProxyError::Config(
"general.beobachten_flush_secs must be > 0".to_string(),
));
}
if config.general.beobachten_file.trim().is_empty() {
return Err(ProxyError::Config(
"general.beobachten_file cannot be empty".to_string(),
));
}
if config.general.me_hardswap_warmup_delay_max_ms == 0 {
return Err(ProxyError::Config(
"general.me_hardswap_warmup_delay_max_ms must be > 0".to_string(),
));
}
if config.general.me_hardswap_warmup_delay_min_ms
> config.general.me_hardswap_warmup_delay_max_ms
{
return Err(ProxyError::Config(
"general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms".to_string(),
));
}
if config.general.me_hardswap_warmup_extra_passes > 10 {
return Err(ProxyError::Config(
"general.me_hardswap_warmup_extra_passes must be within [0, 10]".to_string(),
));
}
if config.general.me_hardswap_warmup_pass_backoff_base_ms == 0 {
return Err(ProxyError::Config(
"general.me_hardswap_warmup_pass_backoff_base_ms must be > 0".to_string(),
));
}
if config.general.me_config_stable_snapshots == 0 {
return Err(ProxyError::Config(
"general.me_config_stable_snapshots must be > 0".to_string(),
));
}
if config.general.me_snapshot_min_proxy_for_lines == 0 {
return Err(ProxyError::Config(
"general.me_snapshot_min_proxy_for_lines must be > 0".to_string(),
));
}
if config.general.proxy_secret_stable_snapshots == 0 {
return Err(ProxyError::Config(
"general.proxy_secret_stable_snapshots must be > 0".to_string(),
));
}
if config.general.me_reinit_trigger_channel == 0 {
return Err(ProxyError::Config(
"general.me_reinit_trigger_channel must be > 0".to_string(),
));
}
if !(32..=4096).contains(&config.general.proxy_secret_len_max) {
return Err(ProxyError::Config(
"general.proxy_secret_len_max must be within [32, 4096]".to_string(),
));
}
if !(0.0..=1.0).contains(&config.general.me_pool_min_fresh_ratio) {
return Err(ProxyError::Config(
"general.me_pool_min_fresh_ratio must be within [0.0, 1.0]".to_string(),
));
}
if config.general.me_route_backpressure_base_timeout_ms == 0 {
return Err(ProxyError::Config(
"general.me_route_backpressure_base_timeout_ms must be > 0".to_string(),
));
}
if config.general.me_route_backpressure_base_timeout_ms > 5000 {
return Err(ProxyError::Config(
"general.me_route_backpressure_base_timeout_ms must be within [1, 5000]".to_string(),
));
}
if config.general.me_route_backpressure_high_timeout_ms
< config.general.me_route_backpressure_base_timeout_ms
{
return Err(ProxyError::Config(
"general.me_route_backpressure_high_timeout_ms must be >= general.me_route_backpressure_base_timeout_ms".to_string(),
));
}
if config.general.me_route_backpressure_high_timeout_ms > 5000 {
return Err(ProxyError::Config(
"general.me_route_backpressure_high_timeout_ms must be within [1, 5000]".to_string(),
));
}
if !(1..=100).contains(&config.general.me_route_backpressure_high_watermark_pct) {
return Err(ProxyError::Config(
"general.me_route_backpressure_high_watermark_pct must be within [1, 100]".to_string(),
));
}
if !(10..=5000).contains(&config.general.me_route_no_writer_wait_ms) {
return Err(ProxyError::Config(
"general.me_route_no_writer_wait_ms must be within [10, 5000]".to_string(),
));
}
if !(50..=60_000).contains(&config.general.me_route_hybrid_max_wait_ms) {
return Err(ProxyError::Config(
"general.me_route_hybrid_max_wait_ms must be within [50, 60000]".to_string(),
));
}
if !(1..=5000).contains(&config.general.me_route_blocking_send_timeout_ms) {
return Err(ProxyError::Config(
"general.me_route_blocking_send_timeout_ms must be within [1, 5000]".to_string(),
));
}
if !(2..=4).contains(&config.general.me_writer_pick_sample_size) {
return Err(ProxyError::Config(
"general.me_writer_pick_sample_size must be within [2, 4]".to_string(),
));
}
if config.general.me_route_inline_recovery_attempts == 0 {
return Err(ProxyError::Config(
"general.me_route_inline_recovery_attempts must be > 0".to_string(),
));
}
if !(10..=30000).contains(&config.general.me_route_inline_recovery_wait_ms) {
return Err(ProxyError::Config(
"general.me_route_inline_recovery_wait_ms must be within [10, 30000]".to_string(),
));
}
Ok(())
}
+220
View File
@@ -0,0 +1,220 @@
use super::*;
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
if config.general.me_writer_cmd_channel_capacity == 0 {
return Err(ProxyError::Config(
"general.me_writer_cmd_channel_capacity must be > 0".to_string(),
));
}
if config.general.me_writer_cmd_channel_capacity > MAX_ME_WRITER_CMD_CHANNEL_CAPACITY {
return Err(ProxyError::Config(format!(
"general.me_writer_cmd_channel_capacity must be within [1, {MAX_ME_WRITER_CMD_CHANNEL_CAPACITY}]"
)));
}
if config.general.me_route_channel_capacity == 0 {
return Err(ProxyError::Config(
"general.me_route_channel_capacity must be > 0".to_string(),
));
}
if config.general.me_route_channel_capacity > MAX_ME_ROUTE_CHANNEL_CAPACITY {
return Err(ProxyError::Config(format!(
"general.me_route_channel_capacity must be within [1, {MAX_ME_ROUTE_CHANNEL_CAPACITY}]"
)));
}
if config.general.me_c2me_channel_capacity == 0 {
return Err(ProxyError::Config(
"general.me_c2me_channel_capacity must be > 0".to_string(),
));
}
if config.general.me_c2me_channel_capacity > MAX_ME_C2ME_CHANNEL_CAPACITY {
return Err(ProxyError::Config(format!(
"general.me_c2me_channel_capacity must be within [1, {MAX_ME_C2ME_CHANNEL_CAPACITY}]"
)));
}
if !(MIN_MAX_CLIENT_FRAME_BYTES..=MAX_MAX_CLIENT_FRAME_BYTES)
.contains(&config.general.max_client_frame)
{
return Err(ProxyError::Config(format!(
"general.max_client_frame must be within [{MIN_MAX_CLIENT_FRAME_BYTES}, {MAX_MAX_CLIENT_FRAME_BYTES}]"
)));
}
let min_writer_byte_budget =
minimum_me_writer_byte_budget_bytes(config.general.max_client_frame);
if config.general.me_writer_byte_budget_bytes % ME_WRITER_BYTE_PERMIT_UNIT_BYTES != 0 {
return Err(ProxyError::Config(format!(
"general.me_writer_byte_budget_bytes must be a multiple of {ME_WRITER_BYTE_PERMIT_UNIT_BYTES}"
)));
}
if !(min_writer_byte_budget..=MAX_ME_WRITER_BYTE_BUDGET_BYTES)
.contains(&config.general.me_writer_byte_budget_bytes)
{
return Err(ProxyError::Config(format!(
"general.me_writer_byte_budget_bytes must be within [{min_writer_byte_budget}, {MAX_ME_WRITER_BYTE_BUDGET_BYTES}] for general.max_client_frame={}",
config.general.max_client_frame
)));
}
if config.general.me_c2me_send_timeout_ms > 60_000 {
return Err(ProxyError::Config(
"general.me_c2me_send_timeout_ms must be within [0, 60000]".to_string(),
));
}
if config.general.me_reader_route_data_wait_ms > 20 {
return Err(ProxyError::Config(
"general.me_reader_route_data_wait_ms must be within [0, 20]".to_string(),
));
}
if !(1..=512).contains(&config.general.me_d2c_flush_batch_max_frames) {
return Err(ProxyError::Config(
"general.me_d2c_flush_batch_max_frames must be within [1, 512]".to_string(),
));
}
if !(4096..=2 * 1024 * 1024).contains(&config.general.me_d2c_flush_batch_max_bytes) {
return Err(ProxyError::Config(
"general.me_d2c_flush_batch_max_bytes must be within [4096, 2097152]".to_string(),
));
}
if config.general.me_d2c_flush_batch_max_delay_us > 5000 {
return Err(ProxyError::Config(
"general.me_d2c_flush_batch_max_delay_us must be within [0, 5000]".to_string(),
));
}
if config.general.me_quota_soft_overshoot_bytes > 16 * 1024 * 1024 {
return Err(ProxyError::Config(
"general.me_quota_soft_overshoot_bytes must be within [0, 16777216]".to_string(),
));
}
if !(4096..=16 * 1024 * 1024).contains(&config.general.me_d2c_frame_buf_shrink_threshold_bytes)
{
return Err(ProxyError::Config(
"general.me_d2c_frame_buf_shrink_threshold_bytes must be within [4096, 16777216]"
.to_string(),
));
}
if !(4096..=1024 * 1024).contains(&config.general.direct_relay_copy_buf_c2s_bytes) {
return Err(ProxyError::Config(
"general.direct_relay_copy_buf_c2s_bytes must be within [4096, 1048576]".to_string(),
));
}
if !(8192..=2 * 1024 * 1024).contains(&config.general.direct_relay_copy_buf_s2c_bytes) {
return Err(ProxyError::Config(
"general.direct_relay_copy_buf_s2c_bytes must be within [8192, 2097152]".to_string(),
));
}
if config.general.direct_relay_buffer_budget_max_bytes != 0 {
if config.general.direct_relay_buffer_budget_max_bytes
% DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES
!= 0
{
return Err(ProxyError::Config(format!(
"general.direct_relay_buffer_budget_max_bytes must be 0 or a multiple of {DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES}"
)));
}
if !(MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES..=MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES)
.contains(&config.general.direct_relay_buffer_budget_max_bytes)
{
return Err(ProxyError::Config(format!(
"general.direct_relay_buffer_budget_max_bytes must be 0 or within [{MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES}, {MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES}]"
)));
}
}
if config.general.me_health_interval_ms_unhealthy == 0 {
return Err(ProxyError::Config(
"general.me_health_interval_ms_unhealthy must be > 0".to_string(),
));
}
if config.general.me_health_interval_ms_healthy == 0 {
return Err(ProxyError::Config(
"general.me_health_interval_ms_healthy must be > 0".to_string(),
));
}
if config.general.me_admission_poll_ms == 0 {
return Err(ProxyError::Config(
"general.me_admission_poll_ms must be > 0".to_string(),
));
}
if config.general.me_warn_rate_limit_ms == 0 {
return Err(ProxyError::Config(
"general.me_warn_rate_limit_ms must be > 0".to_string(),
));
}
if config.general.me_pool_drain_soft_evict_grace_secs > 3600 {
return Err(ProxyError::Config(
"general.me_pool_drain_soft_evict_grace_secs must be within [0, 3600]".to_string(),
));
}
if config.general.me_pool_drain_soft_evict_per_writer == 0
|| config.general.me_pool_drain_soft_evict_per_writer > 16
{
return Err(ProxyError::Config(
"general.me_pool_drain_soft_evict_per_writer must be within [1, 16]".to_string(),
));
}
if config.general.me_pool_drain_soft_evict_budget_per_core == 0
|| config.general.me_pool_drain_soft_evict_budget_per_core > 64
{
return Err(ProxyError::Config(
"general.me_pool_drain_soft_evict_budget_per_core must be within [1, 64]".to_string(),
));
}
if config.general.me_pool_drain_soft_evict_cooldown_ms == 0 {
return Err(ProxyError::Config(
"general.me_pool_drain_soft_evict_cooldown_ms must be > 0".to_string(),
));
}
if config.access.user_max_unique_ips_window_secs == 0 {
return Err(ProxyError::Config(
"access.user_max_unique_ips_window_secs must be > 0".to_string(),
));
}
for (user, limit) in &config.access.user_rate_limits {
if limit.up_bps == 0 && limit.down_bps == 0 {
return Err(ProxyError::Config(format!(
"access.user_rate_limits.{user} must set at least one non-zero direction"
)));
}
}
for (cidr, limit) in &config.access.cidr_rate_limits {
if limit.up_bps == 0 && limit.down_bps == 0 {
return Err(ProxyError::Config(format!(
"access.cidr_rate_limits.{cidr} must set at least one non-zero direction"
)));
}
}
let mut cidr_auto_templates = HashSet::new();
for cidr in config.access.cidr_rate_limits.keys() {
for template in cidr.auto_templates().into_iter().flatten() {
if !cidr_auto_templates.insert(template) {
return Err(ProxyError::Config(format!(
"access.cidr_rate_limits.{cidr} duplicates normalized auto-template {template}"
)));
}
}
}
Ok(())
}
+225
View File
@@ -0,0 +1,225 @@
use super::*;
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
if !(1..=MAX_API_REQUEST_BODY_LIMIT_BYTES).contains(&config.server.api.request_body_limit_bytes)
{
return Err(ProxyError::Config(
"server.api.request_body_limit_bytes must be within [1, 1048576]".to_string(),
));
}
if config.server.api.minimal_runtime_cache_ttl_ms > 60_000 {
return Err(ProxyError::Config(
"server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]".to_string(),
));
}
if config.server.api.runtime_edge_cache_ttl_ms > 60_000 {
return Err(ProxyError::Config(
"server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]".to_string(),
));
}
if !(1..=1000).contains(&config.server.api.runtime_edge_top_n) {
return Err(ProxyError::Config(
"server.api.runtime_edge_top_n must be within [1, 1000]".to_string(),
));
}
if !(16..=4096).contains(&config.server.api.runtime_edge_events_capacity) {
return Err(ProxyError::Config(
"server.api.runtime_edge_events_capacity must be within [16, 4096]".to_string(),
));
}
if config.server.api.listen.parse::<SocketAddr>().is_err() {
return Err(ProxyError::Config(
"server.api.listen must be in IP:PORT format".to_string(),
));
}
if config.server.proxy_protocol_header_timeout_ms == 0 {
return Err(ProxyError::Config(
"server.proxy_protocol_header_timeout_ms must be > 0".to_string(),
));
}
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}")))?;
config
.server
.client_mss_bulk_value()
.map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {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 listener.synlimit_ios_seconds == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_ios_seconds must be > 0"
)));
}
if listener.synlimit_ios_hitcount == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_ios_hitcount must be > 0"
)));
}
if listener.synlimit_ios_burst == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_ios_burst must be > 0"
)));
}
if listener.synlimit_hashlimit_expire_ms == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_hashlimit_expire_ms must be > 0"
)));
}
if listener.synlimit_hashlimit_size == 0 {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].synlimit_hashlimit_size must be > 0"
)));
}
}
if config.server.accept_permit_timeout_ms > 60_000 {
return Err(ProxyError::Config(
"server.accept_permit_timeout_ms must be within [0, 60000]".to_string(),
));
}
if config.server.conntrack_control.pressure_high_watermark_pct == 0
|| config.server.conntrack_control.pressure_high_watermark_pct > 100
{
return Err(ProxyError::Config(
"server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]"
.to_string(),
));
}
if config.server.conntrack_control.pressure_low_watermark_pct
>= config.server.conntrack_control.pressure_high_watermark_pct
{
return Err(ProxyError::Config(
"server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct"
.to_string(),
));
}
if config.server.conntrack_control.delete_budget_per_sec == 0 {
return Err(ProxyError::Config(
"server.conntrack_control.delete_budget_per_sec must be > 0".to_string(),
));
}
if matches!(config.server.conntrack_control.mode, ConntrackMode::Hybrid)
&& config
.server
.conntrack_control
.hybrid_listener_ips
.is_empty()
{
return Err(ProxyError::Config(
"server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid"
.to_string(),
));
}
if config.general.effective_me_pool_force_close_secs() > 0
&& config.general.effective_me_pool_force_close_secs()
< config.general.me_pool_drain_ttl_secs
{
warn!(
me_pool_drain_ttl_secs = config.general.me_pool_drain_ttl_secs,
me_reinit_drain_timeout_secs = config.general.effective_me_pool_force_close_secs(),
"force-close timeout is lower than drain TTL; bumping force-close timeout to TTL"
);
config.general.me_reinit_drain_timeout_secs = config.general.me_pool_drain_ttl_secs;
}
// Validate secrets.
for (user, secret) in &config.access.users {
if !secret.chars().all(|c| c.is_ascii_hexdigit()) || secret.len() != 32 {
return Err(ProxyError::InvalidSecret {
user: user.clone(),
reason: "Must be 32 hex characters".to_string(),
});
}
}
config.censorship.tls_domain =
normalize_domain_to_ascii(&config.censorship.tls_domain, "censorship.tls_domain")?;
// Validate mask_unix_sock.
if let Some(ref sock_path) = config.censorship.mask_unix_sock {
if sock_path.is_empty() {
return Err(ProxyError::Config(
"mask_unix_sock cannot be empty".to_string(),
));
}
#[cfg(unix)]
if sock_path.len() > 107 {
return Err(ProxyError::Config(format!(
"mask_unix_sock path too long: {} bytes (max 107)",
sock_path.len()
)));
}
#[cfg(not(unix))]
return Err(ProxyError::Config(
"mask_unix_sock is only supported on Unix platforms".to_string(),
));
if config.censorship.mask_host.is_some() {
return Err(ProxyError::Config(
"mask_unix_sock and mask_host are mutually exclusive".to_string(),
));
}
}
if let Some(mask_host) = config.censorship.mask_host.as_mut() {
*mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?;
}
for (domain, target) in &config.censorship.exclusive_mask {
if !is_valid_tls_domain_name(domain) {
return Err(ProxyError::Config(format!(
"Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name",
domain
)));
}
if parse_exclusive_mask_target(target).is_none() {
return Err(ProxyError::Config(format!(
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
domain, target
)));
}
}
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
use shadowsocks::config::ServerConfig as ShadowsocksServerConfig;
use tracing::warn;
use crate::error::{ProxyError, Result};
use super::super::types::{
LoggingConfig, LoggingDestination, NetworkConfig, SynLimitMode, UpstreamType,
};
use super::ProxyConfig;
pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> {
if !net.ipv4 && matches!(net.ipv6, Some(false)) {
return Err(ProxyError::Config(
"Both ipv4 and ipv6 are disabled in [network]".to_string(),
));
}
if net.prefer != 4 && net.prefer != 6 {
return Err(ProxyError::Config(
"network.prefer must be 4 or 6".to_string(),
));
}
if !net.ipv4 && net.prefer == 4 {
warn!("prefer=4 but ipv4=false; forcing prefer=6");
net.prefer = 6;
}
if matches!(net.ipv6, Some(false)) && net.prefer == 6 {
warn!("prefer=6 but ipv6=false; forcing prefer=4");
net.prefer = 4;
}
Ok(())
}
pub(super) 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(())
}
pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| {
upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. })
});
if has_enabled_shadowsocks && config.general.use_middle_proxy {
return Err(ProxyError::Config(
"shadowsocks upstreams require general.use_middle_proxy = false".to_string(),
));
}
for upstream in &config.upstreams {
if matches!(upstream.ipv4, Some(false)) && matches!(upstream.ipv6, Some(false)) {
return Err(ProxyError::Config(
"upstream.ipv4 and upstream.ipv6 cannot both be false".to_string(),
));
}
if let Some(prefer) = upstream.prefer
&& prefer != 4
&& prefer != 6
{
return Err(ProxyError::Config(
"upstream.prefer must be 4 or 6".to_string(),
));
}
if let UpstreamType::Shadowsocks { url, .. } = &upstream.upstream_type {
let parsed = ShadowsocksServerConfig::from_url(url)
.map_err(|error| ProxyError::Config(format!("invalid shadowsocks url: {error}")))?;
if parsed.plugin().is_some() {
return Err(ProxyError::Config(
"shadowsocks plugins are not supported".to_string(),
));
}
}
}
Ok(())
}
pub(super) fn validate_listener_runtime_profiles(config: &ProxyConfig) -> Result<()> {
for (index, listener) in config.server.listeners.iter().enumerate() {
let supported = if cfg!(target_os = "linux") {
matches!(
listener.synlimit,
SynLimitMode::Off | SynLimitMode::Iptables | SynLimitMode::Nftables
)
} else if cfg!(target_os = "freebsd") {
matches!(listener.synlimit, SynLimitMode::Off | SynLimitMode::Pf)
} else {
listener.synlimit == SynLimitMode::Off
};
if !supported {
let backend = match listener.synlimit {
SynLimitMode::Off => "off",
SynLimitMode::Iptables => "iptables",
SynLimitMode::Nftables => "nftables",
SynLimitMode::Pf => "pf",
};
let supported = if cfg!(target_os = "linux") {
"off, iptables, nftables"
} else if cfg!(target_os = "freebsd") {
"off, pf"
} else {
"off"
};
return Err(ProxyError::Config(format!(
"server.listeners[{index}].synlimit backend {backend} is unsupported on this platform; supported values: {supported}"
)));
}
}
let Some(bulk_mss) = config
.server
.client_mss_bulk_value()
.map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {error}")))?
else {
return Ok(());
};
if !cfg!(target_os = "linux") {
return Err(ProxyError::Config(
"server.client_mss_bulk is supported only on Linux".to_string(),
));
}
let mut participants = 0usize;
for (index, listener) in config.server.listeners.iter().enumerate() {
let handshake_mss = listener
.effective_client_mss(&config.server)
.map_err(|error| {
ProxyError::Config(format!("server.listeners[{index}].client_mss {error}"))
})?;
let Some(handshake_mss) = handshake_mss else {
continue;
};
participants = participants.saturating_add(1);
if bulk_mss <= handshake_mss {
return Err(ProxyError::Config(format!(
"server.client_mss_bulk ({bulk_mss}) must be greater than the effective handshake MSS ({handshake_mss}) for server.listeners[{index}]"
)));
}
}
if participants == 0 {
return Err(ProxyError::Config(
"server.client_mss_bulk requires an effective client_mss on at least one listener"
.to_string(),
));
}
Ok(())
}
pub(super) 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);
}
}
}
+1
View File
@@ -6,4 +6,5 @@ mod load;
mod types;
pub use load::ProxyConfig;
pub(crate) use load::{ConfigSourceGraph, LoadedConfig};
pub use types::*;
+54
View File
@@ -0,0 +1,54 @@
use super::*;
use crate::config::CidrRateLimitKey;
const TEST_SHADOWSOCKS_URL: &str =
"ss://2022-blake3-aes-256-gcm:MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=@127.0.0.1:8388";
fn load_config_from_temp_toml(toml: &str) -> ProxyConfig {
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_{nonce}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(dir);
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
}
#[path = "load_basic_tests/api_tests.rs"]
mod api_tests;
#[path = "load_basic_tests/conntrack_tests.rs"]
mod conntrack_tests;
#[path = "load_basic_tests/defaults_access_tests.rs"]
mod defaults_access_tests;
#[path = "load_basic_tests/legacy_policy_tests.rs"]
mod legacy_policy_tests;
#[path = "load_basic_tests/me_route_tests.rs"]
mod me_route_tests;
#[path = "load_basic_tests/me_startup_tests.rs"]
mod me_startup_tests;
#[path = "load_basic_tests/synlimit_mss_tests.rs"]
mod synlimit_mss_tests;
#[path = "load_basic_tests/tls_fetch_tests.rs"]
mod tls_fetch_tests;
#[path = "load_basic_tests/upstream_tests.rs"]
mod upstream_tests;
@@ -0,0 +1,89 @@
use super::*;
#[test]
fn api_minimal_runtime_cache_ttl_out_of_range_is_rejected() {
let toml = r#"
[server.api]
enabled = true
listen = "127.0.0.1:9091"
minimal_runtime_cache_ttl_ms = 70000
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_api_minimal_runtime_cache_ttl_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() {
let toml = r#"
[server.api]
enabled = true
listen = "127.0.0.1:9091"
runtime_edge_cache_ttl_ms = 70000
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn api_runtime_edge_top_n_out_of_range_is_rejected() {
let toml = r#"
[server.api]
enabled = true
listen = "127.0.0.1:9091"
runtime_edge_top_n = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn api_runtime_edge_events_capacity_out_of_range_is_rejected() {
let toml = r#"
[server.api]
enabled = true
listen = "127.0.0.1:9091"
runtime_edge_events_capacity = 8
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]"));
let _ = std::fs::remove_file(path);
}
@@ -0,0 +1,175 @@
use super::*;
#[test]
fn conntrack_pressure_high_watermark_out_of_range_is_rejected() {
let toml = r#"
[server.conntrack_control]
pressure_high_watermark_pct = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_conntrack_high_watermark_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(
err.contains(
"server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]"
)
);
let _ = std::fs::remove_file(path);
}
#[test]
fn conntrack_pressure_low_watermark_must_be_below_high() {
let toml = r#"
[server.conntrack_control]
pressure_high_watermark_pct = 50
pressure_low_watermark_pct = 50
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_conntrack_low_watermark_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains(
"server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct"
));
let _ = std::fs::remove_file(path);
}
#[test]
fn conntrack_delete_budget_zero_is_rejected() {
let toml = r#"
[server.conntrack_control]
delete_budget_per_sec = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_conntrack_delete_budget_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.conntrack_control.delete_budget_per_sec must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn conntrack_hybrid_mode_requires_listener_allow_list() {
let toml = r#"
[server.conntrack_control]
mode = "hybrid"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_conntrack_hybrid_requires_ips_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(
err.contains(
"server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid"
)
);
let _ = std::fs::remove_file(path);
}
#[test]
fn conntrack_profile_is_loaded_from_config() {
let toml = r#"
[server.conntrack_control]
profile = "aggressive"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_conntrack_profile_parse_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.server.conntrack_control.profile,
ConntrackPressureProfile::Aggressive
);
let _ = std::fs::remove_file(path);
}
#[test]
fn force_close_default_matches_drain_ttl() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_force_close_default_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90);
assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 90);
let _ = std::fs::remove_file(path);
}
#[test]
fn force_close_zero_uses_runtime_safety_fallback() {
let toml = r#"
[general]
me_reinit_drain_timeout_secs = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_force_close_zero_fallback_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 0);
assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 300);
let _ = std::fs::remove_file(path);
}
#[test]
fn force_close_bumped_when_below_drain_ttl() {
let toml = r#"
[general]
me_pool_drain_ttl_secs = 90
me_reinit_drain_timeout_secs = 30
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_force_close_bump_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90);
let _ = std::fs::remove_file(path);
}
@@ -0,0 +1,478 @@
use super::*;
#[test]
fn serde_defaults_remain_unchanged_for_present_sections() {
let toml = r#"
[network]
[general]
[server]
[access]
"#;
let cfg: ProxyConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.logging, LoggingConfig::default());
assert_eq!(cfg.network.ipv6, default_network_ipv6());
assert_eq!(cfg.network.stun_use, default_true());
assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback());
assert_eq!(
cfg.general.middle_proxy_warm_standby,
default_middle_proxy_warm_standby()
);
assert_eq!(
cfg.general.me_reconnect_max_concurrent_per_dc,
default_me_reconnect_max_concurrent_per_dc()
);
assert_eq!(
cfg.general.me_reconnect_fast_retry_count,
default_me_reconnect_fast_retry_count()
);
assert_eq!(
cfg.general.me_init_retry_attempts,
default_me_init_retry_attempts()
);
assert_eq!(cfg.general.me2dc_fallback, default_me2dc_fallback());
assert_eq!(cfg.general.me2dc_fast, default_me2dc_fast());
assert_eq!(
cfg.general.proxy_config_v4_cache_path,
default_proxy_config_v4_cache_path()
);
assert_eq!(
cfg.general.proxy_config_v6_cache_path,
default_proxy_config_v6_cache_path()
);
assert_eq!(
cfg.general.me_single_endpoint_shadow_writers,
default_me_single_endpoint_shadow_writers()
);
assert_eq!(
cfg.general.me_single_endpoint_outage_mode_enabled,
default_me_single_endpoint_outage_mode_enabled()
);
assert_eq!(
cfg.general.me_single_endpoint_outage_disable_quarantine,
default_me_single_endpoint_outage_disable_quarantine()
);
assert_eq!(
cfg.general.me_single_endpoint_outage_backoff_min_ms,
default_me_single_endpoint_outage_backoff_min_ms()
);
assert_eq!(
cfg.general.me_single_endpoint_outage_backoff_max_ms,
default_me_single_endpoint_outage_backoff_max_ms()
);
assert_eq!(
cfg.general.me_single_endpoint_shadow_rotate_every_secs,
default_me_single_endpoint_shadow_rotate_every_secs()
);
assert_eq!(cfg.general.me_floor_mode, MeFloorMode::default());
assert_eq!(
cfg.general.me_adaptive_floor_idle_secs,
default_me_adaptive_floor_idle_secs()
);
assert_eq!(
cfg.general.me_adaptive_floor_min_writers_single_endpoint,
default_me_adaptive_floor_min_writers_single_endpoint()
);
assert_eq!(
cfg.general.me_adaptive_floor_recover_grace_secs,
default_me_adaptive_floor_recover_grace_secs()
);
assert_eq!(
cfg.general.upstream_connect_retry_attempts,
default_upstream_connect_retry_attempts()
);
assert_eq!(
cfg.general.upstream_connect_retry_backoff_ms,
default_upstream_connect_retry_backoff_ms()
);
assert_eq!(
cfg.general.upstream_unhealthy_fail_threshold,
default_upstream_unhealthy_fail_threshold()
);
assert_eq!(
cfg.general.upstream_connect_failfast_hard_errors,
default_upstream_connect_failfast_hard_errors()
);
assert_eq!(
cfg.general.rpc_proxy_req_every,
default_rpc_proxy_req_every()
);
assert_eq!(cfg.general.beobachten_file, default_beobachten_file());
assert_eq!(cfg.general.update_every, default_update_every());
assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4());
assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt());
assert_eq!(cfg.server.client_mss_value(), Ok(None));
assert_eq!(
cfg.server.proxy_protocol_trusted_cidrs,
default_proxy_protocol_trusted_cidrs()
);
assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop);
assert_eq!(cfg.server.api.listen, default_api_listen());
assert_eq!(cfg.server.api.whitelist, default_api_whitelist());
assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop);
assert_eq!(
cfg.server.api.request_body_limit_bytes,
default_api_request_body_limit_bytes()
);
assert_eq!(
cfg.server.api.minimal_runtime_enabled,
default_api_minimal_runtime_enabled()
);
assert_eq!(
cfg.server.api.minimal_runtime_cache_ttl_ms,
default_api_minimal_runtime_cache_ttl_ms()
);
assert_eq!(
cfg.server.api.runtime_edge_enabled,
default_api_runtime_edge_enabled()
);
assert_eq!(
cfg.server.api.runtime_edge_cache_ttl_ms,
default_api_runtime_edge_cache_ttl_ms()
);
assert_eq!(
cfg.server.api.runtime_edge_top_n,
default_api_runtime_edge_top_n()
);
assert_eq!(
cfg.server.api.runtime_edge_events_capacity,
default_api_runtime_edge_events_capacity()
);
assert_eq!(
cfg.server.conntrack_control.inline_conntrack_control,
default_conntrack_control_enabled()
);
assert_eq!(cfg.server.conntrack_control.mode, ConntrackMode::default());
assert_eq!(
cfg.server.conntrack_control.backend,
ConntrackBackend::default()
);
assert_eq!(
cfg.server.conntrack_control.profile,
ConntrackPressureProfile::default()
);
assert_eq!(
cfg.server.conntrack_control.pressure_high_watermark_pct,
default_conntrack_pressure_high_watermark_pct()
);
assert_eq!(
cfg.server.conntrack_control.pressure_low_watermark_pct,
default_conntrack_pressure_low_watermark_pct()
);
assert_eq!(
cfg.server.conntrack_control.delete_budget_per_sec,
default_conntrack_delete_budget_per_sec()
);
assert_eq!(cfg.access.users, default_access_users());
assert_eq!(
cfg.access.user_max_tcp_conns_global_each,
default_user_max_tcp_conns_global_each()
);
assert_eq!(
cfg.access.user_max_unique_ips_mode,
UserMaxUniqueIpsMode::default()
);
assert_eq!(
cfg.access.user_max_unique_ips_window_secs,
default_user_max_unique_ips_window_secs()
);
}
#[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 cidr_rate_limits_accept_auto_templates_in_strict_config() {
let cfg = load_config_from_temp_toml(
r#"
[general]
config_strict = true
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[access.cidr_rate_limits]
"*/24" = { up_bps = 1024, down_bps = 0 }
"*4/30" = { up_bps = 0, down_bps = 2048 }
"*6/64" = { up_bps = 4096, down_bps = 0 }
"#,
);
assert!(
cfg.access
.cidr_rate_limits
.contains_key(&CidrRateLimitKey::AutoDual(24))
);
assert!(
cfg.access
.cidr_rate_limits
.contains_key(&CidrRateLimitKey::AutoV4(30))
);
assert!(
cfg.access
.cidr_rate_limits
.contains_key(&CidrRateLimitKey::AutoV6(64))
);
}
#[test]
fn cidr_rate_limits_reject_invalid_auto_template_prefix() {
let error = load_config_error_from_temp_toml(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[access.cidr_rate_limits]
"*4/33" = { up_bps = 1024, down_bps = 0 }
"#,
);
assert!(error.contains("prefix must be within 0..=32"));
}
#[test]
fn cidr_rate_limits_reject_duplicate_normalized_auto_templates() {
let error = load_config_error_from_temp_toml(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[access.cidr_rate_limits]
"*/32" = { up_bps = 1024, down_bps = 0 }
"*6/128" = { up_bps = 2048, down_bps = 0 }
"#,
);
assert!(error.contains("duplicates normalized auto-template *6/128"));
}
#[test]
fn file_logging_requires_path() {
let error = load_config_error_from_temp_toml(
r#"
[general.modes]
classic = false
secure = false
tls = true
[logging]
destination = "file"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#,
);
assert!(error.contains("logging.path must be set"));
}
#[test]
fn impl_defaults_are_sourced_from_default_helpers() {
let network = NetworkConfig::default();
assert_eq!(network.ipv6, default_network_ipv6());
assert_eq!(network.stun_use, default_true());
assert_eq!(network.stun_tcp_fallback, default_stun_tcp_fallback());
let general = GeneralConfig::default();
assert_eq!(
general.middle_proxy_warm_standby,
default_middle_proxy_warm_standby()
);
assert_eq!(
general.me_reconnect_max_concurrent_per_dc,
default_me_reconnect_max_concurrent_per_dc()
);
assert_eq!(
general.me_reconnect_fast_retry_count,
default_me_reconnect_fast_retry_count()
);
assert_eq!(
general.me_init_retry_attempts,
default_me_init_retry_attempts()
);
assert_eq!(general.me2dc_fallback, default_me2dc_fallback());
assert_eq!(general.me2dc_fast, default_me2dc_fast());
assert_eq!(
general.proxy_config_v4_cache_path,
default_proxy_config_v4_cache_path()
);
assert_eq!(
general.proxy_config_v6_cache_path,
default_proxy_config_v6_cache_path()
);
assert_eq!(
general.me_single_endpoint_shadow_writers,
default_me_single_endpoint_shadow_writers()
);
assert_eq!(
general.me_single_endpoint_outage_mode_enabled,
default_me_single_endpoint_outage_mode_enabled()
);
assert_eq!(
general.me_single_endpoint_outage_disable_quarantine,
default_me_single_endpoint_outage_disable_quarantine()
);
assert_eq!(
general.me_single_endpoint_outage_backoff_min_ms,
default_me_single_endpoint_outage_backoff_min_ms()
);
assert_eq!(
general.me_single_endpoint_outage_backoff_max_ms,
default_me_single_endpoint_outage_backoff_max_ms()
);
assert_eq!(
general.me_single_endpoint_shadow_rotate_every_secs,
default_me_single_endpoint_shadow_rotate_every_secs()
);
assert_eq!(general.me_floor_mode, MeFloorMode::default());
assert_eq!(
general.me_adaptive_floor_idle_secs,
default_me_adaptive_floor_idle_secs()
);
assert_eq!(
general.me_adaptive_floor_min_writers_single_endpoint,
default_me_adaptive_floor_min_writers_single_endpoint()
);
assert_eq!(
general.me_adaptive_floor_recover_grace_secs,
default_me_adaptive_floor_recover_grace_secs()
);
assert_eq!(
general.upstream_connect_retry_attempts,
default_upstream_connect_retry_attempts()
);
assert_eq!(
general.upstream_connect_retry_backoff_ms,
default_upstream_connect_retry_backoff_ms()
);
assert_eq!(
general.upstream_unhealthy_fail_threshold,
default_upstream_unhealthy_fail_threshold()
);
assert_eq!(
general.upstream_connect_failfast_hard_errors,
default_upstream_connect_failfast_hard_errors()
);
assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every());
assert_eq!(general.beobachten_file, default_beobachten_file());
assert_eq!(general.update_every, default_update_every());
let server = ServerConfig::default();
assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6()));
assert_eq!(
server.proxy_protocol_trusted_cidrs,
default_proxy_protocol_trusted_cidrs()
);
assert_eq!(
AntiCensorshipConfig::default().unknown_sni_action,
UnknownSniAction::Drop
);
assert_eq!(server.api.listen, default_api_listen());
assert_eq!(server.api.whitelist, default_api_whitelist());
assert_eq!(server.api.gray_action, ApiGrayAction::Drop);
assert_eq!(
server.api.request_body_limit_bytes,
default_api_request_body_limit_bytes()
);
assert_eq!(
server.api.minimal_runtime_enabled,
default_api_minimal_runtime_enabled()
);
assert_eq!(
server.api.minimal_runtime_cache_ttl_ms,
default_api_minimal_runtime_cache_ttl_ms()
);
assert_eq!(
server.api.runtime_edge_enabled,
default_api_runtime_edge_enabled()
);
assert_eq!(
server.api.runtime_edge_cache_ttl_ms,
default_api_runtime_edge_cache_ttl_ms()
);
assert_eq!(
server.api.runtime_edge_top_n,
default_api_runtime_edge_top_n()
);
assert_eq!(
server.api.runtime_edge_events_capacity,
default_api_runtime_edge_events_capacity()
);
assert_eq!(
server.conntrack_control.inline_conntrack_control,
default_conntrack_control_enabled()
);
assert_eq!(server.conntrack_control.mode, ConntrackMode::default());
assert_eq!(
server.conntrack_control.backend,
ConntrackBackend::default()
);
assert_eq!(
server.conntrack_control.profile,
ConntrackPressureProfile::default()
);
assert_eq!(
server.conntrack_control.pressure_high_watermark_pct,
default_conntrack_pressure_high_watermark_pct()
);
assert_eq!(
server.conntrack_control.pressure_low_watermark_pct,
default_conntrack_pressure_low_watermark_pct()
);
assert_eq!(
server.conntrack_control.delete_budget_per_sec,
default_conntrack_delete_budget_per_sec()
);
let access = AccessConfig::default();
assert_eq!(access.users, default_access_users());
assert_eq!(
access.user_max_tcp_conns_global_each,
default_user_max_tcp_conns_global_each()
);
}
@@ -0,0 +1,424 @@
use super::*;
#[test]
fn proxy_protocol_trusted_cidrs_missing_uses_trust_all_but_explicit_empty_stays_empty() {
let cfg_missing: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
"#,
)
.unwrap();
assert_eq!(
cfg_missing.server.proxy_protocol_trusted_cidrs,
default_proxy_protocol_trusted_cidrs()
);
let cfg_explicit_empty: ProxyConfig = toml::from_str(
r#"
[server]
proxy_protocol_trusted_cidrs = []
[general]
[network]
[access]
"#,
)
.unwrap();
assert!(
cfg_explicit_empty
.server
.proxy_protocol_trusted_cidrs
.is_empty()
);
}
#[test]
fn conntrack_inline_explicit_flag_is_false_when_omitted() {
let cfg = load_config_from_temp_toml(
r#"
[general]
[network]
[server]
[server.conntrack_control]
[access]
"#,
);
assert!(
!cfg.server
.conntrack_control
.inline_conntrack_control_explicit
);
}
#[test]
fn conntrack_inline_explicit_flag_is_true_when_present() {
let cfg = load_config_from_temp_toml(
r#"
[general]
[network]
[server]
[server.conntrack_control]
inline_conntrack_control = true
[access]
"#,
);
assert!(
cfg.server
.conntrack_control
.inline_conntrack_control_explicit
);
}
#[test]
fn unknown_sni_action_parses_and_defaults_to_drop() {
let cfg_default: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[censorship]
"#,
)
.unwrap();
assert_eq!(
cfg_default.censorship.unknown_sni_action,
UnknownSniAction::Drop
);
let cfg_mask: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[censorship]
unknown_sni_action = "mask"
"#,
)
.unwrap();
assert_eq!(
cfg_mask.censorship.unknown_sni_action,
UnknownSniAction::Mask
);
let cfg_accept: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[censorship]
unknown_sni_action = "accept"
"#,
)
.unwrap();
assert_eq!(
cfg_accept.censorship.unknown_sni_action,
UnknownSniAction::Accept
);
let cfg_reject: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[censorship]
unknown_sni_action = "reject_handshake"
"#,
)
.unwrap();
assert_eq!(
cfg_reject.censorship.unknown_sni_action,
UnknownSniAction::RejectHandshake
);
}
#[test]
fn exclusive_mask_parses_domain_target_map() {
let cfg = load_config_from_temp_toml(
r#"
[general]
[network]
[server]
[access]
[censorship]
tls_domain = "weißbiergärten.de"
tls_domains = ["bürgeramt.de"]
[censorship.exclusive_mask]
"bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443"
"ipv6.example" = "[::1]:443"
"#,
);
assert!(cfg.censorship.tls_domain.is_ascii());
assert!(cfg.censorship.tls_domain.contains("xn--"));
assert_eq!(cfg.censorship.tls_domains.len(), 1);
let normalized_extra = &cfg.censorship.tls_domains[0];
assert!(normalized_extra.is_ascii());
assert!(normalized_extra.contains("xn--"));
let normalized_target = cfg
.censorship
.exclusive_mask
.get(normalized_extra)
.expect("exclusive_mask key must match normalized tls_domains entry");
assert!(normalized_target.is_ascii());
assert!(normalized_target.contains("xn--"));
assert!(normalized_target.ends_with(":443"));
assert_eq!(
cfg.censorship.exclusive_mask.get("ipv6.example"),
Some(&"[::1]:443".to_string())
);
}
#[test]
fn api_gray_action_parses_and_defaults_to_drop() {
let cfg_default: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
"#,
)
.unwrap();
assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop);
let cfg_api: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[server.api]
gray_action = "api"
"#,
)
.unwrap();
assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api);
let cfg_200: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[server.api]
gray_action = "200"
"#,
)
.unwrap();
assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200);
let cfg_drop: ProxyConfig = toml::from_str(
r#"
[server]
[general]
[network]
[access]
[server.api]
gray_action = "drop"
"#,
)
.unwrap();
assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop);
}
#[test]
fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() {
let cfg = load_config_from_temp_toml(
r#"
beobachten = false
beobachten_minutes = 7
beobachten_flush_secs = 3
beobachten_file = "tmp/legacy-beob.txt"
[server]
[general]
[network]
[access]
"#,
);
assert!(!cfg.general.beobachten);
assert_eq!(cfg.general.beobachten_minutes, 7);
assert_eq!(cfg.general.beobachten_flush_secs, 3);
assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt");
}
#[test]
fn general_beobachten_keys_have_priority_over_legacy_top_level() {
let cfg = load_config_from_temp_toml(
r#"
beobachten = true
beobachten_minutes = 30
beobachten_flush_secs = 30
beobachten_file = "tmp/legacy-beob.txt"
[server]
[general]
beobachten = false
beobachten_minutes = 5
beobachten_flush_secs = 2
beobachten_file = "tmp/general-beob.txt"
[network]
[access]
"#,
);
assert!(!cfg.general.beobachten);
assert_eq!(cfg.general.beobachten_minutes, 5);
assert_eq!(cfg.general.beobachten_flush_secs, 2);
assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt");
}
#[test]
fn dc_overrides_allow_string_and_array() {
let toml = r#"
[dc_overrides]
"201" = "149.154.175.50:443"
"202" = ["149.154.167.51:443", "149.154.175.100:443"]
"#;
let cfg: ProxyConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.dc_overrides["201"], vec!["149.154.175.50:443"]);
assert_eq!(
cfg.dc_overrides["202"],
vec!["149.154.167.51:443", "149.154.175.100:443"]
);
}
#[test]
fn load_with_metadata_collects_include_files() {
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_metadata_{nonce}"));
std::fs::create_dir_all(&dir).unwrap();
let main_path = dir.join("config.toml");
let include_path = dir.join("included.toml");
std::fs::write(
&include_path,
r#"
[access.users]
user = "00000000000000000000000000000000"
"#,
)
.unwrap();
std::fs::write(
&main_path,
r#"
include = "included.toml"
[censorship]
tls_domain = "example.com"
"#,
)
.unwrap();
let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap();
let main_normalized = normalize_config_path(&main_path);
let include_normalized = normalize_config_path(&include_path);
assert!(loaded.source_files.contains(&main_normalized));
assert!(loaded.source_files.contains(&include_normalized));
let _ = std::fs::remove_file(main_path);
let _ = std::fs::remove_file(include_path);
let _ = std::fs::remove_dir(dir);
}
#[test]
fn dc_overrides_inject_dc203_default() {
let toml = r#"
[general]
use_middle_proxy = false
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_dc_override_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert!(
cfg.dc_overrides
.get("203")
.map(|v| v.contains(&"91.105.192.100:443".to_string()))
.unwrap_or(false)
);
let _ = std::fs::remove_file(path);
}
#[test]
fn update_every_overrides_legacy_fields() {
let toml = r#"
[general]
update_every = 123
proxy_secret_auto_reload_secs = 700
proxy_config_auto_reload_secs = 800
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_update_every_override_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.general.effective_update_every_secs(), 123);
let _ = std::fs::remove_file(path);
}
#[test]
fn update_every_fallback_to_legacy_min() {
let toml = r#"
[general]
proxy_secret_auto_reload_secs = 600
proxy_config_auto_reload_secs = 120
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_update_every_legacy_min_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.general.update_every, None);
assert_eq!(cfg.general.effective_update_every_secs(), 120);
let _ = std::fs::remove_file(path);
}
#[test]
fn update_every_zero_is_rejected() {
let toml = r#"
[general]
update_every = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_update_every_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.update_every must be > 0"));
let _ = std::fs::remove_file(path);
}
@@ -0,0 +1,336 @@
use super::*;
#[test]
fn me_route_backpressure_base_timeout_ms_out_of_range_is_rejected() {
let toml = r#"
[general]
me_route_backpressure_base_timeout_ms = 5001
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_route_backpressure_base_timeout_ms_out_of_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_route_backpressure_base_timeout_ms must be within [1, 5000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_route_backpressure_high_timeout_ms_out_of_range_is_rejected() {
let toml = r#"
[general]
me_route_backpressure_base_timeout_ms = 100
me_route_backpressure_high_timeout_ms = 5001
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_route_backpressure_high_timeout_ms_out_of_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_route_backpressure_high_timeout_ms must be within [1, 5000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_route_no_writer_wait_ms_out_of_range_is_rejected() {
let toml = r#"
[general]
me_route_no_writer_wait_ms = 5
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_route_no_writer_wait_ms_out_of_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_route_no_writer_wait_ms must be within [10, 5000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_route_blocking_send_timeout_ms_zero_is_rejected() {
let toml = r#"
[general]
me_route_blocking_send_timeout_ms = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_route_no_writer_mode_is_parsed() {
let toml = r#"
[general]
me_route_no_writer_mode = "inline_recovery_legacy"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_route_no_writer_mode_parse_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.general.me_route_no_writer_mode,
crate::config::MeRouteNoWriterMode::InlineRecoveryLegacy
);
let _ = std::fs::remove_file(path);
}
#[test]
fn proxy_config_cache_paths_empty_are_rejected() {
let toml = r#"
[general]
proxy_config_v4_cache_path = " "
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_proxy_config_v4_cache_path_empty_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.proxy_config_v4_cache_path cannot be empty"));
let _ = std::fs::remove_file(path);
let toml_v6 = r#"
[general]
proxy_config_v6_cache_path = ""
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let path_v6 = dir.join("telemt_proxy_config_v6_cache_path_empty_test.toml");
std::fs::write(&path_v6, toml_v6).unwrap();
let err_v6 = ProxyConfig::load(&path_v6).unwrap_err().to_string();
assert!(err_v6.contains("general.proxy_config_v6_cache_path cannot be empty"));
let _ = std::fs::remove_file(path_v6);
}
#[test]
fn me_hardswap_warmup_defaults_are_set() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_hardswap_warmup_defaults_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.general.me_hardswap_warmup_delay_min_ms,
default_me_hardswap_warmup_delay_min_ms()
);
assert_eq!(
cfg.general.me_hardswap_warmup_delay_max_ms,
default_me_hardswap_warmup_delay_max_ms()
);
assert_eq!(
cfg.general.me_hardswap_warmup_extra_passes,
default_me_hardswap_warmup_extra_passes()
);
assert_eq!(
cfg.general.me_hardswap_warmup_pass_backoff_base_ms,
default_me_hardswap_warmup_pass_backoff_base_ms()
);
let _ = std::fs::remove_file(path);
}
#[test]
fn me_hardswap_warmup_delay_range_is_validated() {
let toml = r#"
[general]
me_hardswap_warmup_delay_min_ms = 2001
me_hardswap_warmup_delay_max_ms = 2000
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_hardswap_warmup_delay_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains(
"general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms"
));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_hardswap_warmup_delay_max_zero_is_rejected() {
let toml = r#"
[general]
me_hardswap_warmup_delay_max_ms = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_hardswap_warmup_delay_max_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_hardswap_warmup_delay_max_ms must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_hardswap_warmup_extra_passes_out_of_range_is_rejected() {
let toml = r#"
[general]
me_hardswap_warmup_extra_passes = 11
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_hardswap_warmup_extra_passes_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_hardswap_warmup_extra_passes must be within [0, 10]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_hardswap_warmup_pass_backoff_zero_is_rejected() {
let toml = r#"
[general]
me_hardswap_warmup_pass_backoff_base_ms = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_hardswap_warmup_backoff_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_hardswap_warmup_pass_backoff_base_ms must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_config_stable_snapshots_zero_is_rejected() {
let toml = r#"
[general]
me_config_stable_snapshots = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_config_stable_snapshots_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_config_stable_snapshots must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn proxy_secret_stable_snapshots_zero_is_rejected() {
let toml = r#"
[general]
proxy_secret_stable_snapshots = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_proxy_secret_stable_snapshots_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.proxy_secret_stable_snapshots must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn proxy_secret_len_max_out_of_range_is_rejected() {
let toml = r#"
[general]
proxy_secret_len_max = 16
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_proxy_secret_len_max_out_of_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.proxy_secret_len_max must be within [32, 4096]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_pool_min_fresh_ratio_out_of_range_is_rejected() {
let toml = r#"
[general]
me_pool_min_fresh_ratio = 1.5
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_pool_min_ratio_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_pool_min_fresh_ratio must be within [0.0, 1.0]"));
let _ = std::fs::remove_file(path);
}
@@ -0,0 +1,304 @@
use super::*;
#[test]
fn stun_nat_probe_concurrency_zero_is_rejected() {
let toml = r#"
[general]
stun_nat_probe_concurrency = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_stun_nat_probe_concurrency_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.stun_nat_probe_concurrency must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_reinit_every_default_is_set() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_reinit_every_default_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.general.me_reinit_every_secs,
default_me_reinit_every_secs()
);
let _ = std::fs::remove_file(path);
}
#[test]
fn me_reinit_every_zero_is_rejected() {
let toml = r#"
[general]
me_reinit_every_secs = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_reinit_every_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_reinit_every_secs must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_single_endpoint_outage_backoff_range_is_validated() {
let toml = r#"
[general]
me_single_endpoint_outage_backoff_min_ms = 4000
me_single_endpoint_outage_backoff_max_ms = 3000
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_single_endpoint_outage_backoff_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains(
"general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms"
));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_single_endpoint_shadow_writers_too_large_is_rejected() {
let toml = r#"
[general]
me_single_endpoint_shadow_writers = 33
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_single_endpoint_shadow_writers_limit_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_single_endpoint_shadow_writers must be within [0, 32]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_adaptive_floor_min_writers_out_of_range_is_rejected() {
let toml = r#"
[general]
me_adaptive_floor_min_writers_single_endpoint = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_adaptive_floor_min_writers_out_of_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(
err.contains(
"general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]"
)
);
let _ = std::fs::remove_file(path);
}
#[test]
fn me_floor_mode_adaptive_is_parsed() {
let toml = r#"
[general]
me_floor_mode = "adaptive"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_floor_mode_adaptive_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.general.me_floor_mode, MeFloorMode::Adaptive);
let _ = std::fs::remove_file(path);
}
#[test]
fn me_adaptive_floor_max_active_writers_per_core_zero_is_rejected() {
let toml = r#"
[general]
me_adaptive_floor_max_active_writers_per_core = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_adaptive_floor_max_active_per_core_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_adaptive_floor_max_active_writers_per_core must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn me_adaptive_floor_max_warm_writers_global_zero_is_rejected() {
let toml = r#"
[general]
me_adaptive_floor_max_warm_writers_global = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_me_adaptive_floor_max_warm_global_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.me_adaptive_floor_max_warm_writers_global must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn upstream_connect_retry_attempts_zero_is_rejected() {
let toml = r#"
[general]
upstream_connect_retry_attempts = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_upstream_connect_retry_attempts_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.upstream_connect_retry_attempts must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn upstream_unhealthy_fail_threshold_zero_is_rejected() {
let toml = r#"
[general]
upstream_unhealthy_fail_threshold = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_upstream_unhealthy_fail_threshold_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.upstream_unhealthy_fail_threshold must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn tg_connect_zero_is_rejected() {
let toml = r#"
[general]
tg_connect = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tg_connect_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.tg_connect must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn rpc_proxy_req_every_out_of_range_is_rejected() {
let toml = r#"
[general]
rpc_proxy_req_every = 9
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_rpc_proxy_req_every_out_of_range_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("general.rpc_proxy_req_every must be 0 or within [10, 300]"));
let _ = std::fs::remove_file(path);
}
#[test]
fn rpc_proxy_req_every_zero_and_valid_range_are_accepted() {
let toml_zero = r#"
[general]
rpc_proxy_req_every = 0
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path_zero = dir.join("telemt_rpc_proxy_req_every_zero_ok_test.toml");
std::fs::write(&path_zero, toml_zero).unwrap();
let cfg_zero = ProxyConfig::load(&path_zero).unwrap();
assert_eq!(cfg_zero.general.rpc_proxy_req_every, 0);
let _ = std::fs::remove_file(path_zero);
let toml_valid = r#"
[general]
rpc_proxy_req_every = 40
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let path_valid = dir.join("telemt_rpc_proxy_req_every_valid_ok_test.toml");
std::fs::write(&path_valid, toml_valid).unwrap();
let cfg_valid = ProxyConfig::load(&path_valid).unwrap();
assert_eq!(cfg_valid.general.rpc_proxy_req_every, 40);
let _ = std::fs::remove_file(path_valid);
}
@@ -0,0 +1,367 @@
use super::*;
#[test]
fn synlimit_synfix_defaults_are_loaded_for_listener() {
let cfg = load_config_from_temp_toml(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
"#,
);
let listener = &cfg.server.listeners[0];
assert_eq!(listener.synlimit_seconds, 60);
assert_eq!(listener.synlimit_hitcount, 48);
assert_eq!(listener.synlimit_burst, 24);
assert_eq!(listener.synlimit_ios_seconds, 1);
assert_eq!(listener.synlimit_ios_hitcount, 12);
assert_eq!(listener.synlimit_ios_burst, 24);
assert_eq!(listener.synlimit_hashlimit_expire_ms, 60_000);
assert_eq!(listener.synlimit_hashlimit_size, 32_768);
}
#[cfg(target_os = "freebsd")]
#[test]
fn synlimit_pf_mode_is_loaded_for_listener() {
let cfg = load_config_from_temp_toml(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "pf"
"#,
);
assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf);
}
#[cfg(not(target_os = "freebsd"))]
#[test]
fn synlimit_pf_mode_is_rejected_off_freebsd() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "pf"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_synlimit_pf_unsupported_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("backend pf is unsupported on this platform"));
let _ = std::fs::remove_file(path);
}
#[test]
fn synlimit_synfix_zero_values_are_rejected() {
for (field, expected) in [
(
"synlimit_ios_seconds",
"server.listeners[0].synlimit_ios_seconds must be > 0",
),
(
"synlimit_ios_hitcount",
"server.listeners[0].synlimit_ios_hitcount must be > 0",
),
(
"synlimit_ios_burst",
"server.listeners[0].synlimit_ios_burst must be > 0",
),
(
"synlimit_hashlimit_expire_ms",
"server.listeners[0].synlimit_hashlimit_expire_ms must be > 0",
),
(
"synlimit_hashlimit_size",
"server.listeners[0].synlimit_hashlimit_size must be > 0",
),
] {
let toml = format!(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "iptables"
{field} = 0
"#
);
let error = load_config_error_from_temp_toml(&toml);
assert!(error.contains(expected), "{field}: {error}");
}
}
#[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);
}
#[cfg(target_os = "linux")]
#[test]
fn client_mss_custom_value_is_accepted() {
let toml = r#"
[server]
client_mss = "92"
client_mss_bulk = "1400"
[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(92)));
assert_eq!(cfg.server.client_mss_bulk_value(), Ok(Some(1400)));
let _ = std::fs::remove_file(path);
}
#[cfg(target_os = "linux")]
#[test]
fn client_mss_bulk_requires_a_larger_bulk_profile_and_handshake_participant() {
for (name, server, expected) in [
(
"without_handshake",
"client_mss_bulk = \"1400\"",
"requires an effective client_mss",
),
(
"equal",
"client_mss = \"1400\"\nclient_mss_bulk = \"1400\"",
"must be greater than the effective handshake MSS",
),
(
"inverted",
"client_mss = \"1500\"\nclient_mss_bulk = \"1400\"",
"must be greater than the effective handshake MSS",
),
] {
let toml = format!(
"[server]\n{server}\n\n[censorship]\ntls_domain = \"example.com\"\n\n[access.users]\nuser = \"00000000000000000000000000000000\"\n"
);
let dir = std::env::temp_dir();
let path = dir.join(format!("telemt_client_mss_bulk_{name}_test.toml"));
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains(expected), "unexpected error: {err}");
let _ = std::fs::remove_file(path);
}
}
#[cfg(target_os = "linux")]
#[test]
fn client_mss_bulk_allows_explicit_listener_opt_out() {
let toml = r#"
[server]
client_mss = "92"
client_mss_bulk = "1400"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
[[server.listeners]]
ip = "::"
port = 443
client_mss = ""
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_bulk_listener_opt_out_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
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(None)
);
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_bulk_out_of_range_is_rejected() {
for value in ["87", "4097"] {
let toml = format!(
r#"
[server]
client_mss_bulk = "{value}"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#
);
let dir = std::env::temp_dir();
let path = dir.join(format!(
"telemt_client_mss_bulk_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_bulk 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);
}
@@ -0,0 +1,185 @@
use super::*;
#[test]
fn tls_fetch_scope_default_is_empty() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_scope_default_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert!(cfg.censorship.tls_fetch_scope.is_empty());
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_scope_is_trimmed_during_load() {
let toml = r#"
[censorship]
tls_domain = "example.com"
tls_fetch_scope = " me "
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_scope_trim_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.censorship.tls_fetch_scope, "me");
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_scope_whitespace_becomes_empty() {
let toml = r#"
[censorship]
tls_domain = "example.com"
tls_fetch_scope = " "
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_scope_blank_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert!(cfg.censorship.tls_fetch_scope.is_empty());
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_defaults_are_applied() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_defaults_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.censorship.tls_fetch.profiles,
TlsFetchConfig::default().profiles
);
assert!(cfg.censorship.tls_fetch.strict_route);
assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000);
assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000);
assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600);
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_profiles_are_deduplicated_preserving_order() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[censorship.tls_fetch]
profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"]
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.censorship.tls_fetch.profiles,
vec![
TlsFetchProfile::CompatTls12,
TlsFetchProfile::ModernChromeLike,
TlsFetchProfile::LegacyMinimal
]
);
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_attempt_timeout_zero_is_rejected() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[censorship.tls_fetch]
attempt_timeout_ms = 0
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_total_budget_zero_is_rejected() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[censorship.tls_fetch]
total_budget_ms = 0
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_ad_tag_is_disabled_during_load() {
let toml = r#"
[general]
ad_tag = "not_hex"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_invalid_ad_tag_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert!(cfg.general.ad_tag.is_none());
let _ = std::fs::remove_file(path);
}
#[test]
fn valid_ad_tag_is_preserved_during_load() {
let toml = r#"
[general]
ad_tag = "00112233445566778899aabbccddeeff"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_valid_ad_tag_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.general.ad_tag.as_deref(),
Some("00112233445566778899aabbccddeeff")
);
let _ = std::fs::remove_file(path);
}
@@ -0,0 +1,180 @@
use super::*;
#[test]
fn shadowsocks_upstream_url_loads_successfully() {
let toml = format!(
r#"
[general]
use_middle_proxy = false
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[upstreams]]
type = "shadowsocks"
url = "{url}"
interface = "127.0.0.2"
"#,
url = TEST_SHADOWSOCKS_URL,
);
let dir = std::env::temp_dir();
let path = dir.join("telemt_shadowsocks_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert!(matches!(
&cfg.upstreams[0].upstream_type,
UpstreamType::Shadowsocks { url, interface }
if url == TEST_SHADOWSOCKS_URL && interface.as_deref() == Some("127.0.0.2")
));
let _ = std::fs::remove_file(path);
}
#[test]
fn shadowsocks_requires_direct_mode() {
let toml = format!(
r#"
[general]
use_middle_proxy = true
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[upstreams]]
type = "shadowsocks"
url = "{url}"
"#,
url = TEST_SHADOWSOCKS_URL,
);
let dir = std::env::temp_dir();
let path = dir.join("telemt_shadowsocks_me_reject_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("shadowsocks upstreams require general.use_middle_proxy = false"));
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_shadowsocks_url_is_rejected() {
let toml = r#"
[general]
use_middle_proxy = false
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[upstreams]]
type = "shadowsocks"
url = "not-a-valid-ss-url"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_shadowsocks_invalid_url_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("invalid shadowsocks url"));
let _ = std::fs::remove_file(path);
}
#[test]
fn shadowsocks_plugins_are_rejected() {
let toml = format!(
r#"
[general]
use_middle_proxy = false
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[upstreams]]
type = "shadowsocks"
url = "{url}?plugin=obfs-local%3Bobfs%3Dhttp"
"#,
url = TEST_SHADOWSOCKS_URL,
);
let dir = std::env::temp_dir();
let path = dir.join("telemt_shadowsocks_plugin_reject_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("shadowsocks plugins are not supported"));
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_user_ad_tag_reports_access_user_ad_tags_key() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
alice = "00000000000000000000000000000000"
[access.user_ad_tags]
alice = "not_hex"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_invalid_user_ad_tag_message_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("access.user_ad_tags['alice'] must be exactly 32 hex characters"));
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_dns_override_is_rejected() {
let toml = r#"
[network]
dns_overrides = ["example.com:443:2001:db8::10"]
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_invalid_dns_override_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("must be bracketed"));
let _ = std::fs::remove_file(path);
}
#[test]
fn valid_dns_override_is_accepted() {
let toml = r#"
[network]
dns_overrides = ["example.com:443:127.0.0.1", "example.net:443:[2001:db8::10]"]
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_valid_dns_override_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.network.dns_overrides.len(), 2);
let _ = std::fs::remove_file(path);
}
@@ -1,14 +1,21 @@
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_CONFIG_COUNTER: AtomicU64 = AtomicU64::new(0);
fn write_temp_config(contents: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time must be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("telemt-load-mask-shape-security-{nonce}.toml"));
let seq = TEMP_CONFIG_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!(
"telemt-load-mask-shape-security-{pid}-{seq}-{nonce}.toml"
));
fs::write(&path, contents).expect("temp config write must succeed");
path
}
@@ -95,22 +95,162 @@ max_client_frame = 16777217
remove_temp_config(&path);
}
#[test]
fn load_rejects_writer_byte_budget_below_frame_residency_minimum() {
let path = write_temp_config(
r#"
[general]
max_client_frame = 16777216
me_writer_byte_budget_bytes = 33554432
"#,
);
let err = ProxyConfig::load(&path)
.expect_err("writer byte budget below frame residency minimum must fail");
let msg = err.to_string();
assert!(
msg.contains("general.me_writer_byte_budget_bytes must be within [33570816, 268435456]"),
"error must explain writer byte budget minimum, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_unaligned_writer_byte_budget() {
let path = write_temp_config(
r#"
[general]
me_writer_byte_budget_bytes = 33570817
"#,
);
let err = ProxyConfig::load(&path)
.expect_err("writer byte budget outside permit granularity must fail");
let msg = err.to_string();
assert!(
msg.contains("general.me_writer_byte_budget_bytes must be a multiple of 16384"),
"error must explain writer byte budget alignment, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_writer_byte_budget_above_hard_cap() {
let path = write_temp_config(
r#"
[general]
me_writer_byte_budget_bytes = 268451840
"#,
);
let err = ProxyConfig::load(&path).expect_err("writer byte budget above hard cap must fail");
let msg = err.to_string();
assert!(
msg.contains("general.me_writer_byte_budget_bytes must be within [33570816, 268435456]"),
"error must explain writer byte budget hard cap, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_unaligned_direct_relay_buffer_budget() {
let path = write_temp_config(
r#"
[general]
direct_relay_buffer_budget_max_bytes = 16777217
"#,
);
let err = ProxyConfig::load(&path).expect_err("unaligned direct relay buffer budget must fail");
assert!(
err.to_string().contains(
"general.direct_relay_buffer_budget_max_bytes must be 0 or a multiple of 4096"
)
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_direct_relay_buffer_budget_above_hard_cap() {
let path = write_temp_config(
r#"
[general]
direct_relay_buffer_budget_max_bytes = 2147487744
"#,
);
let err =
ProxyConfig::load(&path).expect_err("direct relay buffer budget above hard cap must fail");
assert!(err.to_string().contains(
"general.direct_relay_buffer_budget_max_bytes must be 0 or within [16777216, 2147483648]"
));
remove_temp_config(&path);
}
#[test]
fn load_rejects_listen_backlog_above_i32_upper_bound() {
let path = write_temp_config(
r#"
[server]
listen_backlog = 2147483648
"#,
);
let err = ProxyConfig::load(&path).expect_err("listen_backlog above socket cap must fail");
let msg = err.to_string();
assert!(
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
"error must explain listen_backlog hard cap, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_rejects_zero_listen_backlog() {
let path = write_temp_config(
r#"
[server]
listen_backlog = 0
"#,
);
let err = ProxyConfig::load(&path).expect_err("zero listen_backlog must fail");
let msg = err.to_string();
assert!(
msg.contains("server.listen_backlog must be within [1, 2147483647]"),
"error must explain listen_backlog lower bound, got: {msg}"
);
remove_temp_config(&path);
}
#[test]
fn load_accepts_memory_limits_at_hard_upper_bounds() {
let path = write_temp_config(
r#"
[general]
me_writer_cmd_channel_capacity = 16384
me_writer_byte_budget_bytes = 268435456
me_route_channel_capacity = 8192
me_c2me_channel_capacity = 8192
direct_relay_buffer_budget_max_bytes = 2147483648
max_client_frame = 16777216
"#,
);
let cfg = ProxyConfig::load(&path).expect("hard upper bound values must be accepted");
assert_eq!(cfg.general.me_writer_cmd_channel_capacity, 16384);
assert_eq!(cfg.general.me_writer_byte_budget_bytes, 256 * 1024 * 1024);
assert_eq!(cfg.general.me_route_channel_capacity, 8192);
assert_eq!(cfg.general.me_c2me_channel_capacity, 8192);
assert_eq!(
cfg.general.direct_relay_buffer_budget_max_bytes,
2 * 1024 * 1024 * 1024
);
assert_eq!(cfg.general.max_client_frame, 16 * 1024 * 1024);
remove_temp_config(&path);
+37 -2168
View File
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
use super::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AccessConfig {
#[serde(default = "default_access_users")]
pub users: HashMap<String, String>,
#[serde(default)]
pub user_enabled: HashMap<String, bool>,
/// Per-user ad_tag (32 hex chars from @MTProxybot).
#[serde(default)]
pub user_ad_tags: HashMap<String, String>,
#[serde(default)]
pub user_max_tcp_conns: HashMap<String, usize>,
/// Global per-user TCP connection limit applied when a user has no
/// positive individual override.
/// `0` disables the inherited limit.
#[serde(default = "default_user_max_tcp_conns_global_each")]
pub user_max_tcp_conns_global_each: usize,
#[serde(default)]
pub user_expirations: HashMap<String, DateTime<Utc>>,
#[serde(default)]
pub user_data_quota: HashMap<String, u64>,
/// Per-user transport rate limits in bits-per-second.
///
/// Each entry supports independent upload (`up_bps`) and download
/// (`down_bps`) ceilings. A value of `0` in one direction means
/// "unlimited" for that direction. Limits are amortized: a relay quantum
/// may pass as a bounded burst, and the limiter applies the resulting wait
/// before later traffic in the same direction proceeds.
#[serde(default)]
pub user_rate_limits: HashMap<String, RateLimitBps>,
/// Per-CIDR aggregate transport rate limits in bits-per-second.
///
/// Explicit CIDR keys use longest-prefix-wins semantics. Auto-template
/// keys (`*4/N`, `*6/N`, `*/N`) lazily create per-source-subnet buckets
/// after explicit CIDR matching misses. A value of `0` in one direction
/// means "unlimited" for that direction. Limits are amortized with the
/// same bounded-burst contract as per-user rate limits.
#[serde(default)]
pub cidr_rate_limits: HashMap<CidrRateLimitKey, RateLimitBps>,
/// Per-username client source IP/CIDR deny list. Checked after successful
/// authentication; matching IPs get the same rejection path as invalid auth
/// (handshake fails closed for that connection).
#[serde(default)]
pub user_source_deny: HashMap<String, Vec<IpNetwork>>,
#[serde(default)]
pub user_max_unique_ips: HashMap<String, usize>,
/// Global per-user unique IP limit applied when a user has no individual override.
/// `0` disables the inherited limit.
#[serde(default = "default_user_max_unique_ips_global_each")]
pub user_max_unique_ips_global_each: usize,
#[serde(default)]
pub user_max_unique_ips_mode: UserMaxUniqueIpsMode,
#[serde(default = "default_user_max_unique_ips_window_secs")]
pub user_max_unique_ips_window_secs: u64,
#[serde(default = "default_replay_check_len")]
pub replay_check_len: usize,
#[serde(default = "default_replay_window_secs")]
pub replay_window_secs: u64,
#[serde(default)]
pub ignore_time_skew: bool,
}
impl Default for AccessConfig {
fn default() -> Self {
Self {
users: default_access_users(),
user_enabled: HashMap::new(),
user_ad_tags: HashMap::new(),
user_max_tcp_conns: HashMap::new(),
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
user_expirations: HashMap::new(),
user_data_quota: HashMap::new(),
user_rate_limits: HashMap::new(),
cidr_rate_limits: HashMap::new(),
user_source_deny: HashMap::new(),
user_max_unique_ips: HashMap::new(),
user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(),
user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(),
user_max_unique_ips_window_secs: default_user_max_unique_ips_window_secs(),
replay_check_len: default_replay_check_len(),
replay_window_secs: default_replay_window_secs(),
ignore_time_skew: false,
}
}
}
impl AccessConfig {
pub fn is_user_enabled(&self, username: &str) -> bool {
self.user_enabled.get(username).copied().unwrap_or(true)
}
/// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`.
pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool {
self.user_source_deny
.get(username)
.is_some_and(|nets| nets.iter().any(|n| n.contains(ip)))
}
}
/// Key used by `access.cidr_rate_limits`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CidrRateLimitKey {
/// Explicit source CIDR rule.
Network(IpNetwork),
/// IPv4 auto-template that creates one bucket for each matching `/N`.
AutoV4(u8),
/// IPv6 auto-template that creates one bucket for each matching `/N`.
AutoV6(u8),
/// Dual-stack auto-template; IPv4 uses `/N`, IPv6 uses `/(N * 4)`.
AutoDual(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum CidrAutoTemplateFamily {
V4,
V6,
}
impl CidrAutoTemplateFamily {
pub(crate) fn marker(self) -> &'static str {
match self {
Self::V4 => "*4",
Self::V6 => "*6",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct CidrAutoTemplate {
pub(crate) family: CidrAutoTemplateFamily,
pub(crate) prefix_len: u8,
}
impl fmt::Display for CidrAutoTemplate {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}/{}", self.family.marker(), self.prefix_len)
}
}
impl CidrRateLimitKey {
pub(crate) fn auto_templates(&self) -> [Option<CidrAutoTemplate>; 2] {
match *self {
Self::Network(_) => [None, None],
Self::AutoV4(prefix_len) => [
Some(CidrAutoTemplate {
family: CidrAutoTemplateFamily::V4,
prefix_len,
}),
None,
],
Self::AutoV6(prefix_len) => [
Some(CidrAutoTemplate {
family: CidrAutoTemplateFamily::V6,
prefix_len,
}),
None,
],
Self::AutoDual(prefix_len) => [
Some(CidrAutoTemplate {
family: CidrAutoTemplateFamily::V4,
prefix_len,
}),
Some(CidrAutoTemplate {
family: CidrAutoTemplateFamily::V6,
prefix_len: prefix_len.saturating_mul(4),
}),
],
}
}
}
impl fmt::Display for CidrRateLimitKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(cidr) => write!(formatter, "{cidr}"),
Self::AutoV4(prefix_len) => write!(formatter, "*4/{prefix_len}"),
Self::AutoV6(prefix_len) => write!(formatter, "*6/{prefix_len}"),
Self::AutoDual(prefix_len) => write!(formatter, "*/{prefix_len}"),
}
}
}
impl Serialize for CidrRateLimitKey {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for CidrRateLimitKey {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
parse_cidr_rate_limit_key(&value).map_err(serde::de::Error::custom)
}
}
fn parse_cidr_rate_limit_key(value: &str) -> std::result::Result<CidrRateLimitKey, String> {
if let Some(prefix) = value.strip_prefix("*4/") {
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoV4);
}
if let Some(prefix) = value.strip_prefix("*6/") {
return parse_cidr_auto_prefix(value, prefix, 128).map(CidrRateLimitKey::AutoV6);
}
if let Some(prefix) = value.strip_prefix("*/") {
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoDual);
}
if value.starts_with('*') {
return Err(format!(
"invalid CIDR rate limit key {value:?}; expected CIDR, *4/N, *6/N, or */N"
));
}
value
.parse::<IpNetwork>()
.map(CidrRateLimitKey::Network)
.map_err(|error| {
format!(
"invalid CIDR rate limit key {value:?}: {error}; expected CIDR, *4/N, *6/N, or */N"
)
})
}
fn parse_cidr_auto_prefix(
key: &str,
prefix: &str,
max_prefix: u8,
) -> std::result::Result<u8, String> {
let prefix = prefix.parse::<u8>().map_err(|_| {
format!("invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}")
})?;
if prefix > max_prefix {
return Err(format!(
"invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}"
));
}
Ok(prefix)
}
/// Transport rate limit in bits-per-second.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RateLimitBps {
/// Upload direction limit in bits-per-second; `0` means unlimited.
#[serde(default)]
pub up_bps: u64,
/// Download direction limit in bits-per-second; `0` means unlimited.
#[serde(default)]
pub down_bps: u64,
}
+94
View File
@@ -0,0 +1,94 @@
use super::*;
/// Control-plane API settings.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiConfig {
/// Enable or disable REST API.
#[serde(default = "default_true")]
pub enabled: bool,
/// Listen address for API in `IP:PORT` format.
#[serde(default = "default_api_listen")]
pub listen: String,
/// CIDR whitelist allowed to access API.
#[serde(default = "default_api_whitelist")]
pub whitelist: Vec<IpNetwork>,
/// Behavior for requests from source IPs outside `whitelist`.
/// - `api`: return structured API forbidden response.
/// - `200`: return `200 OK` with an empty body.
/// - `drop`: close the connection without HTTP response.
#[serde(default)]
pub gray_action: ApiGrayAction,
/// Optional static value for `Authorization` header validation.
/// Empty string disables header auth.
#[serde(default)]
pub auth_header: String,
/// Maximum accepted HTTP request body size in bytes.
#[serde(default = "default_api_request_body_limit_bytes")]
pub request_body_limit_bytes: usize,
/// Enable runtime snapshots that require read-lock aggregation on API request path.
#[serde(default = "default_api_minimal_runtime_enabled")]
pub minimal_runtime_enabled: bool,
/// Cache TTL for minimal runtime snapshots in milliseconds (0 disables caching).
#[serde(default = "default_api_minimal_runtime_cache_ttl_ms")]
pub minimal_runtime_cache_ttl_ms: u64,
/// Enables runtime edge endpoints with optional cached aggregation.
#[serde(default = "default_api_runtime_edge_enabled")]
pub runtime_edge_enabled: bool,
/// Cache TTL for runtime edge aggregation payloads in milliseconds.
#[serde(default = "default_api_runtime_edge_cache_ttl_ms")]
pub runtime_edge_cache_ttl_ms: u64,
/// Top-N limit for edge connection leaderboard payloads.
#[serde(default = "default_api_runtime_edge_top_n")]
pub runtime_edge_top_n: usize,
/// Ring-buffer capacity for runtime edge control-plane events.
#[serde(default = "default_api_runtime_edge_events_capacity")]
pub runtime_edge_events_capacity: usize,
/// Read-only mode: mutating endpoints are rejected.
#[serde(default)]
pub read_only: bool,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
enabled: default_true(),
listen: default_api_listen(),
whitelist: default_api_whitelist(),
gray_action: ApiGrayAction::default(),
auth_header: String::new(),
request_body_limit_bytes: default_api_request_body_limit_bytes(),
minimal_runtime_enabled: default_api_minimal_runtime_enabled(),
minimal_runtime_cache_ttl_ms: default_api_minimal_runtime_cache_ttl_ms(),
runtime_edge_enabled: default_api_runtime_edge_enabled(),
runtime_edge_cache_ttl_ms: default_api_runtime_edge_cache_ttl_ms(),
runtime_edge_top_n: default_api_runtime_edge_top_n(),
runtime_edge_events_capacity: default_api_runtime_edge_events_capacity(),
read_only: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ApiGrayAction {
/// Preserve current API behavior for denied source IPs.
Api,
/// Mimic a plain web endpoint by returning `200 OK` with an empty body.
#[serde(rename = "200")]
Ok200,
/// Drop connection without HTTP response for denied source IPs.
#[default]
Drop,
}
+298
View File
@@ -0,0 +1,298 @@
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UnknownSniAction {
#[default]
Drop,
Mask,
Accept,
/// Reject the TLS handshake by sending a fatal `unrecognized_name` alert
/// (RFC 6066, AlertDescription = 112) before closing the connection.
/// Mimics nginx `ssl_reject_handshake on;` behavior on the default vhost —
/// the wire response indistinguishable from a stock modern web server
/// that simply does not host the requested name.
#[serde(rename = "reject_handshake")]
RejectHandshake,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TlsFetchProfile {
ModernChromeLike,
ModernFirefoxLike,
CompatTls12,
LegacyMinimal,
}
impl TlsFetchProfile {
pub fn as_str(self) -> &'static str {
match self {
TlsFetchProfile::ModernChromeLike => "modern_chrome_like",
TlsFetchProfile::ModernFirefoxLike => "modern_firefox_like",
TlsFetchProfile::CompatTls12 => "compat_tls12",
TlsFetchProfile::LegacyMinimal => "legacy_minimal",
}
}
}
fn default_tls_fetch_profiles() -> Vec<TlsFetchProfile> {
vec![
TlsFetchProfile::ModernChromeLike,
TlsFetchProfile::ModernFirefoxLike,
TlsFetchProfile::CompatTls12,
TlsFetchProfile::LegacyMinimal,
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsFetchConfig {
/// Ordered list of ClientHello profiles used for adaptive fallback.
#[serde(default = "default_tls_fetch_profiles")]
pub profiles: Vec<TlsFetchProfile>,
/// When true and upstream route is configured, TLS fetch fails closed on
/// upstream connect errors and does not fallback to direct TCP.
#[serde(default = "default_tls_fetch_strict_route")]
pub strict_route: bool,
/// Timeout per one profile attempt in milliseconds.
#[serde(default = "default_tls_fetch_attempt_timeout_ms")]
pub attempt_timeout_ms: u64,
/// Total wall-clock budget in milliseconds across all profile attempts.
#[serde(default = "default_tls_fetch_total_budget_ms")]
pub total_budget_ms: u64,
/// Adds GREASE-style values into selected ClientHello extensions.
#[serde(default)]
pub grease_enabled: bool,
/// Produces deterministic ClientHello randomness for debugging/tests.
#[serde(default)]
pub deterministic: bool,
/// TTL for winner-profile cache entries in seconds.
/// Set to 0 to disable profile cache.
#[serde(default = "default_tls_fetch_profile_cache_ttl_secs")]
pub profile_cache_ttl_secs: u64,
}
impl Default for TlsFetchConfig {
fn default() -> Self {
Self {
profiles: default_tls_fetch_profiles(),
strict_route: default_tls_fetch_strict_route(),
attempt_timeout_ms: default_tls_fetch_attempt_timeout_ms(),
total_budget_ms: default_tls_fetch_total_budget_ms(),
grease_enabled: false,
deterministic: false,
profile_cache_ttl_secs: default_tls_fetch_profile_cache_ttl_secs(),
}
}
}
#[derive(Debug, Clone)]
pub struct ExclusiveMaskTarget {
/// Target host after IDNA/IP normalization.
pub host: String,
/// TCP port for the selected target.
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AntiCensorshipConfig {
#[serde(default = "default_tls_domain")]
pub tls_domain: String,
/// Additional TLS domains for generating multiple proxy links.
#[serde(default)]
pub tls_domains: Vec<String>,
/// Policy for TLS ClientHello with unknown (non-configured) SNI.
#[serde(default)]
pub unknown_sni_action: UnknownSniAction,
/// Upstream scope used for TLS front metadata fetches.
/// Empty value keeps default upstream routing behavior.
#[serde(default = "default_tls_fetch_scope")]
pub tls_fetch_scope: String,
/// Fetch strategy for TLS front metadata bootstrap and periodic refresh.
#[serde(default)]
pub tls_fetch: TlsFetchConfig,
#[serde(default = "default_true")]
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)]
pub mask_host: Option<String>,
#[serde(default = "default_mask_port")]
pub mask_port: u16,
/// Per-SNI TCP mask targets. Keys are SNI domains, values are `host:port`.
#[serde(default)]
pub exclusive_mask: HashMap<String, String>,
/// Parsed runtime cache for per-SNI TCP mask targets.
#[serde(skip)]
pub exclusive_mask_targets: HashMap<String, ExclusiveMaskTarget>,
#[serde(default)]
pub mask_unix_sock: Option<String>,
#[serde(default = "default_fake_cert_len")]
pub fake_cert_len: usize,
/// Enable TLS certificate emulation using cached real certificates.
#[serde(default = "default_true")]
pub tls_emulation: bool,
/// Directory to store TLS front cache (on disk).
#[serde(default = "default_tls_front_dir")]
pub tls_front_dir: String,
/// Minimum server_hello delay in milliseconds (anti-fingerprint).
#[serde(default = "default_server_hello_delay_min_ms")]
pub server_hello_delay_min_ms: u64,
/// Maximum server_hello delay in milliseconds.
#[serde(default = "default_server_hello_delay_max_ms")]
pub server_hello_delay_max_ms: u64,
/// Number of NewSessionTicket messages to emit post-handshake.
#[serde(default = "default_tls_new_session_tickets")]
pub tls_new_session_tickets: u8,
/// Enable compact ServerHello payload mode.
/// When false, FakeTLS always uses full ServerHello payload behavior.
/// When true, compact certificate payload mode can be used by TTL policy.
#[serde(default = "default_serverhello_compact")]
pub serverhello_compact: bool,
/// TTL in seconds for sending full certificate payload per client IP.
/// First client connection per (SNI domain, client IP) gets full cert payload.
/// Subsequent handshakes within TTL use compact cert metadata payload.
/// Applied only when `serverhello_compact` is enabled.
#[serde(default = "default_tls_full_cert_ttl_secs")]
pub tls_full_cert_ttl_secs: u64,
/// Enforce ALPN echo of client preference.
#[serde(default = "default_alpn_enforce")]
pub alpn_enforce: bool,
/// Send PROXY protocol header when connecting to mask_host.
/// 0 = disabled, 1 = v1 (text), 2 = v2 (binary).
/// Allows the backend to see the real client IP.
#[serde(default)]
pub mask_proxy_protocol: u8,
/// Enable shape-channel hardening on mask backend path by padding
/// client->mask stream tail to configured buckets on stream end.
#[serde(default = "default_mask_shape_hardening")]
pub mask_shape_hardening: bool,
/// Opt-in aggressive shape hardening mode.
/// When enabled, masking may shape some backend-silent timeout paths and
/// enforces strictly positive above-cap blur when blur is enabled.
#[serde(default = "default_mask_shape_hardening_aggressive_mode")]
pub mask_shape_hardening_aggressive_mode: bool,
/// Minimum bucket size for mask shape hardening padding.
#[serde(default = "default_mask_shape_bucket_floor_bytes")]
pub mask_shape_bucket_floor_bytes: usize,
/// Maximum bucket size for mask shape hardening padding.
#[serde(default = "default_mask_shape_bucket_cap_bytes")]
pub mask_shape_bucket_cap_bytes: usize,
/// Add bounded random tail bytes even when total bytes already exceed
/// mask_shape_bucket_cap_bytes.
#[serde(default = "default_mask_shape_above_cap_blur")]
pub mask_shape_above_cap_blur: bool,
/// Maximum random bytes appended above cap when above-cap blur is enabled.
#[serde(default = "default_mask_shape_above_cap_blur_max_bytes")]
pub mask_shape_above_cap_blur_max_bytes: usize,
/// Maximum bytes relayed per direction on unauthenticated masking fallback paths.
/// Set to 0 to disable byte cap (unlimited within relay/idle timeouts).
#[serde(default = "default_mask_relay_max_bytes")]
pub mask_relay_max_bytes: usize,
/// Wall-clock cap for the full masking relay on non-MTProto fallback paths.
/// Raise when the mask target is a long-lived service (e.g. WebSocket).
/// Default: 60 000 ms (60 s).
#[serde(default = "default_mask_relay_timeout_ms")]
pub mask_relay_timeout_ms: u64,
/// Per-read idle timeout on masking relay and drain paths.
/// Limits resource consumption by slow-loris attacks and port scanners.
/// A read call stalling beyond this is treated as an abandoned connection.
/// Default: 5 000 ms (5 s).
#[serde(default = "default_mask_relay_idle_timeout_ms")]
pub mask_relay_idle_timeout_ms: u64,
/// Prefetch timeout (ms) for extending fragmented masking classifier window.
#[serde(default = "default_mask_classifier_prefetch_timeout_ms")]
pub mask_classifier_prefetch_timeout_ms: u64,
/// Enable outcome-time normalization envelope for masking fallback.
#[serde(default = "default_mask_timing_normalization_enabled")]
pub mask_timing_normalization_enabled: bool,
/// Lower bound (ms) for masking outcome timing envelope.
#[serde(default = "default_mask_timing_normalization_floor_ms")]
pub mask_timing_normalization_floor_ms: u64,
/// Upper bound (ms) for masking outcome timing envelope.
#[serde(default = "default_mask_timing_normalization_ceiling_ms")]
pub mask_timing_normalization_ceiling_ms: u64,
}
impl Default for AntiCensorshipConfig {
fn default() -> Self {
Self {
tls_domain: default_tls_domain(),
tls_domains: Vec::new(),
unknown_sni_action: UnknownSniAction::Drop,
tls_fetch_scope: default_tls_fetch_scope(),
tls_fetch: TlsFetchConfig::default(),
mask: default_true(),
mask_dynamic: default_true(),
mask_host: None,
mask_port: default_mask_port(),
exclusive_mask: HashMap::new(),
exclusive_mask_targets: HashMap::new(),
mask_unix_sock: None,
fake_cert_len: default_fake_cert_len(),
tls_emulation: true,
tls_front_dir: default_tls_front_dir(),
server_hello_delay_min_ms: default_server_hello_delay_min_ms(),
server_hello_delay_max_ms: default_server_hello_delay_max_ms(),
tls_new_session_tickets: default_tls_new_session_tickets(),
serverhello_compact: default_serverhello_compact(),
tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(),
alpn_enforce: default_alpn_enforce(),
mask_proxy_protocol: 0,
mask_shape_hardening: default_mask_shape_hardening(),
mask_shape_hardening_aggressive_mode: default_mask_shape_hardening_aggressive_mode(),
mask_shape_bucket_floor_bytes: default_mask_shape_bucket_floor_bytes(),
mask_shape_bucket_cap_bytes: default_mask_shape_bucket_cap_bytes(),
mask_shape_above_cap_blur: default_mask_shape_above_cap_blur(),
mask_shape_above_cap_blur_max_bytes: default_mask_shape_above_cap_blur_max_bytes(),
mask_relay_max_bytes: default_mask_relay_max_bytes(),
mask_relay_timeout_ms: default_mask_relay_timeout_ms(),
mask_relay_idle_timeout_ms: default_mask_relay_idle_timeout_ms(),
mask_classifier_prefetch_timeout_ms: default_mask_classifier_prefetch_timeout_ms(),
mask_timing_normalization_enabled: default_mask_timing_normalization_enabled(),
mask_timing_normalization_floor_ms: default_mask_timing_normalization_floor_ms(),
mask_timing_normalization_ceiling_ms: default_mask_timing_normalization_ceiling_ms(),
}
}
}
+478
View File
@@ -0,0 +1,478 @@
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default)]
pub data_path: Option<PathBuf>,
/// JSON state file for runtime per-user quota consumption.
#[serde(default = "default_quota_state_path")]
pub quota_state_path: PathBuf,
/// Reject unknown TOML config keys during load.
/// Startup fails fast; hot-reload rejects the new snapshot and keeps the current config.
#[serde(default)]
pub config_strict: bool,
#[serde(default)]
pub modes: ProxyModes,
#[serde(default)]
pub prefer_ipv6: bool,
#[serde(default = "default_true")]
pub fast_mode: bool,
#[serde(default = "default_true")]
pub use_middle_proxy: bool,
/// Path to proxy-secret binary file (auto-downloaded if absent).
/// Infrastructure secret from https://core.telegram.org/getProxySecret.
#[serde(default = "default_proxy_secret_path")]
pub proxy_secret_path: Option<String>,
/// Optional custom URL for infrastructure secret (https://core.telegram.org/getProxySecret if absent).
#[serde(default)]
pub proxy_secret_url: Option<String>,
/// Optional path to cache raw getProxyConfig (IPv4) snapshot for startup fallback.
#[serde(default = "default_proxy_config_v4_cache_path")]
pub proxy_config_v4_cache_path: Option<String>,
/// Optional custom URL for getProxyConfig (https://core.telegram.org/getProxyConfig if absent).
#[serde(default)]
pub proxy_config_v4_url: Option<String>,
/// Optional path to cache raw getProxyConfigV6 snapshot for startup fallback.
#[serde(default = "default_proxy_config_v6_cache_path")]
pub proxy_config_v6_cache_path: Option<String>,
/// Optional custom URL for getProxyConfigV6 (https://core.telegram.org/getProxyConfigV6 if absent).
#[serde(default)]
pub proxy_config_v6_url: Option<String>,
/// Global ad_tag (32 hex chars from @MTProxybot). Fallback when user has no per-user tag in access.user_ad_tags.
#[serde(default)]
pub ad_tag: Option<String>,
/// Public IP override for middle-proxy NAT environments.
/// When set, this IP is used in ME key derivation and local address translation.
#[serde(default)]
pub middle_proxy_nat_ip: Option<IpAddr>,
/// Enable STUN-based NAT probing to discover public IP:port for ME KDF.
#[serde(default = "default_true")]
pub middle_proxy_nat_probe: bool,
/// Deprecated legacy single STUN server for NAT probing.
/// Use `network.stun_servers` instead.
#[serde(default = "default_middle_proxy_nat_stun")]
pub middle_proxy_nat_stun: Option<String>,
/// Deprecated legacy STUN list for NAT probing fallback.
/// Use `network.stun_servers` instead.
#[serde(default = "default_middle_proxy_nat_stun_servers")]
pub middle_proxy_nat_stun_servers: Vec<String>,
/// Maximum number of concurrent STUN probes during NAT detection.
#[serde(default = "default_stun_nat_probe_concurrency")]
pub stun_nat_probe_concurrency: usize,
/// Desired size of active Middle-Proxy writer pool.
#[serde(default = "default_pool_size")]
pub middle_proxy_pool_size: usize,
/// Number of warm standby ME connections kept pre-initialized.
#[serde(default = "default_middle_proxy_warm_standby")]
pub middle_proxy_warm_standby: usize,
/// Startup retries for Middle-End pool initialization before ME→Direct fallback.
/// 0 means unlimited retries.
#[serde(default = "default_me_init_retry_attempts")]
pub me_init_retry_attempts: u32,
/// Allow fallback from Middle-End mode to direct DC when ME startup cannot be initialized.
#[serde(default = "default_me2dc_fallback")]
pub me2dc_fallback: bool,
/// Fast ME->Direct fallback mode for new sessions.
/// Active only when both `use_middle_proxy=true` and `me2dc_fallback=true`.
#[serde(default = "default_me2dc_fast")]
pub me2dc_fast: bool,
/// Enable ME keepalive padding frames.
#[serde(default = "default_true")]
pub me_keepalive_enabled: bool,
/// Keepalive interval in seconds.
#[serde(default = "default_keepalive_interval")]
pub me_keepalive_interval_secs: u64,
/// Keepalive jitter in seconds.
#[serde(default = "default_keepalive_jitter")]
pub me_keepalive_jitter_secs: u64,
/// Keepalive payload randomized (4 bytes); otherwise zeros.
#[serde(default = "default_true")]
pub me_keepalive_payload_random: bool,
/// Interval in seconds for service RPC_PROXY_REQ activity signals to ME.
/// 0 disables service activity signals.
#[serde(default = "default_rpc_proxy_req_every")]
pub rpc_proxy_req_every: u64,
/// Capacity of per-ME writer command channel.
#[serde(default = "default_me_writer_cmd_channel_capacity")]
pub me_writer_cmd_channel_capacity: usize,
/// Resident-memory budget in bytes for each ME writer data queue.
#[serde(default = "default_me_writer_byte_budget_bytes")]
pub me_writer_byte_budget_bytes: usize,
/// Capacity of per-connection ME response route channel.
#[serde(default = "default_me_route_channel_capacity")]
pub me_route_channel_capacity: usize,
/// Capacity of per-client command queue from client reader to ME sender task.
#[serde(default = "default_me_c2me_channel_capacity")]
pub me_c2me_channel_capacity: usize,
/// Maximum wait in milliseconds for enqueueing C2ME commands when the queue is full.
/// `0` keeps legacy unbounded wait behavior.
#[serde(default = "default_me_c2me_send_timeout_ms")]
pub me_c2me_send_timeout_ms: u64,
/// Bounded wait in milliseconds for routing ME DATA to per-connection queue.
/// `0` keeps non-blocking routing; values >0 enable bounded wait for compatibility.
#[serde(default = "default_me_reader_route_data_wait_ms")]
pub me_reader_route_data_wait_ms: u64,
/// Maximum number of ME->Client responses coalesced before flush.
#[serde(default = "default_me_d2c_flush_batch_max_frames")]
pub me_d2c_flush_batch_max_frames: usize,
/// Maximum total payload bytes coalesced before flush.
#[serde(default = "default_me_d2c_flush_batch_max_bytes")]
pub me_d2c_flush_batch_max_bytes: usize,
/// Maximum wait in microseconds to coalesce additional ME->Client responses.
/// `0` disables timed coalescing.
#[serde(default = "default_me_d2c_flush_batch_max_delay_us")]
pub me_d2c_flush_batch_max_delay_us: u64,
/// Flush client writer immediately after quick-ack write.
#[serde(default = "default_me_d2c_ack_flush_immediate")]
pub me_d2c_ack_flush_immediate: bool,
/// Additional bytes above strict per-user quota allowed in hot-path soft mode.
#[serde(default = "default_me_quota_soft_overshoot_bytes")]
pub me_quota_soft_overshoot_bytes: u64,
/// Shrink threshold for reusable ME->Client frame assembly buffer.
#[serde(default = "default_me_d2c_frame_buf_shrink_threshold_bytes")]
pub me_d2c_frame_buf_shrink_threshold_bytes: usize,
/// Copy buffer ceiling for client->DC direction in direct relay.
///
/// This is also the upper bound for one amortized upload rate-limit burst:
/// upload debt is settled before the next relay read instead of blocking
/// inside the completed read path.
#[serde(default = "default_direct_relay_copy_buf_c2s_bytes")]
pub direct_relay_copy_buf_c2s_bytes: usize,
/// Copy buffer ceiling for DC->client direction in direct relay.
///
/// This bounds one direct download rate-limit grant because writes are
/// clipped to the currently available shaper budget.
#[serde(default = "default_direct_relay_copy_buf_s2c_bytes")]
pub direct_relay_copy_buf_s2c_bytes: usize,
/// Process-wide hard ceiling for Direct relay copy buffers.
/// `0` derives the ceiling from host and cgroup memory limits.
#[serde(default = "default_direct_relay_buffer_budget_max_bytes")]
pub direct_relay_buffer_budget_max_bytes: usize,
/// Max pending ciphertext buffer per client writer (bytes).
/// Controls FakeTLS backpressure vs throughput.
#[serde(default = "default_crypto_pending_buffer")]
pub crypto_pending_buffer: usize,
/// Maximum allowed client MTProto frame size (bytes).
#[serde(default = "default_max_client_frame")]
pub max_client_frame: usize,
/// Emit full crypto-desync forensic logs for every event.
/// When false, full forensic details are emitted once per key window.
#[serde(default = "default_desync_all_full")]
pub desync_all_full: bool,
/// Enable per-IP forensic observation buckets for scanners and handshake failures.
#[serde(default = "default_true")]
pub beobachten: bool,
/// Observation retention window in minutes for per-IP forensic buckets.
#[serde(default = "default_beobachten_minutes")]
pub beobachten_minutes: u64,
/// Snapshot flush interval in seconds for beob output file.
#[serde(default = "default_beobachten_flush_secs")]
pub beobachten_flush_secs: u64,
/// Snapshot file path for beob output.
#[serde(default = "default_beobachten_file")]
pub beobachten_file: String,
/// Enable C-like hard-swap for ME pool generations.
/// When true, Telemt prewarms a new generation and switches once full coverage is reached.
#[serde(default = "default_hardswap")]
pub hardswap: bool,
/// Enable staggered warmup of extra ME writers.
#[serde(default = "default_true")]
pub me_warmup_stagger_enabled: bool,
/// Base delay between warmup connections in ms.
#[serde(default = "default_warmup_step_delay_ms")]
pub me_warmup_step_delay_ms: u64,
/// Jitter for warmup delay in ms.
#[serde(default = "default_warmup_step_jitter_ms")]
pub me_warmup_step_jitter_ms: u64,
/// Max concurrent reconnect attempts per DC.
#[serde(default = "default_me_reconnect_max_concurrent_per_dc")]
pub me_reconnect_max_concurrent_per_dc: u32,
/// Base backoff in ms for reconnect.
#[serde(default = "default_reconnect_backoff_base_ms")]
pub me_reconnect_backoff_base_ms: u64,
/// Cap backoff in ms for reconnect.
#[serde(default = "default_reconnect_backoff_cap_ms")]
pub me_reconnect_backoff_cap_ms: u64,
/// Fast retry attempts before backoff.
#[serde(default = "default_me_reconnect_fast_retry_count")]
pub me_reconnect_fast_retry_count: u32,
/// Number of additional reserve writers for DC groups with exactly one endpoint.
#[serde(default = "default_me_single_endpoint_shadow_writers")]
pub me_single_endpoint_shadow_writers: u8,
/// Enable aggressive outage recovery mode for single-endpoint DC groups.
#[serde(default = "default_me_single_endpoint_outage_mode_enabled")]
pub me_single_endpoint_outage_mode_enabled: bool,
/// Ignore endpoint quarantine while in single-endpoint outage mode.
#[serde(default = "default_me_single_endpoint_outage_disable_quarantine")]
pub me_single_endpoint_outage_disable_quarantine: bool,
/// Minimum reconnect backoff in ms for single-endpoint outage mode.
#[serde(default = "default_me_single_endpoint_outage_backoff_min_ms")]
pub me_single_endpoint_outage_backoff_min_ms: u64,
/// Maximum reconnect backoff in ms for single-endpoint outage mode.
#[serde(default = "default_me_single_endpoint_outage_backoff_max_ms")]
pub me_single_endpoint_outage_backoff_max_ms: u64,
/// Periodic shadow writer rotation interval in seconds for single-endpoint DC groups.
/// Set to 0 to disable periodic shadow rotation.
#[serde(default = "default_me_single_endpoint_shadow_rotate_every_secs")]
pub me_single_endpoint_shadow_rotate_every_secs: u64,
/// Floor policy mode for ME writer targets.
#[serde(default)]
pub me_floor_mode: MeFloorMode,
/// Idle time in seconds before adaptive floor can reduce single-endpoint writer target.
#[serde(default = "default_me_adaptive_floor_idle_secs")]
pub me_adaptive_floor_idle_secs: u64,
/// Minimum writer target for single-endpoint DC groups in adaptive floor mode.
#[serde(default = "default_me_adaptive_floor_min_writers_single_endpoint")]
pub me_adaptive_floor_min_writers_single_endpoint: u8,
/// Minimum writer target for multi-endpoint DC groups in adaptive floor mode.
#[serde(default = "default_me_adaptive_floor_min_writers_multi_endpoint")]
pub me_adaptive_floor_min_writers_multi_endpoint: u8,
/// Grace period in seconds to hold static floor after activity in adaptive mode.
#[serde(default = "default_me_adaptive_floor_recover_grace_secs")]
pub me_adaptive_floor_recover_grace_secs: u64,
/// Global ME writer budget per logical CPU core in adaptive mode.
#[serde(default = "default_me_adaptive_floor_writers_per_core_total")]
pub me_adaptive_floor_writers_per_core_total: u16,
/// Override logical CPU core count for adaptive floor calculations.
/// Set to 0 to use runtime auto-detection.
#[serde(default = "default_me_adaptive_floor_cpu_cores_override")]
pub me_adaptive_floor_cpu_cores_override: u16,
/// Per-core max extra writers above base required floor for single-endpoint DC groups.
#[serde(default = "default_me_adaptive_floor_max_extra_writers_single_per_core")]
pub me_adaptive_floor_max_extra_writers_single_per_core: u16,
/// Per-core max extra writers above base required floor for multi-endpoint DC groups.
#[serde(default = "default_me_adaptive_floor_max_extra_writers_multi_per_core")]
pub me_adaptive_floor_max_extra_writers_multi_per_core: u16,
/// Hard cap for active ME writers per logical CPU core.
#[serde(default = "default_me_adaptive_floor_max_active_writers_per_core")]
pub me_adaptive_floor_max_active_writers_per_core: u16,
/// Hard cap for warm ME writers per logical CPU core.
#[serde(default = "default_me_adaptive_floor_max_warm_writers_per_core")]
pub me_adaptive_floor_max_warm_writers_per_core: u16,
/// Hard global cap for active ME writers.
#[serde(default = "default_me_adaptive_floor_max_active_writers_global")]
pub me_adaptive_floor_max_active_writers_global: u32,
/// Hard global cap for warm ME writers.
#[serde(default = "default_me_adaptive_floor_max_warm_writers_global")]
pub me_adaptive_floor_max_warm_writers_global: u32,
/// Connect attempts for the selected upstream before returning error/fallback.
#[serde(default = "default_upstream_connect_retry_attempts")]
pub upstream_connect_retry_attempts: u32,
/// Delay in milliseconds between upstream connect attempts.
#[serde(default = "default_upstream_connect_retry_backoff_ms")]
pub upstream_connect_retry_backoff_ms: u64,
/// Total wall-clock budget in milliseconds for one upstream connect request across retries.
#[serde(default = "default_upstream_connect_budget_ms")]
pub upstream_connect_budget_ms: u64,
/// Per-attempt TCP connect timeout to Telegram DC (seconds).
#[serde(default = "default_connect_timeout")]
pub tg_connect: u64,
/// Consecutive failed requests before upstream is marked unhealthy.
#[serde(default = "default_upstream_unhealthy_fail_threshold")]
pub upstream_unhealthy_fail_threshold: u32,
/// Skip additional retries for hard non-transient upstream connect errors.
#[serde(default = "default_upstream_connect_failfast_hard_errors")]
pub upstream_connect_failfast_hard_errors: bool,
/// Ignore STUN/interface IP mismatch (keep using Middle Proxy even if NAT detected).
#[serde(default)]
pub stun_iface_mismatch_ignore: bool,
/// Log unknown (non-standard) DC requests to a file (default: unknown-dc.txt). Set to null to disable.
#[serde(default = "default_unknown_dc_log_path")]
pub unknown_dc_log_path: Option<String>,
/// Enable unknown-DC file logging.
#[serde(default = "default_unknown_dc_file_log_enabled")]
pub unknown_dc_file_log_enabled: bool,
#[serde(default)]
pub log_level: LogLevel,
/// Disable colored output in logs (useful for files/systemd).
#[serde(default)]
pub disable_colors: bool,
/// Runtime telemetry controls for counters/metrics in hot paths.
#[serde(default)]
pub telemetry: TelemetryConfig,
/// SOCKS-bound KDF policy for Middle-End handshake.
#[serde(default)]
pub me_socks_kdf_policy: MeSocksKdfPolicy,
/// Enable route-level ME backpressure controls in reader fairness path.
#[serde(default = "default_me_route_backpressure_enabled")]
pub me_route_backpressure_enabled: bool,
/// Enable worker-local fairshare scheduler for ME reader routing.
#[serde(default = "default_me_route_fairshare_enabled")]
pub me_route_fairshare_enabled: bool,
/// Base backpressure timeout in milliseconds for ME route channel send.
#[serde(default = "default_me_route_backpressure_base_timeout_ms")]
pub me_route_backpressure_base_timeout_ms: u64,
/// High backpressure timeout in milliseconds when queue occupancy is above watermark.
#[serde(default = "default_me_route_backpressure_high_timeout_ms")]
pub me_route_backpressure_high_timeout_ms: u64,
/// Queue occupancy percent threshold for high backpressure timeout.
#[serde(default = "default_me_route_backpressure_high_watermark_pct")]
pub me_route_backpressure_high_watermark_pct: u8,
/// Health monitor interval in milliseconds while writer coverage is degraded.
#[serde(default = "default_me_health_interval_ms_unhealthy")]
pub me_health_interval_ms_unhealthy: u64,
/// Health monitor interval in milliseconds while writer coverage is stable.
#[serde(default = "default_me_health_interval_ms_healthy")]
pub me_health_interval_ms_healthy: u64,
/// Poll interval in milliseconds for conditional-admission state checks.
#[serde(default = "default_me_admission_poll_ms")]
pub me_admission_poll_ms: u64,
/// Cooldown for repetitive ME warning logs in milliseconds.
#[serde(default = "default_me_warn_rate_limit_ms")]
pub me_warn_rate_limit_ms: u64,
/// ME route behavior when no writer is immediately available.
#[serde(default)]
pub me_route_no_writer_mode: MeRouteNoWriterMode,
/// Maximum wait time in milliseconds for async-recovery failfast mode.
#[serde(default = "default_me_route_no_writer_wait_ms")]
pub me_route_no_writer_wait_ms: u64,
/// Maximum cumulative wait in milliseconds for hybrid no-writer mode before failfast.
#[serde(default = "default_me_route_hybrid_max_wait_ms")]
pub me_route_hybrid_max_wait_ms: u64,
/// Maximum wait in milliseconds for blocking ME writer channel send fallback.
/// Must be within [1, 5000].
#[serde(default = "default_me_route_blocking_send_timeout_ms")]
pub me_route_blocking_send_timeout_ms: u64,
/// Number of inline recovery attempts in legacy mode.
#[serde(default = "default_me_route_inline_recovery_attempts")]
pub me_route_inline_recovery_attempts: u32,
/// Maximum wait time in milliseconds for inline recovery in legacy mode.
#[serde(default = "default_me_route_inline_recovery_wait_ms")]
pub me_route_inline_recovery_wait_ms: u64,
/// [general.links] — proxy link generation overrides.
#[serde(default)]
pub links: LinksConfig,
/// Minimum TLS record size when fast_mode coalescing is enabled (0 = disabled).
#[serde(default = "default_fast_mode_min_tls_record")]
pub fast_mode_min_tls_record: usize,
/// Unified ME updater interval in seconds for getProxyConfig/getProxyConfigV6/getProxySecret.
/// When omitted, effective value falls back to legacy proxy_*_auto_reload_secs fields.
#[serde(default = "default_update_every")]
pub update_every: Option<u64>,
/// Periodic ME pool reinitialization interval in seconds.
#[serde(default = "default_me_reinit_every_secs")]
pub me_reinit_every_secs: u64,
/// Minimum delay in ms between hardswap warmup connect attempts.
#[serde(default = "default_me_hardswap_warmup_delay_min_ms")]
pub me_hardswap_warmup_delay_min_ms: u64,
/// Maximum delay in ms between hardswap warmup connect attempts.
#[serde(default = "default_me_hardswap_warmup_delay_max_ms")]
pub me_hardswap_warmup_delay_max_ms: u64,
/// Additional warmup passes in the same hardswap cycle after the base pass.
#[serde(default = "default_me_hardswap_warmup_extra_passes")]
pub me_hardswap_warmup_extra_passes: u8,
/// Base backoff in ms between hardswap warmup passes when floor is still incomplete.
#[serde(default = "default_me_hardswap_warmup_pass_backoff_base_ms")]
pub me_hardswap_warmup_pass_backoff_base_ms: u64,
/// Number of identical getProxyConfig snapshots required before applying ME map updates.
#[serde(default = "default_me_config_stable_snapshots")]
pub me_config_stable_snapshots: u8,
/// Cooldown in seconds between applied ME map updates.
#[serde(default = "default_me_config_apply_cooldown_secs")]
pub me_config_apply_cooldown_secs: u64,
/// Ensure getProxyConfig snapshots are applied only for 2xx HTTP responses.
#[serde(default = "default_me_snapshot_require_http_2xx")]
pub me_snapshot_require_http_2xx: bool,
/// Reject empty getProxyConfig snapshots instead of marking them applied.
#[serde(default = "default_me_snapshot_reject_empty_map")]
pub me_snapshot_reject_empty_map: bool,
/// Minimum parsed `proxy_for` rows required to accept a snapshot.
#[serde(default = "default_me_snapshot_min_proxy_for_lines")]
pub me_snapshot_min_proxy_for_lines: u32,
/// Number of identical getProxySecret snapshots required before runtime secret rotation.
#[serde(default = "default_proxy_secret_stable_snapshots")]
pub proxy_secret_stable_snapshots: u8,
/// Enable runtime proxy-secret rotation from getProxySecret.
#[serde(default = "default_proxy_secret_rotate_runtime")]
pub proxy_secret_rotate_runtime: bool,
/// Keep key-selector and secret bytes from one snapshot during ME handshake.
#[serde(default = "default_me_secret_atomic_snapshot")]
pub me_secret_atomic_snapshot: bool,
/// Maximum allowed proxy-secret length in bytes for startup and runtime refresh.
#[serde(default = "default_proxy_secret_len_max")]
pub proxy_secret_len_max: usize,
/// Drain-TTL in seconds for stale ME writers after endpoint map changes.
/// During TTL, stale writers may be used only as fallback for new bindings.
#[serde(default = "default_me_pool_drain_ttl_secs")]
pub me_pool_drain_ttl_secs: u64,
/// Force-remove any draining writer on the next cleanup tick, regardless of age/deadline.
#[serde(default = "default_me_instadrain")]
pub me_instadrain: bool,
/// Maximum allowed number of draining ME writers before oldest ones are force-closed in batches.
/// Set to 0 to disable threshold-based draining cleanup and keep timeout-only behavior.
#[serde(default = "default_me_pool_drain_threshold")]
pub me_pool_drain_threshold: u64,
/// Enable staged client eviction for draining ME writers that remain non-empty past TTL.
#[serde(default = "default_me_pool_drain_soft_evict_enabled")]
pub me_pool_drain_soft_evict_enabled: bool,
/// Extra grace in seconds after drain TTL before soft-eviction stage starts.
#[serde(default = "default_me_pool_drain_soft_evict_grace_secs")]
pub me_pool_drain_soft_evict_grace_secs: u64,
/// Maximum number of client sessions to evict from one draining writer per health tick.
#[serde(default = "default_me_pool_drain_soft_evict_per_writer")]
pub me_pool_drain_soft_evict_per_writer: u8,
/// Soft-eviction budget per CPU core for one health tick.
#[serde(default = "default_me_pool_drain_soft_evict_budget_per_core")]
pub me_pool_drain_soft_evict_budget_per_core: u16,
/// Cooldown for repetitive soft-eviction on the same writer in milliseconds.
#[serde(default = "default_me_pool_drain_soft_evict_cooldown_ms")]
pub me_pool_drain_soft_evict_cooldown_ms: u64,
/// Policy for new binds on stale draining writers.
#[serde(default)]
pub me_bind_stale_mode: MeBindStaleMode,
/// TTL for stale bind allowance when `me_bind_stale_mode = \"ttl\"`.
#[serde(default = "default_me_bind_stale_ttl_secs")]
pub me_bind_stale_ttl_secs: u64,
/// Minimum desired-DC coverage ratio required before draining stale writers.
/// Range: 0.0..=1.0.
#[serde(default = "default_me_pool_min_fresh_ratio")]
pub me_pool_min_fresh_ratio: f32,
/// Drain timeout in seconds for stale ME writers after endpoint map changes.
/// Set to 0 to use the runtime safety fallback timeout.
#[serde(default = "default_me_reinit_drain_timeout_secs")]
pub me_reinit_drain_timeout_secs: u64,
/// Deprecated legacy setting; kept for backward compatibility fallback.
/// Use `update_every` instead.
#[serde(default = "default_proxy_secret_reload_secs")]
pub proxy_secret_auto_reload_secs: u64,
/// Deprecated legacy setting; kept for backward compatibility fallback.
/// Use `update_every` instead.
#[serde(default = "default_proxy_config_reload_secs")]
pub proxy_config_auto_reload_secs: u64,
/// Serialize ME reinit cycles across all trigger sources.
#[serde(default = "default_me_reinit_singleflight")]
pub me_reinit_singleflight: bool,
/// Trigger queue capacity for reinit scheduler.
#[serde(default = "default_me_reinit_trigger_channel")]
pub me_reinit_trigger_channel: usize,
/// Trigger coalescing window before starting a reinit cycle.
#[serde(default = "default_me_reinit_coalesce_window_ms")]
pub me_reinit_coalesce_window_ms: u64,
/// Deterministic candidate sort for ME writer binding path.
#[serde(default = "default_me_deterministic_writer_sort")]
pub me_deterministic_writer_sort: bool,
/// Writer selection mode for ME route bind path.
#[serde(default)]
pub me_writer_pick_mode: MeWriterPickMode,
/// Number of candidates sampled by writer picker in `p2c` mode.
#[serde(default = "default_me_writer_pick_sample_size")]
pub me_writer_pick_sample_size: u8,
/// Enable NTP drift check at startup.
#[serde(default = "default_ntp_check")]
pub ntp_check: bool,
/// NTP servers for drift check.
#[serde(default = "default_ntp_servers")]
pub ntp_servers: Vec<String>,
/// Enable auto-degradation from ME to Direct-DC.
#[serde(default = "default_true")]
pub auto_degradation_enabled: bool,
/// Minimum unavailable ME DC groups before degrading.
#[serde(default = "default_degradation_min_unavailable_dc_groups")]
pub degradation_min_unavailable_dc_groups: u8,
/// RST-on-close mode for accepted client sockets.
/// `off` — normal FIN on all closes (default).
/// `errors` — SO_LINGER(0) on accept, cleared after successful auth;
/// pre-handshake failures send RST, relayed sessions close gracefully.
/// `always` — SO_LINGER(0) on accept, never cleared; all closes send RST.
#[serde(default)]
pub rst_on_close: RstOnCloseMode,
}
+201
View File
@@ -0,0 +1,201 @@
use super::*;
impl Default for GeneralConfig {
fn default() -> Self {
Self {
data_path: None,
quota_state_path: default_quota_state_path(),
config_strict: false,
modes: ProxyModes::default(),
prefer_ipv6: false,
fast_mode: default_true(),
use_middle_proxy: default_true(),
ad_tag: None,
proxy_secret_path: default_proxy_secret_path(),
proxy_secret_url: None,
proxy_config_v4_cache_path: default_proxy_config_v4_cache_path(),
proxy_config_v4_url: None,
proxy_config_v6_cache_path: default_proxy_config_v6_cache_path(),
proxy_config_v6_url: None,
middle_proxy_nat_ip: None,
middle_proxy_nat_probe: default_true(),
middle_proxy_nat_stun: default_middle_proxy_nat_stun(),
middle_proxy_nat_stun_servers: default_middle_proxy_nat_stun_servers(),
stun_nat_probe_concurrency: default_stun_nat_probe_concurrency(),
middle_proxy_pool_size: default_pool_size(),
middle_proxy_warm_standby: default_middle_proxy_warm_standby(),
me_init_retry_attempts: default_me_init_retry_attempts(),
me2dc_fallback: default_me2dc_fallback(),
me2dc_fast: default_me2dc_fast(),
me_keepalive_enabled: default_true(),
me_keepalive_interval_secs: default_keepalive_interval(),
me_keepalive_jitter_secs: default_keepalive_jitter(),
me_keepalive_payload_random: default_true(),
rpc_proxy_req_every: default_rpc_proxy_req_every(),
me_writer_cmd_channel_capacity: default_me_writer_cmd_channel_capacity(),
me_writer_byte_budget_bytes: default_me_writer_byte_budget_bytes(),
me_route_channel_capacity: default_me_route_channel_capacity(),
me_c2me_channel_capacity: default_me_c2me_channel_capacity(),
me_c2me_send_timeout_ms: default_me_c2me_send_timeout_ms(),
me_reader_route_data_wait_ms: default_me_reader_route_data_wait_ms(),
me_d2c_flush_batch_max_frames: default_me_d2c_flush_batch_max_frames(),
me_d2c_flush_batch_max_bytes: default_me_d2c_flush_batch_max_bytes(),
me_d2c_flush_batch_max_delay_us: default_me_d2c_flush_batch_max_delay_us(),
me_d2c_ack_flush_immediate: default_me_d2c_ack_flush_immediate(),
me_quota_soft_overshoot_bytes: default_me_quota_soft_overshoot_bytes(),
me_d2c_frame_buf_shrink_threshold_bytes:
default_me_d2c_frame_buf_shrink_threshold_bytes(),
direct_relay_copy_buf_c2s_bytes: default_direct_relay_copy_buf_c2s_bytes(),
direct_relay_copy_buf_s2c_bytes: default_direct_relay_copy_buf_s2c_bytes(),
direct_relay_buffer_budget_max_bytes: default_direct_relay_buffer_budget_max_bytes(),
me_warmup_stagger_enabled: default_true(),
me_warmup_step_delay_ms: default_warmup_step_delay_ms(),
me_warmup_step_jitter_ms: default_warmup_step_jitter_ms(),
me_reconnect_max_concurrent_per_dc: default_me_reconnect_max_concurrent_per_dc(),
me_reconnect_backoff_base_ms: default_reconnect_backoff_base_ms(),
me_reconnect_backoff_cap_ms: default_reconnect_backoff_cap_ms(),
me_reconnect_fast_retry_count: default_me_reconnect_fast_retry_count(),
me_single_endpoint_shadow_writers: default_me_single_endpoint_shadow_writers(),
me_single_endpoint_outage_mode_enabled: default_me_single_endpoint_outage_mode_enabled(
),
me_single_endpoint_outage_disable_quarantine:
default_me_single_endpoint_outage_disable_quarantine(),
me_single_endpoint_outage_backoff_min_ms:
default_me_single_endpoint_outage_backoff_min_ms(),
me_single_endpoint_outage_backoff_max_ms:
default_me_single_endpoint_outage_backoff_max_ms(),
me_single_endpoint_shadow_rotate_every_secs:
default_me_single_endpoint_shadow_rotate_every_secs(),
me_floor_mode: MeFloorMode::default(),
me_adaptive_floor_idle_secs: default_me_adaptive_floor_idle_secs(),
me_adaptive_floor_min_writers_single_endpoint:
default_me_adaptive_floor_min_writers_single_endpoint(),
me_adaptive_floor_min_writers_multi_endpoint:
default_me_adaptive_floor_min_writers_multi_endpoint(),
me_adaptive_floor_recover_grace_secs: default_me_adaptive_floor_recover_grace_secs(),
me_adaptive_floor_writers_per_core_total:
default_me_adaptive_floor_writers_per_core_total(),
me_adaptive_floor_cpu_cores_override: default_me_adaptive_floor_cpu_cores_override(),
me_adaptive_floor_max_extra_writers_single_per_core:
default_me_adaptive_floor_max_extra_writers_single_per_core(),
me_adaptive_floor_max_extra_writers_multi_per_core:
default_me_adaptive_floor_max_extra_writers_multi_per_core(),
me_adaptive_floor_max_active_writers_per_core:
default_me_adaptive_floor_max_active_writers_per_core(),
me_adaptive_floor_max_warm_writers_per_core:
default_me_adaptive_floor_max_warm_writers_per_core(),
me_adaptive_floor_max_active_writers_global:
default_me_adaptive_floor_max_active_writers_global(),
me_adaptive_floor_max_warm_writers_global:
default_me_adaptive_floor_max_warm_writers_global(),
upstream_connect_retry_attempts: default_upstream_connect_retry_attempts(),
upstream_connect_retry_backoff_ms: default_upstream_connect_retry_backoff_ms(),
upstream_connect_budget_ms: default_upstream_connect_budget_ms(),
tg_connect: default_connect_timeout(),
upstream_unhealthy_fail_threshold: default_upstream_unhealthy_fail_threshold(),
upstream_connect_failfast_hard_errors: default_upstream_connect_failfast_hard_errors(),
stun_iface_mismatch_ignore: false,
unknown_dc_log_path: default_unknown_dc_log_path(),
unknown_dc_file_log_enabled: default_unknown_dc_file_log_enabled(),
log_level: LogLevel::Normal,
disable_colors: false,
telemetry: TelemetryConfig::default(),
me_socks_kdf_policy: MeSocksKdfPolicy::Strict,
me_route_backpressure_enabled: default_me_route_backpressure_enabled(),
me_route_fairshare_enabled: default_me_route_fairshare_enabled(),
me_route_backpressure_base_timeout_ms: default_me_route_backpressure_base_timeout_ms(),
me_route_backpressure_high_timeout_ms: default_me_route_backpressure_high_timeout_ms(),
me_route_backpressure_high_watermark_pct:
default_me_route_backpressure_high_watermark_pct(),
me_health_interval_ms_unhealthy: default_me_health_interval_ms_unhealthy(),
me_health_interval_ms_healthy: default_me_health_interval_ms_healthy(),
me_admission_poll_ms: default_me_admission_poll_ms(),
me_warn_rate_limit_ms: default_me_warn_rate_limit_ms(),
me_route_no_writer_mode: MeRouteNoWriterMode::default(),
me_route_no_writer_wait_ms: default_me_route_no_writer_wait_ms(),
me_route_hybrid_max_wait_ms: default_me_route_hybrid_max_wait_ms(),
me_route_blocking_send_timeout_ms: default_me_route_blocking_send_timeout_ms(),
me_route_inline_recovery_attempts: default_me_route_inline_recovery_attempts(),
me_route_inline_recovery_wait_ms: default_me_route_inline_recovery_wait_ms(),
links: LinksConfig::default(),
crypto_pending_buffer: default_crypto_pending_buffer(),
max_client_frame: default_max_client_frame(),
desync_all_full: default_desync_all_full(),
beobachten: default_true(),
beobachten_minutes: default_beobachten_minutes(),
beobachten_flush_secs: default_beobachten_flush_secs(),
beobachten_file: default_beobachten_file(),
hardswap: default_hardswap(),
fast_mode_min_tls_record: default_fast_mode_min_tls_record(),
update_every: default_update_every(),
me_reinit_every_secs: default_me_reinit_every_secs(),
me_hardswap_warmup_delay_min_ms: default_me_hardswap_warmup_delay_min_ms(),
me_hardswap_warmup_delay_max_ms: default_me_hardswap_warmup_delay_max_ms(),
me_hardswap_warmup_extra_passes: default_me_hardswap_warmup_extra_passes(),
me_hardswap_warmup_pass_backoff_base_ms:
default_me_hardswap_warmup_pass_backoff_base_ms(),
me_config_stable_snapshots: default_me_config_stable_snapshots(),
me_config_apply_cooldown_secs: default_me_config_apply_cooldown_secs(),
me_snapshot_require_http_2xx: default_me_snapshot_require_http_2xx(),
me_snapshot_reject_empty_map: default_me_snapshot_reject_empty_map(),
me_snapshot_min_proxy_for_lines: default_me_snapshot_min_proxy_for_lines(),
proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(),
proxy_secret_rotate_runtime: default_proxy_secret_rotate_runtime(),
me_secret_atomic_snapshot: default_me_secret_atomic_snapshot(),
proxy_secret_len_max: default_proxy_secret_len_max(),
me_pool_drain_ttl_secs: default_me_pool_drain_ttl_secs(),
me_instadrain: default_me_instadrain(),
me_pool_drain_threshold: default_me_pool_drain_threshold(),
me_pool_drain_soft_evict_enabled: default_me_pool_drain_soft_evict_enabled(),
me_pool_drain_soft_evict_grace_secs: default_me_pool_drain_soft_evict_grace_secs(),
me_pool_drain_soft_evict_per_writer: default_me_pool_drain_soft_evict_per_writer(),
me_pool_drain_soft_evict_budget_per_core:
default_me_pool_drain_soft_evict_budget_per_core(),
me_pool_drain_soft_evict_cooldown_ms: default_me_pool_drain_soft_evict_cooldown_ms(),
me_bind_stale_mode: MeBindStaleMode::default(),
me_bind_stale_ttl_secs: default_me_bind_stale_ttl_secs(),
me_pool_min_fresh_ratio: default_me_pool_min_fresh_ratio(),
me_reinit_drain_timeout_secs: default_me_reinit_drain_timeout_secs(),
proxy_secret_auto_reload_secs: default_proxy_secret_reload_secs(),
proxy_config_auto_reload_secs: default_proxy_config_reload_secs(),
me_reinit_singleflight: default_me_reinit_singleflight(),
me_reinit_trigger_channel: default_me_reinit_trigger_channel(),
me_reinit_coalesce_window_ms: default_me_reinit_coalesce_window_ms(),
me_deterministic_writer_sort: default_me_deterministic_writer_sort(),
me_writer_pick_mode: MeWriterPickMode::default(),
me_writer_pick_sample_size: default_me_writer_pick_sample_size(),
ntp_check: default_ntp_check(),
ntp_servers: default_ntp_servers(),
auto_degradation_enabled: default_true(),
degradation_min_unavailable_dc_groups: default_degradation_min_unavailable_dc_groups(),
rst_on_close: RstOnCloseMode::default(),
}
}
}
impl GeneralConfig {
/// Resolve the active updater interval for ME infrastructure refresh tasks.
/// `update_every` has priority, otherwise legacy proxy_*_auto_reload_secs are used.
pub fn effective_update_every_secs(&self) -> u64 {
self.update_every.unwrap_or_else(|| {
self.proxy_secret_auto_reload_secs
.min(self.proxy_config_auto_reload_secs)
})
}
/// Resolve periodic zero-downtime reinit interval for ME writers.
pub fn effective_me_reinit_every_secs(&self) -> u64 {
self.me_reinit_every_secs
}
/// Resolve force-close timeout for stale writers.
/// `me_reinit_drain_timeout_secs` remains backward-compatible alias.
/// A configured `0` uses the runtime safety fallback (300s).
pub fn effective_me_pool_force_close_secs(&self) -> u64 {
if self.me_reinit_drain_timeout_secs == 0 {
300
} else {
self.me_reinit_drain_timeout_secs
}
}
}
+124
View File
@@ -0,0 +1,124 @@
use super::*;
/// Proxy link generation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinksConfig {
/// List of usernames whose tg:// links to display at startup.
/// `"*"` = all users, `["alice", "bob"]` = specific users.
#[serde(default = "default_links_show")]
pub show: ShowLink,
/// Public hostname/IP for tg:// link generation (overrides detected IP).
#[serde(default)]
pub public_host: Option<String>,
/// Public port for tg:// link generation.
/// Overrides listener ports and legacy `server.port`.
#[serde(default)]
pub public_port: Option<u16>,
}
impl Default for LinksConfig {
fn default() -> Self {
Self {
show: default_links_show(),
public_host: None,
public_port: None,
}
}
}
/// In TOML, this can be:
/// - `show_link = "*"` — show links for all users
/// - `show_link = ["a", "b"]` — show links for specific users
/// - omitted — default depends on the owning config field
#[derive(Debug, Clone, Default)]
pub enum ShowLink {
/// Don't show any links (default when omitted).
#[default]
None,
/// Show links for all configured users.
All,
/// Show links for specific users.
Specific(Vec<String>),
}
fn default_links_show() -> ShowLink {
ShowLink::All
}
impl ShowLink {
/// Returns true if no links should be shown.
pub fn is_empty(&self) -> bool {
matches!(self, ShowLink::None) || matches!(self, ShowLink::Specific(v) if v.is_empty())
}
/// Resolve the list of user names to display, given all configured users.
pub fn resolve_users<'a>(&'a self, all_users: &'a HashMap<String, String>) -> Vec<&'a String> {
match self {
ShowLink::None => vec![],
ShowLink::All => {
let mut names: Vec<&String> = all_users.keys().collect();
names.sort();
names
}
ShowLink::Specific(names) => names.iter().collect(),
}
}
}
impl Serialize for ShowLink {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
match self {
ShowLink::None => Vec::<String>::new().serialize(serializer),
ShowLink::All => serializer.serialize_str("*"),
ShowLink::Specific(v) => v.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for ShowLink {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
use serde::de;
struct ShowLinkVisitor;
impl<'de> de::Visitor<'de> for ShowLinkVisitor {
type Value = ShowLink;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(r#""*" or an array of user names"#)
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<ShowLink, E> {
if v == "*" {
Ok(ShowLink::All)
} else {
Err(de::Error::invalid_value(de::Unexpected::Str(v), &r#""*""#))
}
}
fn visit_seq<A: de::SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<ShowLink, A::Error> {
let mut names = Vec::new();
while let Some(name) = seq.next_element::<String>()? {
names.push(name);
}
if names.is_empty() {
Ok(ShowLink::None)
} else {
Ok(ShowLink::Specific(names))
}
}
}
deserializer.deserialize_any(ShowLinkVisitor)
}
}
+131
View File
@@ -0,0 +1,131 @@
use super::*;
/// Logging verbosity level.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
/// All messages including trace (trace + debug + info + warn + error).
Debug,
/// Detailed operational logs (debug + info + warn + error).
Verbose,
/// Standard operational logs (info + warn + error).
#[default]
Normal,
/// Minimal output: only warnings and errors (warn + error).
/// Proxy links may still be emitted through their dedicated target.
Silent,
}
impl LogLevel {
/// Convert to tracing EnvFilter directive string.
pub fn to_filter_str(&self) -> &'static str {
match self {
LogLevel::Debug => "trace",
LogLevel::Verbose => "debug",
LogLevel::Normal => "info",
LogLevel::Silent => "warn",
}
}
/// Parse from a loose string (CLI argument).
pub fn from_str_loose(s: &str) -> Self {
match s.to_lowercase().as_str() {
"debug" | "trace" => LogLevel::Debug,
"verbose" => LogLevel::Verbose,
"normal" | "info" => LogLevel::Normal,
"silent" | "quiet" | "error" | "warn" => LogLevel::Silent,
_ => LogLevel::Normal,
}
}
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LogLevel::Debug => write!(f, "debug"),
LogLevel::Verbose => write!(f, "verbose"),
LogLevel::Normal => write!(f, "normal"),
LogLevel::Silent => write!(f, "silent"),
}
}
}
/// 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,
}
}
}
+153
View File
@@ -0,0 +1,153 @@
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyModes {
#[serde(default)]
pub classic: bool,
#[serde(default)]
pub secure: bool,
#[serde(default = "default_true")]
pub tls: bool,
}
impl Default for ProxyModes {
fn default() -> Self {
Self {
classic: false,
secure: false,
tls: default_true(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
#[serde(default = "default_true")]
pub ipv4: bool,
/// None = auto-detect IPv6 availability.
#[serde(default = "default_network_ipv6")]
pub ipv6: Option<bool>,
/// 4 or 6.
#[serde(default = "default_prefer_4")]
pub prefer: u8,
#[serde(default)]
pub multipath: bool,
/// Global switch for STUN probing.
/// When false, STUN is fully disabled and only non-STUN detection remains.
#[serde(default = "default_true")]
pub stun_use: bool,
/// STUN servers list for public IP discovery.
#[serde(default = "default_stun_servers")]
pub stun_servers: Vec<String>,
/// Enable TCP STUN fallback when UDP is blocked.
#[serde(default = "default_stun_tcp_fallback")]
pub stun_tcp_fallback: bool,
/// HTTP-based public IP detection endpoints (fallback after STUN).
#[serde(default = "default_http_ip_detect_urls")]
pub http_ip_detect_urls: Vec<String>,
/// Cache file path for detected public IP.
#[serde(default = "default_cache_public_ip_path")]
pub cache_public_ip_path: String,
/// Runtime DNS overrides in `host:port:ip` format.
/// IPv6 IP values must be bracketed: `[2001:db8::1]`.
#[serde(default)]
pub dns_overrides: Vec<String>,
}
impl Default for NetworkConfig {
fn default() -> Self {
Self {
ipv4: default_true(),
ipv6: default_network_ipv6(),
prefer: default_prefer_4(),
multipath: false,
stun_use: default_true(),
stun_servers: default_stun_servers(),
stun_tcp_fallback: default_stun_tcp_fallback(),
http_ip_detect_urls: default_http_ip_detect_urls(),
cache_public_ip_path: default_cache_public_ip_path(),
dns_overrides: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum UpstreamType {
Direct {
#[serde(default)]
interface: Option<String>,
#[serde(default)]
bind_addresses: Option<Vec<String>>,
/// Linux-only hard interface pinning via `SO_BINDTODEVICE`.
/// Optional alias: `force_bind`.
#[serde(default, alias = "force_bind")]
bindtodevice: Option<String>,
},
Socks4 {
address: String,
#[serde(default)]
interface: Option<String>,
#[serde(default)]
user_id: Option<String>,
},
Socks5 {
address: String,
#[serde(default)]
interface: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
password: Option<String>,
},
Shadowsocks {
url: String,
#[serde(default)]
interface: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpstreamConfig {
#[serde(flatten)]
pub upstream_type: UpstreamType,
#[serde(default = "default_weight")]
pub weight: u16,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub scopes: String,
#[serde(skip)]
pub selected_scope: String,
/// Allow IPv4 DC targets for this upstream.
/// `None` means auto-detect from runtime connectivity state.
#[serde(default)]
pub ipv4: Option<bool>,
/// Allow IPv6 DC targets for this upstream.
/// `None` means auto-detect from runtime connectivity state.
#[serde(default)]
pub ipv6: Option<bool>,
/// Per-upstream IP family preference for Telegram DC targets.
/// `None` inherits the effective global `[network].prefer` decision.
#[serde(default)]
pub prefer: Option<u8>,
}
impl UpstreamConfig {
pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool {
match self.prefer {
Some(6) => true,
Some(4) => false,
_ => default_prefer_ipv6,
}
}
}
+229
View File
@@ -0,0 +1,229 @@
use super::*;
/// Middle-End telemetry verbosity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MeTelemetryLevel {
#[default]
Normal,
Silent,
Debug,
}
impl MeTelemetryLevel {
pub fn as_u8(self) -> u8 {
match self {
MeTelemetryLevel::Silent => 0,
MeTelemetryLevel::Normal => 1,
MeTelemetryLevel::Debug => 2,
}
}
pub fn from_u8(raw: u8) -> Self {
match raw {
0 => MeTelemetryLevel::Silent,
2 => MeTelemetryLevel::Debug,
_ => MeTelemetryLevel::Normal,
}
}
pub fn allows_normal(self) -> bool {
!matches!(self, MeTelemetryLevel::Silent)
}
pub fn allows_debug(self) -> bool {
matches!(self, MeTelemetryLevel::Debug)
}
}
impl std::fmt::Display for MeTelemetryLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MeTelemetryLevel::Silent => write!(f, "silent"),
MeTelemetryLevel::Normal => write!(f, "normal"),
MeTelemetryLevel::Debug => write!(f, "debug"),
}
}
}
/// Middle-End SOCKS KDF fallback policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MeSocksKdfPolicy {
#[default]
Strict,
Compat,
}
impl MeSocksKdfPolicy {
pub fn as_u8(self) -> u8 {
match self {
MeSocksKdfPolicy::Strict => 0,
MeSocksKdfPolicy::Compat => 1,
}
}
pub fn from_u8(raw: u8) -> Self {
match raw {
1 => MeSocksKdfPolicy::Compat,
_ => MeSocksKdfPolicy::Strict,
}
}
}
/// Stale ME writer bind policy during drain window.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MeBindStaleMode {
#[default]
Never,
Ttl,
Always,
}
impl MeBindStaleMode {
pub fn as_u8(self) -> u8 {
match self {
MeBindStaleMode::Never => 0,
MeBindStaleMode::Ttl => 1,
MeBindStaleMode::Always => 2,
}
}
pub fn from_u8(raw: u8) -> Self {
match raw {
0 => MeBindStaleMode::Never,
2 => MeBindStaleMode::Always,
_ => MeBindStaleMode::Ttl,
}
}
}
/// RST-on-close mode for accepted client sockets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RstOnCloseMode {
/// Normal FIN on all closes (default, no behaviour change).
#[default]
Off,
/// SO_LINGER(0) on accept; cleared after successful auth.
/// Pre-handshake failures (scanners, DPI, timeouts) send RST;
/// authenticated relay sessions close gracefully with FIN.
Errors,
/// SO_LINGER(0) on accept, never cleared — all closes send RST.
Always,
}
/// Middle-End writer floor policy mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MeFloorMode {
Static,
#[default]
Adaptive,
}
impl MeFloorMode {
pub fn as_u8(self) -> u8 {
match self {
MeFloorMode::Static => 0,
MeFloorMode::Adaptive => 1,
}
}
pub fn from_u8(raw: u8) -> Self {
match raw {
1 => MeFloorMode::Adaptive,
_ => MeFloorMode::Static,
}
}
}
/// Middle-End route behavior when no writer is immediately available.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MeRouteNoWriterMode {
AsyncRecoveryFailfast,
InlineRecoveryLegacy,
#[default]
HybridAsyncPersistent,
}
impl MeRouteNoWriterMode {
pub fn as_u8(self) -> u8 {
match self {
MeRouteNoWriterMode::AsyncRecoveryFailfast => 0,
MeRouteNoWriterMode::InlineRecoveryLegacy => 1,
MeRouteNoWriterMode::HybridAsyncPersistent => 2,
}
}
pub fn from_u8(raw: u8) -> Self {
match raw {
0 => MeRouteNoWriterMode::AsyncRecoveryFailfast,
1 => MeRouteNoWriterMode::InlineRecoveryLegacy,
2 => MeRouteNoWriterMode::HybridAsyncPersistent,
_ => MeRouteNoWriterMode::HybridAsyncPersistent,
}
}
}
/// Middle-End writer selection mode for new client bindings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MeWriterPickMode {
SortedRr,
#[default]
P2c,
}
impl MeWriterPickMode {
pub fn as_u8(self) -> u8 {
match self {
MeWriterPickMode::SortedRr => 0,
MeWriterPickMode::P2c => 1,
}
}
pub fn from_u8(raw: u8) -> Self {
match raw {
0 => MeWriterPickMode::SortedRr,
1 => MeWriterPickMode::P2c,
_ => MeWriterPickMode::P2c,
}
}
}
/// Per-user unique source IP limit mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UserMaxUniqueIpsMode {
/// Count only currently active source IPs.
#[default]
ActiveWindow,
/// Count source IPs seen within the recent time window.
TimeWindow,
/// Enforce both active and recent-window limits at the same time.
Combined,
}
/// Telemetry controls for hot-path counters and ME diagnostics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TelemetryConfig {
#[serde(default = "default_true")]
pub core_enabled: bool,
#[serde(default = "default_true")]
pub user_enabled: bool,
#[serde(default)]
pub me_level: MeTelemetryLevel,
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
core_enabled: default_true(),
user_enabled: default_true(),
me_level: MeTelemetryLevel::Normal,
}
}
}
+495
View File
@@ -0,0 +1,495 @@
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ConntrackMode {
#[default]
Tracked,
Notrack,
Hybrid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ConntrackBackend {
#[default]
Auto,
Nftables,
Iptables,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ConntrackPressureProfile {
Conservative,
#[default]
Balanced,
Aggressive,
}
impl ConntrackPressureProfile {
pub fn client_first_byte_idle_cap_secs(self) -> u64 {
match self {
Self::Conservative => 30,
Self::Balanced => 20,
Self::Aggressive => 10,
}
}
pub fn direct_activity_timeout_secs(self) -> u64 {
match self {
Self::Conservative => 180,
Self::Balanced => 120,
Self::Aggressive => 60,
}
}
pub fn middle_soft_idle_cap_secs(self) -> u64 {
match self {
Self::Conservative => 60,
Self::Balanced => 30,
Self::Aggressive => 20,
}
}
pub fn middle_hard_idle_cap_secs(self) -> u64 {
match self {
Self::Conservative => 180,
Self::Balanced => 90,
Self::Aggressive => 60,
}
}
}
/// 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 two-tier SYN-fix rules with the hashlimit match.
Iptables,
/// Use nftables two-tier SYN-fix rules with per-source token-bucket meters.
Nftables,
/// Use FreeBSD PF source tracking with connection-rate state limits.
Pf,
}
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"),
Self::Pf => serializer.serialize_str("pf"),
}
}
}
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, nftables, or pf")
}
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\", \"nftables\", or \"pf\"",
))
} 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),
"pf" => Ok(SynLimitMode::Pf),
_ => Err(E::custom(
"synlimit must be false, \"iptables\", \"nftables\", or \"pf\"",
)),
}
}
}
deserializer.deserialize_any(SynLimitModeVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConntrackControlConfig {
/// Enables runtime conntrack-control worker for pressure mitigation.
#[serde(default = "default_conntrack_control_enabled")]
pub inline_conntrack_control: bool,
/// Tracks whether inline_conntrack_control was explicitly set in config.
#[serde(skip)]
pub inline_conntrack_control_explicit: bool,
/// Conntrack mode for listener ingress traffic.
#[serde(default)]
pub mode: ConntrackMode,
/// Netfilter backend used to reconcile notrack rules.
#[serde(default)]
pub backend: ConntrackBackend,
/// Pressure profile for timeout caps under resource saturation.
#[serde(default)]
pub profile: ConntrackPressureProfile,
/// Listener IP allow-list for hybrid mode.
/// Ignored in tracked/notrack mode.
#[serde(default)]
pub hybrid_listener_ips: Vec<IpAddr>,
/// Pressure high watermark as percentage.
#[serde(default = "default_conntrack_pressure_high_watermark_pct")]
pub pressure_high_watermark_pct: u8,
/// Pressure low watermark as percentage.
#[serde(default = "default_conntrack_pressure_low_watermark_pct")]
pub pressure_low_watermark_pct: u8,
/// Maximum conntrack delete operations per second.
#[serde(default = "default_conntrack_delete_budget_per_sec")]
pub delete_budget_per_sec: u64,
}
impl Default for ConntrackControlConfig {
fn default() -> Self {
Self {
inline_conntrack_control: default_conntrack_control_enabled(),
inline_conntrack_control_explicit: false,
mode: ConntrackMode::default(),
backend: ConntrackBackend::default(),
profile: ConntrackPressureProfile::default(),
hybrid_listener_ips: Vec::new(),
pressure_high_watermark_pct: default_conntrack_pressure_high_watermark_pct(),
pressure_low_watermark_pct: default_conntrack_pressure_low_watermark_pct(),
delete_budget_per_sec: default_conntrack_delete_budget_per_sec(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
/// Legacy listener port used for backward compatibility.
/// For new configs prefer `[[server.listeners]].port`.
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_listen_addr_ipv4")]
pub listen_addr_ipv4: Option<String>,
#[serde(default = "default_listen_addr_ipv6_opt")]
pub listen_addr_ipv6: Option<String>,
#[serde(default)]
pub listen_unix_sock: Option<String>,
/// Unix socket file permissions (octal, e.g. "0666" or "0777").
/// Applied via chmod after bind. Default: no change (inherits umask).
#[serde(default)]
pub listen_unix_sock_perm: Option<String>,
/// Enable TCP listening. Default: true when no unix socket, false when
/// listen_unix_sock is set. Set explicitly to override auto-detection.
#[serde(default)]
pub listen_tcp: Option<bool>,
/// Client-facing TCP MSS preset or custom value for all TCP listeners.
/// Empty string or omitted value keeps the kernel default.
#[serde(default)]
pub client_mss: Option<String>,
/// Experimental Linux-only bulk MSS used with best-effort userspace
/// chunking of the authenticated FakeTLS response. TCP offloads, loss, and
/// retransmission may coalesce write boundaries. Empty or omitted keeps
/// `client_mss` connection-wide. Uses the same preset/integer grammar as
/// `client_mss`.
#[serde(default)]
pub client_mss_bulk: Option<String>,
/// Accept HAProxy PROXY protocol headers on incoming connections.
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
#[serde(default)]
pub proxy_protocol: bool,
/// Timeout in milliseconds for reading and parsing PROXY protocol headers.
#[serde(default = "default_proxy_protocol_header_timeout_ms")]
pub proxy_protocol_header_timeout_ms: u64,
/// Trusted source CIDRs allowed to send incoming PROXY protocol headers.
///
/// If this field is omitted in config, it defaults to trust-all CIDRs
/// (`0.0.0.0/0` and `::/0`). If it is explicitly set to an empty list,
/// all PROXY protocol headers are rejected.
#[serde(default = "default_proxy_protocol_trusted_cidrs")]
pub proxy_protocol_trusted_cidrs: Vec<IpNetwork>,
/// Port for the Prometheus-compatible metrics endpoint.
/// Enables metrics when set; binds on all interfaces (dual-stack) by default.
#[serde(default)]
pub metrics_port: Option<u16>,
/// Listen address for metrics in `IP:PORT` format (e.g. `"127.0.0.1:9090"`).
/// When set, takes precedence over `metrics_port` and binds on the specified address only.
#[serde(default)]
pub metrics_listen: Option<String>,
/// CIDR whitelist for the metrics endpoint.
#[serde(default = "default_metrics_whitelist")]
pub metrics_whitelist: Vec<IpNetwork>,
#[serde(default, alias = "admin_api")]
pub api: ApiConfig,
#[serde(default)]
pub listeners: Vec<ListenerConfig>,
/// TCP `listen(2)` backlog for client-facing sockets (also used for the metrics HTTP listener).
/// The effective queue is capped by the kernel (for example `somaxconn` on Linux).
#[serde(default = "default_listen_backlog")]
pub listen_backlog: u32,
/// Maximum number of concurrent client connections.
/// 0 means unlimited.
#[serde(default = "default_server_max_connections")]
pub max_connections: u32,
/// Maximum wait in milliseconds while acquiring a connection slot permit.
/// `0` keeps legacy unbounded wait behavior.
#[serde(default = "default_accept_permit_timeout_ms")]
pub accept_permit_timeout_ms: u64,
/// Runtime conntrack control and pressure policy.
#[serde(default)]
pub conntrack_control: ConntrackControlConfig,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: default_port(),
listen_addr_ipv4: default_listen_addr_ipv4(),
listen_addr_ipv6: default_listen_addr_ipv6_opt(),
listen_unix_sock: None,
listen_unix_sock_perm: None,
listen_tcp: None,
client_mss: None,
client_mss_bulk: None,
proxy_protocol: false,
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
metrics_port: None,
metrics_listen: None,
metrics_whitelist: default_metrics_whitelist(),
api: ApiConfig::default(),
listeners: Vec::new(),
listen_backlog: default_listen_backlog(),
max_connections: default_server_max_connections(),
accept_permit_timeout_ms: default_accept_permit_timeout_ms(),
conntrack_control: ConntrackControlConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeoutsConfig {
/// Maximum idle wait in seconds for the first client byte before handshake parsing starts.
/// `0` disables the separate idle phase and keeps legacy timeout behavior.
#[serde(default = "default_client_first_byte_idle_secs")]
pub client_first_byte_idle_secs: u64,
/// Maximum active handshake duration in seconds after the first client byte is received.
#[serde(default = "default_handshake_timeout")]
pub client_handshake: u64,
/// Enables soft/hard relay client idle policy for middle-relay sessions.
#[serde(default = "default_relay_idle_policy_v2_enabled")]
pub relay_idle_policy_v2_enabled: bool,
/// Soft idle threshold for middle-relay client uplink activity in seconds.
/// Hitting this threshold marks the session as idle-candidate, but does not close it.
#[serde(default = "default_relay_client_idle_soft_secs")]
pub relay_client_idle_soft_secs: u64,
/// Hard idle threshold for middle-relay client uplink activity in seconds.
/// Hitting this threshold closes the session.
#[serde(default = "default_relay_client_idle_hard_secs")]
pub relay_client_idle_hard_secs: u64,
/// Additional grace in seconds added to hard idle window after recent downstream activity.
#[serde(default = "default_relay_idle_grace_after_downstream_activity_secs")]
pub relay_idle_grace_after_downstream_activity_secs: u64,
#[serde(default = "default_keepalive")]
pub client_keepalive: u64,
#[serde(default = "default_ack_timeout")]
pub client_ack: u64,
/// Number of quick ME reconnect attempts for single-address DC.
#[serde(default = "default_me_one_retry")]
pub me_one_retry: u8,
/// Timeout per quick attempt in milliseconds for single-address DC.
#[serde(default = "default_me_one_timeout")]
pub me_one_timeout_ms: u64,
}
impl Default for TimeoutsConfig {
fn default() -> Self {
Self {
client_first_byte_idle_secs: default_client_first_byte_idle_secs(),
client_handshake: default_handshake_timeout(),
relay_idle_policy_v2_enabled: default_relay_idle_policy_v2_enabled(),
relay_client_idle_soft_secs: default_relay_client_idle_soft_secs(),
relay_client_idle_hard_secs: default_relay_client_idle_hard_secs(),
relay_idle_grace_after_downstream_activity_secs:
default_relay_idle_grace_after_downstream_activity_secs(),
client_keepalive: default_keepalive(),
client_ack: default_ack_timeout(),
me_one_retry: default_me_one_retry(),
me_one_timeout_ms: default_me_one_timeout(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListenerConfig {
pub ip: IpAddr,
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
#[serde(default)]
pub port: Option<u16>,
/// Per-listener client-facing TCP MSS preset or custom value.
/// Empty string disables MSS shaping for this listener.
#[serde(default)]
pub client_mss: Option<String>,
/// Per-listener SYN limiter mode.
#[serde(default)]
pub synlimit: SynLimitMode,
/// Generic SYN-fix token-bucket rate interval.
#[serde(default = "default_synlimit_seconds")]
pub synlimit_seconds: u32,
/// Generic SYN-fix token-bucket rate amount.
#[serde(default = "default_synlimit_hitcount")]
pub synlimit_hitcount: u32,
/// Generic SYN-fix token-bucket burst size.
#[serde(default = "default_synlimit_burst")]
pub synlimit_burst: u32,
/// iOS-like SYN-fix token-bucket rate interval.
#[serde(default = "default_synlimit_ios_seconds")]
pub synlimit_ios_seconds: u32,
/// iOS-like SYN-fix token-bucket rate amount.
#[serde(default = "default_synlimit_ios_hitcount")]
pub synlimit_ios_hitcount: u32,
/// iOS-like SYN-fix token-bucket burst size.
#[serde(default = "default_synlimit_ios_burst")]
pub synlimit_ios_burst: u32,
/// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules.
#[serde(default = "default_synlimit_hashlimit_expire_ms")]
pub synlimit_hashlimit_expire_ms: u32,
/// Hashlimit table size for iptables/ip6tables rules.
#[serde(default = "default_synlimit_hashlimit_size")]
pub synlimit_hashlimit_size: u32,
/// IP address or hostname to announce in proxy links.
/// Takes precedence over `announce_ip` if both are set.
#[serde(default)]
pub announce: Option<String>,
/// Deprecated: Use `announce` instead. IP address to announce in proxy links.
/// Migrated to `announce` automatically if `announce` is not set.
#[serde(default)]
pub announce_ip: Option<IpAddr>,
/// Per-listener PROXY protocol override. When set, overrides global server.proxy_protocol.
#[serde(default)]
pub proxy_protocol: Option<bool>,
/// Allow multiple telemt instances to listen on the same IP:port (SO_REUSEPORT).
/// Default is false for safety.
#[serde(default)]
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 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))
}
+8 -6
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
@@ -57,10 +58,11 @@ impl PressureState {
}
}
pub(crate) fn spawn_conntrack_controller(
pub(crate) async fn run_conntrack_controller(
config_rx: watch::Receiver<Arc<ProxyConfig>>,
stats: Arc<Stats>,
shared: Arc<ProxySharedState>,
cancellation: CancellationToken,
) {
if !cfg!(target_os = "linux") {
let cfg = config_rx.borrow();
@@ -87,16 +89,15 @@ pub(crate) fn spawn_conntrack_controller(
let (tx, rx) = mpsc::channel(CONNTRACK_EVENT_QUEUE_CAPACITY);
shared.set_conntrack_close_sender(tx);
tokio::spawn(async move {
run_conntrack_controller(config_rx, stats, shared, rx).await;
});
run_conntrack_controller_worker(config_rx, stats, shared, rx, cancellation).await;
}
async fn run_conntrack_controller(
async fn run_conntrack_controller_worker(
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
stats: Arc<Stats>,
shared: Arc<ProxySharedState>,
mut close_rx: mpsc::Receiver<ConntrackCloseEvent>,
cancellation: CancellationToken,
) {
let mut cfg = config_rx.borrow().clone();
let mut pressure_state = PressureState::new(stats.as_ref());
@@ -115,6 +116,7 @@ async fn run_conntrack_controller(
loop {
tokio::select! {
_ = cancellation.cancelled() => break,
changed = config_rx.changed() => {
if changed.is_err() {
break;
@@ -705,7 +707,7 @@ fn nofile_soft_limit() -> Option<u64> {
if rc != 0 {
return None;
}
return Some(lim.rlim_cur);
return Some(lim.rlim_cur.into());
}
#[cfg(not(target_os = "linux"))]
{
+18 -12
View File
@@ -4,12 +4,12 @@
//!
//! ## Zeroize policy
//!
//! - `AesCbc` stores raw key/IV bytes and zeroizes them on drop.
//! - `AesCtr` wraps an opaque `Aes256Ctr` cipher from the `ctr` crate.
//! The expanded key schedule lives inside that type and cannot be
//! zeroized from outside. Callers that hold raw key material (e.g.
//! `HandshakeSuccess`, `ObfuscationParams`) are responsible for
//! zeroizing their own copies.
//! - `AesCbc` stores raw key/IV bytes and zeroizes them on drop. Temporary
//! expanded key schedules are also zeroized by the RustCrypto backend.
//! - `AesCtr` uses the RustCrypto `zeroize` contract to clear its expanded
//! key schedule, counter, and buffered keystream on drop.
//! - Callers that hold raw key material (e.g. `HandshakeSuccess`,
//! `ObfuscationParams`) remain responsible for zeroizing their own copies.
#![allow(dead_code)]
@@ -19,24 +19,28 @@ use ctr::{
Ctr128BE,
cipher::{KeyIvInit, StreamCipher},
};
use zeroize::Zeroize;
use zeroize::{Zeroize, ZeroizeOnDrop};
type Aes256Ctr = Ctr128BE<Aes256>;
static_assertions::assert_impl_all!(Aes256: ZeroizeOnDrop);
static_assertions::assert_impl_all!(Aes256Ctr: ZeroizeOnDrop);
// ============= AES-256-CTR =============
/// AES-256-CTR encryptor/decryptor
///
/// CTR mode is symmetric — encryption and decryption are the same operation.
///
/// **Zeroize note:** The inner `Aes256Ctr` cipher state (expanded key schedule
/// + counter) is opaque and cannot be zeroized. If you need to protect key
/// material, zeroize the `[u8; 32]` key and `u128` IV at the call site
/// before dropping them.
/// **Zeroize note:** The inner `Aes256Ctr` zeroizes its expanded key schedule,
/// counter, and buffered keystream on drop. Callers remain responsible for
/// zeroizing their own raw key and IV copies.
pub struct AesCtr {
cipher: Aes256Ctr,
}
impl ZeroizeOnDrop for AesCtr {}
impl AesCtr {
/// Create new AES-CTR cipher with key and IV
pub fn new(key: &[u8; 32], iv: u128) -> Self {
@@ -92,7 +96,7 @@ impl AesCtr {
/// are different operations. This implementation handles CBC chaining
/// correctly across multiple blocks.
///
/// Key and IV are zeroized on drop.
/// Key, IV, and temporary expanded key schedules are zeroized on drop.
pub struct AesCbc {
key: [u8; 32],
iv: [u8; 16],
@@ -105,6 +109,8 @@ impl Drop for AesCbc {
}
}
impl ZeroizeOnDrop for AesCbc {}
impl AesCbc {
/// AES block size
const BLOCK_SIZE: usize = 16;
+3 -3
View File
@@ -5,7 +5,7 @@ pub mod hash;
pub mod random;
pub use aes::{AesCbc, AesCtr};
pub use hash::{
build_middleproxy_prekey, crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac,
};
#[allow(unused_imports)]
pub use hash::build_middleproxy_prekey;
pub use hash::{crc32, crc32c, derive_middleproxy_keys, sha256, sha256_hmac};
pub use random::SecureRandom;
+3
View File
@@ -245,6 +245,9 @@ pub enum ProxyError {
InvalidSecret { user: String, reason: String },
// ============= User Errors =============
#[error("User {user} disabled")]
UserDisabled { user: String },
#[error("User {user} expired")]
UserExpired { user: String },
+222 -101
View File
@@ -5,16 +5,41 @@
//! - syslog (Unix only, for traditional init systems)
//! - file (with optional rotation)
#![allow(dead_code)] // Infrastructure module - used via CLI flags
// Infrastructure module used via CLI flags.
#![allow(dead_code)]
use std::path::Path;
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt, reload};
// Submodules:
// - file: bounded file appender for size and retention controls.
mod file;
#[cfg(test)]
mod tests;
/// File logging and retention options resolved from config and CLI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileLogOptions {
/// Log file path or rolling filename prefix path.
pub path: String,
/// Time rotation interval.
pub rotation: LogRotation,
/// Maximum active file size before size rotation. `0` disables it.
pub max_size_bytes: u64,
/// Maximum number of matching log files to keep. `0` disables it.
pub max_files: usize,
/// Maximum rotated file age in seconds. `0` disables it.
pub max_age_secs: u64,
}
/// Log destination configuration.
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LogDestination {
/// Log to stderr (default, captured by systemd journald).
#[default]
@@ -24,12 +49,29 @@ pub enum LogDestination {
Syslog,
/// Log to a file with optional rotation.
File {
path: String,
/// Rotate daily if true.
rotate_daily: bool,
/// Resolved file logging options.
options: FileLogOptions,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LogCliDestination {
Stderr,
Syslog,
File,
}
/// Logging-related CLI overrides.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LogCliOptions {
destination: Option<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.
#[derive(Debug, Clone, Default)]
pub struct LoggingOptions {
@@ -101,23 +143,29 @@ pub fn init_logging(
(filter_handle, LoggingGuard::noop())
}
LogDestination::File { path, rotate_daily } => {
let (non_blocking, guard) = if *rotate_daily {
// Extract directory and filename prefix
let path = Path::new(path);
let dir = path.parent().unwrap_or(Path::new("/var/log"));
let prefix = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("telemt");
let file_appender = tracing_appender::rolling::daily(dir, prefix);
LogDestination::File { options } => {
let (non_blocking, guard) = if options.max_size_bytes > 0
|| options.max_files > 0
|| options.max_age_secs > 0
{
let file_appender = file::BoundedFileAppender::new(options.clone())
.expect("Failed to open log file");
tracing_appender::non_blocking(file_appender)
} else if !matches!(options.rotation, LogRotation::Never) {
let path = Path::new(&options.path);
let dir = log_file_dir(path);
let prefix = log_file_name(path);
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(to_tracing_rotation(options.rotation))
.filename_prefix(prefix)
.build(dir)
.expect("Failed to open log file");
tracing_appender::non_blocking(file_appender)
} else {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.open(&options.path)
.expect("Failed to open log file");
tracing_appender::non_blocking(file)
};
@@ -137,6 +185,28 @@ pub fn init_logging(
}
}
fn log_file_dir(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn log_file_name(path: &Path) -> &str {
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("telemt")
}
fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation {
match rotation {
LogRotation::Never => tracing_appender::rolling::Rotation::NEVER,
LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY,
LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY,
}
}
/// Syslog writer for tracing.
#[cfg(unix)]
#[derive(Clone, Copy)]
@@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter {
}
}
/// Parse log destination from CLI arguments.
pub fn parse_log_destination(args: &[String]) -> LogDestination {
/// Parse logging overrides from CLI arguments.
pub fn parse_log_cli_options(args: &[String]) -> Result<LogCliOptions, String> {
let mut options = LogCliOptions::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
#[cfg(unix)]
"--syslog" => {
return LogDestination::Syslog;
options.destination = Some(LogCliDestination::Syslog);
}
#[cfg(not(unix))]
"--syslog" => {
options.destination = Some(LogCliDestination::Syslog);
}
"--log-file" => {
i += 1;
if i < args.len() {
return LogDestination::File {
path: args[i].clone(),
rotate_daily: false,
};
options.destination = Some(LogCliDestination::File);
options.path = Some(args[i].clone());
} else {
return Err("Missing value for --log-file".to_string());
}
}
s if s.starts_with("--log-file=") => {
return LogDestination::File {
path: s.trim_start_matches("--log-file=").to_string(),
rotate_daily: false,
};
options.destination = Some(LogCliDestination::File);
options.path = Some(s.trim_start_matches("--log-file=").to_string());
}
"--log-file-daily" => {
i += 1;
if i < args.len() {
return LogDestination::File {
path: args[i].clone(),
rotate_daily: true,
};
options.destination = Some(LogCliDestination::File);
options.path = Some(args[i].clone());
options.rotation = Some(LogRotation::Daily);
} else {
return Err("Missing value for --log-file-daily".to_string());
}
}
s if s.starts_with("--log-file-daily=") => {
return LogDestination::File {
path: s.trim_start_matches("--log-file-daily=").to_string(),
rotate_daily: true,
};
options.destination = Some(LogCliDestination::File);
options.path = Some(s.trim_start_matches("--log-file-daily=").to_string());
options.rotation = Some(LogRotation::Daily);
}
"--log-rotation" => {
i += 1;
if i < args.len() {
options.rotation = Some(parse_rotation_cli_value(&args[i])?);
} else {
return Err("Missing value for --log-rotation".to_string());
}
}
s if s.starts_with("--log-rotation=") => {
options.rotation = Some(parse_rotation_cli_value(
s.trim_start_matches("--log-rotation="),
)?);
}
"--log-max-size-bytes" => {
i += 1;
if i < args.len() {
options.max_size_bytes =
Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?);
} else {
return Err("Missing value for --log-max-size-bytes".to_string());
}
}
s if s.starts_with("--log-max-size-bytes=") => {
options.max_size_bytes = Some(parse_u64_cli_value(
"--log-max-size-bytes",
s.trim_start_matches("--log-max-size-bytes="),
)?);
}
"--log-max-files" => {
i += 1;
if i < args.len() {
options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?);
} else {
return Err("Missing value for --log-max-files".to_string());
}
}
s if s.starts_with("--log-max-files=") => {
options.max_files = Some(parse_usize_cli_value(
"--log-max-files",
s.trim_start_matches("--log-max-files="),
)?);
}
"--log-max-age-secs" => {
i += 1;
if i < args.len() {
options.max_age_secs =
Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?);
} else {
return Err("Missing value for --log-max-age-secs".to_string());
}
}
s if s.starts_with("--log-max-age-secs=") => {
options.max_age_secs = Some(parse_u64_cli_value(
"--log-max-age-secs",
s.trim_start_matches("--log-max-age-secs="),
)?);
}
_ => {}
}
i += 1;
}
LogDestination::Stderr
Ok(options)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_rotation_cli_value(value: &str) -> Result<LogRotation, String> {
LogRotation::from_cli_arg(value).ok_or_else(|| {
format!(
"Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly"
)
})
}
#[test]
fn test_parse_log_destination_default() {
let args: Vec<String> = vec![];
assert!(matches!(
parse_log_destination(&args),
LogDestination::Stderr
));
}
fn parse_u64_cli_value(flag: &str, value: &str) -> Result<u64, String> {
value
.parse::<u64>()
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
}
#[test]
fn test_parse_log_destination_file() {
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
match parse_log_destination(&args) {
LogDestination::File { path, rotate_daily } => {
assert_eq!(path, "/var/log/telemt.log");
assert!(!rotate_daily);
fn parse_usize_cli_value(flag: &str, value: &str) -> Result<usize, String> {
value
.parse::<usize>()
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
}
/// Resolve effective logging destination from config and CLI overrides.
pub fn resolve_log_destination(
config: &LoggingConfig,
cli: &LogCliOptions,
) -> Result<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"),
}
}
#[test]
fn test_parse_log_destination_file_daily() {
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
match parse_log_destination(&args) {
LogDestination::File { path, rotate_daily } => {
assert_eq!(path, "/var/log/telemt");
assert!(rotate_daily);
#[cfg(not(unix))]
{
Err("Syslog logging is only supported on Unix platforms".to_string())
}
_ => panic!("Expected File destination"),
}
}
LogCliDestination::File => {
let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| {
"logging.path or --log-file must be set when file logging is enabled".to_string()
})?;
if path.trim().is_empty() {
return Err("Log file path cannot be empty".to_string());
}
#[cfg(unix)]
#[test]
fn test_parse_log_destination_syslog() {
let args = vec!["--syslog".to_string()];
assert!(matches!(
parse_log_destination(&args),
LogDestination::Syslog
));
}
#[cfg(unix)]
#[test]
fn test_syslog_priority_for_level_mapping() {
assert_eq!(
syslog_priority_for_level(&tracing::Level::ERROR),
libc::LOG_ERR
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::WARN),
libc::LOG_WARNING
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::INFO),
libc::LOG_INFO
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::DEBUG),
libc::LOG_DEBUG
);
assert_eq!(
syslog_priority_for_level(&tracing::Level::TRACE),
libc::LOG_DEBUG
);
Ok(LogDestination::File {
options: FileLogOptions {
path: path.clone(),
rotation: cli.rotation.unwrap_or(config.rotation),
max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes),
max_files: cli.max_files.unwrap_or(config.max_files),
max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs),
},
})
}
}
}
+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
);
}
+4 -1
View File
@@ -8,6 +8,8 @@ use crate::config::ProxyConfig;
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
use crate::transport::middle_proxy::MePool;
use super::generation::RuntimeTaskScope;
const STARTUP_FALLBACK_AFTER: Duration = Duration::from_secs(80);
const RUNTIME_FALLBACK_AFTER: Duration = Duration::from_secs(6);
@@ -19,6 +21,7 @@ pub(crate) async fn configure_admission_gate(
admission_tx: &watch::Sender<bool>,
config_rx: watch::Receiver<Arc<ProxyConfig>>,
me_ready_rx: watch::Receiver<u64>,
task_scope: RuntimeTaskScope,
) {
if config.general.use_middle_proxy {
if me_pool.is_some() || config.general.me2dc_fallback {
@@ -64,7 +67,7 @@ pub(crate) async fn configure_admission_gate(
let mut config_rx_gate = config_rx.clone();
let mut me_ready_rx_gate = me_ready_rx;
let mut admission_poll_ms = config.general.me_admission_poll_ms.max(1);
tokio::spawn(async move {
task_scope.spawn(async move {
let mut gate_open = initial_gate_open;
let mut route_mode = initial_route_mode;
let mut ready_observed = initial_ready;
+382
View File
@@ -0,0 +1,382 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::{info, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload};
use crate::config::{LogLevel, ProxyConfig};
use crate::startup::{COMPONENT_CONFIG_LOAD, COMPONENT_TRACING_INIT, StartupTracker};
use super::helpers::{
parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path,
set_maestro_colors_enabled,
};
use super::runtime_tasks;
use super::validate_synlimit_privilege_drop;
pub(super) struct BootstrapState {
pub(super) process_started_at: Instant,
pub(super) process_started_at_epoch_secs: u64,
pub(super) startup_tracker: Arc<StartupTracker>,
pub(super) config: ProxyConfig,
pub(super) config_path: PathBuf,
pub(super) has_rust_log: bool,
pub(super) effective_log_level: LogLevel,
pub(super) runtime_log_filter: runtime_tasks::RuntimeLogFilter,
pub(super) logging_guard: Option<crate::logging::LoggingGuard>,
}
pub(super) async fn bootstrap(
privilege_drop_requested: bool,
) -> std::result::Result<BootstrapState, Box<dyn std::error::Error>> {
let process_started_at = Instant::now();
let process_started_at_epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let startup_tracker = Arc::new(StartupTracker::new(process_started_at_epoch_secs));
startup_tracker
.start_component(
COMPONENT_CONFIG_LOAD,
Some("load and validate config".to_string()),
)
.await;
let cli_args = parse_cli();
let config_path_cli = cli_args.config_path;
let config_path_explicit = cli_args.config_path_explicit;
let data_path = cli_args.data_path;
let cli_silent = cli_args.silent;
let cli_log_level = cli_args.log_level;
let log_cli_options = cli_args.log_cli_options;
let startup_cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(e) => {
eprintln!("[telemt] Can't read current_dir: {}", e);
std::process::exit(1);
}
};
if let Some(ref data_path) = data_path
&& !data_path.is_absolute()
{
eprintln!(
"[telemt] data_path must be absolute: {}",
data_path.display()
);
std::process::exit(1);
}
let mut config_path =
resolve_runtime_config_path(&config_path_cli, &startup_cwd, config_path_explicit);
let runtime_base_dir = resolve_runtime_base_dir(
&config_path,
&startup_cwd,
config_path_explicit,
data_path.as_deref(),
);
if !runtime_base_dir.exists()
&& let Err(e) = std::fs::create_dir_all(&runtime_base_dir)
{
eprintln!(
"[telemt] Can't create runtime directory {}: {}",
runtime_base_dir.display(),
e
);
std::process::exit(1);
}
if !runtime_base_dir.is_dir() {
eprintln!(
"[telemt] Runtime path exists but is not a directory: {}",
runtime_base_dir.display()
);
std::process::exit(1);
}
if let Err(e) = std::env::set_current_dir(&runtime_base_dir) {
eprintln!(
"[telemt] Can't use runtime directory {}: {}",
runtime_base_dir.display(),
e
);
std::process::exit(1);
}
let mut config = match ProxyConfig::load(&config_path) {
Ok(c) => c,
Err(e) => {
if config_path.exists() {
eprintln!("[telemt] Error: {}", e);
std::process::exit(1);
} else {
let default = ProxyConfig::default();
let serialized =
match toml::to_string_pretty(&default).or_else(|_| toml::to_string(&default)) {
Ok(value) => Some(value),
Err(serialize_error) => {
eprintln!(
"[telemt] Warning: failed to serialize default config: {}",
serialize_error
);
None
}
};
if config_path_explicit {
if let Some(serialized) = serialized.as_ref() {
if let Err(write_error) = std::fs::write(&config_path, serialized) {
eprintln!(
"[telemt] Error: failed to create explicit config at {}: {}",
config_path.display(),
write_error
);
std::process::exit(1);
}
eprintln!(
"[telemt] Created default config at {}",
config_path.display()
);
} else {
eprintln!(
"[telemt] Warning: running with in-memory default config without writing to disk"
);
}
} else {
let runtime_config_path = runtime_base_dir.join("telemt.toml");
let fallback_config_path = runtime_base_dir.join("config.toml");
let mut persisted = false;
if let Some(serialized) = serialized.as_ref() {
match std::fs::create_dir_all(&runtime_base_dir) {
Ok(()) => match std::fs::write(&runtime_config_path, serialized) {
Ok(()) => {
config_path = runtime_config_path;
eprintln!(
"[telemt] Created default config at {}",
config_path.display()
);
persisted = true;
}
Err(write_error) => {
eprintln!(
"[telemt] Warning: failed to write default config at {}: {}",
runtime_config_path.display(),
write_error
);
}
},
Err(create_error) => {
eprintln!(
"[telemt] Warning: failed to create {}: {}",
runtime_base_dir.display(),
create_error
);
}
}
if !persisted {
match std::fs::write(&fallback_config_path, serialized) {
Ok(()) => {
config_path = fallback_config_path;
eprintln!(
"[telemt] Created default config at {}",
config_path.display()
);
persisted = true;
}
Err(write_error) => {
eprintln!(
"[telemt] Warning: failed to write default config at {}: {}",
fallback_config_path.display(),
write_error
);
}
}
}
}
if !persisted {
eprintln!(
"[telemt] Warning: running with in-memory default config without writing to disk"
);
}
}
default
}
}
};
if let Err(e) = config.validate() {
eprintln!("[telemt] Invalid config: {}", e);
std::process::exit(1);
}
validate_synlimit_privilege_drop(&config, privilege_drop_requested)?;
if let Some(p) = data_path {
config.general.data_path = Some(p);
}
if let Some(ref data_path) = config.general.data_path {
if !data_path.is_absolute() {
eprintln!(
"[telemt] data_path must be absolute: {}",
data_path.display()
);
std::process::exit(1);
}
if data_path.exists() {
if !data_path.is_dir() {
eprintln!(
"[telemt] data_path exists but is not a directory: {}",
data_path.display()
);
std::process::exit(1);
}
} else if let Err(e) = std::fs::create_dir_all(data_path) {
eprintln!(
"[telemt] Can't create data_path {}: {}",
data_path.display(),
e
);
std::process::exit(1);
}
if let Err(e) = std::env::set_current_dir(data_path) {
eprintln!(
"[telemt] Can't use data_path {}: {}",
data_path.display(),
e
);
std::process::exit(1);
}
}
if let Err(e) = crate::network::dns_overrides::install_entries(&config.network.dns_overrides) {
eprintln!("[telemt] Invalid network.dns_overrides: {}", e);
std::process::exit(1);
}
set_maestro_colors_enabled(!config.general.disable_colors);
startup_tracker
.complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string()))
.await;
let has_rust_log = std::env::var("RUST_LOG").is_ok();
let effective_log_level = if cli_silent {
LogLevel::Silent
} else if let Some(ref s) = cli_log_level {
LogLevel::from_str_loose(s)
} else {
config.general.log_level.clone()
};
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) =
tracing_reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
startup_tracker
.start_component(
COMPONENT_TRACING_INIT,
Some("initialize tracing subscriber".to_string()),
)
.await;
let logging_guard: Option<crate::logging::LoggingGuard>;
match log_destination {
crate::logging::LogDestination::Stderr => {
let fmt_layer = if config.general.disable_colors {
fmt::Layer::default().with_ansi(false)
} else {
fmt::Layer::default().with_ansi(true)
};
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.init();
logging_guard = None;
}
#[cfg(unix)]
crate::logging::LogDestination::Syslog => {
let logging_opts = crate::logging::LoggingOptions {
destination: log_destination,
disable_colors: true,
};
let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec);
logging_guard = Some(guard);
}
crate::logging::LogDestination::File { .. } => {
let logging_opts = crate::logging::LoggingOptions {
destination: log_destination,
disable_colors: true,
};
let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec);
logging_guard = Some(guard);
}
}
let runtime_log_filter = runtime_tasks::RuntimeLogFilter::new(filter_handle);
startup_tracker
.complete_component(
COMPONENT_TRACING_INIT,
Some("tracing initialized".to_string()),
)
.await;
print_maestro_line(format!("Telemt MTProxy v{}", env!("CARGO_PKG_VERSION")));
info!("Log level: {}", effective_log_level);
if config.general.disable_colors {
info!("Colors: disabled");
}
info!(
"Modes: classic={} secure={} tls={}",
config.general.modes.classic, config.general.modes.secure, config.general.modes.tls
);
if config.general.modes.classic {
warn!("Classic mode is vulnerable to DPI detection; enable only for legacy clients");
}
info!("TLS domain: {}", config.censorship.tls_domain);
if let Some(ref sock) = config.censorship.mask_unix_sock {
info!("Mask: {} -> unix:{}", config.censorship.mask, sock);
if !std::path::Path::new(sock).exists() {
warn!(
"Unix socket '{}' does not exist yet. Masking will fail until it appears.",
sock
);
}
} else {
info!(
"Mask: {} -> {}:{}",
config.censorship.mask,
config
.censorship
.mask_host
.as_deref()
.unwrap_or(&config.censorship.tls_domain),
config.censorship.mask_port
);
}
if config.censorship.tls_domain == "www.google.com" {
warn!("Using default tls_domain. Consider setting a custom domain.");
}
Ok(BootstrapState {
process_started_at,
process_started_at_epoch_secs,
startup_tracker,
config,
config_path,
has_rust_log,
effective_log_level,
runtime_log_filter,
logging_guard,
})
}
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) async fn run_startup_connectivity(
.any(|r| r.rtt_ms.is_some());
if upstream_result.both_available {
if prefer_ipv6 {
if upstream_result.prefer_ipv6 {
info!(" IPv6 in use / IPv4 is fallback");
} else {
info!(" IPv4 in use / IPv6 is fallback");
+435
View File
@@ -0,0 +1,435 @@
use std::future::Future;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{RwLock, Semaphore, watch};
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config::ProxyConfig;
use crate::crypto::SecureRandom;
use crate::ip_tracker::UserIpTracker;
#[cfg(test)]
use crate::proxy::route_mode::RelayRouteMode;
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::stats::beobachten::BeobachtenStore;
use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool;
use crate::tls_front::TlsFrontCache;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
const SESSION_STOP_TIMEOUT: Duration = Duration::from_secs(5);
const BACKGROUND_STOP_TIMEOUT: Duration = Duration::from_secs(5);
const SESSION_ADMISSION_CLOSED: usize = 1 << (usize::BITS - 1);
const SESSION_REGISTRATION_COUNT: usize = SESSION_ADMISSION_CLOSED - 1;
struct SessionAdmission {
state: AtomicUsize,
}
struct SessionRegistration<'a> {
admission: &'a SessionAdmission,
}
impl SessionAdmission {
fn new() -> Self {
Self {
state: AtomicUsize::new(0),
}
}
fn try_register(&self) -> Option<SessionRegistration<'_>> {
let mut state = self.state.load(Ordering::Acquire);
loop {
if state & SESSION_ADMISSION_CLOSED != 0
|| state & SESSION_REGISTRATION_COUNT == SESSION_REGISTRATION_COUNT
{
return None;
}
match self.state.compare_exchange_weak(
state,
state + 1,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return Some(SessionRegistration { admission: self }),
Err(observed) => state = observed,
}
}
}
fn close(&self) {
self.state
.fetch_or(SESSION_ADMISSION_CLOSED, Ordering::AcqRel);
}
fn reopen(&self) {
self.state
.fetch_and(!SESSION_ADMISSION_CLOSED, Ordering::AcqRel);
}
async fn wait_for_registrations(&self) {
while self.state.load(Ordering::Acquire) & SESSION_REGISTRATION_COUNT != 0 {
tokio::task::yield_now().await;
}
}
}
impl Drop for SessionRegistration<'_> {
fn drop(&mut self) {
self.admission.state.fetch_sub(1, Ordering::Release);
}
}
/// Process-visible control-plane receivers for one active runtime generation.
#[derive(Clone)]
pub(crate) struct RuntimeWatchState {
pub(crate) generation_id: u64,
pub(crate) config_rx: watch::Receiver<Arc<ProxyConfig>>,
pub(crate) admission_rx: watch::Receiver<bool>,
}
/// Cancellation and join ownership for one generation's background tasks.
#[derive(Clone)]
pub(crate) struct RuntimeTaskScope {
tracker: TaskTracker,
cancel: CancellationToken,
}
impl RuntimeTaskScope {
/// Creates an open generation-owned task scope.
pub(crate) fn new() -> Self {
Self {
tracker: TaskTracker::new(),
cancel: CancellationToken::new(),
}
}
/// Spawns one task that is cancelled when the generation stops.
pub(crate) fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
let cancel = self.cancel.clone();
self.tracker.spawn(async move {
tokio::select! {
_ = cancel.cancelled() => {}
_ = future => {}
}
});
}
/// Returns the cancellation signal shared by generation-owned controllers.
pub(crate) fn cancellation_token(&self) -> CancellationToken {
self.cancel.clone()
}
/// Cancels the scope and waits within the bounded background-task budget.
pub(crate) async fn stop(&self) {
self.cancel.cancel();
self.tracker.close();
let _ = tokio::time::timeout(BACKGROUND_STOP_TIMEOUT, self.tracker.wait()).await;
}
}
/// Runtime-owned data plane and control-plane dependencies for one generation.
pub(crate) struct RuntimeGeneration {
pub(crate) id: u64,
pub(crate) config_rx: watch::Receiver<Arc<ProxyConfig>>,
pub(crate) admission_rx: watch::Receiver<bool>,
pub(crate) stats: Arc<Stats>,
pub(crate) upstream_manager: Arc<UpstreamManager>,
pub(crate) replay_checker: Arc<ReplayChecker>,
pub(crate) buffer_pool: Arc<BufferPool>,
pub(crate) rng: Arc<SecureRandom>,
pub(crate) me_pool: Option<Arc<MePool>>,
pub(crate) me_pool_runtime: Arc<RwLock<Option<Arc<MePool>>>>,
pub(crate) route_runtime: Arc<RouteRuntimeController>,
pub(crate) tls_cache: Option<Arc<TlsFrontCache>>,
pub(crate) ip_tracker: Arc<UserIpTracker>,
pub(crate) beobachten: Arc<BeobachtenStore>,
pub(crate) proxy_shared: Arc<ProxySharedState>,
pub(crate) max_connections: Arc<Semaphore>,
background_tasks: RuntimeTaskScope,
sessions: TaskTracker,
session_cancel: CancellationToken,
session_admission: SessionAdmission,
}
impl RuntimeGeneration {
#[allow(clippy::too_many_arguments)]
/// Builds one fully owned runtime generation.
pub(crate) fn new(
id: u64,
config_rx: watch::Receiver<Arc<ProxyConfig>>,
admission_rx: watch::Receiver<bool>,
stats: Arc<Stats>,
upstream_manager: Arc<UpstreamManager>,
replay_checker: Arc<ReplayChecker>,
buffer_pool: Arc<BufferPool>,
rng: Arc<SecureRandom>,
me_pool: Option<Arc<MePool>>,
me_pool_runtime: Arc<RwLock<Option<Arc<MePool>>>>,
route_runtime: Arc<RouteRuntimeController>,
tls_cache: Option<Arc<TlsFrontCache>>,
ip_tracker: Arc<UserIpTracker>,
beobachten: Arc<BeobachtenStore>,
proxy_shared: Arc<ProxySharedState>,
max_connections: Arc<Semaphore>,
background_tasks: RuntimeTaskScope,
) -> Arc<Self> {
Arc::new(Self {
id,
config_rx,
admission_rx,
stats,
upstream_manager,
replay_checker,
buffer_pool,
rng,
me_pool,
me_pool_runtime,
route_runtime,
tls_cache,
ip_tracker,
beobachten,
proxy_shared,
max_connections,
background_tasks,
sessions: TaskTracker::new(),
session_cancel: CancellationToken::new(),
session_admission: SessionAdmission::new(),
})
}
/// Returns the latest hot-reloaded configuration for this generation.
pub(crate) fn config(&self) -> Arc<ProxyConfig> {
self.config_rx.borrow().clone()
}
/// Returns receivers used by process-scoped observers of this generation.
pub(crate) fn watch_state(&self) -> RuntimeWatchState {
RuntimeWatchState {
generation_id: self.id,
config_rx: self.config_rx.clone(),
admission_rx: self.admission_rx.clone(),
}
}
/// Returns the initial or asynchronously published Middle-End pool.
pub(crate) async fn current_me_pool(&self) -> Option<Arc<MePool>> {
if let Some(pool) = &self.me_pool {
return Some(pool.clone());
}
self.me_pool_runtime.read().await.clone()
}
/// Registers a session only while admission remains open.
pub(crate) fn spawn_session<F>(&self, future: F) -> bool
where
F: Future<Output = ()> + Send + 'static,
{
let Some(_registration) = self.session_admission.try_register() else {
return false;
};
let cancel = self.session_cancel.clone();
self.sessions.spawn(async move {
tokio::select! {
_ = cancel.cancelled() => {}
_ = future => {}
}
});
true
}
/// Closes admission while preserving already registered sessions.
pub(crate) fn stop_accepting_sessions(&self) {
self.session_admission.close();
}
/// Reopens admission after a candidate activation rolls back.
pub(crate) fn resume_accepting_sessions(&self) {
self.session_admission.reopen();
}
/// Waits for registered sessions and cancels them when the deadline expires.
pub(crate) async fn drain_sessions(&self, timeout: Duration) -> bool {
self.stop_accepting_sessions();
self.session_admission.wait_for_registrations().await;
self.sessions.close();
if tokio::time::timeout(timeout, self.sessions.wait())
.await
.is_ok()
{
return true;
}
self.stop_sessions().await;
false
}
/// Cancels all sessions and waits within the bounded session-stop budget.
pub(crate) async fn stop_sessions(&self) {
self.stop_accepting_sessions();
self.session_admission.wait_for_registrations().await;
self.session_cancel.cancel();
self.sessions.close();
let _ = tokio::time::timeout(SESSION_STOP_TIMEOUT, self.sessions.wait()).await;
}
/// Stops all background tasks owned by this generation.
pub(crate) async fn stop_background_tasks(&self) {
self.background_tasks.stop().await;
}
}
#[cfg(test)]
/// Builds a lightweight runtime generation without network startup tasks.
pub(super) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc<RuntimeGeneration> {
let (config_tx, config_rx) = watch::channel(Arc::new(config.clone()));
let (_admission_tx, admission_rx) = watch::channel(true);
let stats = Arc::new(Stats::new());
let upstream_manager = Arc::new(UpstreamManager::new(
config.upstreams,
config.general.upstream_connect_retry_attempts,
config.general.upstream_connect_retry_backoff_ms,
config.general.upstream_connect_budget_ms,
config.general.tg_connect,
config.general.upstream_unhealthy_fail_threshold,
config.general.upstream_connect_failfast_hard_errors,
stats.clone(),
));
let _config_tx = config_tx;
RuntimeGeneration::new(
id,
config_rx,
admission_rx,
stats,
upstream_manager,
Arc::new(ReplayChecker::new(128, Duration::from_secs(60))),
Arc::new(BufferPool::with_config(4096, 16)),
Arc::new(SecureRandom::new()),
None,
Arc::new(RwLock::new(None)),
Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct)),
None,
Arc::new(UserIpTracker::new()),
Arc::new(BeobachtenStore::new()),
ProxySharedState::new(),
Arc::new(Semaphore::new(64)),
RuntimeTaskScope::new(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::Barrier;
#[tokio::test]
async fn stop_sessions_cancels_tracked_future() {
let generation = test_runtime_generation(1, ProxyConfig::default());
let started = Arc::new(tokio::sync::Notify::new());
let dropped = Arc::new(tokio::sync::Notify::new());
let started_task = started.clone();
let dropped_task = dropped.clone();
assert!(generation.spawn_session(async move {
struct DropSignal(Arc<tokio::sync::Notify>);
impl Drop for DropSignal {
fn drop(&mut self) {
self.0.notify_one();
}
}
let _drop_signal = DropSignal(dropped_task);
started_task.notify_one();
std::future::pending::<()>().await;
}));
started.notified().await;
generation.stop_sessions().await;
tokio::time::timeout(Duration::from_secs(1), dropped.notified())
.await
.unwrap();
assert!(!generation.spawn_session(async {}));
}
#[tokio::test]
async fn runtime_task_scope_joins_cancelled_background_task() {
let scope = RuntimeTaskScope::new();
scope.spawn(std::future::pending());
tokio::time::timeout(Duration::from_secs(1), scope.stop())
.await
.unwrap();
}
#[tokio::test]
async fn session_admission_waits_for_registration_started_before_cutover() {
let admission = Arc::new(SessionAdmission::new());
let registration = admission.try_register().unwrap();
admission.close();
assert!(admission.try_register().is_none());
let wait_admission = admission.clone();
let waiter = tokio::spawn(async move {
wait_admission.wait_for_registrations().await;
});
tokio::task::yield_now().await;
assert!(!waiter.is_finished());
drop(registration);
tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.unwrap()
.unwrap();
admission.reopen();
assert!(admission.try_register().is_some());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cutover_never_leaves_late_session_registrations() {
const ATTEMPTS: usize = 10_000;
let admission = Arc::new(SessionAdmission::new());
let tracker = TaskTracker::new();
let cancel = CancellationToken::new();
let start = Arc::new(Barrier::new(ATTEMPTS + 1));
let live = Arc::new(AtomicUsize::new(0));
let mut attempts = tokio::task::JoinSet::new();
for _ in 0..ATTEMPTS {
let admission = admission.clone();
let tracker = tracker.clone();
let cancel = cancel.clone();
let start = start.clone();
let live = live.clone();
attempts.spawn(async move {
start.wait().await;
let Some(_registration) = admission.try_register() else {
return;
};
tracker.spawn(async move {
live.fetch_add(1, Ordering::AcqRel);
cancel.cancelled().await;
live.fetch_sub(1, Ordering::AcqRel);
});
});
}
start.wait().await;
admission.close();
admission.wait_for_registrations().await;
tracker.close();
cancel.cancel();
while attempts.join_next().await.is_some() {}
tokio::time::timeout(Duration::from_secs(1), tracker.wait())
.await
.unwrap();
assert_eq!(live.load(Ordering::Acquire), 0);
assert!(admission.try_register().is_none());
}
}
+67 -9
View File
@@ -1,6 +1,7 @@
#![allow(clippy::items_after_test_module)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::watch;
@@ -8,7 +9,7 @@ use tracing::{debug, error, info, warn};
use crate::cli;
use crate::config::ProxyConfig;
use crate::logging::LogDestination;
use crate::logging::LogCliOptions;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::{
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
@@ -18,8 +19,27 @@ use crate::transport::middle_proxy::{
const MAESTRO_COLOR: &str = "\x1b[92m";
const COLOR_RESET: &str = "\x1b[0m";
static MAESTRO_COLORS_ENABLED: AtomicBool = AtomicBool::new(true);
/// Enables or disables ANSI color in direct MAESTRO status lines.
pub(crate) fn set_maestro_colors_enabled(enabled: bool) {
MAESTRO_COLORS_ENABLED.store(enabled, Ordering::Relaxed);
}
fn format_maestro_line(message: impl AsRef<str>, colors_enabled: bool) -> String {
if colors_enabled {
format!("{MAESTRO_COLOR}MAESTRO{COLOR_RESET}: {}", message.as_ref())
} else {
format!("MAESTRO: {}", message.as_ref())
}
}
/// Prints a direct MAESTRO status line outside the tracing subscriber.
pub(crate) fn print_maestro_line(message: impl AsRef<str>) {
eprintln!("{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(
@@ -93,7 +113,7 @@ pub(crate) struct CliArgs {
pub data_path: Option<PathBuf>,
pub silent: bool,
pub log_level: Option<String>,
pub log_destination: LogDestination,
pub log_cli_options: LogCliOptions,
}
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();
// Parse log destination
let log_destination = crate::logging::parse_log_destination(&args);
let log_cli_options = match crate::logging::parse_log_cli_options(&args) {
Ok(options) => options,
Err(error) => {
eprintln!("[telemt] {error}");
std::process::exit(2);
}
};
// Check for --init first (handled before tokio)
if let Some(init_opts) = cli::parse_init_args(&args) {
@@ -160,6 +185,21 @@ pub(crate) fn parse_cli() -> CliArgs {
s if s.starts_with("--log-level=") => {
log_level = Some(s.trim_start_matches("--log-level=").to_string());
}
"--log-file" | "--log-file-daily" => {
i += 1;
}
s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {}
"--log-rotation"
| "--log-max-size-bytes"
| "--log-max-files"
| "--log-max-age-secs" => {
i += 1;
}
s if s.starts_with("--log-rotation=")
|| s.starts_with("--log-max-size-bytes=")
|| s.starts_with("--log-max-files=")
|| s.starts_with("--log-max-age-secs=") => {}
"--syslog" => {}
"--help" | "-h" => {
print_help();
std::process::exit(0);
@@ -172,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs {
"--daemon" | "-d" | "--foreground" | "-f" => {}
s if s.starts_with("--pid-file") => {
if !s.contains('=') {
i += 1; // skip value
// Skip the pid-file value consumed by daemon argument parsing.
i += 1;
}
}
s if s.starts_with("--run-as-user") => {
@@ -204,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs {
data_path,
silent,
log_level,
log_destination,
log_cli_options,
}
}
@@ -234,6 +275,10 @@ fn print_help() {
eprintln!("Logging options:");
eprintln!(" --log-file <PATH> Log to file (default: stderr)");
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)]
eprintln!(" --syslog Log to syslog (Unix only)");
eprintln!();
@@ -274,11 +319,24 @@ mod tests {
use std::path::{Path, PathBuf};
use super::{
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
resolve_runtime_base_dir, resolve_runtime_config_path,
expected_handshake_close_description, format_maestro_line, is_expected_handshake_eof,
peer_close_description, resolve_runtime_base_dir, resolve_runtime_config_path,
};
use crate::error::{ProxyError, StreamError};
#[test]
fn maestro_line_formatter_respects_disabled_colors() {
let plain = format_maestro_line("boot", false);
assert_eq!(plain, "MAESTRO: boot");
assert!(!plain.contains('\x1b'));
}
#[test]
fn maestro_line_formatter_keeps_color_when_enabled() {
let colored = format_maestro_line("boot", true);
assert!(colored.contains("\x1b[92mMAESTRO\x1b[0m"));
}
#[test]
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
let nonce = std::time::SystemTime::now()

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