Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add :inet4 transport option to disable IPv4 fallback #425

Merged
merged 2 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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? ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe stack mismatch shows up as :nxdomain so maybe there's no reason to try again on other error reasons? Or the idea is we get say :closed or :timeout and we might as well try on ipv4?

@dch you know more about this stuff, perhaps you have anything to add?

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
Loading