Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 80 additions & 7 deletions src/gun.erl
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@
keepalive_ref :: undefined | reference(),
socket :: undefined | inet:socket() | ssl:sslsocket() | pid(),
transport :: module(),
active = true :: boolean(),
active = false :: boolean(),
messages :: {atom(), atom(), atom()},
protocol :: module(),
protocol_state :: any(),
Expand Down Expand Up @@ -1331,10 +1331,18 @@ normal_tls_handshake(Socket, State=#state{
EvHandlerState1 = EvHandler:tls_handshake_start(HandshakeEvent, EvHandlerState0),
case gun_tls:connect(Socket, TLSOpts, TLSTimeout) of
{ok, TLSSocket} ->
%% This call may return {error,closed} when the socket has
%% been closed by the peer. This should be very rare (due to
%% timing) but can happen for example when client certificates
%% were required but not sent or invalid with some servers.
%% When initially connecting we are in passive mode and
%% in that state we expect this call to always succeed.
%% In rare scenarios (suspended Gun process) it may
%% return {error,closed}, but this indicates that the
%% socket process is gone and we cannot retrieve a potential
%% TLS alert.
%%
%% When using HTTP/1.1 CONNECT we are also in passive mode
%% because CONNECT involves a response that is received via
%% active mode, which automatically goes into passive mode
%% ({active,once}), and we only reenable active mode after
%% processing commands.
case ssl:negotiated_protocol(TLSSocket) of
{error, Reason = closed} ->
EvHandlerState = EvHandler:tls_handshake_end(HandshakeEvent#{
Expand Down Expand Up @@ -1368,7 +1376,8 @@ connected_protocol_init(internal, {connected, Retries, Socket, NewProtocol},
{ok, StateName, ProtoState} ->
%% @todo Don't send gun_up and gun_down if active/1 fails here.
reply(Owner, {gun_up, self(), Protocol:name()}),
State1 = State0#state{socket=Socket, protocol=Protocol, protocol_state=ProtoState},
State1 = State0#state{socket=Socket, protocol=Protocol,
protocol_state=ProtoState, active=true},
case active(State1) of
{ok, State2} ->
State = case Protocol:has_keepalive() of
Expand Down Expand Up @@ -1899,7 +1908,8 @@ commands([TLSHandshake={tls_handshake, _, _, _}], State) ->
disconnect(State0=#state{owner=Owner, status=Status, opts=Opts,
intermediaries=Intermediaries, socket=Socket, transport=Transport0,
protocol=Protocol, protocol_state=ProtoState,
event_handler=EvHandler, event_handler_state=EvHandlerState0}, Reason) ->
event_handler=EvHandler, event_handler_state=EvHandlerState0}, Reason0) ->
Reason = maybe_tls_alert(State0, Reason0),
EvHandlerState1 = Protocol:close(Reason, ProtoState, EvHandler, EvHandlerState0),
_ = Transport0:close(Socket),
EvHandlerState = EvHandler:disconnect(#{reason => Reason}, EvHandlerState1),
Expand All @@ -1913,6 +1923,9 @@ disconnect(State0=#state{owner=Owner, status=Status, opts=Opts,
%% We closed the socket, discard any remaining socket events.
disconnect_flush(State1),
KilledStreams = Protocol:down(ProtoState),
%% @todo Reason here may be {error, Reason1} which leads to
%% different behavior compared to down messages received
%% from failing to connect where Reason1 is what gets sent.
reply(Owner, {gun_down, self(), Protocol:name(), Reason, KilledStreams}),
Retry = maps:get(retry, Opts, 5),
State2 = keepalive_cancel(State1#state{
Expand All @@ -1935,6 +1948,66 @@ disconnect(State0=#state{owner=Owner, status=Status, opts=Opts,
{next_event, internal, {retries, Retry, Reason}}}
end.

%% With TLS 1.3 the handshake may not have validated the certificate
%% by the time it completes. The validation may therefore fail at any
%% time afterwards. TLS 1.3 also introduced post-handshake authentication
%% which would produce the same results. Erlang/OTP's ssl has a number
%% of asynchronous functions which won't return the alert as an error
%% and instead return a plain {error,closed}, including ssl:send.
%% Gun must therefore check whether a close is resulting from a TLS alert
%% and use that alert as a more descriptive disconnect reason.
%%
%% Sometimes, ssl:send will return {error,einval}, because while the
%% TLS pseudo-socket still exists, the underlying TCP socket is already
%% gone. In that case we can still query the TLS pseudo-socket to get
%% the detailed TLS alert.
%%
%% @todo We currently do not support retrieving the alert from a gun_tls_proxy
%% socket. We need a test case to best understand what should be done there.
%% But since the socket belongs to that process we likely need additional
%% changes there to make it work.
maybe_tls_alert(#state{socket=Socket, transport=gun_tls,
active=true, messages={_, _, Error}}, Reason0)
%% The unwrapped tuple we get half the time makes this clause more complex.
when Reason0 =:= {error, closed}; Reason0 =:= {error, einval}; Reason0 =:= closed ->
%% When active mode is enabled we should have the alert in our
%% mailbox so we can just retrieve it. In case it is late we
%% use a short timeout to increase the chances of catching it.
receive
{Error, Socket, Reason} ->
Reason
after 200 ->
Reason0
end;
maybe_tls_alert(#state{socket=Socket, transport=Transport=gun_tls,
active=false}, Reason0)
when Reason0 =:= {error, closed}; Reason0 =:= {error, einval}; Reason0 =:= closed ->
%% When active mode is disabled we can do a number of operations to
%% receive the alert. Enabling active mode is one of them.
case Transport:setopts(Socket, [{active, once}]) of
{error, Reason={tls_alert, _}} ->
Reason;
_ ->
Reason0
end;
%% We unwrap the TLS alert error for consistency.
%% @todo Consistenly wrap/unwrap all errors instead of just this one.
maybe_tls_alert(_, {error, Reason={tls_alert, _}}) ->
Reason;
%% We may also need to receive the alert when proxying TLS.
maybe_tls_alert(#state{socket=Socket, transport=gun_tls_proxy,
active=true, messages={_, _, Error}}, Reason0)
%% The unwrapped tuple we get half the time makes this clause more complex.
when Reason0 =:= {error, closed}; Reason0 =:= {error, einval}; Reason0 =:= closed ->
receive
{Error, Socket, Reason} ->
Reason
after 200 ->
Reason0
end;
maybe_tls_alert(_, Reason) ->
Reason.

disconnect_flush(State=#state{socket=Socket, messages={OK, Closed, Error}}) ->
receive
{OK, Socket, _} -> disconnect_flush(State);
Expand Down
25 changes: 24 additions & 1 deletion src/gun_http2.erl
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,16 @@ init(ReplyTo, Socket, Transport, Opts0) ->
{ok, connected, #http2_state{reply_to=ReplyTo, socket=Socket, transport=Transport,
opts=Opts, base_stream_ref=BaseStreamRef, tunnel_transport=TunnelTransport,
content_handlers=Handlers, http2_machine=HTTP2Machine}};
Error0={error, R} when R =:= closed; R =:= einval ->
%% Check whether we have a TLS alert and in that case,
%% return it. We must do this here because Protocol:init
%% failure doesn't go through disconnect.
case Transport:setopts(Socket, [{active, once}]) of
Error={error, {tls_alert, _}} ->
Error;
_ ->
Error0
end;
Error={error, _Reason} ->
Error
end.
Expand Down Expand Up @@ -488,8 +498,20 @@ tunnel_commands([{state, ProtoState}|Tail], Stream=#stream{tunnel=Tunnel},
State, EvHandler, EvHandlerState) ->
tunnel_commands(Tail, Stream#stream{tunnel=Tunnel#tunnel{protocol_state=ProtoState}},
State, EvHandler, EvHandlerState);
tunnel_commands([{error, Reason}|_], #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo},
tunnel_commands([{error, Reason0}|_], #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo},
State, _EvHandler, EvHandlerState) ->
%% See gun:maybe_tls_alert for details.
Reason = case Reason0 of
closed ->
receive
{handle_continue, StreamRef, {tls_proxy_error, _Socket, Reason1}} ->
Reason1
after 200 ->
Reason0
end;
_ ->
Reason0
end,
gun:reply(ReplyTo, {gun_error, self(), stream_ref(State, StreamRef),
{stream_error, Reason, 'Tunnel closed unexpectedly.'}}),
{{state, delete_stream(State, StreamID)}, EvHandlerState};
Expand Down Expand Up @@ -948,6 +970,7 @@ close(Reason0, State=#http2_state{streams=Streams}, _, EvHandlerState) ->
end, [], Streams),
EvHandlerState.

%% @todo This can get {error,closed} leading to {closed,{error,closed}}.
close_reason(closed) -> closed;
close_reason(Reason) -> {closed, Reason}.

Expand Down
1 change: 1 addition & 0 deletions src/gun_http3.erl
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ default_keepalive() -> infinity.
init(ReplyTo, Conn, Transport, Opts) ->
Handlers = maps:get(content_handlers, Opts, [gun_data_h]),
{ok, SettingsBin, HTTP3Machine0} = cow_http3_machine:init(client, Opts),
%% @todo We may get a TLS 1.3 error/alert here in mTLS scenarios.
{ok, ControlID} = Transport:start_unidi_stream(Conn, [<<0>>, SettingsBin]),
{ok, EncoderID} = Transport:start_unidi_stream(Conn, [<<2>>]),
{ok, DecoderID} = Transport:start_unidi_stream(Conn, [<<3>>]),
Expand Down
2 changes: 1 addition & 1 deletion src/gun_tls.erl
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ connect(Socket, Opts, Timeout) ->
send(Socket, Packet) ->
ssl:send(Socket, Packet).

-spec setopts(ssl:sslsocket(), list()) -> ok | {error, atom()}.
-spec setopts(ssl:sslsocket(), list()) -> ok | {error, any()}.
setopts(Socket, Opts) ->
ssl:setopts(Socket, Opts).

Expand Down
33 changes: 27 additions & 6 deletions src/gun_tls_proxy.erl
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ cb_send(Pid, Data) ->
try
gen_statem:call(Pid, {?FUNCTION_NAME, Data})
catch
exit:{{shutdown, close}, _} ->
{error, closed};
exit:{noproc, _} ->
{error, closed}
end.
Expand All @@ -140,6 +142,8 @@ cb_setopts(Pid, Opts) ->
try
gen_statem:call(Pid, {?FUNCTION_NAME, Opts})
catch
exit:{{shutdown, close}, _} ->
{error, closed};
exit:{noproc, _} ->
{error, einval}
end.
Expand Down Expand Up @@ -178,6 +182,9 @@ sockname(Pid) ->
close(Pid) ->
?DEBUG_LOG("pid ~0p", [Pid]),
try
%% We must unlink before closing otherwise the closing
%% will take down the Gun process with it.
unlink(Pid),
gen_statem:call(Pid, ?FUNCTION_NAME)
catch
%% May happen for example when the handshake fails.
Expand Down Expand Up @@ -236,11 +243,13 @@ not_connected(cast, Msg={setopts, _}, State) ->
{keep_state_and_data, postpone};
not_connected(cast, Msg={connect_proc, {ok, Socket}}, State=#state{owner_pid=OwnerPid, extra=Extra}) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
OwnerPid ! {?MODULE, self(), {ok, ssl:negotiated_protocol(Socket)}, Extra},
%% We need to spawn this call before OTP-21.2 because it triggers
%% a cb_setopts call that blocks us. Might be OK to just leave it
%% like this once we support 21.2+ only.
spawn(fun() -> ok = ssl:setopts(Socket, [{active, true}]) end),
Negotiated = ssl:negotiated_protocol(Socket),
_ = case ssl:setopts(Socket, [{active, true}]) of
ok ->
OwnerPid ! {?MODULE, self(), {ok, Negotiated}, Extra};
Error ->
OwnerPid ! {?MODULE, self(), Error, Extra}
end,
{next_state, connected, State#state{proxy_socket=Socket}};
not_connected(cast, Msg={connect_proc, Error}, State=#state{owner_pid=OwnerPid, extra=Extra}) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
Expand Down Expand Up @@ -332,8 +341,20 @@ handle_common(cast, Msg={cb_controlling_process, ProxyPid}, State) ->
handle_common(cast, Msg={setopts, Opts}, State) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
{keep_state, owner_setopts(Opts, State)};
handle_common(cast, Msg={send_result, From, Result}, State) ->
handle_common(cast, Msg={send_result, From, Result0}, State=#state{proxy_socket=Socket}) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
%% See gun:maybe_tls_alert for details.
Result = case Result0 of
{error, closed} ->
receive
{ssl_error, Socket, Reason} ->
{error, Reason}
after 200 ->
Result0
end;
_ ->
Result0
end,
gen_statem:reply(From, Result),
keep_state_and_data;
%% Messages from the real socket.
Expand Down
Loading