diff --git a/changelog.d/gun-ipv6-only-hosts.fix b/changelog.d/gun-ipv6-only-hosts.fix new file mode 100644 index 000000000..69cdc56e3 --- /dev/null +++ b/changelog.d/gun-ipv6-only-hosts.fix @@ -0,0 +1 @@ +Gun adapter: connect to hosts that only have IPv6 (AAAA) addresses diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 85a2fb0e8..3291b9048 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -14,7 +14,9 @@ defmodule Pleroma.Gun.Conn do opts |> Enum.into(%{}) |> Map.put_new(:connect_timeout, pool_opts[:connect_timeout] || 5_000) - |> Map.put_new(:supervise, false) + # Under gun's supervisor, a failed connect does not kill the caller + # through the link. Gun still stops when its owner goes down. + |> Map.put_new(:supervise, true) |> maybe_add_tls_opts(uri) do_open(uri, opts) @@ -117,8 +119,7 @@ defmodule Pleroma.Gun.Conn do host = Pleroma.HTTP.AdapterHelper.parse_host(host) opts = Map.put(opts, :transport, transport(uri.scheme)) - with {:ok, conn} <- Gun.open(host, port, opts), - {:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do + with {:ok, conn, protocol} <- open_direct(host, port, opts) do {:ok, conn, protocol, nil} else error -> @@ -130,6 +131,40 @@ defmodule Pleroma.Gun.Conn do end end + defp open_direct(host, port, opts) do + result = + with {:ok, conn} <- Gun.open(host, port, opts), + {:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do + {:ok, conn, protocol} + end + + case inet6_fallback(result, host, opts) do + {:ok, inet6_opts} -> open_direct(host, port, inet6_opts) + :error -> result + end + end + + # Gun's default when :tcp_opts is not set. + @default_tcp_opts [send_timeout: 15_000, send_timeout_close: true] + + @doc """ + Gun resolves hostnames with `inet_tcp` (A records only) unless `:inet6` is + in `:tcp_opts`, for both the :tcp and :tls transports. When the A lookup + finds nothing, returns the options for one more attempt over IPv6. + """ + @spec inet6_fallback(term(), Pleroma.HTTP.AdapterHelper.host(), map()) :: {:ok, map()} | :error + def inet6_fallback({:error, {:down, {:shutdown, :nxdomain}}}, host, opts) when is_list(host) do + tcp_opts = Map.get(opts, :tcp_opts, @default_tcp_opts) + + if :inet6 in tcp_opts do + :error + else + {:ok, Map.put(opts, :tcp_opts, [:inet6 | tcp_opts])} + end + end + + def inet6_fallback(_result, _host, _opts), do: :error + defp destination_opts(%URI{host: host, port: port}) do host = Pleroma.HTTP.AdapterHelper.parse_host(host) %{host: host, port: port} diff --git a/test/pleroma/gun/conn_test.exs b/test/pleroma/gun/conn_test.exs index a38740a42..db3ce6275 100644 --- a/test/pleroma/gun/conn_test.exs +++ b/test/pleroma/gun/conn_test.exs @@ -42,6 +42,7 @@ defmodule Pleroma.Gun.ConnTest do expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8080, opts -> assert opts.transport == :tcp + assert opts.supervise refute Map.has_key?(opts, :tls_opts) {:ok, conn} end) @@ -172,6 +173,67 @@ defmodule Pleroma.Gun.ConnTest do ) end + describe "IPv6-only hosts" do + @nxdomain {:error, {:down, {:shutdown, :nxdomain}}} + + test "retries once over IPv6 when the IPv4 lookup finds no address" do + ipv4_conn = spawn_link(fn -> Process.sleep(:infinity) end) + ipv6_conn = spawn_link(fn -> Process.sleep(:infinity) end) + + expect(Pleroma.GunMock, :open, 2, fn + ~c"v6only.example", 443, %{tcp_opts: tcp_opts} = opts -> + assert :inet6 in tcp_opts + assert opts.transport == :tls + assert opts.tls_opts[:verify] == :verify_peer + {:ok, ipv6_conn} + + ~c"v6only.example", 443, opts -> + refute Map.has_key?(opts, :tcp_opts) + # A linked Gun process would take the caller down with the lookup failure. + assert opts.supervise + {:ok, ipv4_conn} + end) + + stub(Pleroma.GunMock, :await_up, fn + ^ipv4_conn, _ -> @nxdomain + ^ipv6_conn, _ -> {:ok, :http2} + end) + + assert {:ok, ^ipv6_conn, :http2, nil} = + Conn.open(URI.parse("https://v6only.example/inbox"), []) + + stop_conn(ipv4_conn) + stop_conn(ipv6_conn) + end + + test "does not retry IP literals or other errors" do + expect(Pleroma.GunMock, :open, 2, fn _, _, _ -> + {:ok, spawn(fn -> Process.sleep(:infinity) end)} + end) + + stub(Pleroma.GunMock, :await_up, fn _, _ -> @nxdomain end) + assert @nxdomain = Conn.open(URI.parse("https://192.0.2.1/inbox"), []) + + stub(Pleroma.GunMock, :await_up, fn _, _ -> + {:error, {:down, {:shutdown, :econnrefused}}} + end) + + assert {:error, _} = Conn.open(URI.parse("https://v6only.example/inbox"), []) + end + + test "inet6_fallback/3 keeps configured TCP options and skips hosts already on IPv6" do + assert {:ok, %{tcp_opts: [:inet6, nodelay: true]}} = + Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{tcp_opts: [nodelay: true]}) + + assert {:ok, %{tcp_opts: [:inet6, send_timeout: 15_000, send_timeout_close: true]}} = + Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{}) + + assert :error = Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{tcp_opts: [:inet6]}) + assert :error = Conn.inet6_fallback(@nxdomain, {192, 0, 2, 1}, %{}) + assert :error = Conn.inet6_fallback({:error, :timeout}, ~c"v6only.example", %{}) + end + end + defp stop_conn(conn) do Process.unlink(conn) Process.exit(conn, :kill)