mtproto_proxy/src/mtp_handler.erl
Sergey Prokhorov 26fe4bc0e8
bugfix: Clear .hello_acc in mtp_handler when no longer needed
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).
2026-04-06 17:43:28 +02:00

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