diff options
Diffstat (limited to 'src/gun_socks.erl')
-rw-r--r-- | src/gun_socks.erl | 184 |
1 files changed, 184 insertions, 0 deletions
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. |