Add domain fronting for fake-TLS connections

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>
This commit is contained in:
Sergey Prokhorov 2026-04-03 19:20:20 +02:00
parent 421a6cc90c
commit 9cf3e9e847
No known key found for this signature in database
GPG key ID: 1C570244E4EF3337
9 changed files with 722 additions and 18 deletions

1
.gitignore vendored
View file

@ -19,3 +19,4 @@ rebar3.crashdump
*~
config/prod*
.run
/.agent-shell/

146
AGENTS.md Normal file
View file

@ -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.

164
README.md
View file

@ -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

View file

@ -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(<<?TLS_REC_HANDSHAKE, ?TLS_10_VERSION, TlsFrameLen:?u16, %Frame
?TLS_TAG_CLI_HELLO, HelloLen:?u24, ?TLS_12_VERSION,

View file

@ -55,10 +55,12 @@
timer_state = init :: init | hibernate | stop,
timer :: gen_timeout:tout(),
last_queue_check :: integer(),
srv_error_filter :: first | on | off}).
srv_error_filter :: first | on | off,
front_sock = undefined :: gen_tcp:socket() | undefined,
hello_acc = <<>> :: 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(<<?TLS_START, _/binary>> = AllData,
assert_protocol(mtp_fake_tls),
<<Data:FullPacketSize/binary, Tail/binary>> = 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(<<?TLS_START, _/binary>> = 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(<<?TLS_START, _/binary>> = Data, #state{stage = init} = S) ->
parse_upstream_data(Data, S#state{stage = tls_hello});
@ -348,12 +389,13 @@ parse_upstream_data(<<Header:64/binary, Rest/binary>>,
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),

View file

@ -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),

View file

@ -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, []},

View file

@ -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.

View file

@ -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,
<<Part1:SplitAt/binary, Part2/binary>> = 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,
<<Part1:SplitAt/binary, Part2/binary>> = 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).