From 92fd84f61f95a0ecb8aea75c28207d81a9c6f94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Wed, 11 Sep 2019 07:22:07 +0200 Subject: Initial support for Socks5 --- doc/src/manual/gun_up.asciidoc | 4 + ebin/gun.app | 2 +- src/gun.erl | 95 ++++++++++++++++----- src/gun_socks.erl | 184 +++++++++++++++++++++++++++++++++++++++++ src/gun_tls_proxy.erl | 1 + test/socks_SUITE.erl | 179 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 443 insertions(+), 22 deletions(-) create mode 100644 src/gun_socks.erl create mode 100644 test/socks_SUITE.erl diff --git a/doc/src/manual/gun_up.asciidoc b/doc/src/manual/gun_up.asciidoc index c7089fb..a103594 100644 --- a/doc/src/manual/gun_up.asciidoc +++ b/doc/src/manual/gun_up.asciidoc @@ -25,6 +25,10 @@ then this may not be desirable for all requests. Those requests should be cancelled when the connection goes down, and any subsequent messages ignored. +// @todo Gun doesn't process messages immediately if it +// is using the socks protocol, there are gun_socks_connected +// messages coming up before reaching HTTP. + == Elements ConnPid:: diff --git a/ebin/gun.app b/ebin/gun.app index e3ad981..c488c9a 100644 --- a/ebin/gun.app +++ b/ebin/gun.app @@ -1,7 +1,7 @@ {application, 'gun', [ {description, "HTTP/1.1, HTTP/2 and Websocket client for Erlang/OTP."}, {vsn, "1.3.0"}, - {modules, ['gun','gun_app','gun_content_handler','gun_data_h','gun_default_event_h','gun_event','gun_http','gun_http2','gun_sse_h','gun_sup','gun_tcp','gun_tls','gun_tls_proxy','gun_tls_proxy_cb','gun_ws','gun_ws_h']}, + {modules, ['gun','gun_app','gun_content_handler','gun_data_h','gun_default_event_h','gun_event','gun_http','gun_http2','gun_socks','gun_sse_h','gun_sup','gun_tcp','gun_tls','gun_tls_proxy','gun_tls_proxy_cb','gun_ws','gun_ws_h']}, {registered, [gun_sup]}, {applications, [kernel,stdlib,ssl,cowlib]}, {mod, {gun_app, []}}, 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 +%% +%% 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 = < <<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 diff --git a/test/socks_SUITE.erl b/test/socks_SUITE.erl new file mode 100644 index 0000000..01bc760 --- /dev/null +++ b/test/socks_SUITE.erl @@ -0,0 +1,179 @@ +%% Copyright (c) 2019, Loïc Hoguin +%% +%% 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. + +%% This test suite covers the following RFCs and specifications: +%% +%% * RFC 1928 +%% * RFC 1929 +%% * http://ftp.icm.edu.pl/packages/socks/socks4/SOCKS4.protocol +%% * https://www.openssh.com/txt/socks4a.protocol + +-module(socks_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [doc/1]). +-import(gun_test, [init_origin/1]). +-import(gun_test, [init_origin/2]). +-import(gun_test, [receive_from/1]). +-import(gun_test, [receive_from/2]). + +all() -> + ct_helper:all(?MODULE). + +%% Proxy helpers. + +do_proxy_start(Transport0, Auth) -> + Transport = case Transport0 of + tcp -> gun_tcp; + tls -> gun_tls + end, + Self = self(), + Pid = spawn_link(fun() -> do_proxy_init(Self, Transport, Auth) end), + Port = receive_from(Pid), + {ok, Pid, Port}. + +do_proxy_init(Parent, Transport, Auth) -> + {ok, ListenSocket} = case Transport of + gun_tcp -> + gen_tcp:listen(0, [binary, {active, false}]); + gun_tls -> + Opts = ct_helper:get_certs_from_ets(), + ssl:listen(0, [binary, {active, false}|Opts]) + end, + {ok, {_, Port}} = Transport:sockname(ListenSocket), + Parent ! {self(), Port}, + {ok, ClientSocket} = case Transport of + gun_tcp -> + gen_tcp:accept(ListenSocket, 5000); + gun_tls -> + {ok, ClientSocket0} = ssl:transport_accept(ListenSocket, 5000), + ssl:ssl_accept(ClientSocket0, 5000), + {ok, ClientSocket0} + end, + Recv = case Transport of + gun_tcp -> fun gen_tcp:recv/3; + gun_tls -> fun ssl:recv/3 + end, + %% Authentication method. + {ok, <<5, NumAuths, Auths0/bits>>} = Recv(ClientSocket, 0, 1000), + Auths = [case A of + 0 -> none; + 2 -> username_password + end || <> <= Auths0], + Parent ! {self(), {auth_methods, NumAuths, Auths}}, + ok = case {Auth, lists:member(Auth, Auths)} of + {none, true} -> + Transport:send(ClientSocket, <<5, 0>>); + {username_password, true} -> + %% @todo + ok; + {_, false} -> + %% @todo + not_ok + end, + %% Connection request. + {ok, <<5, 1, 0, AType, Rest/bits>>} = Recv(ClientSocket, 0, 1000), + {OriginHost, OriginPort} = case AType of + 1 -> + <> = Rest, + {{A, B, C, D}, P}; + 3 -> + <> = Rest, + {H, P}; + 4 -> + <> = Rest, + {{A, B, C, D, E, F, G, H}, P} + end, + Parent ! {self(), {connect, OriginHost, OriginPort}}, + %% @todo Test errors too (byte 2). + %% @todo Configurable bound address. + Transport:send(ClientSocket, <<5, 0, 0, 1, 1, 2, 3, 4, 33333:16>>), + if + true -> + {ok, OriginSocket} = gen_tcp:connect( + binary_to_list(OriginHost), OriginPort, + [binary, {active, false}]), + Transport:setopts(ClientSocket, [{active, true}]), + inet:setopts(OriginSocket, [{active, true}]), + do_proxy_loop(Transport, ClientSocket, OriginSocket) + end. + +do_proxy_loop(Transport, ClientSocket, OriginSocket) -> + {OK, _, _} = Transport:messages(), + receive + {OK, ClientSocket, Data} -> + case gen_tcp:send(OriginSocket, Data) of + ok -> + do_proxy_loop(Transport, ClientSocket, OriginSocket); + {error, _} -> + ok + end; + {tcp, OriginSocket, Data} -> + case Transport:send(ClientSocket, Data) of + ok -> + do_proxy_loop(Transport, ClientSocket, OriginSocket); + {error, _} -> + ok + end; + {tcp_closed, _} -> + ok; + {ssl_closed, _} -> + ok; + Msg -> + error(Msg) + end. + +%% Tests. + +socks5_tcp_http_none(_) -> + doc("Use Socks5 over TCP and without authentication to connect to an HTTP server."), + do_socks5_tcp_http(<<"http">>, tcp, tcp, none). + +do_socks5_tcp_http(OriginScheme, OriginTransport, ProxyTransport, SocksAuth) -> + {ok, OriginPid, OriginPort} = init_origin(OriginTransport, http), + {ok, ProxyPid, ProxyPort} = do_proxy_start(ProxyTransport, SocksAuth), + Authority = iolist_to_binary(["localhost:", integer_to_binary(OriginPort)]), + {ok, ConnPid} = gun:open("localhost", ProxyPort, #{ + transport => ProxyTransport, + protocols => [{socks, #{ + auth => [SocksAuth], + host => "localhost", + port => OriginPort + }}] + }), + %% We receive a gun_up and a gun_socks_connected. + {ok, socks} = gun:await_up(ConnPid), + {ok, http} = gun:await_up(ConnPid), + %% The proxy received two packets. + {auth_methods, 1, [SocksAuth]} = receive_from(ProxyPid), + {connect, <<"localhost">>, OriginPort} = receive_from(ProxyPid), + _ = gun:get(ConnPid, "/proxied"), + Data = receive_from(OriginPid), + Lines = binary:split(Data, <<"\r\n">>, [global]), + [<<"host: ", Authority/bits>>] = [L || <<"host: ", _/bits>> = L <- Lines], + #{ + transport := OriginTransport, + protocol := http, + origin_scheme := OriginScheme, + origin_host := "localhost", + origin_port := OriginPort, + intermediaries := [#{ + type := socks5, + host := "localhost", + port := ProxyPort, + transport := ProxyTransport, + protocol := socks + }]} = gun:info(ConnPid), + gun:close(ConnPid). -- cgit v1.2.3