diff options
author | Loïc Hoguin <[email protected]> | 2019-09-11 07:22:07 +0200 |
---|---|---|
committer | Loïc Hoguin <[email protected]> | 2019-09-22 16:46:28 +0200 |
commit | 92fd84f61f95a0ecb8aea75c28207d81a9c6f94d (patch) | |
tree | f93a875d3ab457e8f4189a7a2a86377f6e900349 /src | |
parent | 4194682d4edaee3da34783c46a513698eb1e8d05 (diff) | |
download | gun-92fd84f61f95a0ecb8aea75c28207d81a9c6f94d.tar.gz gun-92fd84f61f95a0ecb8aea75c28207d81a9c6f94d.tar.bz2 gun-92fd84f61f95a0ecb8aea75c28207d81a9c6f94d.zip |
Initial support for Socks5
Diffstat (limited to 'src')
-rw-r--r-- | src/gun.erl | 95 | ||||
-rw-r--r-- | src/gun_socks.erl | 184 | ||||
-rw-r--r-- | src/gun_tls_proxy.erl | 1 |
3 files changed, 259 insertions, 21 deletions
diff --git a/src/gun.erl b/src/gun.erl index bb659e7..d0cf2c3 100644 --- a/src/gun.erl +++ b/src/gun.erl @@ -97,6 +97,7 @@ -export([domain_lookup/3]). -export([connecting/3]). -export([tls_handshake/3]). +-export([not_fully_connected/3]). -export([connected/3]). -export([closing/3]). -export([terminate/3]). @@ -118,11 +119,13 @@ event_handler => {module(), any()}, http_opts => http_opts(), http2_opts => http2_opts(), - protocols => [http | http2], + protocols => [http | http2 | {socks, socks_opts()}], retry => non_neg_integer(), retry_fun => fun((non_neg_integer(), opts()) -> #{retries => non_neg_integer(), timeout => pos_integer()}), retry_timeout => pos_integer(), + %% @todo Not sure this should be allowed, there could be loops. +% socks_opts => socks_opts(), supervise => boolean(), tcp_opts => [gen_tcp:connect_option()], tls_handshake_timeout => timeout(), @@ -140,7 +143,9 @@ username => iodata(), password => iodata(), protocol => http | http2, %% @todo Remove in Gun 2.0. - protocols => [http | http2], + %% @todo It could be interesting to accept {http, http_opts()} + %% as well since we may want different options for proxy and origin. + protocols => [http | http2 | {socks, socks_opts()}], transport => tcp | tls, tls_opts => [ssl:tls_client_option()], tls_handshake_timeout => timeout() @@ -148,11 +153,11 @@ -export_type([connect_destination/0]). -type intermediary() :: #{ - type := connect, + type := connect | socks5, host := inet:hostname() | inet:ip_address(), port := inet:port_number(), transport := tcp | tls, - protocol := http | http2 + protocol := http | http2 | socks }. %% @todo When/if HTTP/2 CONNECT gets implemented, we will want an option here @@ -181,6 +186,18 @@ }. -export_type([http2_opts/0]). +-type socks_opts() :: #{ + version => 5, + auth => [{username_password, binary(), binary()} | none], + host := inet:hostname() | inet:ip_address(), + port := inet:port_number(), + protocols => [http | http2 | {socks, socks_opts()}], + transport => tcp | tls, + tls_opts => [ssl:tls_client_option()], + tls_handshake_timeout => timeout() +}. +-export_type([socks_opts/0]). + %% @todo keepalive -type ws_opts() :: #{ closing_timeout => timeout(), @@ -278,18 +295,9 @@ check_options([{http2_opts, ProtoOpts}|Opts]) when is_map(ProtoOpts) -> Error end; check_options([Opt = {protocols, L}|Opts]) when is_list(L) -> - Len = length(L), - case length(lists:usort(L)) of - Len when Len > 0 -> - Check = lists:usort([(P =:= http) orelse (P =:= http2) || P <- L]), - case Check of - [true] -> - check_options(Opts); - _ -> - {error, {options, Opt}} - end; - _ -> - {error, {options, Opt}} + case check_protocols_opt(L) of + ok -> check_options(Opts); + error -> {error, {options, Opt}} end; check_options([{retry, R}|Opts]) when is_integer(R), R >= 0 -> check_options(Opts); @@ -321,11 +329,33 @@ check_options([{ws_opts, ProtoOpts}|Opts]) when is_map(ProtoOpts) -> check_options([Opt|_]) -> {error, {options, Opt}}. +check_protocols_opt(Protocols) -> + %% Protocols must not appear more than once, and they + %% must be one of http, http2 or socks. + ProtoNames0 = lists:usort([case P0 of {P, _} -> P; P -> P end || P0 <- Protocols]), + ProtoNames = [P || P <- ProtoNames0, lists:member(P, [http, http2, socks])], + case length(Protocols) =:= length(ProtoNames) of + false -> error; + true -> + %% When options are given alongside a protocol, they + %% must be checked as well. + %% @todo It may be interesting to allow more than just socks here. + TupleCheck = [case P of + {socks, Opts} -> gun_socks:check_options(Opts) + end || P <- Protocols, is_tuple(P)], + case lists:usort(TupleCheck) of + [] -> ok; + [ok] -> ok; + _ -> error + end + end. + consider_tracing(ServerPid, #{trace := true}) -> dbg:tracer(), dbg:tpl(gun, [{'_', [], [{return_trace}]}]), dbg:tpl(gun_http, [{'_', [], [{return_trace}]}]), dbg:tpl(gun_http2, [{'_', [], [{return_trace}]}]), + dbg:tpl(gun_socks, [{'_', [], [{return_trace}]}]), dbg:tpl(gun_ws, [{'_', [], [{return_trace}]}]), dbg:p(ServerPid, all); consider_tracing(_, _) -> @@ -652,6 +682,8 @@ await_up(ServerPid, Timeout, MRef) -> receive {gun_up, ServerPid, Protocol} -> {ok, Protocol}; + {gun_socks_connected, ServerPid, Protocol} -> + {ok, Protocol}; {'DOWN', MRef, process, ServerPid, Reason} -> {error, {down, Reason}} after Timeout -> @@ -861,7 +893,8 @@ connecting(_, {retries, Retries, LookupInfo}, State=#state{opts=Opts, {ok, Socket} when Transport =:= gun_tcp -> Protocol = case maps:get(protocols, Opts, [http]) of [http] -> gun_http; - [http2] -> gun_http2 + [http2] -> gun_http2; + [{socks, _}] -> gun_socks end, EvHandlerState = EvHandler:connect_end(ConnectEvent#{ socket => Socket, @@ -885,8 +918,9 @@ connecting(_, {retries, Retries, LookupInfo}, State=#state{opts=Opts, tls_handshake(_, {retries, Retries, Socket0}, State=#state{opts=Opts, event_handler=EvHandler, event_handler_state=EvHandlerState0}) -> + Protocols = maps:get(protocols, Opts, [http2, http]), TransOpts0 = maps:get(tls_opts, Opts, []), - TransOpts = ensure_alpn(maps:get(protocols, Opts, [http2, http]), TransOpts0), + TransOpts = ensure_alpn(Protocols, TransOpts0), HandshakeTimeout = maps:get(tls_handshake_timeout, Opts, infinity), HandshakeEvent = #{ socket => Socket0, @@ -898,7 +932,12 @@ tls_handshake(_, {retries, Retries, Socket0}, State=#state{opts=Opts, {ok, Socket} -> Protocol = case ssl:negotiated_protocol(Socket) of {ok, <<"h2">>} -> gun_http2; - _ -> gun_http + {ok, <<"http/1.1">>} -> gun_http; + {error, protocol_not_negotiated} -> + case Protocols of + [{socks, _}] -> gun_socks; + _ -> gun_http + end end, EvHandlerState = EvHandler:tls_handshake_end(HandshakeEvent#{ socket => Socket, @@ -918,12 +957,22 @@ ensure_alpn(Protocols0, TransOpts) -> Protocols = [case P of http -> <<"http/1.1">>; http2 -> <<"h2">> - end || P <- Protocols0], + end || P <- Protocols0, is_atom(P)], [ {alpn_advertised_protocols, Protocols}, {client_preferred_next_protocols, {client, Protocols, <<"http/1.1">>}} |TransOpts]. +not_fully_connected(Type, Event, State) -> + handle_common_connected(Type, Event, ?FUNCTION_NAME, State). + +connected(internal, {connected, Socket, Protocol=gun_socks}, + State=#state{owner=Owner, opts=Opts, transport=Transport}) -> + [{socks, ProtoOpts}] = [Proto || Proto = {socks, _} <- maps:get(protocols, Opts)], + ProtoState = Protocol:init(Owner, Socket, Transport, ProtoOpts), + Owner ! {gun_up, self(), Protocol:name()}, + {next_state, not_fully_connected, active(State#state{socket=Socket, + protocol=Protocol, protocol_state=ProtoState})}; connected(internal, {connected, Socket, Protocol}, State=#state{owner=Owner, opts=Opts, transport=Transport}) -> ProtoOptsKey = case Protocol of @@ -1211,6 +1260,7 @@ commands([{switch_protocol, Protocol=gun_ws, ProtoState}], State=#state{ {keep_state, keepalive_cancel(State#state{protocol=Protocol, protocol_state=ProtoState, event_handler_state=EvHandlerState})}; %% @todo And this state should probably not be ignored. +%% @todo Socks is switching to *http* and we don't seem to support it properly yet. commands([{switch_protocol, Protocol, _ProtoState0}|Tail], State=#state{ owner=Owner, opts=Opts, socket=Socket, transport=Transport, event_handler=EvHandler, event_handler_state=EvHandlerState0}) -> @@ -1218,7 +1268,10 @@ commands([{switch_protocol, Protocol, _ProtoState0}|Tail], State=#state{ ProtoState = Protocol:init(Owner, Socket, Transport, ProtoOpts), EvHandlerState = EvHandler:protocol_changed(#{protocol => Protocol:name()}, EvHandlerState0), commands(Tail, keepalive_timeout(State#state{protocol=Protocol, protocol_state=ProtoState, - event_handler_state=EvHandlerState})). + event_handler_state=EvHandlerState})); +%% Switch from not_fully_connected to connected. +commands([{mode, http}], State) -> + {next_state, connected, State}. disconnect(State0=#state{owner=Owner, status=Status, opts=Opts, socket=Socket, transport=Transport, diff --git a/src/gun_socks.erl b/src/gun_socks.erl new file mode 100644 index 0000000..6c3e6fc --- /dev/null +++ b/src/gun_socks.erl @@ -0,0 +1,184 @@ +%% Copyright (c) 2019, Loïc Hoguin <[email protected]> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(gun_socks). + +-export([check_options/1]). +-export([name/0]). +-export([init/4]). +-export([handle/4]). +-export([closing/4]). +-export([close/4]). +%% @todo down + +-record(socks_state, { + owner :: pid(), + socket :: inet:socket() | ssl:sslsocket(), + transport :: module(), + opts = #{} :: map(), %% @todo + %% We only support version 5 at the moment. + version :: 5, + status :: auth_method_select | auth_username_password | connect +}). + +check_options(Opts=#{host := _, port := _}) -> + do_check_options(maps:to_list(maps:without([host, port], Opts))); +%% Host and port are not optional. +check_options(#{host := _}) -> + {error, options, {socks, port}}; +check_options(#{}) -> + {error, options, {socks, host}}. + +do_check_options([]) -> + ok; +do_check_options([Opt={auth, L}|Opts]) -> + case check_auth_opt(L) of + ok -> do_check_options(Opts); + error -> {error, {options, {socks, Opt}}} + end; +%% @todo Proper protocols check. +do_check_options([{protocols, P}|Opts]) when is_list(P) -> + do_check_options(Opts); +do_check_options([{tls_handshake_timeout, infinity}|Opts]) -> + do_check_options(Opts); +do_check_options([{tls_handshake_timeout, T}|Opts]) when is_integer(T), T >= 0 -> + do_check_options(Opts); +do_check_options([{tls_opts, L}|Opts]) when is_list(L) -> + do_check_options(Opts); +do_check_options([{transport, T}|Opts]) when T =:= tcp; T =:= tls -> + do_check_options(Opts); +do_check_options([{version, 5}|Opts]) -> + do_check_options(Opts); +do_check_options([Opt|_]) -> + {error, {options, {socks, Opt}}}. + +check_auth_opt(Methods) -> + %% Methods must not appear more than once, and they + %% must be one of none or {username_password, binary(), binary()}. + Check = lists:usort([case M of + none -> ok; + {username_password, U, P} when is_binary(U), is_binary(P) -> ok + end || M <- Methods]), + case {length(Methods) =:= length(Check), lists:usort(Check)} of + {true, []} -> ok; + {true, [ok]} -> ok; + _ -> error + end. + +name() -> socks. + +init(Owner, Socket, Transport, Opts) -> + 5 = Version = maps:get(version, Opts, 5), + Auth = maps:get(auth, Opts, [none]), + Methods = <<case A of + {username_password, _, _} -> <<2>>; + none -> <<0>> + end || A <- Auth>>, + Transport:send(Socket, [<<5, (length(Auth))>>, Methods]), + #socks_state{owner=Owner, socket=Socket, transport=Transport, opts=Opts, + version=Version, status=auth_method_select}. + +handle(Data, State, _, EvHandlerState) -> + {handle(Data, State), EvHandlerState}. + +%% No authentication. +handle(<<5, 0>>, State=#socks_state{version=5, status=auth_method_select}) -> + send_socks5_connect(State), + {state, State#socks_state{status=connect}}; +%% Username/password authentication. +handle(<<5, 2>>, State=#socks_state{socket=Socket, transport=Transport, opts=Opts, + version=5, status=auth_method_select}) -> + #{auth := {username_password, Username, Password}} = Opts, + ULen = byte_size(Username), + PLen = byte_size(Password), + Transport:send(Socket, <<1, ULen, Username/binary, PLen, Password/binary>>), + {state, State#socks_state{status=auth_username_password}}; +%% Username/password authentication successful. +handle(<<1, 0>>, State=#socks_state{version=5, status=auth_username_password}) -> + send_socks5_connect(State), + {state, State#socks_state{status=connect}}; +%% Username/password authentication error. +handle(<<1, _>>, #socks_state{version=5, status=auth_username_password}) -> + {error, {socks5, username_password_auth_failure}}; +%% Connect reply. +handle(<<5, 0, 0, Rest0/bits>>, State=#socks_state{owner=Owner, socket=Socket, transport=Transport, opts=Opts, + version=5, status=connect}) -> + %% @todo What to do with BoundAddr and BoundPort? Add as metadata to origin info? + {_BoundAddr, _BoundPort} = case Rest0 of + %% @todo Seen a server with <<1, 0:48>>. + <<1, A, B, C, D, Port:16>> -> + {{A, B, C, D}, Port}; + <<3, Len, Host:Len/binary, Port:16>> -> + %% We convert to list to get an inet:hostname(). + {unicode:characters_to_list(Host), Port}; + <<4, A:16, B:16, C:16, D:16, E:16, F:16, G:16, H:16, Port:16>> -> + {{A, B, C, D, E, F, G, H}, Port} + end, + %% @todo Maybe an event indicating success. + #{host := NewHost, port := NewPort} = Opts, + case Opts of + %% @todo TLS over TLS here as well. +% #{protocols := Protocols, transport := tls} -> +% TLSOpts = maps:get(tls_opts, Destination, []), +% TLSTimeout = maps:get(tls_handshake_timeout, Destination, infinity), + %% + #{protocols := [{socks, SockOpts}]} -> + Owner ! {gun_socks_connected, self(), name()}, + [{origin, <<"http">>, NewHost, NewPort, socks5}, + {switch_protocol, ?MODULE, init(Owner, Socket, Transport, SockOpts)}]; + #{protocols := [http2]} -> + Owner ! {gun_socks_connected, self(), gun_http2:name()}, + [{origin, <<"http">>, NewHost, NewPort, socks5}, + {switch_protocol, gun_http2, State}, + {mode, http}]; + _ -> + Owner ! {gun_socks_connected, self(), gun_http:name()}, + [{origin, <<"http">>, NewHost, NewPort, socks5}, + {switch_protocol, gun_http, State}, + {mode, http}] + end; +handle(<<5, Error, _/bits>>, #socks_state{version=5, status=connect}) -> + Reason = case Error of + 1 -> general_socks_server_failure; + 2 -> connection_not_allowed_by_ruleset; + 3 -> network_unreachable; + 4 -> host_unreachable; + 5 -> connection_refused; + 6 -> ttl_expired; + 7 -> command_not_supported; + 8 -> address_type_not_supported; + _ -> {unknown_error, Error} + end, + {error, {socks5, Reason}}. + +send_socks5_connect(#socks_state{socket=Socket, transport=Transport, opts=Opts}) -> + ATypeAndDestAddr = case maps:get(host, Opts) of + {A, B, C, D} -> <<1, A, B, C, D>>; + {A, B, C, D, E, F, G, H} -> <<4, A:16, B:16, C:16, D:16, E:16, F:16, G:16, H:16>>; + Host when is_atom(Host) -> + DestAddr0 = atom_to_binary(Host, utf8), + <<3, (byte_size(DestAddr0)), DestAddr0/binary>>; + Host -> + DestAddr0 = unicode:characters_to_binary(Host, utf8), + <<3, (byte_size(DestAddr0)), DestAddr0/binary>> + end, + DestPort = maps:get(port, Opts), + Transport:send(Socket, <<5, 1, 0, ATypeAndDestAddr/binary, DestPort:16>>). + +%% We can always close immediately. +closing(_, _, _, EvHandlerState) -> + {close, EvHandlerState}. + +close(_, _, _, EvHandlerState) -> + EvHandlerState. diff --git a/src/gun_tls_proxy.erl b/src/gun_tls_proxy.erl index 3978822..d519919 100644 --- a/src/gun_tls_proxy.erl +++ b/src/gun_tls_proxy.erl @@ -237,6 +237,7 @@ 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]), + %% @todo We should distinguish case when no protocol was negotiated (for socks). Protocol = case ssl:negotiated_protocol(Socket) of {ok, <<"h2">>} -> gun_http2; _ -> gun_http |