%% Copyright (c) 2019-2023, 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}, {verify, verify_none}, {fail_if_no_peer_cert, 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), {ok, ClientSocket1} = ssl:handshake(ClientSocket0, 5000), {ok, ClientSocket1} 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}}, AuthMethod = do_auth_method(Auth), ok = case {AuthMethod, lists:member(AuthMethod, Auths)} of {none, true} -> Transport:send(ClientSocket, <<5, 0>>); {username_password, true} -> Transport:send(ClientSocket, <<5, 2>>), {ok, <<1, ULen, User:ULen/binary, PLen, Pass:PLen/binary>>} = Recv(ClientSocket, 0, 1000), Parent ! {self(), {username_password, User, Pass}}, %% @todo Test errors too (byte 2). Transport:send(ClientSocket, <<1, 0>>); {_, 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. do_auth_method(none) -> none; do_auth_method({username_password, _, _}) -> username_password. %% Tests. socks5_tcp_http_none(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTP server."), do_socks5(<<"http">>, tcp, http, tcp, none). socks5_tcp_http_username_password(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTP server."), do_socks5(<<"http">>, tcp, http, tcp, {username_password, <<"user">>, <<"password">>}). socks5_tcp_https_none(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTPS server."), do_socks5(<<"https">>, tls, http, tcp, none). socks5_tcp_https_username_password(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTPS server."), do_socks5(<<"https">>, tls, http, tcp, {username_password, <<"user">>, <<"password">>}). socks5_tls_http_none(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTP server."), do_socks5(<<"http">>, tcp, http, tls, none). socks5_tls_http_username_password(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTP server."), do_socks5(<<"http">>, tcp, http, tls, {username_password, <<"user">>, <<"password">>}). socks5_tls_https_none(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTPS server."), do_socks5(<<"https">>, tls, http, tls, none). socks5_tls_https_username_password(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTPS server."), do_socks5(<<"https">>, tls, http, tls, {username_password, <<"user">>, <<"password">>}). socks5_tcp_h2c_none(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTP/2 server over TCP."), do_socks5(<<"http">>, tcp, http2, tcp, none). socks5_tcp_h2c_username_password(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTP/2 server over TCP."), do_socks5(<<"http">>, tcp, http2, tcp, {username_password, <<"user">>, <<"password">>}). socks5_tcp_h2_none(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTP/2 server over TLS."), do_socks5(<<"https">>, tls, http2, tcp, none). socks5_tcp_h2_username_password(_) -> doc("Use Socks5 over TCP and without authentication to connect to an HTTP/2 server over TLS."), do_socks5(<<"https">>, tls, http2, tcp, {username_password, <<"user">>, <<"password">>}). socks5_tls_h2c_none(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTP/2 server over TCP."), do_socks5(<<"http">>, tcp, http2, tls, none). socks5_tls_h2c_username_password(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTP/2 server over TCP."), do_socks5(<<"http">>, tcp, http2, tls, {username_password, <<"user">>, <<"password">>}). socks5_tls_h2_none(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTP/2 server over TLS."), do_socks5(<<"https">>, tls, http2, tls, none). socks5_tls_h2_username_password(_) -> doc("Use Socks5 over TLS and without authentication to connect to an HTTP/2 server over TLS."), do_socks5(<<"https">>, tls, http2, tls, {username_password, <<"user">>, <<"password">>}). do_socks5(OriginScheme, OriginTransport, OriginProtocol, ProxyTransport, SocksAuth) -> {ok, OriginPid, OriginPort} = init_origin(OriginTransport, OriginProtocol), {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, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [{socks, #{ auth => [SocksAuth], host => "localhost", port => OriginPort, transport => OriginTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [OriginProtocol] }}] }), %% We receive a gun_up and a gun_tunnel_up. {ok, socks} = gun:await_up(ConnPid), {up, OriginProtocol} = gun:await(ConnPid, undefined), %% The proxy received two packets. AuthMethod = do_auth_method(SocksAuth), {auth_methods, 1, [AuthMethod]} = receive_from(ProxyPid), _ = case AuthMethod of none -> ok; username_password -> SocksAuth = receive_from(ProxyPid) end, {connect, <<"localhost">>, OriginPort} = receive_from(ProxyPid), handshake_completed = receive_from(OriginPid), _ = gun:get(ConnPid, "/proxied"), _ = case OriginProtocol of http -> Data = receive_from(OriginPid), Lines = binary:split(Data, <<"\r\n">>, [global]), [<<"host: ", Authority/bits>>] = [L || <<"host: ", _/bits>> = L <- Lines]; http2 -> <<_:24, 1:8, _/bits>> = receive_from(OriginPid) end, #{ transport := OriginTransport, protocol := OriginProtocol, 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). socks5_tcp_through_multiple_tcp_proxies(_) -> doc("Gun can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "two separate TCP Socks5 proxies."), do_socks5_through_multiple_proxies(<<"http">>, tcp, tcp). socks5_tcp_through_multiple_tls_proxies(_) -> doc("Gun can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "two separate TLS Socks5 proxies."), do_socks5_through_multiple_proxies(<<"http">>, tcp, tls). socks5_tls_through_multiple_tcp_proxies(_) -> doc("Gun can be used to establish a TLS connection " "to an HTTP/1.1 server via a tunnel going through " "two separate TCP Socks5 proxies."), do_socks5_through_multiple_proxies(<<"https">>, tls, tcp). socks5_tls_through_multiple_tls_proxies(_) -> doc("Gun can be used to establish a TLS connection " "to an HTTP/1.1 server via a tunnel going through " "two separate TLS Socks5 proxies."), do_socks5_through_multiple_proxies(<<"https">>, tls, tls). do_socks5_through_multiple_proxies(OriginScheme, OriginTransport, ProxyTransport) -> {ok, OriginPid, OriginPort} = init_origin(OriginTransport, http), {ok, Proxy1Pid, Proxy1Port} = do_proxy_start(ProxyTransport, none), {ok, Proxy2Pid, Proxy2Port} = do_proxy_start(ProxyTransport, none), Authority = iolist_to_binary(["localhost:", integer_to_binary(OriginPort)]), {ok, ConnPid} = gun:open("localhost", Proxy1Port, #{ transport => ProxyTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [{socks, #{ host => "localhost", port => Proxy2Port, transport => ProxyTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [{socks, #{ host => "localhost", port => OriginPort, transport => OriginTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}] }}] }}] }), %% We receive a gun_up and two gun_tunnel_up. {ok, socks} = gun:await_up(ConnPid), {up, socks} = gun:await(ConnPid, undefined), {up, http} = gun:await(ConnPid, undefined), %% The first proxy received two packets. {auth_methods, 1, [none]} = receive_from(Proxy1Pid), {connect, <<"localhost">>, Proxy2Port} = receive_from(Proxy1Pid), %% So did the second proxy. {auth_methods, 1, [none]} = receive_from(Proxy2Pid), {connect, <<"localhost">>, OriginPort} = receive_from(Proxy2Pid), handshake_completed = receive_from(OriginPid), _ = 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 := Proxy1Port, transport := ProxyTransport, protocol := socks }, #{ type := socks5, host := "localhost", port := Proxy2Port, transport := ProxyTransport, protocol := socks }]} = gun:info(ConnPid), gun:close(ConnPid). socks5_tcp_through_connect_tcp_to_tcp_origin(_) -> doc("CONNECT can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "an HTTP proxy followed by a Socks5 proxy."), do_socks5_through_connect_proxy(<<"http">>, tcp, tcp). socks5_tls_through_connect_tls_to_tcp_origin(_) -> doc("CONNECT can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "an HTTPS proxy followed by a TLS Socks5 proxy."), do_socks5_through_connect_proxy(<<"http">>, tcp, tls). socks5_tcp_through_connect_tcp_to_tls_origin(_) -> doc("CONNECT can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "an HTTP proxy followed by a Socks5 proxy."), do_socks5_through_connect_proxy(<<"https">>, tls, tcp). socks5_tls_through_connect_tls_to_tls_origin(_) -> doc("CONNECT can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "an HTTPS proxy followed by a TLS Socks5 proxy."), do_socks5_through_connect_proxy(<<"https">>, tls, tls). do_socks5_through_connect_proxy(OriginScheme, OriginTransport, ProxyTransport) -> {ok, OriginPid, OriginPort} = init_origin(OriginTransport, http), {ok, Proxy1Pid, Proxy1Port} = rfc7231_SUITE:do_proxy_start(ProxyTransport), {ok, Proxy2Pid, Proxy2Port} = do_proxy_start(ProxyTransport, none), {ok, ConnPid} = gun:open("localhost", Proxy1Port, #{ transport => ProxyTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}] }), %% We receive a gun_up first. This is the HTTP proxy. {ok, http} = gun:await_up(ConnPid), Authority1 = iolist_to_binary(["localhost:", integer_to_binary(Proxy2Port)]), StreamRef = gun:connect(ConnPid, #{ host => "localhost", port => Proxy2Port, transport => ProxyTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [{socks, #{ host => "localhost", port => OriginPort, transport => OriginTransport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}] }}] }), {request, <<"CONNECT">>, Authority1, 'HTTP/1.1', _} = receive_from(Proxy1Pid), {response, fin, 200, _} = gun:await(ConnPid, StreamRef), %% We receive two gun_tunnel_up messages. First the SOCKS server and then the origin HTTP server. {up, socks} = gun:await(ConnPid, StreamRef), {up, http} = gun:await(ConnPid, StreamRef), %% The second proxy receives a Socks5 auth/connect request. {auth_methods, 1, [none]} = receive_from(Proxy2Pid), {connect, <<"localhost">>, OriginPort} = receive_from(Proxy2Pid), handshake_completed = receive_from(OriginPid), Authority2 = iolist_to_binary(["localhost:", integer_to_binary(OriginPort)]), _ = gun:get(ConnPid, "/proxied", [], #{tunnel => StreamRef}), Data = receive_from(OriginPid), Lines = binary:split(Data, <<"\r\n">>, [global]), [<<"host: ", Authority2/bits>>] = [L || <<"host: ", _/bits>> = L <- Lines], #{ transport := OriginTransport, protocol := http, origin_scheme := OriginScheme, origin_host := "localhost", origin_port := OriginPort, intermediaries := [#{ type := connect, host := "localhost", port := Proxy1Port, transport := ProxyTransport, protocol := http }, #{ type := socks5, host := "localhost", port := Proxy2Port, transport := ProxyTransport, protocol := socks }]} = gun:info(ConnPid), gun:close(ConnPid). socks5_tcp_through_h2_connect_tcp_to_tcp_origin(_) -> doc("CONNECT can be used to establish a TCP connection " "to an HTTP/1.1 server via a tunnel going through " "a TCP HTTP/2 proxy followed by a Socks5 proxy."), do_socks5_through_h2_connect_proxy(<<"http">>, tcp, <<"http">>, tcp). do_socks5_through_h2_connect_proxy(_OriginScheme, OriginTransport, ProxyScheme, ProxyTransport) -> {ok, OriginPid, OriginPort} = init_origin(OriginTransport, http), {ok, Proxy1Pid, Proxy1Port} = rfc7540_SUITE:do_proxy_start(ProxyTransport, [ {proxy_stream, 1, 200, [], 0, undefined} ]), {ok, Proxy2Pid, Proxy2Port} = do_proxy_start(ProxyTransport, none), {ok, ConnPid} = gun:open("localhost", Proxy1Port, #{ transport => ProxyTransport, protocols => [http2] }), %% We receive a gun_up first. This is the HTTP proxy. {ok, http2} = gun:await_up(ConnPid), handshake_completed = receive_from(Proxy1Pid), Authority1 = iolist_to_binary(["localhost:", integer_to_binary(Proxy2Port)]), StreamRef = gun:connect(ConnPid, #{ host => "localhost", port => Proxy2Port, transport => ProxyTransport, protocols => [{socks, #{ host => "localhost", port => OriginPort, transport => OriginTransport }}] }), {request, #{ <<":method">> := <<"CONNECT">>, <<":authority">> := Authority1 }} = receive_from(Proxy1Pid), {response, fin, 200, _} = gun:await(ConnPid, StreamRef), %% First the HTTP/2 tunnel is up, then the SOCKS tunnel to the origin HTTP server. {up, socks} = gun:await(ConnPid, StreamRef), {up, http} = gun:await(ConnPid, StreamRef), %% The second proxy receives a Socks5 auth/connect request. {auth_methods, 1, [none]} = receive_from(Proxy2Pid), {connect, <<"localhost">>, OriginPort} = receive_from(Proxy2Pid), handshake_completed = receive_from(OriginPid), ProxiedStreamRef = gun:get(ConnPid, "/proxied", #{}, #{tunnel => StreamRef}), Authority2 = iolist_to_binary(["localhost:", integer_to_binary(OriginPort)]), Data = receive_from(OriginPid), Lines = binary:split(Data, <<"\r\n">>, [global]), [<<"host: ", Authority2/bits>>] = [L || <<"host: ", _/bits>> = L <- Lines], #{ transport := ProxyTransport, protocol := http2, origin_scheme := ProxyScheme, origin_host := "localhost", origin_port := Proxy1Port, intermediaries := [] %% Intermediaries are specific to the CONNECT stream. } = gun:info(ConnPid), {ok, #{ ref := StreamRef, reply_to := Self, state := running, tunnel := #{ transport := ProxyTransport, protocol := socks, %% @todo They're not necessarily the origin. Should be named scheme/host/port. origin_scheme := ProxyScheme, origin_host := "localhost", origin_port := Proxy2Port } }} = gun:stream_info(ConnPid, StreamRef), {ok, #{ ref := ProxiedStreamRef, reply_to := Self, state := running, %% @todo Add "authority" when the stream is not a tunnel. % authority := #{ % scheme := OriginScheme % transport := % protocol := % host := % port := % }, intermediaries := [#{ type := connect, host := "localhost", port := Proxy1Port, transport := ProxyTransport, protocol := http2 }, #{ type := socks5, host := "localhost", port := Proxy2Port, transport := ProxyTransport, protocol := socks }] }} = gun:stream_info(ConnPid, ProxiedStreamRef), gun:close(ConnPid).