Skip to content

Commit

Permalink
Add :inet4 transport option to disable IPv4 fallback (#425)
Browse files Browse the repository at this point in the history
  • Loading branch information
ericmj committed Feb 13, 2024
1 parent 74e0ec6 commit 23b431b
Show file tree
Hide file tree
Showing 5 changed files with 301 additions and 12 deletions.
8 changes: 6 additions & 2 deletions lib/mint/core/transport/ssl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 6 additions & 2 deletions lib/mint/core/transport/tcp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions lib/mint/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 75 additions & 8 deletions test/mint/core/transport/ssl_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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} =
Expand All @@ -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
Expand Down
208 changes: 208 additions & 0 deletions test/mint/core/transport/tcp_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 23b431b

Please sign in to comment.