mirror of
https://github.com/seriyps/mtproto_proxy.git
synced 2026-09-21 20:24:41 +00:00
Before fix accumulator was never cleaned, taking memory. Clear the accumulator on transition to tunnel stage (handshake complete) and on transition to fronting stage (data already forwarded to front_sock).
691 lines
28 KiB
Erlang
691 lines
28 KiB
Erlang
%%% @author Sergey Prokhorov <me@seriyps.ru>
|
|
%%% @copyright (C) 2018, Sergey Prokhorov
|
|
%%% @doc
|
|
%%% MTProto proxy network layer
|
|
%%% @end
|
|
%%% Created : 9 Apr 2018 by Sergey Prokhorov <me@seriyps.ru>
|
|
|
|
-module(mtp_handler).
|
|
-behaviour(gen_server).
|
|
-behaviour(ranch_protocol).
|
|
|
|
%% API
|
|
-export([start_link/3, start_link/4, send/2]).
|
|
-export([hex/1, unhex/1]).
|
|
-export([keys_str/0]).
|
|
|
|
%% Callbacks
|
|
-export([ranch_init/1]).
|
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
|
terminate/2, code_change/3]).
|
|
-export_type([handle/0]).
|
|
|
|
-type handle() :: pid().
|
|
|
|
-include_lib("kernel/include/logger.hrl").
|
|
|
|
-define(MAX_SOCK_BUF_SIZE, 1024 * 50). % Decrease if CPU is cheaper than RAM
|
|
-define(MAX_UP_INIT_BUF_SIZE, 1024 * 1024). %1mb
|
|
|
|
-define(HEALTH_CHECK_INTERVAL, 5000).
|
|
% telegram server responds with "l\xfe\xff\xff" if client packet MTProto is invalid
|
|
-define(SRV_ERROR, <<108, 254, 255, 255>>).
|
|
-define(TLS_START, 22, 3, 1).
|
|
-define(TLS_CLIENT_HELLO_MIN_LEN, 512).
|
|
|
|
|
|
-define(APP, mtproto_proxy).
|
|
|
|
-record(state,
|
|
{stage = init :: stage(),
|
|
secret :: binary(),
|
|
listener :: atom(),
|
|
|
|
sock :: gen_tcp:socket(),
|
|
transport :: transport(),
|
|
codec :: mtp_codec:codec() | undefined,
|
|
|
|
down :: mtp_down_conn:handle() | undefined,
|
|
dc_id :: {DcId :: integer(), Pool :: pid()} | undefined,
|
|
|
|
ad_tag :: binary(),
|
|
addr :: mtp_config:netloc_v4v6(), % IP/Port of remote side
|
|
policy_state :: any(),
|
|
started_at :: pos_integer(),
|
|
timer_state = init :: init | hibernate | stop,
|
|
timer :: gen_timeout:tout(),
|
|
last_queue_check :: integer(),
|
|
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 | fronting.
|
|
|
|
|
|
%% APIs
|
|
|
|
start_link(Ref, Transport, Opts) ->
|
|
{ok, proc_lib:spawn_link(?MODULE, ranch_init, [{Ref, Transport, Opts}])}.
|
|
|
|
%% Ranch 1.x compatibility shim (socket is obtained via ranch:handshake/1 in 2.x)
|
|
start_link(Ref, _Socket, Transport, Opts) ->
|
|
start_link(Ref, Transport, Opts).
|
|
|
|
keys_str() ->
|
|
[{Name, Port, hex(Secret)}
|
|
|| {Name, Port, Secret} <- application:get_env(?APP, ports, [])].
|
|
|
|
-spec send(pid(), {proxy_ans, pid(), binary()} | {simple_ack, pid(), binary()} | {close_ext, pid()}) -> ok.
|
|
send(Upstream, Packet) ->
|
|
gen_server:cast(Upstream, Packet).
|
|
|
|
%% Callbacks
|
|
|
|
%% Custom gen_server init
|
|
ranch_init({Ref, Transport, Opts}) ->
|
|
{ok, Socket} = ranch:handshake(Ref),
|
|
case init({Socket, Transport, Opts}) of
|
|
{ok, State} ->
|
|
BufSize = application:get_env(?APP, upstream_socket_buffer_size, ?MAX_SOCK_BUF_SIZE),
|
|
Linger = case application:get_env(?APP, reset_close_socket, off) of
|
|
off -> [];
|
|
_ ->
|
|
[{linger, {true, 0}}]
|
|
end,
|
|
ok = Transport:setopts(
|
|
Socket,
|
|
[{active, once},
|
|
%% {recbuf, ?MAX_SOCK_BUF_SIZE},
|
|
%% {sndbuf, ?MAX_SOCK_BUF_SIZE},
|
|
{buffer, BufSize}
|
|
| Linger]),
|
|
gen_server:enter_loop(?MODULE, [], State);
|
|
{stop, error} ->
|
|
exit(normal)
|
|
end.
|
|
|
|
init({Socket, Transport, [Name, Secret, Tag]}) ->
|
|
mtp_metric:count_inc([?APP, in_connection, total], 1, #{labels => [Name]}),
|
|
case Transport:peername(Socket) of
|
|
{ok, {Ip, Port}} ->
|
|
?LOG_INFO("~s: new connection ~s:~p", [Name, inet:ntoa(Ip), Port]),
|
|
{TimeoutKey, TimeoutDefault} = state_timeout(init),
|
|
Timer = gen_timeout:new(
|
|
#{timeout => {env, ?APP, TimeoutKey, TimeoutDefault}}),
|
|
Filter = application:get_env(?APP, replay_check_server_error_filter, off),
|
|
NowMs = erlang:system_time(millisecond),
|
|
NoopSt = mtp_noop_codec:new(),
|
|
Codec = mtp_codec:new(mtp_noop_codec, NoopSt,
|
|
mtp_noop_codec, NoopSt),
|
|
State = #state{sock = Socket,
|
|
secret = unhex(Secret),
|
|
listener = Name,
|
|
transport = Transport,
|
|
codec = Codec,
|
|
ad_tag = unhex(Tag),
|
|
addr = {Ip, Port},
|
|
started_at = NowMs,
|
|
timer = Timer,
|
|
last_queue_check = NowMs,
|
|
srv_error_filter = Filter},
|
|
{ok, State};
|
|
{error, Reason} ->
|
|
mtp_metric:count_inc([?APP, in_connection_closed, total], 1, #{labels => [Name]}),
|
|
?LOG_INFO("Can't read peername: ~p", [Reason]),
|
|
{stop, error}
|
|
end.
|
|
|
|
handle_call(_Request, _From, State) ->
|
|
Reply = ok,
|
|
{reply, Reply, State}.
|
|
|
|
handle_cast({proxy_ans, Down, Data}, #state{down = Down, srv_error_filter = off} = S) ->
|
|
%% telegram server -> proxy
|
|
%% srv_error_filter is 'off'
|
|
{ok, S1} = up_send(Data, S),
|
|
ok = mtp_down_conn:ack(Down, 1, iolist_size(Data)),
|
|
maybe_check_health(bump_timer(S1));
|
|
handle_cast({proxy_ans, Down, ?SRV_ERROR = Data},
|
|
#state{down = Down, srv_error_filter = Filter, listener = Listener,
|
|
addr = {Ip, _}} = S) when Filter =/= off ->
|
|
%% telegram server -> proxy
|
|
%% Server replied with server error; it might be another kind of replay attack;
|
|
%% Don't send this packet to client so proxy won't be fingerprinted
|
|
ok = mtp_down_conn:ack(Down, 1, iolist_size(Data)),
|
|
?LOG_WARNING("~s: protocol_error srv_error_filtered", [inet:ntoa(Ip)]),
|
|
mtp_metric:count_inc([?APP, protocol_error, total], 1, #{labels => [Listener, srv_error_filtered]}),
|
|
{noreply,
|
|
case Filter of
|
|
first -> S#state{srv_error_filter = off};
|
|
on -> S
|
|
end};
|
|
handle_cast({proxy_ans, Down, Data}, #state{down = Down, srv_error_filter = Filter} = S) when Filter =/= off ->
|
|
%% telegram server -> proxy
|
|
%% Normal data packet
|
|
%% srv_error_filter is 'on' or srv_error_filter is 'first' and it's 1st server packet
|
|
{ok, S1} = up_send(Data, S),
|
|
ok = mtp_down_conn:ack(Down, 1, iolist_size(Data)),
|
|
S2 = case Filter of
|
|
first -> S1#state{srv_error_filter = off};
|
|
on -> S1
|
|
end,
|
|
maybe_check_health(bump_timer(S2));
|
|
handle_cast({close_ext, Down}, #state{down = Down, sock = USock, transport = UTrans} = S) ->
|
|
?LOG_DEBUG("asked to close connection by downstream"),
|
|
ok = UTrans:close(USock),
|
|
{stop, normal, S#state{down = undefined}};
|
|
handle_cast({simple_ack, Down, Confirm}, #state{down = Down} = S) ->
|
|
?LOG_INFO("Simple ack: ~p, ~p", [Down, Confirm]),
|
|
{noreply, S};
|
|
handle_cast(Other, State) ->
|
|
?LOG_WARNING("Unexpected msg ~p", [Other]),
|
|
{noreply, State}.
|
|
|
|
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]}),
|
|
%% 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(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]),
|
|
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)};
|
|
handle_info({tcp_error, Sock, Reason}, #state{sock = Sock} = S) ->
|
|
?LOG_WARNING("upstream sock error: ~p", [Reason]),
|
|
{stop, normal, maybe_close_down(S)};
|
|
|
|
handle_info(timeout, #state{timer = Timer, timer_state = TState, listener = Listener} = S) ->
|
|
case gen_timeout:is_expired(Timer) of
|
|
true when TState == stop;
|
|
TState == init ->
|
|
mtp_metric:count_inc([?APP, inactive_timeout, total], 1, #{labels => [Listener]}),
|
|
?LOG_INFO("inactive timeout in state ~p", [TState]),
|
|
{stop, normal, S};
|
|
true when TState == hibernate ->
|
|
mtp_metric:count_inc([?APP, inactive_hibernate, total], 1, #{labels => [Listener]}),
|
|
{noreply, switch_timer(S, stop), hibernate};
|
|
false ->
|
|
Timer1 = gen_timeout:reset(Timer),
|
|
{noreply, S#state{timer = Timer1}}
|
|
end;
|
|
handle_info(Other, S) ->
|
|
?LOG_WARNING("Unexpected msg ~p", [Other]),
|
|
{noreply, S}.
|
|
|
|
terminate(_Reason, #state{started_at = Started, listener = Listener,
|
|
addr = {Ip, _}, policy_state = PolicyState,
|
|
sock = Sock, transport = Trans,
|
|
front_sock = FrontSock} = S) ->
|
|
case PolicyState of
|
|
{ok, TlsDomain} ->
|
|
try mtp_policy:dec(
|
|
application:get_env(?APP, policy, []),
|
|
Listener, Ip, TlsDomain)
|
|
catch T:R ->
|
|
?LOG_WARNING("Failed to decrement policy: ~p:~p", [T, R])
|
|
end;
|
|
_ ->
|
|
%% Failed before policy was stored in state. Eg, because of "policy_error"
|
|
ok
|
|
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(
|
|
[?APP, session_lifetime, seconds],
|
|
erlang:convert_time_unit(Lifetime, millisecond, native), #{labels => [Listener]}),
|
|
?LOG_INFO("terminate ~p", [_Reason]),
|
|
ok.
|
|
|
|
code_change(_OldVsn, State, _Extra) ->
|
|
{ok, State}.
|
|
|
|
maybe_close_down(#state{down = undefined} = S) -> S;
|
|
maybe_close_down(#state{dc_id = {_DcId, Pool}} = S) ->
|
|
mtp_dc_pool:return(Pool, self()),
|
|
S#state{down = undefined}.
|
|
|
|
bump_timer(#state{timer = Timer, timer_state = TState} = S) ->
|
|
Timer1 = gen_timeout:bump(Timer),
|
|
case TState of
|
|
stop ->
|
|
switch_timer(S#state{timer = Timer1}, hibernate);
|
|
_ ->
|
|
S#state{timer = Timer1}
|
|
end.
|
|
|
|
switch_timer(#state{timer_state = TState} = S, TState) ->
|
|
S;
|
|
switch_timer(#state{timer_state = FromState, timer = Timer, listener = Listener} = S, ToState) ->
|
|
mtp_metric:count_inc([?APP, timer_switch, total], 1,
|
|
#{labels => [Listener, FromState, ToState]}),
|
|
{NewTimeKey, NewTimeDefault} = state_timeout(ToState),
|
|
Timer1 = gen_timeout:set_timeout(
|
|
{env, ?APP, NewTimeKey, NewTimeDefault}, Timer),
|
|
S#state{timer_state = ToState,
|
|
timer = Timer1}.
|
|
|
|
state_timeout(init) ->
|
|
{init_timeout_sec, 60};
|
|
state_timeout(hibernate) ->
|
|
{hibernate_timeout_sec, 60};
|
|
state_timeout(stop) ->
|
|
{ready_timeout_sec, 1200}.
|
|
|
|
|
|
%% Stream handlers
|
|
|
|
%% Handle telegram client -> proxy stream
|
|
handle_upstream_data(Bin, #state{stage = tunnel,
|
|
codec = UpCodec} = S) ->
|
|
{ok, S3, UpCodec1} =
|
|
mtp_codec:fold_packets(
|
|
fun(Decoded, S1, Codec1) ->
|
|
mtp_metric:histogram_observe(
|
|
[?APP, tg_packet_size, bytes],
|
|
byte_size(Decoded),
|
|
#{labels => [upstream_to_downstream]}),
|
|
{ok, S2} = down_send(Decoded, S1#state{codec = Codec1}),
|
|
{S2, S2#state.codec}
|
|
end, S, Bin, UpCodec),
|
|
{ok, S3#state{codec = UpCodec1}};
|
|
handle_upstream_data(Bin, #state{codec = Codec0} = S0) ->
|
|
{ok, S, Codec} =
|
|
mtp_codec:fold_packets_if(
|
|
fun(Decoded, S1, Codec1) ->
|
|
case parse_upstream_data(Decoded, S1#state{codec = Codec1}) of
|
|
{ok, S2} ->
|
|
{next, S2, S2#state.codec};
|
|
{incomplete, S2} ->
|
|
{stop, S2, S2#state.codec}
|
|
end
|
|
end, S0, Bin, Codec0),
|
|
{ok, S#state{codec = Codec}}.
|
|
|
|
|
|
parse_upstream_data(<<?TLS_START, _/binary>> = AllData,
|
|
#state{stage = tls_hello, secret = Secret, codec = Codec0,
|
|
addr = {Ip, _}, listener = Listener} = S) when
|
|
byte_size(AllData) >= 5 ->
|
|
%% TLS record format: Type(1) + Version(2) + Length(2) + Payload(Length)
|
|
%% We need at least 5 bytes to read the header
|
|
<<?TLS_START, TlsPacketLen:16/unsigned-big, _/binary>> = AllData,
|
|
%% Validate minimum length
|
|
(TlsPacketLen >= ?TLS_CLIENT_HELLO_MIN_LEN) orelse
|
|
error({protocol_error, tls_client_hello_too_short, TlsPacketLen}),
|
|
FullPacketSize = 5 + TlsPacketLen,
|
|
case byte_size(AllData) >= FullPacketSize of
|
|
true ->
|
|
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),
|
|
ok = up_send_raw(Response, S), %FIXME: if this send fail, we will get counter policy leak
|
|
{ok, S#state{codec = Codec, stage = init,
|
|
policy_state = {ok, maps:get(sni_domain, Meta, undefined)}}};
|
|
false ->
|
|
%% 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});
|
|
parse_upstream_data(<<Header:64/binary, Rest/binary>>,
|
|
#state{stage = init, secret = Secret, listener = Listener, codec = Codec0,
|
|
ad_tag = Tag, addr = {Ip, _} = Addr, policy_state = PState0,
|
|
sock = Sock, transport = Transport} = S) ->
|
|
{TlsHandshakeDone, _} = mtp_codec:info(tls, Codec0),
|
|
AllowedProtocols = allowed_protocols(),
|
|
%% If the only enabled protocol is fake-tls and tls handshake haven't been performed yet - raise
|
|
%% protocol error.
|
|
(is_tls_only(AllowedProtocols) andalso not TlsHandshakeDone) andalso
|
|
error({protocol_error, tls_client_hello_expected, Header}),
|
|
case mtp_obfuscated:from_header(Header, Secret) of
|
|
{ok, DcId, PacketLayerMod, CryptoCodecSt} ->
|
|
{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
|
|
{PacketLayerMod, {ok, undefined}}
|
|
end,
|
|
mtp_metric:count_inc([?APP, protocol_ok, total],
|
|
1, #{labels => [Listener, ProtoToReport]}),
|
|
case application:get_env(?APP, reset_close_socket, off) of
|
|
handshake_error ->
|
|
ok = Transport:setopts(Sock, [{linger, {false, 0}}]);
|
|
_ ->
|
|
ok
|
|
end,
|
|
Codec1 = mtp_codec:replace(crypto, mtp_obfuscated, CryptoCodecSt, Codec0),
|
|
PacketCodec = PacketLayerMod:new(),
|
|
Codec2 = mtp_codec:replace(packet, PacketLayerMod, PacketCodec, Codec1),
|
|
Codec = mtp_codec:push_back(crypto, Rest, Codec2),
|
|
Opts = #{ad_tag => Tag,
|
|
addr => Addr},
|
|
{RealDcId, Pool, Downstream} = mtp_config:get_downstream_safe(DcId, Opts),
|
|
handle_upstream_data(
|
|
<<>>,
|
|
switch_timer(
|
|
S#state{down = Downstream,
|
|
dc_id = {RealDcId, Pool},
|
|
codec = Codec,
|
|
policy_state = PState,
|
|
stage = tunnel,
|
|
hello_acc = <<>>},
|
|
hibernate));
|
|
{error, Reason} when is_atom(Reason) ->
|
|
mtp_metric:count_inc([?APP, protocol_error, total], 1, #{labels => [Listener, Reason]}),
|
|
error({protocol_error, Reason, Header})
|
|
end;
|
|
parse_upstream_data(Bin, #state{stage = Stage, codec = Codec0} = S) when Stage =/= tunnel ->
|
|
Codec = mtp_codec:push_back(first, Bin, Codec0),
|
|
{incomplete, S#state{codec = Codec}}.
|
|
|
|
|
|
allowed_protocols() ->
|
|
{ok, AllowedProtocols} = application:get_env(?APP, allowed_protocols),
|
|
AllowedProtocols.
|
|
|
|
is_tls_only([mtp_fake_tls]) -> true;
|
|
is_tls_only(_) -> false.
|
|
|
|
assert_protocol(Protocol) ->
|
|
assert_protocol(Protocol, allowed_protocols()).
|
|
|
|
assert_protocol(Protocol, AllowedProtocols) ->
|
|
lists:member(Protocol, AllowedProtocols)
|
|
orelse error({protocol_error, disabled_protocol, Protocol}).
|
|
|
|
maybe_check_replay(Packet) ->
|
|
%% Check for session replay attack: attempt to connect with the same 1st 64byte packet
|
|
case application:get_env(?APP, replay_check_session_storage, off) of
|
|
on ->
|
|
(new == mtp_session_storage:check_add(Packet)) orelse
|
|
error({protocol_error, replay_session_detected, Packet});
|
|
off ->
|
|
ok
|
|
end.
|
|
|
|
check_tls_policy(Listener, Ip, #{sni_domain := TlsDomain}) ->
|
|
%% TODO validate timestamp!
|
|
check_policy(Listener, Ip, TlsDomain);
|
|
check_tls_policy(_, Ip, Meta) ->
|
|
error({protocol_error, tls_no_sni, {Ip, Meta}}).
|
|
|
|
check_policy(Listener, Ip, Domain) ->
|
|
Rules = application:get_env(?APP, policy, []),
|
|
case mtp_policy:check(Rules, Listener, Ip, Domain) of
|
|
[] -> ok;
|
|
[Rule | _] ->
|
|
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, hello_acc = <<>>}};
|
|
{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),
|
|
ok = up_send_raw(Encoded, S),
|
|
{ok, S#state{codec = UpCodec1}}.
|
|
|
|
up_send_raw(Data, #state{sock = Sock,
|
|
transport = Transport,
|
|
listener = Listener} = S) ->
|
|
mtp_metric:rt([?APP, upstream_send_duration, seconds],
|
|
fun() ->
|
|
case Transport:send(Sock, Data) of
|
|
ok ->
|
|
mtp_metric:count_inc(
|
|
[?APP, sent, upstream, bytes],
|
|
iolist_size(Data), #{labels => [Listener]}),
|
|
ok;
|
|
{error, Reason} ->
|
|
is_atom(Reason) andalso
|
|
mtp_metric:count_inc(
|
|
[?APP, upstream_send_error, total], 1,
|
|
#{labels => [Listener, Reason]}),
|
|
?LOG_WARNING("Upstream send error: ~p", [Reason]),
|
|
throw({stop, normal, S})
|
|
end
|
|
end, #{labels => [Listener]}).
|
|
|
|
down_send(Packet, #state{down = Down} = S) ->
|
|
%% ?LOG_DEBUG(">Down: ~p", [Packet]),
|
|
case mtp_down_conn:send(Down, Packet) of
|
|
ok ->
|
|
{ok, S};
|
|
{error, unknown_upstream} ->
|
|
handle_unknown_upstream(S)
|
|
end.
|
|
|
|
handle_unknown_upstream(#state{down = Down, sock = USock, transport = UTrans} = S) ->
|
|
%% there might be a race-condition between packets from upstream socket and
|
|
%% downstream's 'close_ext' message. Most likely because of slow up_send
|
|
ok = UTrans:close(USock),
|
|
receive
|
|
{'$gen_cast', {close_ext, Down}} ->
|
|
?LOG_DEBUG("asked to close connection by downstream"),
|
|
throw({stop, normal, S#state{down = undefined}})
|
|
after 0 ->
|
|
throw({stop, got_unknown_upstream, S})
|
|
end.
|
|
|
|
|
|
%% Internal
|
|
|
|
|
|
%% @doc Terminate if message queue is too big
|
|
maybe_check_health(#state{last_queue_check = LastCheck} = S) ->
|
|
NowMs = erlang:system_time(millisecond),
|
|
Delta = NowMs - LastCheck,
|
|
case Delta < ?HEALTH_CHECK_INTERVAL of
|
|
true ->
|
|
{noreply, S};
|
|
false ->
|
|
case check_health() of
|
|
ok ->
|
|
{noreply, S#state{last_queue_check = NowMs}};
|
|
overflow ->
|
|
{stop, normal, S}
|
|
end
|
|
end.
|
|
|
|
%% 1. If proc queue > qlen - stop
|
|
%% 2. If proc total memory > gc - do GC and go to 3
|
|
%% 3. If proc total memory > total_mem - stop
|
|
check_health() ->
|
|
%% see .app.src
|
|
Defaults = [{qlen, 300},
|
|
{gc, 409600},
|
|
{total_mem, 3145728}],
|
|
Checks = application:get_env(?APP, upstream_healthchecks, Defaults),
|
|
do_check_health(Checks, calc_health()).
|
|
|
|
do_check_health([{qlen, Limit} | _], #{message_queue_len := QLen} = Health) when QLen > Limit ->
|
|
mtp_metric:count_inc([?APP, healthcheck, total], 1,
|
|
#{labels => [message_queue_len]}),
|
|
?LOG_WARNING("Upstream too large queue_len=~w, health=~p", [QLen, Health]),
|
|
overflow;
|
|
do_check_health([{gc, Limit} | Other], #{total_mem := TotalMem}) when TotalMem > Limit ->
|
|
%% Maybe it doesn't makes sense to do GC if queue len is more than, eg, 50?
|
|
%% In this case almost all memory will be in msg queue
|
|
mtp_metric:count_inc([?APP, healthcheck, total], 1,
|
|
#{labels => [force_gc]}),
|
|
erlang:garbage_collect(self()),
|
|
do_check_health(Other, calc_health());
|
|
do_check_health([{total_mem, Limit} | _Other], #{total_mem := TotalMem} = Health) when
|
|
TotalMem > Limit ->
|
|
mtp_metric:count_inc([?APP, healthcheck, total], 1,
|
|
#{labels => [total_memory]}),
|
|
?LOG_WARNING("Process too large total_mem=~p, health=~p", [TotalMem / 1024, Health]),
|
|
overflow;
|
|
do_check_health([_Ok | Other], Health) ->
|
|
do_check_health(Other, Health);
|
|
do_check_health([], _) ->
|
|
ok.
|
|
|
|
calc_health() ->
|
|
[{_, QLen}, {_, Mem}, {_, BinInfo}] =
|
|
erlang:process_info(self(), [message_queue_len, memory, binary]),
|
|
RefcBinSize = sum_binary(BinInfo),
|
|
TotalMem = Mem + RefcBinSize,
|
|
#{message_queue_len => QLen,
|
|
memory => Mem,
|
|
refc_bin_size => RefcBinSize,
|
|
refc_bin_count => length(BinInfo),
|
|
total_mem => TotalMem}.
|
|
|
|
sum_binary(BinInfo) ->
|
|
trunc(lists:foldl(fun({_, Size, RefC}, Sum) ->
|
|
Sum + (Size / RefC)
|
|
end, 0, BinInfo)).
|
|
|
|
hex(Bin) ->
|
|
<<begin
|
|
if N < 10 ->
|
|
<<($0 + N)>>;
|
|
true ->
|
|
<<($W + N)>>
|
|
end
|
|
end || <<N:4>> <= Bin>>.
|
|
|
|
unhex(Chars) ->
|
|
UnHChar = fun(C) when C < $W -> C - $0;
|
|
(C) when C > $W -> C - $W
|
|
end,
|
|
<< <<(UnHChar(C)):4>> || <<C>> <= Chars>>.
|