From 9cf3e9e847e1616fb5ca9d98a2ce0f95b078d6e9 Mon Sep 17 00:00:00 2001 From: Sergey Prokhorov Date: Fri, 3 Apr 2026 19:20:20 +0200 Subject: [PATCH] Add domain fronting for fake-TLS connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a fake-TLS handshake fails (wrong secret, DPI probe, replay attack), forward the raw TCP connection transparently to the SNI host instead of closing — making the proxy indistinguishable from a normal HTTPS server. Replay detection is moved to ClientHello level (before ServerHello) to allow clean forwarding. Controlled by {domain_fronting, off|sni|"host:port"}. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + AGENTS.md | 146 +++++++++++++++++++++++++++++++ README.md | 164 +++++++++++++++++++++++++++++++++++ src/mtp_fake_tls.erl | 25 +++++- src/mtp_handler.erl | 148 +++++++++++++++++++++++++++++--- src/mtp_session_storage.erl | 20 +++++ src/mtproto_proxy.app.src | 28 +++++- test/prop_mtp_fake_tls.erl | 41 ++++++++- test/single_dc_SUITE.erl | 167 +++++++++++++++++++++++++++++++++++- 9 files changed, 722 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 9611477..2e84309 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ rebar3.crashdump *~ config/prod* .run +/.agent-shell/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..22ff451 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# AGENTS.md + +## Project Overview + +This is a high-performance **Telegram MTProto proxy** written in **Erlang/OTP**. It sits between Telegram +clients and Telegram servers, helping users bypass DPI-based censorship. It supports multiple anti-detection +protocols (fake-TLS, obfuscated/secure), connection multiplexing, replay attack protection, domain fronting, +and flexible connection policies. + +## Repository Layout + +``` +src/ Erlang source files (OTP application) +test/ EUnit, Common Test, and PropEr test suites + benchmarks +config/ Example configs (sys.config.example, vm.args.example) +rebar.config Build tool configuration and dependencies +Makefile Build, test, install targets +start.sh Foreground start script for development +Dockerfile Docker image build +``` + +### Key source modules + +| Module | Role | +|--------------------------------------------------|-------------------------------------------------------| +| `mtp_handler` | Accepts client TCP connections (Ranch listener) | +| `mtp_obfuscated` | Obfuscated MTProto protocol (client-side codec) | +| `mtp_fake_tls` | Fake-TLS protocol (mimics TLSv1.3 + HTTP/2) | +| `mtp_secure` | "Secure" randomized-packet-size protocol | +| `mtp_dc_pool` / `mtp_down_conn` | Pooled/multiplexed connections to Telegram DCs | +| `mtp_rpc` | RPC framing protocol between proxy and Telegram | +| `mtp_config` | Periodically fetches Telegram DC configuration | +| `mtp_policy` / `mtp_policy_table` | Connection limit, blacklist, and whitelist rules | +| `mtp_codec` / `mtp_aes_cbc` | Codec pipeline (MTProto framing + AES-CBC encryption) | +| `mtp_abridged` / `mtp_full` / `mtp_intermediate` | MTProto transport codec variants | +| `mtp_metric` | Metrics/telemetry | +| `mtp_session_storage` | Replay-attack protection (session deduplication) | + +## Build + +Requires Erlang/OTP 25+. + +```bash +# Install dependencies and compile +./rebar3 compile + +# Build a production release (requires config/prod-sys.config and config/prod-vm.args) +cp config/sys.config.example config/prod-sys.config +cp config/vm.args.example config/prod-vm.args +make +``` + +## Running Locally (dev) + +```bash +./rebar3 shell # starts an Erlang shell with the app loaded (easiest for dev/debugging) +``` + +`start.sh` is the Docker container entry-point; use `rebar3 shell` for local development instead. + +## Testing + +Run the full test suite (xref, eunit, common test, property-based tests, dialyzer, coverage): + +```bash +make test +``` + +Individual steps: + +```bash +./rebar3 xref # cross-reference checks (undefined calls, unused locals) +./rebar3 eunit -c # unit tests +./rebar3 ct -c # common tests (integration, uses test/test-sys.config) +./rebar3 proper -c -n 50 # PropEr property-based tests (50 runs each) +./rebar3 dialyzer # type analysis +./rebar3 cover -v # coverage report +``` + +Always run `make test` before committing. Fix all xref warnings and dialyzer errors — they are treated as errors. + +## Code Style + +- Language: **Erlang**. Follow standard Erlang OTP conventions. +- Module names use `snake_case`; all prefixed with `mtp_` (or `mtproto_` for top-level app modules). +- Keep modules focused; each codec/protocol has its own module. +- Avoid adding dependencies — the dep list in `rebar.config` is intentionally minimal (Ranch + psq). +- Comments use `%%` (module-level) or `%` (inline). Don't over-comment obvious code. +- Codecs are implemented as layered pipelines via `mtp_codec` — follow this pattern for new protocols. + +## Configuration + +- Config lives in `config/prod-sys.config` (Erlang term format). Do **not** edit `src/mtproto_proxy.app.src` — it documents defaults only. +- All configuration options are documented in `src/mtproto_proxy.app.src`. +- Config can be reloaded without restart: `make update-sysconfig && systemctl reload mtproto-proxy`. + +## Security Considerations + +- Do **not** commit real secrets, tags, or credentials into config files. +- Replay attack protection (`replay_check_session_storage`) must stay correct — the session storage logic is security-critical. +- The fake-TLS and obfuscated protocol implementations must stay byte-exact with the reference (`../MTProxy/`). +- When modifying crypto code (`mtp_aes_cbc`, `mtp_obfuscated`, `mtp_fake_tls`), verify against reference + implementations: `../MTProxy/` (C), `../mtprotoproxy/` (Python), `../mtg/` (Go), `../telemt/` (Rust). + +## Reference Implementations + +*Feature comparison last verified: 2026-04-03. These projects evolve independently — re-check if significant time has passed.* + +Reference implementations may or may not be checked out in sibling directories. If a directory is missing, clone it from GitHub: + +| Implementation | Sibling dir | GitHub URL | +|-----------------------|--------------------|----------------------------------------------| +| MTProxy (C, official) | `../MTProxy/` | https://github.com/TelegramMessenger/MTProxy | +| mtprotoproxy (Python) | `../mtprotoproxy/` | https://github.com/alexbers/mtprotoproxy | +| mtg (Go) | `../mtg/` | https://github.com/9seconds/mtg | +| telemt (Rust) | `../telemt/` | https://github.com/telemt/telemt | + +There are two ways a proxy can connect to Telegram on the backend: + +- **Middle proxy (RPC/multiplexed)**: the proxy speaks the Telegram internal RPC protocol to a Telegram + "middle server". Many client connections are multiplexed over a small number of long-lived proxy→Telegram + connections. Required for `ad_tag` (promoted channels) support. +- **Direct**: the proxy opens a new raw TCP connection to a Telegram DC per client connection. + Simpler, but no `ad_tag` support and more connections to Telegram. + +Client-side connection protocols (what the Telegram app uses to connect to the proxy): + +| Implementation | Classic (no prefix) | Secure (`dd`) | Fake-TLS (`ee`) | Domain fronting² | Backend mode | +|----------------------------------|---------------------|------------------|-----------------|----------------------------------|-----------------------------------------------------| +| **mtproto_proxy** (this, Erlang) | ✅ | ✅ | ✅ | ✅ (`domain_fronting` config) | Middle proxy (multiplexed) | +| **MTProxy** (C, official) | ✅ | ✅ | ✅ | ✅ (`--domain` flag) | Middle proxy (multiplexed) | +| **mtprotoproxy** (Python) | ✅ | ✅ | ✅ | ✅ (`TLS_DOMAIN` config) | Both (`USE_MIDDLE_PROXY`, auto-enabled on `AD_TAG`) | +| **mtg** (Go) | ❌ dropped in v2 | ❌ dropped in v2 | ✅ only | ✅ (`domain-fronting-port` flag) | Direct (per-client connection) | +| **telemt** (Rust) | ✅ | ✅ | ✅ | ✅ (TLS-fronting) | Both (configurable: `use_middle_proxy`) | + +² **Domain fronting**: when a fake-TLS handshake fails (non-proxy client, e.g. a real browser or DPI probe), + the proxy forwards the connection to the real host from the TLS SNI field, making the proxy indistinguishable + from a normal HTTPS server. Without this, a failed handshake results in an abrupt close, which itself can + be a detection signal. + +Key takeaways: +- **mtproto_proxy** and **MTProxy** always use the middle proxy (multiplexed) backend. +- **mtprotoproxy** and **telemt** support both backend modes (middle proxy auto-enabled when an ad_tag is configured). +- **mtg** v2 intentionally dropped `dd`/classic support and ad_tag/middle-proxy in favour of simplicity; + it only accepts `ee` (fake-TLS) secrets and always connects directly to Telegram DCs. + diff --git a/README.md b/README.md index 705d3d5..b9f8efd 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ Features * Supports multiplexing (Many connections Client -> Proxy are wrapped to small amount of connections Proxy -> Telegram Server) - lower pings and better OS network utilization * Protection from [replay attacks](https://habr.com/ru/post/452144/) used to detect proxies in some countries +* Domain fronting for fake-TLS connections — when a browser or DPI probe connects with a + wrong/absent secret, the connection is forwarded transparently to the real HTTPS server + in the SNI field; the proxy is indistinguishable from a normal web server * Automatic telegram configuration reload (no need for restarts once per day) * IPv6 for client connections * All configuration options can be updated without service restart @@ -324,6 +327,167 @@ be list with only `mtp_fake_tls`. <..> ``` +For even stronger DPI resistance you can enable domain fronting — see +[Domain fronting for fake-TLS](#domain-fronting-for-fake-tls). + +### Domain fronting for fake-TLS + +When `mtp_fake_tls` is the active protocol and an incoming TLS connection fails the MTProto +handshake (wrong or absent secret — e.g. a real browser or a DPI probe), the proxy can +**forward the raw TCP connection transparently** to the real HTTPS host instead of closing it. +Replay-attack connections are also fronted: the replayed ClientHello is forwarded and the +probe receives a genuine TLS certificate from the fronting host. In both cases the proxy +is indistinguishable from a normal HTTPS server to any external observer. + +Two configuration keys control this feature: + +```erlang +%% Values: off | sni | "host:port" +{domain_fronting, off}, + +%% TCP connect timeout to the fronting host (seconds). +{domain_fronting_timeout_sec, 10}, +``` + +The SNI domain extracted from the client's TLS ClientHello is always required (if absent, +the connection is closed). It is used for policy checks in all modes and as the forwarding +target in `sni` mode. + +#### a. Forward to the SNI host (simplest) + +```erlang +{mtproto_proxy, + [ + {domain_fronting, sni}, + {ports, + [#{name => mtp_handler_1, + ... +``` + +The proxy connects to whatever domain the client presented in the SNI field, on port 443. +No additional configuration is needed. + +**Pros:** zero config; works with any domain automatically. +**Cons:** can be used to relay arbitrary HTTPS traffic through your server. If this is a +concern, add policy rules to restrict which SNI domains are accepted — the same +`in_table` / `not_in_table` rules used for normal connection policies apply here too +(connections with disallowed SNI are closed rather than fronted): + +```erlang +{mtproto_proxy, + [ + {domain_fronting, sni}, + {policy, + [{not_in_table, tls_domain, front_blacklist}]}, + {ports, + [#{name => mtp_handler_1, + ... +``` + +Add domains to the blacklist at runtime: + +```bash +/opt/mtp_proxy/bin/mtp_proxy eval ' +mtp_policy_table:add(front_blacklist, tls_domain, "unwanted.example.com").' +``` + +#### b. Forward to a fixed third-party host + +```erlang +{mtproto_proxy, + [ + {domain_fronting, "my-website.com:443"}, + {ports, + [#{name => mtp_handler_1, + ... +``` + +All unrecognised TLS connections are forwarded to a single fixed target regardless of the +SNI field. SNI is still extracted and checked against policy rules, but the TCP connection +goes to the configured host. + +**Pros:** predictable destination; easy to lock down with a whitelist so only your own +domains trigger fronting. +**Cons:** requires knowing the target host in advance. + +Example with an SNI whitelist (only listed domains trigger fronting; all others are closed): + +```erlang +{mtproto_proxy, + [ + {domain_fronting, "my-website.com:443"}, + {policy, + [{in_table, tls_domain, front_allowlist}]}, + {ports, + [#{name => mtp_handler_1, + ... +``` + +Add allowed domains at runtime: + +```bash +/opt/mtp_proxy/bin/mtp_proxy eval ' +mtp_policy_table:add(front_allowlist, tls_domain, "my-website.com").' +``` + +#### c. Forward to a local web server (Nginx on localhost:1443) + +The most production-ready setup: run a real web server on the same machine so the proxy +port serves genuine HTTPS content for any browser that connects. + +**Step 1 — configure Nginx** to listen on `127.0.0.1:1443`: + +```nginx +server { + listen 127.0.0.1:1443 ssl; + server_name my-website.com; + + ssl_certificate /etc/letsencrypt/live/my-website.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/my-website.com/privkey.pem; + + location / { + root /var/www/html; + index index.html; + } +} +``` + +**Step 2 — obtain a TLS certificate.** + +With [certbot](https://certbot.eff.org/) (Let's Encrypt, recommended — requires a real +domain pointing to your server and port 80 open to the internet): + +```bash +sudo apt install certbot +sudo certbot certonly --standalone -d my-website.com +``` + +Or generate a self-signed certificate (works for any hostname, but browsers will show a +warning — still enough to fool DPI): + +```bash +openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/proxy-selfsigned.key \ + -out /etc/ssl/certs/proxy-selfsigned.crt -days 3650 -nodes \ + -subj "/CN=my-website.com" +``` + +Then reference those paths in the `ssl_certificate` / `ssl_certificate_key` lines above. + +**Step 3 — configure the proxy:** + +```erlang +{mtproto_proxy, + [ + {domain_fronting, "127.0.0.1:1443"}, + {ports, + [#{name => mtp_handler_1, + ... +``` + +**Pros:** proxy port truly serves HTTPS; real certificates; ideal for servers that already +run a website. +**Cons:** requires a running web server and a TLS certificate. + ### Connection limit policies Proxy supports flexible connection limit rules. It's possible to limit number of connections from diff --git a/src/mtp_fake_tls.erl b/src/mtp_fake_tls.erl index 3026ac5..2768455 100644 --- a/src/mtp_fake_tls.erl +++ b/src/mtp_fake_tls.erl @@ -14,6 +14,7 @@ -export([format_secret_base64/2, format_secret_hex/2]). -export([from_client_hello/2, + parse_sni/1, new/0, try_decode_packet/2, decode_all/2, @@ -78,6 +79,7 @@ -type meta() :: #{session_id := binary(), timestamp := non_neg_integer(), + client_digest := binary(), sni_domain => binary()}. @@ -127,7 +129,8 @@ from_client_hello(Data, Secret) -> CC, DD], Meta0 = #{session_id => SessionId, - timestamp => Timestamp}, + timestamp => Timestamp, + client_digest => ClientDigest}, Meta = case lists:keyfind(?EXT_SNI, 1, Extensions) of {_, [{?EXT_SNI_HOST_NAME, Domain}]} -> Meta0#{sni_domain => Domain}; @@ -136,6 +139,26 @@ from_client_hello(Data, Secret) -> end, {ok, Response, Meta, new()}. +%% Extract the SNI domain from a raw ClientHello binary without validating the secret. +%% Used for domain fronting: call this when from_client_hello/2 raises tls_invalid_digest, +%% to determine where to forward the connection. +%% +%% Returns {ok, Domain :: binary()} or {error, no_sni | bad_hello}. +-spec parse_sni(binary()) -> {ok, binary()} | {error, no_sni | bad_hello}. +parse_sni(Data) -> + try + #client_hello{extensions = Extensions} = parse_client_hello(Data), + case lists:keyfind(?EXT_SNI, 1, Extensions) of + {_, [{?EXT_SNI_HOST_NAME, Domain}]} -> + {ok, Domain}; + _ -> + {error, no_sni} + end + catch + error:_ -> + {error, bad_hello} + end. + parse_client_hello(<> :: binary()}). -type transport() :: module(). --type stage() :: init | tls_hello | tunnel. +-type stage() :: init | tls_hello | tunnel | fronting. %% APIs @@ -176,22 +178,53 @@ handle_cast(Other, State) -> ?LOG_WARNING("Unexpected msg ~p", [Other]), {noreply, State}. -handle_info({tcp, Sock, Data}, #state{sock = Sock, transport = Transport, - listener = Listener, addr = {Ip, _}} = S) -> - %% client -> proxy +handle_info({tcp, Sock, Data}, #state{sock = Sock, stage = Stage, transport = Transport, + listener = Listener, addr = {Ip, _}} = S) + when Stage =/= fronting -> + %% client -> proxy (tunnel / handshake stages) Size = byte_size(Data), mtp_metric:count_inc([?APP, received, upstream, bytes], Size, #{labels => [Listener]}), mtp_metric:histogram_observe([?APP, tracker_packet_size, bytes], Size, #{labels => [upstream]}), - try handle_upstream_data(Data, S) of - {ok, S1} -> + %% Accumulate raw bytes before processing so that attempt_fronting has the full buffer + %% even when the ClientHello or TLS Application Data arrived in multiple fragments. + %% Skipped for tunnel stage (hot path) since fronting can never trigger there. + S1 = case Stage of + tunnel -> S; + _ -> S#state{hello_acc = <<(S#state.hello_acc)/binary, Data/binary>>} + end, + try handle_upstream_data(Data, S1) of + {ok, S2} -> ok = Transport:setopts(Sock, [{active, once}]), %% Consider checking health here as well - {noreply, bump_timer(S1)} + {noreply, bump_timer(S2)} catch error:{protocol_error, Type, Extra} -> mtp_metric:count_inc([?APP, protocol_error, total], 1, #{labels => [Listener, Type]}), ?LOG_WARNING("~s: protocol_error ~p ~p", [inet:ntoa(Ip), Type, Extra]), - {stop, normal, maybe_close_down(S)} + case attempt_fronting(Type, Extra, S1) of + {ok, S2} -> + {noreply, bump_timer(S2)}; + skip -> + {stop, normal, maybe_close_down(S)} + end end; +%% fronting stage: data from client -> relay to front +handle_info({tcp, Sock, Data}, #state{sock = Sock, stage = fronting, + front_sock = FrontSock, transport = Transport} = S) -> + ok = gen_tcp:send(FrontSock, Data), + ok = Transport:setopts(Sock, [{active, once}]), + {noreply, bump_timer(S)}; +%% fronting stage: data from front -> relay to client +handle_info({tcp, FrontSock, Data}, #state{front_sock = FrontSock, stage = fronting, + sock = Sock, transport = Transport} = S) -> + ok = Transport:send(Sock, Data), + ok = inet:setopts(FrontSock, [{active, once}]), + {noreply, bump_timer(S)}; +handle_info({tcp_closed, FrontSock}, #state{front_sock = FrontSock, stage = fronting} = S) -> + ?LOG_DEBUG("front sock closed"), + {stop, normal, S}; +handle_info({tcp_error, FrontSock, Reason}, #state{front_sock = FrontSock, stage = fronting} = S) -> + ?LOG_WARNING("front sock error: ~p", [Reason]), + {stop, normal, S}; handle_info({tcp_closed, Sock}, #state{sock = Sock} = S) -> ?LOG_DEBUG("upstream sock closed"), {stop, normal, maybe_close_down(S)}; @@ -219,7 +252,8 @@ handle_info(Other, S) -> terminate(_Reason, #state{started_at = Started, listener = Listener, addr = {Ip, _}, policy_state = PolicyState, - sock = Sock, transport = Trans} = S) -> + sock = Sock, transport = Trans, + front_sock = FrontSock} = S) -> case PolicyState of {ok, TlsDomain} -> try mtp_policy:dec( @@ -234,6 +268,10 @@ terminate(_Reason, #state{started_at = Started, listener = Listener, end, maybe_close_down(S), ok = Trans:close(Sock), + case FrontSock of + undefined -> ok; + _ -> gen_tcp:close(FrontSock) + end, mtp_metric:count_inc([?APP, in_connection_closed, total], 1, #{labels => [Listener]}), Lifetime = erlang:system_time(millisecond) - Started, mtp_metric:histogram_observe( @@ -324,6 +362,7 @@ parse_upstream_data(<> = AllData, assert_protocol(mtp_fake_tls), <> = AllData, {ok, Response, Meta, TlsCodec} = mtp_fake_tls:from_client_hello(Data, Secret), + maybe_check_replay_tls(Meta), check_tls_policy(Listener, Ip, Meta), Codec1 = mtp_codec:replace(tls, true, TlsCodec, Codec0), Codec = mtp_codec:push_back(tls, Tail, Codec1), @@ -331,8 +370,10 @@ parse_upstream_data(<> = AllData, {ok, S#state{codec = Codec, stage = init, policy_state = {ok, maps:get(sni_domain, Meta, undefined)}}}; false -> - %% Wait for more data - {incomplete, S} + %% Received only part of the ClientHello — push it back into the codec + %% buffer so the next TCP fragment is reassembled with it before we try again. + Codec1 = mtp_codec:push_back(first, AllData, Codec0), + {incomplete, S#state{codec = Codec1, stage = tls_hello}} end; parse_upstream_data(<> = Data, #state{stage = init} = S) -> parse_upstream_data(Data, S#state{stage = tls_hello}); @@ -348,12 +389,13 @@ parse_upstream_data(<>, error({protocol_error, tls_client_hello_expected, Header}), case mtp_obfuscated:from_header(Header, Secret) of {ok, DcId, PacketLayerMod, CryptoCodecSt} -> - maybe_check_replay(Header), {ProtoToReport, PState} = case TlsHandshakeDone of true when PacketLayerMod == mtp_secure -> + %% Replay was already checked at the ClientHello stage; skip here. {mtp_secure_fake_tls, PState0}; false -> + maybe_check_replay(Header), assert_protocol(PacketLayerMod, AllowedProtocols), check_policy(Listener, Ip, undefined), %FIXME: if any codebelow fail, we will get counter policy leak @@ -430,6 +472,86 @@ check_policy(Listener, Ip, Domain) -> error({protocol_error, policy_error, {Rule, Listener, Ip, Domain}}) end. +%% Like check_policy/3 but skips max_connections rules — fronted connections do not consume +%% Telegram resources and must not count against connection limits. +check_front_policy(Listener, Ip, Domain) -> + AllRules = application:get_env(?APP, policy, []), + Rules = [R || R <- AllRules, element(1, R) =/= max_connections], + case mtp_policy:check(Rules, Listener, Ip, Domain) of + [] -> ok; + [Rule | _] -> + error({protocol_error, policy_error, {Rule, Listener, Ip, Domain}}) + end. + +%% Attempt to initiate domain fronting for the given protocol error type. +%% Returns {ok, NewState} if fronting was initiated, skip otherwise. +%% State#state.hello_acc contains the full raw byte stream accumulated so far. +attempt_fronting(tls_invalid_digest, _Extra, + #state{hello_acc = Acc, addr = {Ip, _}, listener = Listener} = S) -> + case application:get_env(?APP, domain_fronting, off) of + off -> skip; + Config -> + case mtp_fake_tls:parse_sni(Acc) of + {ok, SniDomain} -> + do_front(SniDomain, Config, Acc, Ip, Listener, S); + {error, Reason} -> + ?LOG_DEBUG("Domain fronting: no SNI (~p), closing", [Reason]), + skip + end + end; +attempt_fronting(replay_session_detected, SniDomain, + #state{hello_acc = Acc, addr = {Ip, _}, listener = Listener} = S) + when is_binary(SniDomain) -> + %% Replay detected at ClientHello level (before ServerHello was sent). + %% hello_acc = raw ClientHello bytes → forward to fronting host which responds with + %% a real ServerHello — transparent forward, no TLS breakage. + case application:get_env(?APP, domain_fronting, off) of + off -> skip; + Config -> + do_front(SniDomain, Config, Acc, Ip, Listener, S) + end; +attempt_fronting(_Type, _Extra, _S) -> + skip. + +maybe_check_replay_tls(#{client_digest := Digest} = Meta) -> + case application:get_env(?APP, replay_check_session_storage, off) of + on -> + (new == mtp_session_storage:check_add_tls(Digest)) orelse + error({protocol_error, replay_session_detected, + maps:get(sni_domain, Meta, undefined)}); + off -> + ok + end. + +do_front(SniDomain, Config, Data, Ip, Listener, + #state{sock = Sock, transport = Transport} = S) -> + try + check_front_policy(Listener, Ip, SniDomain), + {Host, Port} = fronting_target(Config, SniDomain), + TimeoutMs = application:get_env(?APP, domain_fronting_timeout_sec, 10) * 1000, + case gen_tcp:connect(Host, Port, [binary, {active, once}], TimeoutMs) of + {ok, FrontSock} -> + ok = gen_tcp:send(FrontSock, Data), + ok = Transport:setopts(Sock, [{active, once}]), + ?LOG_INFO("Domain fronting to ~s:~p for SNI ~s", [Host, Port, SniDomain]), + {ok, S#state{stage = fronting, front_sock = FrontSock}}; + {error, Reason} -> + ?LOG_WARNING("Domain fronting connect to ~s:~p failed: ~p", + [Host, Port, Reason]), + skip + end + catch error:{protocol_error, policy_error, _} -> + skip + end. + +fronting_target(sni, SniDomain) -> + {binary_to_list(SniDomain), 443}; +fronting_target(HostPort, _SniDomain) when is_list(HostPort) -> + case string:split(HostPort, ":") of + [Host, PortStr] -> {Host, list_to_integer(PortStr)}; + _ -> error({badarg, invalid_domain_fronting_config, HostPort}) + end. + up_send(Packet, #state{stage = tunnel, codec = UpCodec} = S) -> %% ?LOG_DEBUG(">Up: ~p", [Packet]), {Encoded, UpCodec1} = mtp_codec:encode_packet(Packet, UpCodec), diff --git a/src/mtp_session_storage.erl b/src/mtp_session_storage.erl index 7149d64..dc9f89e 100644 --- a/src/mtp_session_storage.erl +++ b/src/mtp_session_storage.erl @@ -16,6 +16,7 @@ %% API -export([start_link/0, check_add/1, + check_add_tls/1, status/0]). %% gen_server callbacks @@ -49,6 +50,25 @@ check_add(Packet) when byte_size(Packet) == 64 -> Now = erlang:system_time(second), check_add_at(Packet, Now). +%% @doc Like check_add/1 but keyed on the 32-byte TLS ClientHello pseudorandom (client_random). +%% Used for FakeTLS replay detection at the ClientHello stage, before ServerHello is sent. +-spec check_add_tls(binary()) -> new | used. +check_add_tls(ClientDigest) when byte_size(ClientDigest) == 32 -> + Now = erlang:system_time(second), + check_add_tls_at(ClientDigest, Now). + +check_add_tls_at(ClientDigest, Now) -> + Record = {ClientDigest, Now}, + HistogramBucket = bucket(Now), + ets:update_counter(?HISTOGRAM_TAB, HistogramBucket, 1, {HistogramBucket, 0}), + case ets:insert_new(?DATA_TAB, Record) of + true -> + new; + false -> + ets:insert(?DATA_TAB, Record), + used + end. + check_add_at(Packet, Now) -> Record = {fingerprint(Packet), Now}, HistogramBucket = bucket(Now), diff --git a/src/mtproto_proxy.app.src b/src/mtproto_proxy.app.src index 91709f2..dbd8651 100644 --- a/src/mtproto_proxy.app.src +++ b/src/mtproto_proxy.app.src @@ -119,7 +119,7 @@ %% Remove items used for the last time more than `max_age_minutes` %% minutes ago. %% Less than 10 minutes doesn't make much sense - max_age_minutes => 360}} + max_age_minutes => 360}}, %% Should be module with function `notify/4' exported. %% See mtp_metric:notify/4 for details @@ -169,6 +169,32 @@ %% %% float >= 1 %% %% if not specified this check is skipped %% packets_per_upstream => 3}} + + %% Domain fronting: when a fake-TLS (base64 secret) handshake fails because the + %% secret does not match (wrong secret / real browser / DPI probe), forward the + %% raw TCP connection to the real TLS host instead of closing it. This makes the + %% proxy indistinguishable from a normal HTTPS server. + %% + %% Values: + %% off - disabled (default): close the connection as before + %% sni - forward to the SNI hostname from the ClientHello on port 443 + %% "host:port" - always forward to this fixed host:port regardless of SNI + %% (e.g. "127.0.0.1:443" to forward to a local nginx/caddy) + %% + %% In all enabled modes the SNI field must be present in the ClientHello; + %% if it is absent the connection is closed. In "host:port" mode the SNI + %% is still extracted for policy checks (blacklist / whitelist). + %% + %% Policy rules are applied to the SNI domain: + %% blacklisted domain -> close (no forwarding) + %% not in whitelist -> close (no forwarding) + %% max_connections -> ignored (fronted traffic is not Telegram traffic) + {domain_fronting, off}, + + %% Timeout in seconds for the TCP connect to the fronting host. + %% Default: 10 + {domain_fronting_timeout_sec, 10} + ]}, {modules, []}, diff --git a/test/prop_mtp_fake_tls.erl b/test/prop_mtp_fake_tls.erl index 0745317..2cf2fef 100644 --- a/test/prop_mtp_fake_tls.erl +++ b/test/prop_mtp_fake_tls.erl @@ -3,7 +3,10 @@ -include_lib("proper/include/proper.hrl"). -include_lib("stdlib/include/assert.hrl"). --export([prop_codec_small/1, prop_codec_big/1, prop_stream/1, prop_variable_length_hello/1]). +-export([prop_codec_small/1, prop_codec_big/1, prop_stream/1, + prop_variable_length_hello/1, + prop_parse_sni_valid/1, + prop_parse_sni_garbage/1]). prop_codec_small(doc) -> "Tests that any binary below 65535 bytes can be encoded and decoded back as single frame". @@ -85,3 +88,39 @@ variable_length_hello(TlsPacketLen, Secret, Domain) -> ?assertEqual(Timestamp, maps:get(timestamp, Meta)), ?assertEqual(Domain, maps:get(sni_domain, Meta)), true. + + +prop_parse_sni_valid(doc) -> + "parse_sni/1 returns {ok, Domain} for any valid ClientHello with SNI". + +prop_parse_sni_valid() -> + ?FORALL({TlsPacketLen, Secret, Domain}, + {proper_types:integer(512, 4096), + proper_types:binary(16), + <<"example.com">>}, + parse_sni_valid(TlsPacketLen, Secret, Domain)). + +parse_sni_valid(TlsPacketLen, Secret, Domain) -> + Timestamp = erlang:system_time(second), + SessionId = crypto:strong_rand_bytes(32), + %% Build a ClientHello with a WRONG secret so from_client_hello/2 would throw + WrongSecret = crypto:strong_rand_bytes(16), + ClientHello = mtp_fake_tls:make_client_hello(Timestamp, SessionId, WrongSecret, Domain, TlsPacketLen), + %% parse_sni/1 must still extract the domain regardless of the secret + ?assertEqual({ok, Domain}, mtp_fake_tls:parse_sni(ClientHello)), + %% Also works on a correctly-signed hello + ValidHello = mtp_fake_tls:make_client_hello(Timestamp, SessionId, Secret, Domain, TlsPacketLen), + ?assertEqual({ok, Domain}, mtp_fake_tls:parse_sni(ValidHello)), + true. + + +prop_parse_sni_garbage(doc) -> + "parse_sni/1 returns {error, bad_hello} for arbitrary garbage binaries". + +prop_parse_sni_garbage() -> + ?FORALL(Bin, proper_types:binary(), parse_sni_garbage(Bin)). + +parse_sni_garbage(Bin) -> + Result = mtp_fake_tls:parse_sni(Bin), + ?assert(Result =:= {error, bad_hello} orelse Result =:= {error, no_sni}), + true. diff --git a/test/single_dc_SUITE.erl b/test/single_dc_SUITE.erl index 63091fd..ebbf692 100644 --- a/test/single_dc_SUITE.erl +++ b/test/single_dc_SUITE.erl @@ -19,7 +19,12 @@ policy_max_conns_case/1, policy_whitelist_case/1, replay_attack_case/1, - replay_attack_server_error_case/1 + replay_attack_server_error_case/1, + domain_fronting_fixed_case/1, + domain_fronting_off_case/1, + domain_fronting_blacklist_case/1, + domain_fronting_fragmented_case/1, + domain_fronting_replay_case/1 ]). -export([set_env/2, @@ -520,7 +525,165 @@ policy_whitelist_case(Cfg) when is_list(Cfg) -> count, [?APP, protocol_error, total], [?FUNCTION_NAME, policy_error])), ok. -%% Helpers +%% @doc Domain fronting: wrong secret + fixed target -> connection forwarded to fronting host. +%% The proxy should transparently relay the raw ClientHello to the configured target +%% instead of closing the connection. +domain_fronting_fixed_case({pre, Cfg}) -> + {ok, FrontLSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, FrontPort} = inet:port(FrontLSock), + Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg), + Cfg2 = set_env([{domain_fronting, "127.0.0.1:" ++ integer_to_list(FrontPort)}], Cfg1), + [{front_lsock, FrontLSock} | Cfg2]; +domain_fronting_fixed_case({post, Cfg}) -> + stop_single(Cfg), + reset_env(Cfg), + gen_tcp:close(?config(front_lsock, Cfg)); +domain_fronting_fixed_case(Cfg) when is_list(Cfg) -> + Host = ?config(mtp_host, Cfg), + Port = ?config(mtp_port, Cfg), + FrontLSock = ?config(front_lsock, Cfg), + WrongSecret = crypto:strong_rand_bytes(16), + Domain = <<"example.com">>, + ClientHello = mtp_fake_tls:make_client_hello(WrongSecret, Domain), + {ok, Sock} = gen_tcp:connect(Host, Port, [binary, {active, false}], 2000), + ok = gen_tcp:send(Sock, ClientHello), + %% Proxy should connect to our fronting server and forward the ClientHello + {ok, FrontSock} = gen_tcp:accept(FrontLSock, 5000), + {ok, Received} = gen_tcp:recv(FrontSock, byte_size(ClientHello), 5000), + ?assertEqual(ClientHello, Received), + %% Relay works both ways: send data from front -> client + FrontReply = <<"HTTP/1.1 200 OK\r\n\r\n">>, + ok = gen_tcp:send(FrontSock, FrontReply), + {ok, ClientReceived} = gen_tcp:recv(Sock, byte_size(FrontReply), 5000), + ?assertEqual(FrontReply, ClientReceived), + gen_tcp:close(FrontSock), + gen_tcp:close(Sock). + +%% @doc Domain fronting disabled (off): wrong secret -> connection is closed, not forwarded. +domain_fronting_off_case({pre, Cfg}) -> + setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg); +domain_fronting_off_case({post, Cfg}) -> + stop_single(Cfg); +domain_fronting_off_case(Cfg) when is_list(Cfg) -> + Host = ?config(mtp_host, Cfg), + Port = ?config(mtp_port, Cfg), + WrongSecret = crypto:strong_rand_bytes(16), + Domain = <<"example.com">>, + ClientHello = mtp_fake_tls:make_client_hello(WrongSecret, Domain), + {ok, Sock} = gen_tcp:connect(Host, Port, [binary, {active, false}], 2000), + ok = gen_tcp:send(Sock, ClientHello), + %% Proxy must close the connection (fronting is off) + ?assertEqual({error, closed}, gen_tcp:recv(Sock, 0, 5000)), + gen_tcp:close(Sock). + +%% @doc Domain fronting with blacklisted SNI: connection must be closed, not forwarded. +domain_fronting_blacklist_case({pre, Cfg}) -> + {ok, FrontLSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, FrontPort} = inet:port(FrontLSock), + BlacklistedDomain = <<"blocked.example.com">>, + Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg), + ok = mtp_policy_table:add(df_blacklist, tls_domain, BlacklistedDomain), + Cfg2 = set_env([{domain_fronting, "127.0.0.1:" ++ integer_to_list(FrontPort)}, + {policy, [{not_in_table, tls_domain, df_blacklist}]}], Cfg1), + [{front_lsock, FrontLSock}, {blacklisted_domain, BlacklistedDomain} | Cfg2]; +domain_fronting_blacklist_case({post, Cfg}) -> + stop_single(Cfg), + reset_env(Cfg), + gen_tcp:close(?config(front_lsock, Cfg)); +domain_fronting_blacklist_case(Cfg) when is_list(Cfg) -> + Host = ?config(mtp_host, Cfg), + Port = ?config(mtp_port, Cfg), + FrontLSock = ?config(front_lsock, Cfg), + BlacklistedDomain = ?config(blacklisted_domain, Cfg), + WrongSecret = crypto:strong_rand_bytes(16), + ClientHello = mtp_fake_tls:make_client_hello(WrongSecret, BlacklistedDomain), + {ok, Sock} = gen_tcp:connect(Host, Port, [binary, {active, false}], 2000), + ok = gen_tcp:send(Sock, ClientHello), + %% Proxy must close the connection (domain is blacklisted) + ?assertEqual({error, closed}, gen_tcp:recv(Sock, 0, 5000)), + %% Fronting server must NOT have received a connection + ?assertEqual({error, timeout}, gen_tcp:accept(FrontLSock, 500)), + gen_tcp:close(Sock). + +%% @doc Domain fronting with fragmented ClientHello: proxy must still extract SNI and forward. +%% ClientHello is split into two sends to simulate fragmented TCP delivery. +domain_fronting_fragmented_case({pre, Cfg}) -> + {ok, FrontLSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, FrontPort} = inet:port(FrontLSock), + Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg), + Cfg2 = set_env([{domain_fronting, "127.0.0.1:" ++ integer_to_list(FrontPort)}], Cfg1), + [{front_lsock, FrontLSock} | Cfg2]; +domain_fronting_fragmented_case({post, Cfg}) -> + stop_single(Cfg), + reset_env(Cfg), + gen_tcp:close(?config(front_lsock, Cfg)); +domain_fronting_fragmented_case(Cfg) when is_list(Cfg) -> + Host = ?config(mtp_host, Cfg), + Port = ?config(mtp_port, Cfg), + FrontLSock = ?config(front_lsock, Cfg), + WrongSecret = crypto:strong_rand_bytes(16), + Domain = <<"example.com">>, + ClientHello = mtp_fake_tls:make_client_hello(WrongSecret, Domain), + %% Split at byte 10 (middle of TLS record header) to simulate TCP fragmentation. + %% {nodelay, true} disables Nagle's algorithm so each send() produces a distinct segment. + SplitAt = 10, + <> = ClientHello, + {ok, Sock} = gen_tcp:connect(Host, Port, [binary, {active, false}, {nodelay, true}], 2000), + ok = gen_tcp:send(Sock, Part1), + timer:sleep(50), + ok = gen_tcp:send(Sock, Part2), + %% Proxy must reassemble and still front us + {ok, FrontSock} = gen_tcp:accept(FrontLSock, 5000), + {ok, Received} = gen_tcp:recv(FrontSock, byte_size(ClientHello), 5000), + ?assertEqual(ClientHello, Received), + gen_tcp:close(FrontSock), + gen_tcp:close(Sock). + +%% @doc Replay attack: same TLS seed used twice -> replay_session_detected -> domain fronting. +%% The fronting server should receive a connection whose data starts with a TLS record. +domain_fronting_replay_case({pre, Cfg}) -> + {ok, FrontLSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, FrontPort} = inet:port(FrontLSock), + Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg), + Cfg2 = set_env([{domain_fronting, "127.0.0.1:" ++ integer_to_list(FrontPort)}], Cfg1), + [{front_lsock, FrontLSock} | Cfg2]; +domain_fronting_replay_case({post, Cfg}) -> + stop_single(Cfg), + reset_env(Cfg), + gen_tcp:close(?config(front_lsock, Cfg)); +domain_fronting_replay_case(Cfg) when is_list(Cfg) -> + Host = ?config(mtp_host, Cfg), + Port = ?config(mtp_port, Cfg), + FrontLSock = ?config(front_lsock, Cfg), + Secret = ?config(mtp_secret, Cfg), + Domain = <<"example.com">>, + %% Build a deterministic ClientHello so we can replay it byte-for-byte + Timestamp = erlang:system_time(second), + SessionId = crypto:strong_rand_bytes(32), + ClientHello = mtp_fake_tls:make_client_hello(Timestamp, SessionId, Secret, Domain), + %% First connection: ClientHello digest not yet in storage → stored, ServerHello sent + {ok, Sock1} = gen_tcp:connect(Host, Port, [binary, {active, false}], 2000), + ok = gen_tcp:send(Sock1, ClientHello), + {ok, _ServerHello} = gen_tcp:recv(Sock1, 0, 3000), + gen_tcp:close(Sock1), + timer:sleep(50), + %% Second connection: same ClientHello → replay_session_detected fires BEFORE ServerHello. + %% Send it fragmented with {nodelay, true} to cover the fragmentation+replay path. + SplitAt = 10, + <> = ClientHello, + {ok, Sock2} = gen_tcp:connect(Host, Port, [binary, {active, false}, {nodelay, true}], 2000), + ok = gen_tcp:send(Sock2, Part1), + timer:sleep(50), + ok = gen_tcp:send(Sock2, Part2), + %% Fronting server must accept — proxy forwards the ClientHello without sending a ServerHello + {ok, FrontSock} = gen_tcp:accept(FrontLSock, 5000), + {ok, Data} = gen_tcp:recv(FrontSock, 0, 5000), + %% Data forwarded is the raw TLS ClientHello (0x16 = TLS handshake record) + ?assertMatch(<<16#16, _/binary>>, Data), + %% Proxy must NOT have sent a ServerHello to the client + ?assertEqual({error, timeout}, gen_tcp:recv(Sock2, 0, 200)), + gen_tcp:close(FrontSock), + gen_tcp:close(Sock2). setup_single(Name, MtpPort, DcCfg0, Cfg) -> setup_single(Name, "127.0.0.1", MtpPort, DcCfg0, Cfg).