mirror of
https://github.com/seriyps/mtproto_proxy.git
synced 2026-05-13 08:46:46 +00:00
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:
parent
421a6cc90c
commit
9cf3e9e847
9 changed files with 722 additions and 18 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,3 +19,4 @@ rebar3.crashdump
|
|||
*~
|
||||
config/prod*
|
||||
.run
|
||||
/.agent-shell/
|
||||
|
|
|
|||
146
AGENTS.md
Normal file
146
AGENTS.md
Normal 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
164
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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, []},
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue