diff --git a/lib/mint/core/transport/ssl.ex b/lib/mint/core/transport/ssl.ex index 2e1a8c9b..82569324 100644 --- a/lib/mint/core/transport/ssl.ex +++ b/lib/mint/core/transport/ssl.ex @@ -323,6 +323,7 @@ defmodule Mint.Core.Transport.SSL do defp connect(address, hostname, port, opts) do timeout = Keyword.get(opts, :timeout, @default_timeout) + inet4? = Keyword.get(opts, :inet4, true) inet6? = Keyword.get(opts, :inet6, false) opts = ssl_opts(String.to_charlist(hostname), opts) @@ -334,8 +335,11 @@ defmodule Mint.Core.Transport.SSL do {:ok, sslsocket} -> {:ok, sslsocket} - _error -> + _error when inet4? -> wrap_err(:ssl.connect(address, port, opts, timeout)) + + error -> + wrap_err(error) end else # Use the defaults provided by ssl/gen_tcp. @@ -428,7 +432,7 @@ defmodule Mint.Core.Transport.SSL do default_ssl_opts(hostname) |> Keyword.merge(opts) |> Keyword.merge(@transport_opts) - |> Keyword.drop([:timeout, :inet6]) + |> Keyword.drop([:timeout, :inet4, :inet6]) |> add_verify_opts(hostname) |> remove_incompatible_ssl_opts() |> add_ciphers_opt() diff --git a/lib/mint/core/transport/tcp.ex b/lib/mint/core/transport/tcp.ex index cb8c773d..151ada27 100644 --- a/lib/mint/core/transport/tcp.ex +++ b/lib/mint/core/transport/tcp.ex @@ -19,12 +19,13 @@ defmodule Mint.Core.Transport.TCP do opts = Keyword.delete(opts, :hostname) timeout = Keyword.get(opts, :timeout, @default_timeout) + inet4? = Keyword.get(opts, :inet4, true) inet6? = Keyword.get(opts, :inet6, false) opts = opts |> Keyword.merge(@transport_opts) - |> Keyword.drop([:alpn_advertised_protocols, :timeout, :inet6]) + |> Keyword.drop([:alpn_advertised_protocols, :timeout, :inet4, :inet6]) if inet6? do # Try inet6 first, then fall back to the defaults provided by @@ -33,8 +34,11 @@ defmodule Mint.Core.Transport.TCP do {:ok, socket} -> {:ok, socket} - _error -> + _error when inet4? -> wrap_err(:gen_tcp.connect(address, port, opts, timeout)) + + error -> + wrap_err(error) end else # Use the defaults provided by gen_tcp. diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 2d00f0d1..dffad505 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -299,6 +299,12 @@ defmodule Mint.HTTP do seconds), and may be overridden by the caller. Set to `:infinity` to disable the connect timeout. + * `:inet6` - if set to `true` enables IPv6 connection. Defaults to `false` + and may be overridden by the caller. + + * `:inet4` - if set to `true` falls back to IPv4 if IPv6 connection fails. + Defaults to `true` and may be overridden by the caller. + Options for `:https` only: * `:alpn_advertised_protocols` - managed by Mint. Cannot be overridden. diff --git a/test/mint/core/transport/ssl_test.exs b/test/mint/core/transport/ssl_test.exs index 3604655d..6da52f69 100644 --- a/test/mint/core/transport/ssl_test.exs +++ b/test/mint/core/transport/ssl_test.exs @@ -186,15 +186,15 @@ defmodule Mint.Core.Transport.SSLTest do task = Task.async(fn -> - with {:ok, socket} <- :ssl.transport_accept(listen_socket) do - if function_exported?(:ssl, :handshake, 1) do - {:ok, _} = apply(:ssl, :handshake, [socket]) - else - :ok = apply(:ssl, :ssl_accept, [socket]) - end - - {:ok, socket} + {:ok, socket} = :ssl.transport_accept(listen_socket) + + if function_exported?(:ssl, :handshake, 1) do + {:ok, _} = apply(:ssl, :handshake, [socket]) + else + :ok = apply(:ssl, :ssl_accept, [socket]) end + + {:ok, socket} end) assert {:ok, _socket} = @@ -208,6 +208,73 @@ defmodule Mint.Core.Transport.SSLTest do assert {:ok, _server_socket} = Task.await(task) end + + test "can fall back to IPv4 if IPv6 fails" do + ssl_opts = [ + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true, + nodelay: true, + certfile: Path.expand("../../../support/mint/certificate.pem", __DIR__), + keyfile: Path.expand("../../../support/mint/key.pem", __DIR__) + ] + + {:ok, listen_socket} = :ssl.listen(0, ssl_opts) + {:ok, {_address, port}} = :ssl.sockname(listen_socket) + + task = + Task.async(fn -> + {:ok, socket} = :ssl.transport_accept(listen_socket) + + if function_exported?(:ssl, :handshake, 1) do + {:ok, _} = apply(:ssl, :handshake, [socket]) + else + :ok = apply(:ssl, :ssl_accept, [socket]) + end + + {:ok, socket} + end) + + assert {:ok, _socket} = + SSL.connect("localhost", port, + active: false, + inet6: true, + timeout: 1000, + verify: :verify_none + ) + + assert {:ok, _server_socket} = Task.await(task) + end + + test "does not fall back to IPv4 if IPv4 is disabled" do + ssl_opts = [ + :inet, + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true, + nodelay: true, + certfile: Path.expand("../../../support/mint/certificate.pem", __DIR__), + keyfile: Path.expand("../../../support/mint/key.pem", __DIR__) + ] + + {:ok, listen_socket} = :ssl.listen(0, ssl_opts) + {:ok, {_address, port}} = :ssl.sockname(listen_socket) + + Task.async(fn -> + {:ok, _socket} = :ssl.transport_accept(listen_socket) + end) + + assert {:error, %Mint.TransportError{reason: :econnrefused}} = + SSL.connect("localhost", port, + active: false, + inet6: true, + inet4: false, + timeout: 1000, + verify: :verify_none + ) + end end describe "controlling_process/2" do diff --git a/test/mint/core/transport/tcp_test.exs b/test/mint/core/transport/tcp_test.exs new file mode 100644 index 00000000..efd6234c --- /dev/null +++ b/test/mint/core/transport/tcp_test.exs @@ -0,0 +1,208 @@ +defmodule Mint.Core.Transport.TCPTest do + use ExUnit.Case, async: true + + alias Mint.Core.Transport.TCP + + describe "connect/3" do + test "can connect to IPv6 addresses" do + tcp_opts = [ + :inet6, + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true, + nodelay: true + ] + + {:ok, listen_socket} = :gen_tcp.listen(0, tcp_opts) + {:ok, {_address, port}} = :inet.sockname(listen_socket) + + task = + Task.async(fn -> + {:ok, _socket} = :gen_tcp.accept(listen_socket) + end) + + assert {:ok, _socket} = + TCP.connect({127, 0, 0, 1}, port, + active: false, + inet6: true, + timeout: 1000 + ) + + assert {:ok, _server_socket} = Task.await(task) + end + + test "can fall back to IPv4 if IPv6 fails" do + tcp_opts = [ + :inet6, + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true, + nodelay: true + ] + + {:ok, listen_socket} = :gen_tcp.listen(0, tcp_opts) + {:ok, {_address, port}} = :inet.sockname(listen_socket) + + task = + Task.async(fn -> + {:ok, _socket} = :gen_tcp.accept(listen_socket) + end) + + assert {:ok, _socket} = + TCP.connect("localhost", port, + active: false, + inet6: true, + timeout: 1000 + ) + + assert {:ok, _server_socket} = Task.await(task) + end + + test "does not fall back to IPv4 if IPv4 is disabled" do + tcp_opts = [ + :inet, + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true, + nodelay: true + ] + + {:ok, listen_socket} = :gen_tcp.listen(0, tcp_opts) + {:ok, {_address, port}} = :inet.sockname(listen_socket) + + Task.async(fn -> + {:ok, _socket} = :gen_tcp.accept(listen_socket) + end) + + assert {:error, %Mint.TransportError{reason: :econnrefused}} = + TCP.connect("localhost", port, + active: false, + inet6: true, + inet4: false, + timeout: 1000 + ) + end + end + + describe "controlling_process/2" do + @describetag :capture_log + + setup do + parent = self() + ref = make_ref() + + ssl_opts = [ + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true, + nodelay: true + ] + + spawn_link(fn -> + {:ok, listen_socket} = :gen_tcp.listen(0, ssl_opts) + {:ok, {_address, port}} = :inet.sockname(listen_socket) + send(parent, {ref, port}) + + {:ok, socket} = :gen_tcp.accept(listen_socket) + + send(parent, {ref, socket}) + + # Keep the server alive forever. + :ok = Process.sleep(:infinity) + end) + + assert_receive {^ref, port} when is_integer(port), 500 + + {:ok, socket} = TCP.connect("localhost", port, []) + assert_receive {^ref, server_socket}, 200 + + {:ok, server_port: port, socket: socket, server_socket: server_socket} + end + + test "changing the controlling process of a active: :once socket", + %{socket: socket, server_socket: server_socket} do + parent = self() + ref = make_ref() + + # Send two SSL messages (that get translated to Erlang messages right + # away because of "nodelay: true"), but wait after each one so that + # it actually arrives and we can set the socket back to active: :once. + :ok = TCP.setopts(socket, active: :once) + :ok = :gen_tcp.send(server_socket, "some data 1") + Process.sleep(100) + + :ok = TCP.setopts(socket, active: :once) + :ok = :gen_tcp.send(server_socket, "some data 2") + + wait_until_passes(500, fn -> + {:messages, messages} = Process.info(self(), :messages) + assert {:tcp, socket, "some data 1"} in messages + assert {:tcp, socket, "some data 2"} in messages + end) + + other_process = spawn_link(fn -> process_mirror(parent, ref) end) + + assert :ok = TCP.controlling_process(socket, other_process) + + assert_receive {^ref, {:tcp, ^socket, "some data 1"}} + assert_receive {^ref, {:tcp, ^socket, "some data 2"}} + + refute_received _message + end + + test "changing the controlling process of a passive socket", + %{socket: socket, server_socket: server_socket} do + parent = self() + ref = make_ref() + + :ok = :gen_tcp.send(server_socket, "some data") + + other_process = + spawn_link(fn -> + assert_receive message, 500 + send(parent, {ref, message}) + end) + + assert :ok = TCP.controlling_process(socket, other_process) + assert {:ok, [active: false]} = TCP.getopts(socket, [:active]) + :ok = TCP.setopts(socket, active: :once) + + assert_receive {^ref, {:tcp, ^socket, "some data"}}, 500 + + refute_received _message + end + + test "changing the controlling process of a closed socket", + %{socket: socket} do + other_process = spawn_link(fn -> :ok = Process.sleep(:infinity) end) + + :ok = TCP.close(socket) + + assert {:error, _error} = TCP.controlling_process(socket, other_process) + end + end + + defp process_mirror(parent, ref) do + receive do + message -> + send(parent, {ref, message}) + process_mirror(parent, ref) + end + end + + defp wait_until_passes(time_left, fun) when time_left <= 0 do + fun.() + end + + defp wait_until_passes(time_left, fun) do + fun.() + rescue + _exception -> + Process.sleep(10) + wait_until_passes(time_left - 10, fun) + end +end