Merge pull request 'Rework Gun connection pooling' (#7934) from issue-2263-gun-validation into develop
Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7934
This commit is contained in:
commit
cc1b95c88e
27 changed files with 2384 additions and 197 deletions
1
changelog.d/gun-pool-rework.fix
Normal file
1
changelog.d/gun-pool-rework.fix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Fixed Gun HTTP/1 concurrency, forwarding and authenticated proxy handling, tunnel requests, and streamed connection cleanup. Updated Gun and Cowlib for HTTP client security fixes.
|
||||||
|
|
@ -3098,9 +3098,16 @@ config :pleroma, :config_description, [
|
||||||
%{
|
%{
|
||||||
key: :max_connections,
|
key: :max_connections,
|
||||||
type: :integer,
|
type: :integer,
|
||||||
description: "Maximum number of connections in the pool. Default: 250 connections.",
|
description:
|
||||||
|
"Maximum total number of connections in the pool. HTTP/1 origins may use multiple connections within this limit, while HTTP/2 connections are multiplexed. Default: 250 connections.",
|
||||||
suggestions: [250]
|
suggestions: [250]
|
||||||
},
|
},
|
||||||
|
%{
|
||||||
|
key: :max_idle_time,
|
||||||
|
type: :integer,
|
||||||
|
description: "Time before an unused connection is closed. Default: 30000ms.",
|
||||||
|
suggestions: [30_000]
|
||||||
|
},
|
||||||
%{
|
%{
|
||||||
key: :connect_timeout,
|
key: :connect_timeout,
|
||||||
type: :integer,
|
type: :integer,
|
||||||
|
|
|
||||||
|
|
@ -573,7 +573,8 @@ Settings for HTTP connection pool.
|
||||||
|
|
||||||
* `:connection_acquisition_wait` - Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries.
|
* `:connection_acquisition_wait` - Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries.
|
||||||
* `connection_acquisition_retries` - Number of attempts to acquire the connection from the pool if it is overloaded. Each attempt is timed `:connection_acquisition_wait` apart.
|
* `connection_acquisition_retries` - Number of attempts to acquire the connection from the pool if it is overloaded. Each attempt is timed `:connection_acquisition_wait` apart.
|
||||||
* `:max_connections` - Maximum number of connections in the pool.
|
* `:max_connections` - Maximum total number of connections in the pool. HTTP/1 origins may use multiple connections within this limit, while HTTP/2 connections are multiplexed.
|
||||||
|
* `:max_idle_time` - Time before an unused connection is closed.
|
||||||
* `:connect_timeout` - Timeout to connect to the host.
|
* `:connect_timeout` - Timeout to connect to the host.
|
||||||
* `:reclaim_multiplier` - Multiplied by `:max_connections` this will be the maximum number of idle connections that will be reclaimed in case the pool is overloaded.
|
* `:reclaim_multiplier` - Multiplied by `:max_connections` this will be the maximum number of idle connections that will be reclaimed in case the pool is overloaded.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,6 @@ The other way to do it, for example, with Tor you would most likely add somethin
|
||||||
```
|
```
|
||||||
config :pleroma, :http, proxy_url: {:socks5, :localhost, 9050}
|
config :pleroma, :http, proxy_url: {:socks5, :localhost, 9050}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Gun HTTP adapter supports SOCKS5. SOCKS4 proxies require another HTTP adapter.
|
||||||
|
For regular HTTP proxies, Gun forwards plain HTTP requests directly and uses CONNECT tunnels for HTTPS requests.
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ defmodule Pleroma.Gun do
|
||||||
@callback open(charlist(), pos_integer(), map()) :: {:ok, pid()}
|
@callback open(charlist(), pos_integer(), map()) :: {:ok, pid()}
|
||||||
@callback info(pid()) :: map()
|
@callback info(pid()) :: map()
|
||||||
@callback close(pid()) :: :ok
|
@callback close(pid()) :: :ok
|
||||||
|
@callback cancel(pid(), reference() | [reference()]) :: :ok
|
||||||
@callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()}
|
@callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()}
|
||||||
@callback connect(pid(), map()) :: reference()
|
@callback connect(pid(), map()) :: reference()
|
||||||
@callback await(pid(), reference()) :: {:response, :fin, 200, []}
|
@callback await(pid(), reference()) :: tuple()
|
||||||
|
@callback await_tunnel_up(pid(), reference() | :undefined, timeout()) ::
|
||||||
|
{:ok, atom()} | {:error, term()}
|
||||||
@callback set_owner(pid(), pid()) :: :ok
|
@callback set_owner(pid(), pid()) :: :ok
|
||||||
|
|
||||||
defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API)
|
defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API)
|
||||||
|
|
@ -19,11 +22,15 @@ defmodule Pleroma.Gun do
|
||||||
|
|
||||||
def close(pid), do: api().close(pid)
|
def close(pid), do: api().close(pid)
|
||||||
|
|
||||||
|
def cancel(pid, stream), do: api().cancel(pid, stream)
|
||||||
|
|
||||||
def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout)
|
def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout)
|
||||||
|
|
||||||
def connect(pid, opts), do: api().connect(pid, opts)
|
def connect(pid, opts), do: api().connect(pid, opts)
|
||||||
|
|
||||||
def await(pid, ref), do: api().await(pid, ref)
|
def await(pid, ref), do: api().await(pid, ref)
|
||||||
|
|
||||||
|
def await_tunnel_up(pid, ref, timeout), do: api().await_tunnel_up(pid, ref, timeout)
|
||||||
|
|
||||||
def set_owner(pid, owner), do: api().set_owner(pid, owner)
|
def set_owner(pid, owner), do: api().set_owner(pid, owner)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ defmodule Pleroma.Gun.API do
|
||||||
@impl Gun
|
@impl Gun
|
||||||
defdelegate close(pid), to: :gun
|
defdelegate close(pid), to: :gun
|
||||||
|
|
||||||
|
@impl Gun
|
||||||
|
defdelegate cancel(pid, stream), to: :gun
|
||||||
|
|
||||||
@impl Gun
|
@impl Gun
|
||||||
defdelegate await_up(pid, timeout \\ 5_000), to: :gun
|
defdelegate await_up(pid, timeout \\ 5_000), to: :gun
|
||||||
|
|
||||||
|
|
@ -41,6 +44,28 @@ defmodule Pleroma.Gun.API do
|
||||||
@impl Gun
|
@impl Gun
|
||||||
defdelegate await(pid, ref), to: :gun
|
defdelegate await(pid, ref), to: :gun
|
||||||
|
|
||||||
|
@impl Gun
|
||||||
|
def await_tunnel_up(pid, stream_ref, timeout) do
|
||||||
|
monitor_ref = Process.monitor(pid)
|
||||||
|
|
||||||
|
result =
|
||||||
|
receive do
|
||||||
|
{:gun_tunnel_up, ^pid, ^stream_ref, protocol} ->
|
||||||
|
{:ok, protocol}
|
||||||
|
|
||||||
|
{:gun_down, ^pid, _protocol, reason, _killed_streams} ->
|
||||||
|
{:error, {:down, reason}}
|
||||||
|
|
||||||
|
{:DOWN, ^monitor_ref, :process, ^pid, reason} ->
|
||||||
|
{:error, {:down, reason}}
|
||||||
|
after
|
||||||
|
timeout -> {:error, :timeout}
|
||||||
|
end
|
||||||
|
|
||||||
|
Process.demonitor(monitor_ref, [:flush])
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
@impl Gun
|
@impl Gun
|
||||||
defdelegate set_owner(pid, owner), to: :gun
|
defdelegate set_owner(pid, owner), to: :gun
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -42,18 +42,34 @@ defmodule Pleroma.Gun.Conn do
|
||||||
Map.put(opts, :tls_opts, tls_opts)
|
Map.put(opts, :tls_opts, tls_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp do_open(%URI{scheme: "http"} = uri, %{proxy: {proxy_host, proxy_port}} = opts) do
|
||||||
|
with open_opts <- opts |> proxy_open_opts() |> Map.put(:protocols, [:http]),
|
||||||
|
{:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts),
|
||||||
|
{:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do
|
||||||
|
{:ok, conn, protocol, :forward_proxy}
|
||||||
|
else
|
||||||
|
error ->
|
||||||
|
Logger.warning(
|
||||||
|
"Opening forward proxy connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do
|
defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do
|
||||||
connect_opts =
|
connect_opts =
|
||||||
uri
|
uri
|
||||||
|> destination_opts()
|
|> destination_opts()
|
||||||
|> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, []))
|
|> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, []))
|
||||||
|
|> add_proxy_auth(opts)
|
||||||
|
|
||||||
with open_opts <- Map.delete(opts, :tls_opts),
|
with open_opts <- proxy_open_opts(opts),
|
||||||
{:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts),
|
{:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts),
|
||||||
{:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]),
|
{:ok, _proxy_protocol} <- await_up(conn, opts[:connect_timeout]) do
|
||||||
stream <- Gun.connect(conn, connect_opts),
|
conn
|
||||||
{:response, :fin, 200, _} <- Gun.await(conn, stream) do
|
|> Gun.connect(connect_opts)
|
||||||
{:ok, conn, protocol}
|
|> await_proxy_connect(conn, opts[:connect_timeout])
|
||||||
else
|
else
|
||||||
error ->
|
error ->
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
|
|
@ -64,30 +80,29 @@ defmodule Pleroma.Gun.Conn do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do
|
defp do_open(_uri, %{proxy: {:socks4, _proxy_host, _proxy_port}}) do
|
||||||
version =
|
{:error, :socks4_unsupported}
|
||||||
proxy_type
|
|
||||||
|> to_string()
|
|
||||||
|> String.last()
|
|
||||||
|> case do
|
|
||||||
"4" -> 4
|
|
||||||
_ -> 5
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts)
|
||||||
|
when proxy_type in [:socks, :socks5] do
|
||||||
socks_opts =
|
socks_opts =
|
||||||
uri
|
uri
|
||||||
|> destination_opts()
|
|> destination_opts()
|
||||||
|> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, []))
|
|> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, []))
|
||||||
|> Map.put(:version, version)
|
|> Map.put(:version, 5)
|
||||||
|
|> add_socks_proxy_auth(opts)
|
||||||
|
|
||||||
opts =
|
open_opts =
|
||||||
opts
|
opts
|
||||||
|
|> proxy_open_opts()
|
||||||
|> Map.put(:protocols, [:socks])
|
|> Map.put(:protocols, [:socks])
|
||||||
|> Map.put(:socks_opts, socks_opts)
|
|> Map.put(:socks_opts, socks_opts)
|
||||||
|
|
||||||
with {:ok, conn} <- Gun.open(proxy_host, proxy_port, opts),
|
with {:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts),
|
||||||
{:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]) do
|
{:ok, :socks} <- await_up(conn, opts[:connect_timeout]),
|
||||||
{:ok, conn, protocol}
|
{:ok, protocol} <- await_tunnel_up(conn, :undefined, opts[:connect_timeout]) do
|
||||||
|
{:ok, conn, protocol, nil}
|
||||||
else
|
else
|
||||||
error ->
|
error ->
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
|
|
@ -100,10 +115,11 @@ defmodule Pleroma.Gun.Conn do
|
||||||
|
|
||||||
defp do_open(%URI{host: host, port: port} = uri, opts) do
|
defp do_open(%URI{host: host, port: port} = uri, opts) do
|
||||||
host = Pleroma.HTTP.AdapterHelper.parse_host(host)
|
host = Pleroma.HTTP.AdapterHelper.parse_host(host)
|
||||||
|
opts = Map.put(opts, :transport, transport(uri.scheme))
|
||||||
|
|
||||||
with {:ok, conn} <- Gun.open(host, port, opts),
|
with {:ok, conn} <- Gun.open(host, port, opts),
|
||||||
{:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]) do
|
{:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do
|
||||||
{:ok, conn, protocol}
|
{:ok, conn, protocol, nil}
|
||||||
else
|
else
|
||||||
error ->
|
error ->
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
|
|
@ -120,11 +136,90 @@ defmodule Pleroma.Gun.Conn do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp add_http2_opts(opts, "https", tls_opts) do
|
defp add_http2_opts(opts, "https", tls_opts) do
|
||||||
Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts})
|
Map.merge(opts, %{protocols: [:http2, :http], transport: :tls, tls_opts: tls_opts})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp add_http2_opts(opts, _, _), do: opts
|
defp add_http2_opts(opts, _, _), do: opts
|
||||||
|
|
||||||
|
defp add_proxy_auth(connect_opts, %{proxy_auth: {username, password}})
|
||||||
|
when is_binary(username) and is_binary(password) do
|
||||||
|
Map.merge(connect_opts, %{username: username, password: password})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_proxy_auth(connect_opts, _), do: connect_opts
|
||||||
|
|
||||||
|
defp add_socks_proxy_auth(socks_opts, %{proxy_auth: {username, password}})
|
||||||
|
when is_binary(username) and is_binary(password) do
|
||||||
|
Map.put(socks_opts, :auth, [{:username_password, username, password}])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_socks_proxy_auth(socks_opts, _), do: socks_opts
|
||||||
|
|
||||||
|
defp proxy_open_opts(opts) do
|
||||||
|
proxy_tls_opts = Map.get(opts, :proxy_tls_opts, [])
|
||||||
|
|
||||||
|
opts =
|
||||||
|
opts
|
||||||
|
|> Map.drop([:proxy, :proxy_auth, :proxy_tls_opts, :tls_opts])
|
||||||
|
|> Map.put_new(:transport, :tcp)
|
||||||
|
|
||||||
|
if opts.transport == :tls do
|
||||||
|
Map.put(opts, :tls_opts, proxy_tls_opts)
|
||||||
|
else
|
||||||
|
opts
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_up(conn, timeout) do
|
||||||
|
case Gun.await_up(conn, timeout) do
|
||||||
|
{:ok, protocol} ->
|
||||||
|
{:ok, protocol}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
Gun.close(conn)
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_proxy_connect(stream, conn, timeout) do
|
||||||
|
case Gun.await(conn, stream) do
|
||||||
|
{:response, :fin, 200, _headers} ->
|
||||||
|
case await_tunnel_up(conn, stream, timeout) do
|
||||||
|
{:ok, protocol} -> {:ok, conn, protocol, stream}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
|
||||||
|
{:response, _fin, 407, _headers} ->
|
||||||
|
close_with_error(conn, :proxy_auth_failed)
|
||||||
|
|
||||||
|
{:response, _fin, status, _headers} when is_integer(status) ->
|
||||||
|
close_with_error(conn, {:proxy_connect_failed, status})
|
||||||
|
|
||||||
|
error ->
|
||||||
|
Gun.close(conn)
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp close_with_error(conn, reason) do
|
||||||
|
Gun.close(conn)
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transport("https"), do: :tls
|
||||||
|
defp transport(_), do: :tcp
|
||||||
|
|
||||||
|
defp await_tunnel_up(conn, stream_ref, timeout) do
|
||||||
|
case Gun.await_tunnel_up(conn, stream_ref, timeout) do
|
||||||
|
{:ok, protocol} ->
|
||||||
|
{:ok, protocol}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
Gun.close(conn)
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do
|
def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do
|
||||||
"#{scheme}://#{host}#{path}"
|
"#{scheme}://#{host}#{path}"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,53 +4,132 @@
|
||||||
|
|
||||||
defmodule Pleroma.Gun.ConnectionPool do
|
defmodule Pleroma.Gun.ConnectionPool do
|
||||||
@registry __MODULE__
|
@registry __MODULE__
|
||||||
|
@locks Module.concat(__MODULE__, Locks)
|
||||||
|
|
||||||
alias Pleroma.Gun.ConnectionPool.WorkerSupervisor
|
alias Pleroma.Gun.ConnectionPool.WorkerSupervisor
|
||||||
|
|
||||||
|
@connection_option_keys [
|
||||||
|
:http_opts,
|
||||||
|
:http2_opts,
|
||||||
|
:protocols,
|
||||||
|
:proxy,
|
||||||
|
:proxy_auth,
|
||||||
|
:proxy_tls_opts,
|
||||||
|
:retry,
|
||||||
|
:retry_timeout,
|
||||||
|
:socks_opts,
|
||||||
|
:tcp_opts,
|
||||||
|
:tls_opts,
|
||||||
|
:transport,
|
||||||
|
:ws_opts
|
||||||
|
]
|
||||||
|
|
||||||
def children do
|
def children do
|
||||||
[
|
[
|
||||||
{Registry, keys: :unique, name: @registry},
|
{Registry, keys: :duplicate, name: @registry},
|
||||||
|
{Registry, keys: :unique, name: @locks},
|
||||||
Pleroma.Gun.ConnectionPool.WorkerSupervisor
|
Pleroma.Gun.ConnectionPool.WorkerSupervisor
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_conn(URI.t(), keyword()) :: {:ok, pid()} | {:error, term()}
|
@spec get_conn(URI.t(), keyword()) :: {:ok, pid()} | {:error, term()}
|
||||||
def get_conn(uri, opts) do
|
def get_conn(uri, opts) do
|
||||||
key = "#{uri.scheme}:#{uri.host}:#{uri.port}"
|
key = connection_key(uri, opts)
|
||||||
|
|
||||||
case Registry.lookup(@registry, key) do
|
case checkout_existing(Registry.lookup(@registry, key)) do
|
||||||
# The key has already been registered, but connection is not up yet
|
{:ok, gun_pid} ->
|
||||||
[{worker_pid, nil}] ->
|
|
||||||
get_gun_pid_from_worker(worker_pid, true)
|
|
||||||
|
|
||||||
[{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] ->
|
|
||||||
GenServer.call(worker_pid, :add_client)
|
|
||||||
{:ok, gun_pid}
|
{:ok, gun_pid}
|
||||||
|
|
||||||
[] ->
|
:not_available ->
|
||||||
# :gun.set_owner fails in :connected state for whatevever reason,
|
provision_or_wait(key, uri, opts)
|
||||||
# so we open the connection in the process directly and send it's pid back
|
end
|
||||||
# We trust gun to handle timeouts by itself
|
end
|
||||||
|
|
||||||
|
defp checkout_existing([]), do: :not_available
|
||||||
|
|
||||||
|
defp checkout_existing([{_worker_pid, nil} | workers]), do: checkout_existing(workers)
|
||||||
|
|
||||||
|
defp checkout_existing([{worker_pid, _value} | workers]) do
|
||||||
|
case checkout_worker(worker_pid) do
|
||||||
|
{:ok, gun_pid} -> {:ok, gun_pid}
|
||||||
|
_ -> checkout_existing(workers)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp provision_or_wait(key, uri, opts) do
|
||||||
|
result = with_route_lock(key, fn -> provision(key, uri, opts) end)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, gun_pid} ->
|
||||||
|
{:ok, gun_pid}
|
||||||
|
|
||||||
|
{:new, worker_pid} ->
|
||||||
|
get_gun_pid_from_worker(worker_pid)
|
||||||
|
|
||||||
|
{:wait, worker_pid} ->
|
||||||
|
case checkout_worker(worker_pid, :infinity) do
|
||||||
|
{:ok, gun_pid} -> {:ok, gun_pid}
|
||||||
|
:busy -> get_conn(uri, opts)
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp provision(key, uri, opts) do
|
||||||
|
workers = Registry.lookup(@registry, key)
|
||||||
|
|
||||||
|
case checkout_existing(workers) do
|
||||||
|
{:ok, gun_pid} ->
|
||||||
|
{:ok, gun_pid}
|
||||||
|
|
||||||
|
:not_available ->
|
||||||
|
case Enum.find(workers, fn {_worker_pid, value} -> is_nil(value) end) do
|
||||||
|
{worker_pid, nil} -> {:wait, worker_pid}
|
||||||
|
nil -> start_worker(key, uri, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_route_lock(key, function) do
|
||||||
|
case Registry.register(@locks, key, nil) do
|
||||||
|
{:ok, _} ->
|
||||||
|
try do
|
||||||
|
function.()
|
||||||
|
after
|
||||||
|
Registry.unregister(@locks, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, {:already_registered, _owner}} ->
|
||||||
|
Process.sleep(1)
|
||||||
|
with_route_lock(key, function)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_worker(key, uri, opts) do
|
||||||
|
# :gun.set_owner fails in :connected state, so the worker opens the connection itself.
|
||||||
case WorkerSupervisor.start_worker([key, uri, opts, self()]) do
|
case WorkerSupervisor.start_worker([key, uri, opts, self()]) do
|
||||||
{:ok, worker_pid} ->
|
{:ok, worker_pid} -> {:new, worker_pid}
|
||||||
get_gun_pid_from_worker(worker_pid, false)
|
error -> error
|
||||||
|
|
||||||
{:error, {:already_started, worker_pid}} ->
|
|
||||||
get_gun_pid_from_worker(worker_pid, true)
|
|
||||||
|
|
||||||
err ->
|
|
||||||
err
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_gun_pid_from_worker(worker_pid, register) do
|
defp checkout_worker(worker_pid, timeout \\ 5_000) do
|
||||||
|
try do
|
||||||
|
GenServer.call(worker_pid, :checkout, timeout)
|
||||||
|
catch
|
||||||
|
:exit, reason -> {:error, {:connection_worker_exit, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_gun_pid_from_worker(worker_pid) do
|
||||||
# GenServer.call will block the process for timeout length if
|
# GenServer.call will block the process for timeout length if
|
||||||
# the server crashes on startup (which will happen if gun fails to connect)
|
# the server crashes on startup (which will happen if gun fails to connect)
|
||||||
# so instead we use cast + monitor
|
# so instead we use cast + monitor
|
||||||
|
|
||||||
ref = Process.monitor(worker_pid)
|
ref = Process.monitor(worker_pid)
|
||||||
if register, do: GenServer.cast(worker_pid, {:add_client, self()})
|
|
||||||
|
|
||||||
receive do
|
receive do
|
||||||
{:conn_pid, pid} ->
|
{:conn_pid, pid} ->
|
||||||
|
|
@ -68,19 +147,115 @@ defmodule Pleroma.Gun.ConnectionPool do
|
||||||
|
|
||||||
@spec release_conn(pid()) :: :ok
|
@spec release_conn(pid()) :: :ok
|
||||||
def release_conn(conn_pid) do
|
def release_conn(conn_pid) do
|
||||||
# :ets.fun2ms(fn {_, {worker_pid, {gun_pid, _, _, _}}} when gun_pid == conn_pid ->
|
# :ets.fun2ms(fn {_, {worker_pid, {gun_pid, _tunnel}}} when gun_pid == conn_pid ->
|
||||||
# worker_pid end)
|
# worker_pid end)
|
||||||
query_result =
|
query_result =
|
||||||
Registry.select(@registry, [
|
Registry.select(@registry, [
|
||||||
{{:_, :"$1", {:"$2", :_, :_, :_}}, [{:==, :"$2", conn_pid}], [:"$1"]}
|
{{:_, :"$1", {:"$2", :_}}, [{:==, :"$2", conn_pid}], [:"$1"]}
|
||||||
])
|
])
|
||||||
|
|
||||||
case query_result do
|
case query_result do
|
||||||
[worker_pid] ->
|
[worker_pid] ->
|
||||||
|
try do
|
||||||
GenServer.call(worker_pid, :remove_client)
|
GenServer.call(worker_pid, :remove_client)
|
||||||
|
catch
|
||||||
|
:exit, _ -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
[] ->
|
[] ->
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec discard_conn(pid()) :: :ok
|
||||||
|
def discard_conn(conn_pid) do
|
||||||
|
case worker_for_conn(conn_pid) do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
worker_pid ->
|
||||||
|
DynamicSupervisor.terminate_child(WorkerSupervisor, worker_pid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec cancel_stream(pid(), reference() | [reference()]) :: :ok
|
||||||
|
def cancel_stream(conn_pid, stream) do
|
||||||
|
case worker_for_conn(conn_pid) do
|
||||||
|
nil ->
|
||||||
|
Pleroma.Gun.cancel(conn_pid, stream)
|
||||||
|
|
||||||
|
worker_pid ->
|
||||||
|
try do
|
||||||
|
GenServer.call(worker_pid, {:cancel_stream, stream})
|
||||||
|
catch
|
||||||
|
:exit, _ -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec register_stream(pid(), reference() | [reference()]) :: :ok
|
||||||
|
def register_stream(conn_pid, stream) do
|
||||||
|
call_worker(conn_pid, {:register_stream, stream})
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec finish_stream(pid(), reference() | [reference()]) :: :ok
|
||||||
|
def finish_stream(conn_pid, stream) do
|
||||||
|
call_worker(conn_pid, {:finish_stream, stream})
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec release_stream(pid(), reference() | [reference()]) :: :ok
|
||||||
|
def release_stream(conn_pid, stream) do
|
||||||
|
call_worker(conn_pid, {:release_stream, stream})
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec tunnel_ref(pid()) :: reference() | :forward_proxy | nil
|
||||||
|
def tunnel_ref(conn_pid) do
|
||||||
|
case Registry.select(@registry, [
|
||||||
|
{{:_, :_, {:"$1", :"$2"}}, [{:==, :"$1", conn_pid}], [:"$2"]}
|
||||||
|
]) do
|
||||||
|
[tunnel] -> tunnel
|
||||||
|
[] -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp worker_for_conn(conn_pid) do
|
||||||
|
case Registry.select(@registry, [
|
||||||
|
{{:_, :"$1", {:"$2", :_}}, [{:==, :"$2", conn_pid}], [:"$1"]}
|
||||||
|
]) do
|
||||||
|
[worker_pid] -> worker_pid
|
||||||
|
[] -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp call_worker(conn_pid, message) do
|
||||||
|
case worker_for_conn(conn_pid) do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
worker_pid ->
|
||||||
|
try do
|
||||||
|
GenServer.call(worker_pid, message)
|
||||||
|
catch
|
||||||
|
:exit, _ -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp connection_key(uri, opts) do
|
||||||
|
origin = {
|
||||||
|
String.downcase(uri.scheme),
|
||||||
|
String.downcase(uri.host),
|
||||||
|
uri.port
|
||||||
|
}
|
||||||
|
|
||||||
|
profile =
|
||||||
|
opts
|
||||||
|
|> Map.new()
|
||||||
|
|> Map.take(@connection_option_keys)
|
||||||
|
|> :erlang.term_to_binary()
|
||||||
|
|> then(&:crypto.hash(:sha256, &1))
|
||||||
|
|
||||||
|
fingerprint = Base.encode16(profile, case: :lower)
|
||||||
|
Enum.join(Tuple.to_list(origin) ++ [fingerprint], ":")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ defmodule Pleroma.Gun.ConnectionPool.Reclaimer do
|
||||||
|
|
||||||
def start_monitor do
|
def start_monitor do
|
||||||
pid =
|
pid =
|
||||||
case GenServer.start(__MODULE__, [], name: {:via, Registry, {registry(), "reclaimer"}}) do
|
case GenServer.start(__MODULE__, [], name: __MODULE__) do
|
||||||
{:ok, pid} ->
|
{:ok, pid} ->
|
||||||
pid
|
pid
|
||||||
|
|
||||||
{:error, {:already_registered, pid}} ->
|
{:error, {:already_started, pid}} ->
|
||||||
pid
|
pid
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -41,17 +41,16 @@ defmodule Pleroma.Gun.ConnectionPool.Reclaimer do
|
||||||
reclaim_max: reclaim_max
|
reclaim_max: reclaim_max
|
||||||
})
|
})
|
||||||
|
|
||||||
# :ets.fun2ms(
|
workers =
|
||||||
# fn {_, {worker_pid, {_, used_by, crf, last_reference}}} when used_by == [] ->
|
|
||||||
# {worker_pid, crf, last_reference} end)
|
|
||||||
unused_conns =
|
|
||||||
Registry.select(
|
Registry.select(
|
||||||
registry(),
|
registry(),
|
||||||
[
|
[
|
||||||
{{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], [{{:"$1", :"$3", :"$4"}}]}
|
{{:_, :"$1", {:_, :_}}, [], [:"$1"]}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
unused_conns = Enum.flat_map(workers, &reclaim_info/1)
|
||||||
|
|
||||||
case unused_conns do
|
case unused_conns do
|
||||||
[] ->
|
[] ->
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
|
|
@ -65,25 +64,43 @@ defmodule Pleroma.Gun.ConnectionPool.Reclaimer do
|
||||||
{:stop, :no_unused_conns, nil}
|
{:stop, :no_unused_conns, nil}
|
||||||
|
|
||||||
unused_conns ->
|
unused_conns ->
|
||||||
reclaimed =
|
reclaimed_count =
|
||||||
unused_conns
|
unused_conns
|
||||||
|> Enum.sort(fn {_pid1, crf1, last_reference1}, {_pid2, crf2, last_reference2} ->
|
|> Enum.sort_by(fn {_pid, crf, last_reference} -> {crf, last_reference} end)
|
||||||
crf1 <= crf2 and last_reference1 <= last_reference2
|
|> Enum.reduce_while(0, fn
|
||||||
end)
|
_worker, count when count == reclaim_max ->
|
||||||
|> Enum.take(reclaim_max)
|
{:halt, count}
|
||||||
|
|
||||||
reclaimed
|
{worker_pid, _crf, _last_reference}, count ->
|
||||||
|> Enum.each(fn {pid, _, _} ->
|
if reclaim(worker_pid), do: {:cont, count + 1}, else: {:cont, count}
|
||||||
DynamicSupervisor.terminate_child(Pleroma.Gun.ConnectionPool.WorkerSupervisor, pid)
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
[:pleroma, :connection_pool, :reclaim, :stop],
|
[:pleroma, :connection_pool, :reclaim, :stop],
|
||||||
%{reclaimed_count: Enum.count(reclaimed)},
|
%{reclaimed_count: reclaimed_count},
|
||||||
%{max_connections: max_connections}
|
%{max_connections: max_connections}
|
||||||
)
|
)
|
||||||
|
|
||||||
{:stop, :normal, nil}
|
{:stop, :normal, nil}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp reclaim_info(worker_pid) do
|
||||||
|
try do
|
||||||
|
case GenServer.call(worker_pid, :reclaim_info) do
|
||||||
|
{:idle, crf, last_reference} -> [{worker_pid, crf, last_reference}]
|
||||||
|
:in_use -> []
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
:exit, _ -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reclaim(worker_pid) do
|
||||||
|
try do
|
||||||
|
GenServer.call(worker_pid, :reclaim) == :reclaimed
|
||||||
|
catch
|
||||||
|
:exit, _ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,22 @@ defmodule Pleroma.Gun.ConnectionPool.Worker do
|
||||||
|
|
||||||
defp registry, do: Pleroma.Gun.ConnectionPool
|
defp registry, do: Pleroma.Gun.ConnectionPool
|
||||||
|
|
||||||
def start_link([key | _] = opts) do
|
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
|
||||||
GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry(), key}})
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init([_key, _uri, _opts, _client_pid] = opts) do
|
def init([key, _uri, _opts, _client_pid] = opts) do
|
||||||
|
{:ok, _} = Registry.register(registry(), key, nil)
|
||||||
{:ok, nil, {:continue, {:connect, opts}}}
|
{:ok, nil, {:continue, {:connect, opts}}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do
|
def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do
|
||||||
with {:ok, conn_pid, protocol} <- Gun.Conn.open(uri, opts),
|
with {:ok, conn_pid, protocol, tunnel} <- Gun.Conn.open(uri, opts),
|
||||||
Process.link(conn_pid) do
|
Process.link(conn_pid) do
|
||||||
time = :erlang.monotonic_time(:millisecond)
|
time = :erlang.monotonic_time(:millisecond)
|
||||||
|
|
||||||
{_, _} =
|
Registry.unregister(registry(), key)
|
||||||
Registry.update_value(registry(), key, fn _ ->
|
{:ok, _} = Registry.register(registry(), key, {conn_pid, tunnel})
|
||||||
{conn_pid, [client_pid], 1, time}
|
|
||||||
end)
|
|
||||||
|
|
||||||
send(client_pid, {:conn_pid, conn_pid})
|
send(client_pid, {:conn_pid, conn_pid})
|
||||||
|
|
||||||
|
|
@ -34,8 +31,14 @@ defmodule Pleroma.Gun.ConnectionPool.Worker do
|
||||||
%{
|
%{
|
||||||
key: key,
|
key: key,
|
||||||
timer: nil,
|
timer: nil,
|
||||||
client_monitors: %{client_pid => Process.monitor(client_pid)},
|
clients: %{
|
||||||
protocol: protocol
|
client_pid => %{count: 1, monitor: Process.monitor(client_pid), streams: MapSet.new()}
|
||||||
|
},
|
||||||
|
conn_pid: conn_pid,
|
||||||
|
protocol: protocol,
|
||||||
|
proxied: Keyword.has_key?(opts, :proxy),
|
||||||
|
crf: 1,
|
||||||
|
last_reference: time
|
||||||
}, :hibernate}
|
}, :hibernate}
|
||||||
else
|
else
|
||||||
err ->
|
err ->
|
||||||
|
|
@ -44,106 +47,225 @@ defmodule Pleroma.Gun.ConnectionPool.Worker do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:add_client, client_pid}, state) do
|
def handle_call(:checkout, _from, %{protocol: protocol, clients: clients} = state)
|
||||||
case handle_call(:add_client, {client_pid, nil}, state) do
|
when protocol != :http2 and map_size(clients) > 0 do
|
||||||
{:reply, conn_pid, state, :hibernate} ->
|
{:reply, :busy, state, :hibernate}
|
||||||
send(client_pid, {:conn_pid, conn_pid})
|
|
||||||
{:noreply, state, :hibernate}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_call(:checkout, {client_pid, _}, %{key: key, protocol: protocol} = state) do
|
||||||
def handle_cast({:remove_client, client_pid}, state) do
|
|
||||||
case handle_call(:remove_client, {client_pid, nil}, state) do
|
|
||||||
{:reply, _, state, :hibernate} ->
|
|
||||||
{:noreply, state, :hibernate}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_call(:add_client, {client_pid, _}, %{key: key, protocol: protocol} = state) do
|
|
||||||
time = :erlang.monotonic_time(:millisecond)
|
time = :erlang.monotonic_time(:millisecond)
|
||||||
|
|
||||||
{{conn_pid, used_by, _, _}, _} =
|
[{_, {conn_pid, _tunnel}}] =
|
||||||
Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} ->
|
Registry.lookup(registry(), key) |> Enum.filter(&(elem(&1, 0) == self()))
|
||||||
{conn_pid, [client_pid | used_by], crf(time - last_reference, crf), time}
|
|
||||||
end)
|
state =
|
||||||
|
state
|
||||||
|
|> cancel_idle_timer()
|
||||||
|
|> add_client(client_pid)
|
||||||
|
|> Map.put(:crf, crf(time - state.last_reference, state.crf))
|
||||||
|
|> Map.put(:last_reference, time)
|
||||||
|
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
[:pleroma, :connection_pool, :client, :add],
|
[:pleroma, :connection_pool, :client, :add],
|
||||||
%{client_pid: client_pid, clients: used_by},
|
%{client_pid: client_pid, clients: Map.keys(state.clients)},
|
||||||
%{key: state.key, protocol: protocol}
|
%{key: state.key, protocol: protocol}
|
||||||
)
|
)
|
||||||
|
|
||||||
state =
|
{:reply, {:ok, conn_pid}, state, :hibernate}
|
||||||
if state.timer != nil do
|
|
||||||
Process.cancel_timer(state[:timer])
|
|
||||||
%{state | timer: nil}
|
|
||||||
else
|
|
||||||
state
|
|
||||||
end
|
|
||||||
|
|
||||||
ref = Process.monitor(client_pid)
|
|
||||||
|
|
||||||
state = put_in(state.client_monitors[client_pid], ref)
|
|
||||||
{:reply, conn_pid, state, :hibernate}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call(:remove_client, {client_pid, _}, %{key: key} = state) do
|
def handle_call(:remove_client, {client_pid, _}, state) do
|
||||||
{{_conn_pid, used_by, _crf, _last_reference}, _} =
|
case active_streams(state, client_pid) do
|
||||||
Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} ->
|
[] ->
|
||||||
{conn_pid, List.delete(used_by, client_pid), crf, last_reference}
|
{:reply, :ok, remove_client(state, client_pid, true), :hibernate}
|
||||||
end)
|
|
||||||
|
|
||||||
{ref, state} = pop_in(state.client_monitors[client_pid])
|
streams when state.protocol == :http2 ->
|
||||||
|
Enum.each(streams, &(:ok = Gun.cancel(state.conn_pid, &1)))
|
||||||
|
{:reply, :ok, remove_client(state, client_pid, true), :hibernate}
|
||||||
|
|
||||||
Process.demonitor(ref, [:flush])
|
_streams ->
|
||||||
|
{:stop, :normal, :ok, state}
|
||||||
timer =
|
end
|
||||||
if used_by == [] do
|
|
||||||
max_idle = Pleroma.Config.get([:connections_pool, :max_idle_time], 30_000)
|
|
||||||
Process.send_after(self(), :idle_close, max_idle)
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{:reply, :ok, %{state | timer: timer}, :hibernate}
|
def handle_call({:register_stream, stream}, {client_pid, _}, state) do
|
||||||
|
{:reply, :ok, update_stream(state, client_pid, &MapSet.put(&1, stream)), :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:finish_stream, stream}, {client_pid, _}, state) do
|
||||||
|
{:reply, :ok, update_stream(state, client_pid, &MapSet.delete(&1, stream)), :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:release_stream, stream}, {client_pid, _}, state) do
|
||||||
|
state = update_stream(state, client_pid, &MapSet.delete(&1, stream))
|
||||||
|
{:reply, :ok, remove_client(state, client_pid, true), :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:cancel_stream, stream}, {client_pid, _}, %{protocol: :http2} = state) do
|
||||||
|
:ok = Gun.cancel(state.conn_pid, stream)
|
||||||
|
state = update_stream(state, client_pid, &MapSet.delete(&1, stream))
|
||||||
|
{:reply, :ok, remove_client(state, client_pid, true), :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:cancel_stream, _stream}, _from, state) do
|
||||||
|
{:stop, :normal, :ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:reclaim_info, _from, %{clients: clients} = state)
|
||||||
|
when map_size(clients) == 0 do
|
||||||
|
{:reply, {:idle, state.crf, state.last_reference}, state, :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:reclaim_info, _from, state) do
|
||||||
|
{:reply, :in_use, state, :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:reclaim, _from, %{clients: clients} = state) when map_size(clients) == 0 do
|
||||||
|
{:stop, :normal, :reclaimed, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:reclaim, _from, state) do
|
||||||
|
{:reply, :in_use, state, :hibernate}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:idle_close, state) do
|
def handle_info({:idle_close, token}, %{timer: {_timer_ref, token}, clients: clients} = state)
|
||||||
# Gun monitors the owner process, and will close the connection automatically
|
when map_size(clients) == 0 do
|
||||||
# when it's terminated
|
# Gun monitors the owner process, and will close the connection automatically.
|
||||||
{:stop, :normal, state}
|
{:stop, :normal, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:idle_close, _token}, state), do: {:noreply, state, :hibernate}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:gun_up, _pid, _protocol}, state) do
|
def handle_info({:gun_up, _pid, protocol}, state) do
|
||||||
{:noreply, state, :hibernate}
|
{:noreply, %{state | protocol: protocol}, :hibernate}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gracefully shutdown if the connection got closed without any streams left
|
@impl true
|
||||||
|
def handle_info({:gun_error, conn_pid, stream, _reason}, %{conn_pid: conn_pid} = state) do
|
||||||
|
{:noreply, delete_stream(state, stream), :hibernate}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:gun_down, _pid, _protocol, _reason, _streams}, %{proxied: true} = state) do
|
||||||
|
{:stop, :normal, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Gracefully shutdown if the connection got closed without any streams left.
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:gun_down, _pid, _protocol, _reason, []}, state) do
|
def handle_info({:gun_down, _pid, _protocol, _reason, []}, state) do
|
||||||
{:stop, :normal, state}
|
{:stop, :normal, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Otherwise, wait for retry
|
# Otherwise, wait for retry.
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams}, state) do
|
def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams}, state) do
|
||||||
{:noreply, state, :hibernate}
|
{:noreply, state, :hibernate}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
|
def handle_info({:DOWN, ref, :process, pid, reason}, state) do
|
||||||
|
case state.clients[pid] do
|
||||||
|
%{monitor: ^ref} ->
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
[:pleroma, :connection_pool, :client, :dead],
|
[:pleroma, :connection_pool, :client, :dead],
|
||||||
%{client_pid: pid, reason: reason},
|
%{client_pid: pid, reason: reason},
|
||||||
%{key: state.key}
|
%{key: state.key}
|
||||||
)
|
)
|
||||||
|
|
||||||
handle_cast({:remove_client, pid}, state)
|
handle_dead_client(state, pid)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, state, :hibernate}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_client(state, client_pid) do
|
||||||
|
update_in(state.clients, fn clients ->
|
||||||
|
case clients[client_pid] do
|
||||||
|
nil ->
|
||||||
|
Map.put(clients, client_pid, %{
|
||||||
|
count: 1,
|
||||||
|
monitor: Process.monitor(client_pid),
|
||||||
|
streams: MapSet.new()
|
||||||
|
})
|
||||||
|
|
||||||
|
client ->
|
||||||
|
Map.put(clients, client_pid, %{client | count: client.count + 1})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_dead_client(state, client_pid) do
|
||||||
|
streams = active_streams(state, client_pid)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
streams == [] ->
|
||||||
|
{:noreply, remove_client(state, client_pid, false, true), :hibernate}
|
||||||
|
|
||||||
|
state.protocol == :http2 ->
|
||||||
|
Enum.each(streams, &(:ok = Gun.cancel(state.conn_pid, &1)))
|
||||||
|
{:noreply, remove_client(state, client_pid, false, true), :hibernate}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:stop, :normal, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_streams(state, client_pid) do
|
||||||
|
case state.clients[client_pid] do
|
||||||
|
%{streams: streams} -> MapSet.to_list(streams)
|
||||||
|
nil -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_stream(state, client_pid, update) do
|
||||||
|
update_in(state.clients, fn clients ->
|
||||||
|
case clients[client_pid] do
|
||||||
|
nil -> clients
|
||||||
|
client -> Map.put(clients, client_pid, %{client | streams: update.(client.streams)})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp delete_stream(state, stream) do
|
||||||
|
update_in(state.clients, fn clients ->
|
||||||
|
Map.new(clients, fn {pid, client} ->
|
||||||
|
{pid, %{client | streams: MapSet.delete(client.streams, stream)}}
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp remove_client(state, client_pid, demonitor, all \\ false) do
|
||||||
|
case state.clients[client_pid] do
|
||||||
|
nil ->
|
||||||
|
state
|
||||||
|
|
||||||
|
%{count: count} = client when count > 1 and not all ->
|
||||||
|
put_in(state.clients[client_pid], %{client | count: count - 1})
|
||||||
|
|
||||||
|
client ->
|
||||||
|
if demonitor, do: Process.demonitor(client.monitor, [:flush])
|
||||||
|
state = update_in(state.clients, &Map.delete(&1, client_pid))
|
||||||
|
|
||||||
|
if map_size(state.clients) == 0, do: schedule_idle_close(state), else: state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cancel_idle_timer(%{timer: nil} = state), do: state
|
||||||
|
|
||||||
|
defp cancel_idle_timer(%{timer: {timer_ref, _token}} = state) do
|
||||||
|
Process.cancel_timer(timer_ref)
|
||||||
|
%{state | timer: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_idle_close(state) do
|
||||||
|
max_idle = Pleroma.Config.get([:connections_pool, :max_idle_time], 30_000)
|
||||||
|
token = make_ref()
|
||||||
|
timer_ref = Process.send_after(self(), {:idle_close, token}, max_idle)
|
||||||
|
%{state | timer: {timer_ref, token}}
|
||||||
end
|
end
|
||||||
|
|
||||||
# LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478
|
# LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,14 @@ defmodule Pleroma.Gun.ConnectionPool.WorkerSupervisor do
|
||||||
def start_worker(opts, true) do
|
def start_worker(opts, true) do
|
||||||
case DynamicSupervisor.start_child(__MODULE__, {Worker, opts}) do
|
case DynamicSupervisor.start_child(__MODULE__, {Worker, opts}) do
|
||||||
{:error, :max_children} ->
|
{:error, :max_children} ->
|
||||||
:telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts})
|
[key | _] = opts
|
||||||
|
|
||||||
|
:telemetry.execute(
|
||||||
|
[:pleroma, :connection_pool, :provision_failure],
|
||||||
|
%{},
|
||||||
|
%{key: key}
|
||||||
|
)
|
||||||
|
|
||||||
{:error, :pool_full}
|
{:error, :pool_full}
|
||||||
|
|
||||||
res ->
|
res ->
|
||||||
|
|
|
||||||
253
lib/pleroma/http/adapter/gun.ex
Normal file
253
lib/pleroma/http/adapter/gun.ex
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.HTTP.Adapter.Gun do
|
||||||
|
@behaviour Tesla.Adapter
|
||||||
|
|
||||||
|
alias Pleroma.Gun.ConnectionPool
|
||||||
|
alias Tesla.Multipart
|
||||||
|
|
||||||
|
@default_timeout 1_000
|
||||||
|
|
||||||
|
@impl Tesla.Adapter
|
||||||
|
def call(env, opts) do
|
||||||
|
adapter_opts =
|
||||||
|
Tesla.Adapter.opts(
|
||||||
|
[close_conn: true, body_as: :plain, send_body: :at_once, receive: true],
|
||||||
|
env,
|
||||||
|
opts
|
||||||
|
)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
with {:ok, status, headers, body} <- request(env, adapter_opts) do
|
||||||
|
env = %{env | status: status, headers: format_headers(headers), body: body}
|
||||||
|
{:ok, put_stream_ref(env, body)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_stream_ref(env, %{stream: stream}) do
|
||||||
|
put_in(env.opts[:adapter][:stream], stream)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_stream_ref(env, _body), do: env
|
||||||
|
|
||||||
|
defp request(env, %{conn: conn, tunnel: tunnel} = opts) do
|
||||||
|
uri = URI.parse(Tesla.build_url(env))
|
||||||
|
path = request_path(uri, tunnel)
|
||||||
|
method = Tesla.Adapter.Shared.format_method(env.method)
|
||||||
|
{headers, body, send_body} = prepare_body(env.headers, env.body, opts[:send_body])
|
||||||
|
headers = maybe_add_forward_proxy_headers(headers, uri, opts, tunnel)
|
||||||
|
request_opts = request_opts(opts[:reply_to], tunnel)
|
||||||
|
|
||||||
|
stream =
|
||||||
|
try do
|
||||||
|
open_stream(conn, method, path, headers, body, request_opts, send_body)
|
||||||
|
catch
|
||||||
|
kind, reason ->
|
||||||
|
ConnectionPool.release_conn(conn)
|
||||||
|
:erlang.raise(kind, reason, __STACKTRACE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok = ConnectionPool.register_stream(conn, stream)
|
||||||
|
:ok = send_stream_body(conn, stream, body, send_body)
|
||||||
|
response = read_response(conn, stream, opts)
|
||||||
|
|
||||||
|
case response do
|
||||||
|
{:error, reason} ->
|
||||||
|
ConnectionPool.cancel_stream(conn, stream)
|
||||||
|
{:error, {:gun_stream_error, reason}}
|
||||||
|
|
||||||
|
response ->
|
||||||
|
unless opts[:body_as] in [:chunks, :stream] and stream_body?(response) do
|
||||||
|
:ok = ConnectionPool.finish_stream(conn, stream)
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts[:close_conn] and opts[:body_as] not in [:stream, :chunks] do
|
||||||
|
:gun.close(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stream_body?({:ok, _status, _headers, %{stream: _stream}}), do: true
|
||||||
|
defp stream_body?({:ok, _status, _headers, %Stream{}}), do: true
|
||||||
|
defp stream_body?(_response), do: false
|
||||||
|
|
||||||
|
defp request_opts(reply_to, nil), do: %{reply_to: reply_to || self()}
|
||||||
|
|
||||||
|
defp request_opts(reply_to, :forward_proxy), do: %{reply_to: reply_to || self()}
|
||||||
|
|
||||||
|
defp request_opts(reply_to, tunnel) do
|
||||||
|
%{reply_to: reply_to || self(), tunnel: tunnel}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_path(uri, :forward_proxy) do
|
||||||
|
path = Tesla.Adapter.Shared.prepare_path(uri.path, uri.query)
|
||||||
|
"#{uri.scheme}://#{authority(uri)}#{path}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_path(uri, _tunnel) do
|
||||||
|
Tesla.Adapter.Shared.prepare_path(uri.path, uri.query)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_forward_proxy_headers(headers, uri, opts, :forward_proxy) do
|
||||||
|
headers
|
||||||
|
|> put_header_new("host", authority(uri))
|
||||||
|
|> maybe_put_proxy_authorization(opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_forward_proxy_headers(headers, _uri, _opts, _tunnel), do: headers
|
||||||
|
|
||||||
|
defp maybe_put_proxy_authorization(headers, %{proxy_auth: {username, password}})
|
||||||
|
when is_binary(username) and is_binary(password) do
|
||||||
|
value = "Basic " <> Base.encode64(username <> ":" <> password)
|
||||||
|
List.keystore(headers, "proxy-authorization", 0, {"proxy-authorization", value})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_proxy_authorization(headers, _opts), do: headers
|
||||||
|
|
||||||
|
defp put_header_new(headers, name, value) do
|
||||||
|
if List.keymember?(headers, name, 0), do: headers, else: [{name, value} | headers]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp authority(%URI{scheme: scheme, host: host, port: port}) do
|
||||||
|
host = if String.contains?(host, ":"), do: "[#{host}]", else: host
|
||||||
|
if port == URI.default_port(scheme), do: host, else: "#{host}:#{port}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare_body(headers, %Multipart{} = multipart, _send_body) do
|
||||||
|
{format_headers(headers ++ Multipart.headers(multipart)), Multipart.body(multipart), :stream}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare_body(headers, body, _send_body)
|
||||||
|
when is_struct(body, Stream) or is_function(body) do
|
||||||
|
{format_headers(headers), body, :stream}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare_body(headers, body, send_body) do
|
||||||
|
{format_headers(headers), body || "", send_body}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_stream(conn, method, path, headers, _body, request_opts, :stream) do
|
||||||
|
:gun.headers(conn, method, path, headers, request_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_stream(conn, method, path, headers, body, request_opts, :at_once) do
|
||||||
|
:gun.request(conn, method, path, headers, body, request_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_stream_body(conn, stream, body, :stream) do
|
||||||
|
try do
|
||||||
|
for data <- body, do: :ok = :gun.data(conn, stream, :nofin, data)
|
||||||
|
:gun.data(conn, stream, :fin, "")
|
||||||
|
catch
|
||||||
|
kind, reason ->
|
||||||
|
ConnectionPool.cancel_stream(conn, stream)
|
||||||
|
:erlang.raise(kind, reason, __STACKTRACE__)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_stream_body(_conn, _stream, _body, :at_once), do: :ok
|
||||||
|
|
||||||
|
defp read_response(conn, stream, opts) do
|
||||||
|
receive? = opts[:receive]
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:gun_response, ^conn, ^stream, :fin, status, headers} ->
|
||||||
|
{:ok, status, headers, ""}
|
||||||
|
|
||||||
|
{:gun_response, ^conn, ^stream, :nofin, status, headers} ->
|
||||||
|
format_response(conn, stream, opts, status, headers, opts[:body_as])
|
||||||
|
|
||||||
|
{:gun_up, ^conn, _protocol} when receive? ->
|
||||||
|
read_response(conn, stream, opts)
|
||||||
|
|
||||||
|
{:gun_error, ^conn, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
|
||||||
|
{:gun_error, ^conn, ^stream, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
|
||||||
|
{:gun_down, ^conn, _protocol, _reason, _killed_streams} when receive? ->
|
||||||
|
read_response(conn, stream, opts)
|
||||||
|
|
||||||
|
{:DOWN, _ref, :process, ^conn, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
after
|
||||||
|
opts[:timeout] || @default_timeout -> {:error, :recv_response_timeout}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_response(conn, stream, opts, status, headers, :plain) do
|
||||||
|
case read_body(conn, stream, opts) do
|
||||||
|
{:ok, body} ->
|
||||||
|
{:ok, status, headers, body}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
:ok = :gun.flush(stream)
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_response(conn, stream, opts, status, headers, :stream) do
|
||||||
|
body =
|
||||||
|
Stream.resource(
|
||||||
|
fn -> :reading end,
|
||||||
|
fn
|
||||||
|
:reading ->
|
||||||
|
case Tesla.Adapter.Gun.read_chunk(conn, stream, opts) do
|
||||||
|
{:nofin, part} -> {[part], :reading}
|
||||||
|
{:fin, part} -> {[part], :done}
|
||||||
|
{:error, reason} -> raise "Gun stream failed: #{inspect(reason)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
:done ->
|
||||||
|
{:halt, :done}
|
||||||
|
end,
|
||||||
|
fn _state ->
|
||||||
|
if opts[:close_conn], do: :gun.close(conn)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, status, headers, body}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_response(conn, stream, opts, status, headers, :chunks) do
|
||||||
|
{:ok, status, headers, %{pid: conn, stream: stream, opts: Map.to_list(opts)}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_body(conn, stream, opts, acc \\ "") do
|
||||||
|
receive do
|
||||||
|
{:gun_data, ^conn, ^stream, :fin, body} ->
|
||||||
|
append_body(acc, body, opts[:max_body])
|
||||||
|
|
||||||
|
{:gun_data, ^conn, ^stream, :nofin, body} ->
|
||||||
|
with {:ok, acc} <- append_body(acc, body, opts[:max_body]) do
|
||||||
|
read_body(conn, stream, opts, acc)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:gun_error, ^conn, ^stream, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
|
||||||
|
{:DOWN, _ref, :process, ^conn, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
after
|
||||||
|
opts[:timeout] || @default_timeout -> {:error, :recv_body_timeout}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_body(acc, part, nil), do: {:ok, acc <> part}
|
||||||
|
|
||||||
|
defp append_body(acc, part, limit) do
|
||||||
|
body = acc <> part
|
||||||
|
if byte_size(body) <= limit, do: {:ok, body}, else: {:error, :body_too_large}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_headers(headers) do
|
||||||
|
Enum.map(headers, fn {key, value} ->
|
||||||
|
{String.downcase(to_string(key)), to_string(value)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -48,10 +48,10 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do
|
||||||
Keyword.put(opts, :timeout, recv_timeout)
|
Keyword.put(opts, :timeout, recv_timeout)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gun uses [body_as: :stream]
|
# Keep the pool lease until the caller consumes or cancels the body.
|
||||||
defp maybe_stream(opts) do
|
defp maybe_stream(opts) do
|
||||||
case Keyword.pop(opts, :stream, nil) do
|
case Keyword.pop(opts, :stream, nil) do
|
||||||
{true, opts} -> Keyword.put(opts, :body_as, :stream)
|
{true, opts} -> Keyword.put(opts, :body_as, :chunks)
|
||||||
{_, opts} -> opts
|
{_, opts} -> opts
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,8 @@ defmodule Pleroma.ReverseProxy do
|
||||||
end
|
end
|
||||||
|
|
||||||
with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url),
|
with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url),
|
||||||
{:ok, code, headers, client} <- request(method, url, req_headers, client_opts),
|
{:ok, code, headers, client} <-
|
||||||
:ok <-
|
request_with_constraints(method, url, req_headers, client_opts, opts) do
|
||||||
header_length_constraint(
|
|
||||||
headers,
|
|
||||||
Keyword.get(opts, :max_body_length, @max_body_length)
|
|
||||||
) do
|
|
||||||
response(conn, client, url, code, headers, opts)
|
response(conn, client, url, code, headers, opts)
|
||||||
else
|
else
|
||||||
{:ok, true} ->
|
{:ok, true} ->
|
||||||
|
|
@ -181,7 +177,8 @@ defmodule Pleroma.ReverseProxy do
|
||||||
{:ok, code, headers} when code in @valid_resp_codes ->
|
{:ok, code, headers} when code in @valid_resp_codes ->
|
||||||
{:ok, code, downcase_headers(headers)}
|
{:ok, code, downcase_headers(headers)}
|
||||||
|
|
||||||
{:ok, code, _, _} ->
|
{:ok, code, _, client} ->
|
||||||
|
client().close(client)
|
||||||
{:error, {:invalid_http_response, code}}
|
{:error, {:invalid_http_response, code}}
|
||||||
|
|
||||||
{:ok, code, _} ->
|
{:ok, code, _} ->
|
||||||
|
|
@ -192,6 +189,26 @@ defmodule Pleroma.ReverseProxy do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp request_with_constraints(method, url, headers, client_opts, opts) do
|
||||||
|
case request(method, url, headers, client_opts) do
|
||||||
|
{:ok, _code, headers, client} = response ->
|
||||||
|
case header_length_constraint(
|
||||||
|
headers,
|
||||||
|
Keyword.get(opts, :max_body_length, @max_body_length)
|
||||||
|
) do
|
||||||
|
:ok ->
|
||||||
|
response
|
||||||
|
|
||||||
|
error ->
|
||||||
|
client().close(client)
|
||||||
|
error
|
||||||
|
end
|
||||||
|
|
||||||
|
response ->
|
||||||
|
response
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp response(conn, client, url, status, headers, opts) do
|
defp response(conn, client, url, status, headers, opts) do
|
||||||
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
|
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
|
||||||
|
|
||||||
|
|
@ -530,7 +547,7 @@ defmodule Pleroma.ReverseProxy do
|
||||||
|
|
||||||
defp header_length_constraint(_, _), do: :ok
|
defp header_length_constraint(_, _), do: :ok
|
||||||
|
|
||||||
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
|
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size > limit do
|
||||||
{:error, :body_too_large}
|
{:error, :body_too_large}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do
|
||||||
@behaviour Pleroma.ReverseProxy.Client
|
@behaviour Pleroma.ReverseProxy.Client
|
||||||
|
|
||||||
alias Pleroma.Gun.ConnectionPool
|
alias Pleroma.Gun.ConnectionPool
|
||||||
|
alias Pleroma.HTTP.AdapterHelper
|
||||||
|
|
||||||
@type headers() :: [{String.t(), String.t()}]
|
@type headers() :: [{String.t(), String.t()}]
|
||||||
@type status() :: pos_integer()
|
@type status() :: pos_integer()
|
||||||
|
|
@ -20,6 +21,13 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do
|
||||||
def request(method, url, headers, body, opts \\ []) do
|
def request(method, url, headers, body, opts \\ []) do
|
||||||
check_adapter()
|
check_adapter()
|
||||||
|
|
||||||
|
# MediaProxyController inserts MediaProxy proxy configuration into the ReverseProxy call.
|
||||||
|
# This config can be unformatted and in String representation, while this works for Hackney,
|
||||||
|
# it does not work for Tesla and the proxy needs to be reformatted.
|
||||||
|
# Gun AdapterHelper doesn't do it, since it does not overwrite proxy config.
|
||||||
|
proxy = AdapterHelper.format_proxy(opts[:proxy])
|
||||||
|
opts = if proxy, do: Keyword.put(opts, :proxy, proxy), else: opts
|
||||||
|
|
||||||
opts = Keyword.put(opts, :body_as, :chunks)
|
opts = Keyword.put(opts, :body_as, :chunks)
|
||||||
|
|
||||||
with {:ok, response} <-
|
with {:ok, response} <-
|
||||||
|
|
@ -45,8 +53,8 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do
|
||||||
@impl true
|
@impl true
|
||||||
@spec stream_body(map()) ::
|
@spec stream_body(map()) ::
|
||||||
{:ok, binary(), map()} | {:error, atom() | String.t()} | :done | no_return()
|
{:ok, binary(), map()} | {:error, atom() | String.t()} | :done | no_return()
|
||||||
def stream_body(%{pid: pid, fin: true}) do
|
def stream_body(%{pid: pid, stream: stream, fin: true}) do
|
||||||
ConnectionPool.release_conn(pid)
|
ConnectionPool.release_stream(pid, stream)
|
||||||
:done
|
:done
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -70,6 +78,10 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@spec close(map) :: :ok | no_return()
|
@spec close(map) :: :ok | no_return()
|
||||||
|
def close(%{pid: pid, stream: stream}) do
|
||||||
|
ConnectionPool.cancel_stream(pid, stream)
|
||||||
|
end
|
||||||
|
|
||||||
def close(%{pid: pid}) do
|
def close(%{pid: pid}) do
|
||||||
ConnectionPool.release_conn(pid)
|
ConnectionPool.release_conn(pid)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ defmodule Pleroma.Telemetry.Logger do
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
[:pleroma, :connection_pool, :provision_failure],
|
[:pleroma, :connection_pool, :provision_failure],
|
||||||
%{opts: [key | _]},
|
|
||||||
_,
|
_,
|
||||||
|
%{key: key},
|
||||||
_
|
_
|
||||||
) do
|
) do
|
||||||
Logger.debug(fn ->
|
Logger.debug(fn ->
|
||||||
|
|
|
||||||
|
|
@ -12,25 +12,38 @@ defmodule Pleroma.Tesla.Middleware.ConnectionPool do
|
||||||
alias Pleroma.Gun.ConnectionPool
|
alias Pleroma.Gun.ConnectionPool
|
||||||
|
|
||||||
@impl Tesla.Middleware
|
@impl Tesla.Middleware
|
||||||
def call(%Tesla.Env{url: url, opts: opts} = env, next, _) do
|
def call(%Tesla.Env{opts: opts} = env, next, middleware_opts) do
|
||||||
|
if opts[:adapter][:body_as] == :stream do
|
||||||
|
{:error, :pooled_streaming_requires_chunks}
|
||||||
|
else
|
||||||
|
do_call(env, next, middleware_opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_call(%Tesla.Env{url: url, opts: opts} = env, next, _) do
|
||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
|
|
||||||
# Avoid leaking connections when the middleware is called twice
|
# Avoid leaking connections when the middleware is called twice
|
||||||
# with body_as: :chunks. We assume only the middleware can set
|
# with body_as: :chunks. We assume only the middleware can set
|
||||||
# opts[:adapter][:conn]
|
# opts[:adapter][:conn]
|
||||||
if opts[:adapter][:conn] do
|
cleanup_previous_request(env)
|
||||||
ConnectionPool.release_conn(opts[:adapter][:conn])
|
|
||||||
end
|
|
||||||
|
|
||||||
case ConnectionPool.get_conn(uri, opts[:adapter]) do
|
case ConnectionPool.get_conn(uri, opts[:adapter]) do
|
||||||
{:ok, conn_pid} ->
|
{:ok, conn_pid} ->
|
||||||
adapter_opts = Keyword.merge(opts[:adapter], conn: conn_pid, close_conn: false)
|
tunnel = ConnectionPool.tunnel_ref(conn_pid)
|
||||||
|
|
||||||
|
adapter_opts =
|
||||||
|
opts[:adapter]
|
||||||
|
|> Keyword.drop([:conn, :stream, :tunnel])
|
||||||
|
|> Keyword.merge(conn: conn_pid, close_conn: false, tunnel: tunnel)
|
||||||
|
|
||||||
opts = Keyword.put(opts, :adapter, adapter_opts)
|
opts = Keyword.put(opts, :adapter, adapter_opts)
|
||||||
env = %{env | opts: opts}
|
env = %{env | opts: opts}
|
||||||
|
next = use_pool_adapter(next)
|
||||||
|
|
||||||
case Tesla.run(env, next) do
|
case Tesla.run(env, next) do
|
||||||
{:ok, env} ->
|
{:ok, env} ->
|
||||||
unless opts[:adapter][:body_as] == :chunks do
|
unless opts[:adapter][:body_as] == :chunks and chunk_client?(env.body) do
|
||||||
ConnectionPool.release_conn(conn_pid)
|
ConnectionPool.release_conn(conn_pid)
|
||||||
{_, res} = pop_in(env.opts[:adapter][:conn])
|
{_, res} = pop_in(env.opts[:adapter][:conn])
|
||||||
{:ok, res}
|
{:ok, res}
|
||||||
|
|
@ -38,8 +51,11 @@ defmodule Pleroma.Tesla.Middleware.ConnectionPool do
|
||||||
{:ok, env}
|
{:ok, env}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{:error, {:gun_stream_error, reason}} ->
|
||||||
|
{:error, reason}
|
||||||
|
|
||||||
err ->
|
err ->
|
||||||
ConnectionPool.release_conn(conn_pid)
|
ConnectionPool.discard_conn(conn_pid)
|
||||||
err
|
err
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -47,4 +63,34 @@ defmodule Pleroma.Tesla.Middleware.ConnectionPool do
|
||||||
err
|
err
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp cleanup_previous_request(%Tesla.Env{
|
||||||
|
body: %{pid: pid, stream: stream},
|
||||||
|
opts: opts
|
||||||
|
}) do
|
||||||
|
if opts[:adapter][:conn], do: ConnectionPool.cancel_stream(pid, stream)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cleanup_previous_request(%Tesla.Env{opts: opts}) do
|
||||||
|
conn = opts[:adapter][:conn]
|
||||||
|
stream = opts[:adapter][:stream]
|
||||||
|
|
||||||
|
cond do
|
||||||
|
conn && stream -> ConnectionPool.cancel_stream(conn, stream)
|
||||||
|
conn -> ConnectionPool.release_conn(conn)
|
||||||
|
true -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chunk_client?(%{pid: pid, stream: stream}) when is_pid(pid) and not is_nil(stream),
|
||||||
|
do: true
|
||||||
|
|
||||||
|
defp chunk_client?(_body), do: false
|
||||||
|
|
||||||
|
defp use_pool_adapter(next) do
|
||||||
|
List.update_at(next, -1, fn
|
||||||
|
{Tesla.Adapter.Gun, function, args} -> {Pleroma.HTTP.Adapter.Gun, function, args}
|
||||||
|
adapter -> adapter
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
defmodule Pleroma.Web.RichMedia.Helpers do
|
defmodule Pleroma.Web.RichMedia.Helpers do
|
||||||
alias Pleroma.Config
|
alias Pleroma.Config
|
||||||
|
alias Pleroma.Gun.ConnectionPool
|
||||||
|
alias Pleroma.ReverseProxy.Client.Tesla, as: TeslaClient
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -19,13 +21,31 @@ defmodule Pleroma.Web.RichMedia.Helpers do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stream(url) do
|
defp stream(url) do
|
||||||
with {_, {:ok, %Tesla.Env{status: 200, body: stream_body, headers: headers}}} <-
|
case Pleroma.HTTP.get(url, req_headers(), http_options()) do
|
||||||
{:get, Pleroma.HTTP.get(url, req_headers(), http_options())},
|
{:ok, %Tesla.Env{status: 200, body: stream_body, headers: headers}} ->
|
||||||
{_, :ok} <- {:content_type, check_content_type(headers)},
|
result =
|
||||||
|
try do
|
||||||
|
with {_, :ok} <- {:content_type, check_content_type(headers)},
|
||||||
{_, :ok} <- {:content_length, check_content_length(headers)},
|
{_, :ok} <- {:content_length, check_content_length(headers)},
|
||||||
{:read_stream, {:ok, body}} <- {:read_stream, read_stream(stream_body)} do
|
{:read_stream, {:ok, body}} <- {:read_stream, read_stream(stream_body)} do
|
||||||
{:ok, body}
|
{:ok, body}
|
||||||
end
|
end
|
||||||
|
rescue
|
||||||
|
exception ->
|
||||||
|
cleanup_stream(stream_body, :error)
|
||||||
|
reraise exception, __STACKTRACE__
|
||||||
|
end
|
||||||
|
|
||||||
|
cleanup_stream(stream_body, result)
|
||||||
|
result
|
||||||
|
|
||||||
|
{:ok, %Tesla.Env{body: stream_body}} = response ->
|
||||||
|
cleanup_stream(stream_body, :rejected)
|
||||||
|
{:get, response}
|
||||||
|
|
||||||
|
response ->
|
||||||
|
{:get, response}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp head_first(url) do
|
defp head_first(url) do
|
||||||
|
|
@ -95,6 +115,10 @@ defmodule Pleroma.Web.RichMedia.Helpers do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp read_stream(%{pid: pid, stream: stream, opts: opts}) do
|
||||||
|
read_chunks(pid, stream, opts, "", 0, Keyword.get(http_options(), :max_body))
|
||||||
|
end
|
||||||
|
|
||||||
defp read_stream(stream) do
|
defp read_stream(stream) do
|
||||||
max_body = Keyword.get(http_options(), :max_body)
|
max_body = Keyword.get(http_options(), :max_body)
|
||||||
|
|
||||||
|
|
@ -117,6 +141,33 @@ defmodule Pleroma.Web.RichMedia.Helpers do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp read_chunks(pid, stream, opts, acc, total_bytes, max_body) do
|
||||||
|
case Tesla.Adapter.Gun.read_chunk(pid, stream, opts) do
|
||||||
|
{fin, chunk} when fin in [:fin, :nofin] ->
|
||||||
|
total_bytes = total_bytes + byte_size(chunk)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
total_bytes > max_body ->
|
||||||
|
:error
|
||||||
|
|
||||||
|
fin == :fin ->
|
||||||
|
{:ok, acc <> chunk}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
read_chunks(pid, stream, opts, acc <> chunk, total_bytes, max_body)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cleanup_stream(%{pid: pid, stream: stream}, {:ok, _body}),
|
||||||
|
do: ConnectionPool.release_stream(pid, stream)
|
||||||
|
|
||||||
|
defp cleanup_stream(%{pid: _pid} = client, _result), do: TeslaClient.close(client)
|
||||||
|
defp cleanup_stream(_stream, _result), do: :ok
|
||||||
|
|
||||||
defp http_options do
|
defp http_options do
|
||||||
[
|
[
|
||||||
pool: :rich_media,
|
pool: :rich_media,
|
||||||
|
|
|
||||||
4
mix.exs
4
mix.exs
|
|
@ -154,8 +154,8 @@ defmodule Pleroma.Mixfile do
|
||||||
{:cachex, "~> 3.6"},
|
{:cachex, "~> 3.6"},
|
||||||
{:tesla, "~> 1.15"},
|
{:tesla, "~> 1.15"},
|
||||||
{:castore, "~> 1.0"},
|
{:castore, "~> 1.0"},
|
||||||
{:cowlib, "~> 2.15"},
|
{:cowlib, "~> 2.17.1"},
|
||||||
{:gun, "~> 2.2"},
|
{:gun, "~> 2.4.1"},
|
||||||
{:finch, "~> 0.20"},
|
{:finch, "~> 0.20"},
|
||||||
{:jason, "~> 1.4"},
|
{:jason, "~> 1.4"},
|
||||||
{:mogrify, "~> 0.9.3", override: true},
|
{:mogrify, "~> 0.9.3", override: true},
|
||||||
|
|
|
||||||
4
mix.lock
4
mix.lock
|
|
@ -22,7 +22,7 @@
|
||||||
"covertool": {:hex, :covertool, "2.0.7", "398be995c4cf1a2861174389b3577ca97beee43b60c8f1afcf510f1b07d32408", [:rebar3], [], "hexpm", "46158ed6e1a0df7c0a912e314c7b8e053bd74daa5fc6b790614922a155b5720c"},
|
"covertool": {:hex, :covertool, "2.0.7", "398be995c4cf1a2861174389b3577ca97beee43b60c8f1afcf510f1b07d32408", [:rebar3], [], "hexpm", "46158ed6e1a0df7c0a912e314c7b8e053bd74daa5fc6b790614922a155b5720c"},
|
||||||
"cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
|
"cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
|
||||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
"cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"},
|
"cowlib": {:hex, :cowlib, "2.17.1", "3e6053016d1ab245730f0af688755476dcedb1c25ed8fb5751f59a2bfdc0c9af", [:make, :rebar3], [], "hexpm", "ff08bd17e6dd931445b18af77315b9b5fe052407110964ad2588c686b57b5e3f"},
|
||||||
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
|
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
|
||||||
"crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
|
"crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
|
||||||
"custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"},
|
"custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"},
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
|
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
|
||||||
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
|
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
|
||||||
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
||||||
"gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"},
|
"gun": {:hex, :gun, "2.4.1", "c3a8bff8e155e1fc8e499216599bb7effbd27bc1985662a8b25016090dc18428", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "1450575b4d393aa0811322727b90ad1dd94b5c10026f54a1641785bcbd250384"},
|
||||||
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
||||||
|
|
|
||||||
179
test/pleroma/gun/conn_test.exs
Normal file
179
test/pleroma/gun/conn_test.exs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.Gun.ConnTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
|
||||||
|
alias Pleroma.Gun.Conn
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
setup do
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http} end)
|
||||||
|
stub(Pleroma.GunMock, :await_tunnel_up, fn _, _, _ -> {:ok, :http2} end)
|
||||||
|
stub(Pleroma.GunMock, :close, fn _ -> :ok end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selects the direct transport from the URI scheme" do
|
||||||
|
expect(Pleroma.GunMock, :open, 2, fn
|
||||||
|
~c"example.com", 8443, %{transport: :tls, tls_opts: tls_opts} ->
|
||||||
|
assert tls_opts[:verify] == :verify_peer
|
||||||
|
{:ok, spawn_link(fn -> Process.sleep(:infinity) end)}
|
||||||
|
|
||||||
|
~c"example.com", 443, %{transport: :tcp} ->
|
||||||
|
{:ok, spawn_link(fn -> Process.sleep(:infinity) end)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, https_conn, :http, nil} = Conn.open(URI.parse("https://example.com:8443"), [])
|
||||||
|
assert {:ok, http_conn, :http, nil} = Conn.open(URI.parse("http://example.com:443"), [])
|
||||||
|
|
||||||
|
stop_conn(https_conn)
|
||||||
|
stop_conn(http_conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passes authentication to an HTTP CONNECT proxy" do
|
||||||
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
stream = make_ref()
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8080, opts ->
|
||||||
|
assert opts.transport == :tcp
|
||||||
|
refute Map.has_key?(opts, :tls_opts)
|
||||||
|
{:ok, conn}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :connect, fn ^conn, connect_opts ->
|
||||||
|
assert connect_opts.username == "alice"
|
||||||
|
assert connect_opts.password == "secret"
|
||||||
|
assert connect_opts.transport == :tls
|
||||||
|
assert connect_opts.protocols == [:http2, :http]
|
||||||
|
stream
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 200, []} end)
|
||||||
|
|
||||||
|
assert {:ok, ^conn, :http2, ^stream} =
|
||||||
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
proxy: {~c"proxy.example", 8080},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses separate TLS options for a TLS CONNECT proxy" do
|
||||||
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
stream = make_ref()
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8443, opts ->
|
||||||
|
assert opts.transport == :tls
|
||||||
|
assert opts.tls_opts == [verify: :verify_none]
|
||||||
|
{:ok, conn}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :connect, fn ^conn, _connect_opts -> stream end)
|
||||||
|
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 200, []} end)
|
||||||
|
|
||||||
|
assert {:ok, ^conn, :http2, ^stream} =
|
||||||
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
proxy: {~c"proxy.example", 8443},
|
||||||
|
transport: :tls,
|
||||||
|
proxy_tls_opts: [verify: :verify_none]
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses HTTP/1 for a TLS forwarding proxy" do
|
||||||
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8443, opts ->
|
||||||
|
assert opts.transport == :tls
|
||||||
|
assert opts.tls_opts == [verify: :verify_none]
|
||||||
|
assert opts.protocols == [:http]
|
||||||
|
{:ok, conn}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, ^conn, :http, :forward_proxy} =
|
||||||
|
Conn.open(URI.parse("http://origin.example/inbox"),
|
||||||
|
proxy: {~c"proxy.example", 8443},
|
||||||
|
transport: :tls,
|
||||||
|
proxy_tls_opts: [verify: :verify_none]
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passes authentication and remote DNS destination to a SOCKS5 proxy" do
|
||||||
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 1080, opts ->
|
||||||
|
assert opts.protocols == [:socks]
|
||||||
|
assert opts.transport == :tcp
|
||||||
|
assert opts.socks_opts.host == ~c"origin.example"
|
||||||
|
assert opts.socks_opts.port == 443
|
||||||
|
assert opts.socks_opts.auth == [{:username_password, "alice", "secret"}]
|
||||||
|
assert opts.socks_opts.transport == :tls
|
||||||
|
assert opts.socks_opts.protocols == [:http2, :http]
|
||||||
|
refute Map.has_key?(opts, :tls_opts)
|
||||||
|
{:ok, conn}
|
||||||
|
end)
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :socks} end)
|
||||||
|
|
||||||
|
assert {:ok, ^conn, :http2, nil} =
|
||||||
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
proxy: {:socks5, ~c"proxy.example", 1080},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "closes a CONNECT socket when proxy authentication fails" do
|
||||||
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
stream = make_ref()
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :open, fn _, _, _ -> {:ok, conn} end)
|
||||||
|
expect(Pleroma.GunMock, :connect, fn ^conn, _ -> stream end)
|
||||||
|
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :nofin, 407, []} end)
|
||||||
|
expect(Pleroma.GunMock, :close, fn ^conn -> Process.exit(conn, :normal) end)
|
||||||
|
|
||||||
|
assert {:error, :proxy_auth_failed} =
|
||||||
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
proxy: {~c"proxy.example", 8080},
|
||||||
|
proxy_auth: {"alice", "wrong"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the proxy status when CONNECT is forbidden" do
|
||||||
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
stream = make_ref()
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :open, fn _, _, _ -> {:ok, conn} end)
|
||||||
|
expect(Pleroma.GunMock, :connect, fn ^conn, _ -> stream end)
|
||||||
|
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 403, []} end)
|
||||||
|
expect(Pleroma.GunMock, :close, fn ^conn -> Process.exit(conn, :normal) end)
|
||||||
|
|
||||||
|
assert {:error, {:proxy_connect_failed, 403}} =
|
||||||
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
proxy: {~c"proxy.example", 8080}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects SOCKS4 before opening a socket" do
|
||||||
|
assert {:error, :socks4_unsupported} =
|
||||||
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
proxy: {:socks4, ~c"proxy.example", 1080}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stop_conn(conn) do
|
||||||
|
Process.unlink(conn)
|
||||||
|
Process.exit(conn, :kill)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,8 +11,11 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
|
||||||
|
|
||||||
defp gun_mock(_) do
|
defp gun_mock(_) do
|
||||||
Pleroma.GunMock
|
Pleroma.GunMock
|
||||||
|> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(100) end) end)
|
|> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(:infinity) end) end)
|
||||||
|> stub(:await_up, fn _, _ -> {:ok, :http} end)
|
|> stub(:await_up, fn _, _ -> {:ok, :http} end)
|
||||||
|
|> stub(:connect, fn _, _ -> make_ref() end)
|
||||||
|
|> stub(:await, fn _, _ -> {:response, :fin, 200, []} end)
|
||||||
|
|> stub(:await_tunnel_up, fn _, _, _ -> {:ok, :http2} end)
|
||||||
|> stub(:set_owner, fn _, _ -> :ok end)
|
|> stub(:set_owner, fn _, _ -> :ok end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -20,40 +23,213 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
|
||||||
|
|
||||||
setup :gun_mock
|
setup :gun_mock
|
||||||
|
|
||||||
test "gives the same connection to 2 concurrent requests" do
|
test "opens sibling HTTP/1 connections while an existing connection is in use" do
|
||||||
Enum.map(
|
uri = URI.parse("http://www.korean-books.com.kp/document.pdf")
|
||||||
[
|
|
||||||
"http://www.korean-books.com.kp/KBMbooks/en/periodic/pictorial/20200530163914.pdf",
|
|
||||||
"http://www.korean-books.com.kp/KBMbooks/en/periodic/pictorial/20200528183427.pdf"
|
|
||||||
],
|
|
||||||
fn uri ->
|
|
||||||
uri = URI.parse(uri)
|
|
||||||
task_parent = self()
|
|
||||||
|
|
||||||
Task.start_link(fn ->
|
assert {:ok, first} = ConnectionPool.get_conn(uri, [])
|
||||||
{:ok, conn} = ConnectionPool.get_conn(uri, [])
|
assert {:ok, second} = ConnectionPool.get_conn(uri, [])
|
||||||
ConnectionPool.release_conn(conn)
|
assert first != second
|
||||||
send(task_parent, conn)
|
|
||||||
end)
|
assert :ok = ConnectionPool.release_conn(first)
|
||||||
|
assert :ok = ConnectionPool.release_conn(second)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "multiplexes concurrent HTTP/2 leases on one connection" do
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http2} end)
|
||||||
|
uri = URI.parse("https://h2.example/inbox")
|
||||||
|
|
||||||
|
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
|
||||||
|
assert :ok = ConnectionPool.release_conn(conn)
|
||||||
|
assert :ok = ConnectionPool.release_conn(conn)
|
||||||
|
assert Process.alive?(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not share connections across direct and authenticated proxy routes" do
|
||||||
|
uri = URI.parse("https://routes.example/inbox")
|
||||||
|
|
||||||
|
assert {:ok, direct} = ConnectionPool.get_conn(uri, [])
|
||||||
|
|
||||||
|
assert {:ok, proxied} =
|
||||||
|
ConnectionPool.get_conn(uri,
|
||||||
|
proxy: {~c"proxy.example", 8080},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
)
|
)
|
||||||
|
|
||||||
[pid, pid] =
|
assert {:ok, other_credentials} =
|
||||||
for _ <- 1..2 do
|
ConnectionPool.get_conn(uri,
|
||||||
|
proxy: {~c"proxy.example", 8080},
|
||||||
|
proxy_auth: {"bob", "different-secret"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Enum.uniq([direct, proxied, other_credentials]) ==
|
||||||
|
[direct, proxied, other_credentials]
|
||||||
|
|
||||||
|
registry_dump = inspect(Registry.select(ConnectionPool, [{{:"$1", :_, :_}, [], [:"$1"]}]))
|
||||||
|
refute registry_dump =~ "secret"
|
||||||
|
|
||||||
|
Enum.each([direct, proxied, other_credentials], &ConnectionPool.release_conn/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "coalesces concurrent cold HTTP/2 checkouts behind one connection" do
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ ->
|
||||||
|
send(test_pid, {:awaiting_connection, self()})
|
||||||
|
|
||||||
receive do
|
receive do
|
||||||
pid -> pid
|
:continue -> {:ok, :http2}
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
uri = URI.parse("https://cold-h2.example/inbox")
|
||||||
|
|
||||||
|
tasks =
|
||||||
|
for _ <- 1..10 do
|
||||||
|
Task.async(fn -> ConnectionPool.get_conn(uri, []) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
assert_receive {:awaiting_connection, worker_pid}
|
||||||
|
refute_receive {:awaiting_connection, _other_worker}, 50
|
||||||
|
send(worker_pid, :continue)
|
||||||
|
|
||||||
|
assert [{:ok, conn}] = tasks |> Enum.map(&Task.await/1) |> Enum.uniq()
|
||||||
|
assert Process.alive?(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shares a cold connection failure with concurrent waiters" do
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :open, fn _, _, _ ->
|
||||||
|
send(test_pid, {:opening_connection, self()})
|
||||||
|
|
||||||
|
receive do
|
||||||
|
:fail -> {:error, :econnrefused}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
uri = URI.parse("https://unreachable.example/inbox")
|
||||||
|
|
||||||
|
tasks =
|
||||||
|
for _ <- 1..10 do
|
||||||
|
Task.async(fn -> ConnectionPool.get_conn(uri, []) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_receive {:opening_connection, worker_pid}
|
||||||
|
refute_receive {:opening_connection, _other_worker}, 50
|
||||||
|
send(worker_pid, :fail)
|
||||||
|
|
||||||
|
assert Enum.all?(tasks, fn task -> match?({:error, _reason}, Task.await(task)) end)
|
||||||
|
refute_receive {:opening_connection, _other_worker}, 50
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bounds many-host workloads and closes released idle workers" do
|
||||||
|
clear_config([:connections_pool, :max_connections]) do
|
||||||
|
clear_config([:connections_pool, :max_connections], 25)
|
||||||
|
clear_config([:connections_pool, :max_idle_time], 5)
|
||||||
|
restart_worker_supervisor()
|
||||||
|
|
||||||
|
on_exit(&restart_worker_supervisor/0)
|
||||||
|
end
|
||||||
|
|
||||||
|
connections =
|
||||||
|
Enum.map(1..25, fn host_number ->
|
||||||
|
uri = URI.parse("https://host-#{host_number}.example/inbox")
|
||||||
|
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
conn
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert DynamicSupervisor.count_children(Pleroma.Gun.ConnectionPool.WorkerSupervisor).active ==
|
||||||
|
25
|
||||||
|
|
||||||
|
assert {:error, :pool_full} =
|
||||||
|
ConnectionPool.get_conn(URI.parse("https://overflow.example/inbox"), [])
|
||||||
|
|
||||||
|
Enum.each(connections, &ConnectionPool.release_conn/1)
|
||||||
|
|
||||||
|
assert eventually(fn ->
|
||||||
|
DynamicSupervisor.count_children(Pleroma.Gun.ConnectionPool.WorkerSupervisor).active ==
|
||||||
|
0
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not reclaim a worker that became active after idle selection" do
|
||||||
|
uri = URI.parse("https://reclaim-race.example/inbox")
|
||||||
|
|
||||||
|
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
assert :ok = ConnectionPool.release_conn(conn)
|
||||||
|
worker = worker_for_conn(conn)
|
||||||
|
|
||||||
|
assert {:idle, _crf, _last_reference} = GenServer.call(worker, :reclaim_info)
|
||||||
|
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
assert :in_use = GenServer.call(worker, :reclaim)
|
||||||
|
assert Process.alive?(worker)
|
||||||
|
|
||||||
|
assert :ok = ConnectionPool.release_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cancels one HTTP/2 stream without discarding the connection" do
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http2} end)
|
||||||
|
uri = URI.parse("https://cancel-h2.example/inbox")
|
||||||
|
stream = make_ref()
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :cancel, fn conn, ^stream when is_pid(conn) -> :ok end)
|
||||||
|
|
||||||
|
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
assert :ok = ConnectionPool.register_stream(conn, stream)
|
||||||
|
assert :ok = ConnectionPool.cancel_stream(conn, stream)
|
||||||
|
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
assert :ok = ConnectionPool.release_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores a stale Gun error after cancelling an HTTP/2 stream" do
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http2} end)
|
||||||
|
uri = URI.parse("https://stale-cancel-h2.example/inbox")
|
||||||
|
stream = make_ref()
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :cancel, fn conn, ^stream when is_pid(conn) -> :ok end)
|
||||||
|
|
||||||
|
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
worker = worker_for_conn(conn)
|
||||||
|
assert :ok = ConnectionPool.register_stream(conn, stream)
|
||||||
|
assert :ok = ConnectionPool.cancel_stream(conn, stream)
|
||||||
|
|
||||||
|
send(worker, {:gun_error, conn, stream, {:badstate, "The stream cannot be found."}})
|
||||||
|
assert %{clients: %{}} = :sys.get_state(worker)
|
||||||
|
assert Process.alive?(worker)
|
||||||
|
|
||||||
|
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
assert :ok = ConnectionPool.release_conn(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stops an HTTP/1 worker when a client dies with an active stream" do
|
||||||
|
uri = URI.parse("http://dead-stream.example/media")
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
client =
|
||||||
|
spawn(fn ->
|
||||||
|
{:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||||
|
:ok = ConnectionPool.register_stream(conn, make_ref())
|
||||||
|
send(parent, {:active_stream, conn})
|
||||||
|
Process.sleep(:infinity)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_receive {:active_stream, conn}
|
||||||
|
worker = worker_for_conn(conn)
|
||||||
|
Process.exit(client, :kill)
|
||||||
|
|
||||||
|
assert eventually(fn -> not Process.alive?(worker) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "connection limit is respected with concurrent requests" do
|
test "connection limit is respected with concurrent requests" do
|
||||||
clear_config([:connections_pool, :max_connections]) do
|
clear_config([:connections_pool, :max_connections]) do
|
||||||
clear_config([:connections_pool, :max_connections], 1)
|
clear_config([:connections_pool, :max_connections], 1)
|
||||||
# The supervisor needs a reboot to apply the new config setting
|
# The supervisor needs a reboot to apply the new config setting
|
||||||
Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill)
|
restart_worker_supervisor()
|
||||||
|
|
||||||
on_exit(fn ->
|
on_exit(fn ->
|
||||||
Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill)
|
restart_worker_supervisor()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -96,4 +272,41 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
|
||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp restart_worker_supervisor do
|
||||||
|
supervisor = Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor)
|
||||||
|
monitor = Process.monitor(supervisor)
|
||||||
|
Process.exit(supervisor, :kill)
|
||||||
|
|
||||||
|
assert_receive {:DOWN, ^monitor, :process, ^supervisor, :killed}
|
||||||
|
|
||||||
|
assert eventually(fn ->
|
||||||
|
case Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor) do
|
||||||
|
pid when is_pid(pid) -> pid != supervisor and Process.alive?(pid)
|
||||||
|
nil -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp worker_for_conn(conn) do
|
||||||
|
[worker] =
|
||||||
|
Registry.select(ConnectionPool, [
|
||||||
|
{{:_, :"$1", {:"$2", :_}}, [{:==, :"$2", conn}], [:"$1"]}
|
||||||
|
])
|
||||||
|
|
||||||
|
worker
|
||||||
|
end
|
||||||
|
|
||||||
|
defp eventually(predicate, attempts \\ 50)
|
||||||
|
|
||||||
|
defp eventually(_predicate, 0), do: false
|
||||||
|
|
||||||
|
defp eventually(predicate, attempts) do
|
||||||
|
if predicate.() do
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Process.sleep(10)
|
||||||
|
eventually(predicate, attempts - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -73,5 +73,11 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do
|
||||||
|
|
||||||
assert opts[:proxy] == {~c"example.com", 4321}
|
assert opts[:proxy] == {~c"example.com", 4321}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "uses managed chunks for streamed responses" do
|
||||||
|
opts = Gun.options([stream: true], URI.parse("https://example.com"))
|
||||||
|
|
||||||
|
assert opts[:body_as] == :chunks
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
808
test/pleroma/http/gun_integration_test.exs
Normal file
808
test/pleroma/http/gun_integration_test.exs
Normal file
|
|
@ -0,0 +1,808 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.HTTP.GunIntegrationTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
use Pleroma.Tests.Helpers
|
||||||
|
|
||||||
|
alias Pleroma.Tesla.Middleware.ConnectionPool
|
||||||
|
|
||||||
|
setup do
|
||||||
|
clear_config([Pleroma.Gun], Pleroma.Gun.API)
|
||||||
|
clear_config([:connections_pool, :max_idle_time], 1_000)
|
||||||
|
|
||||||
|
origin = start_tls_origin(self())
|
||||||
|
connect_proxy = start_connect_proxy(self())
|
||||||
|
forward_proxy = start_forward_proxy(self())
|
||||||
|
socks_proxy = start_socks5_proxy(self())
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Process.sleep(20)
|
||||||
|
stop_server(socks_proxy)
|
||||||
|
stop_server(forward_proxy)
|
||||||
|
stop_server(connect_proxy)
|
||||||
|
stop_server(origin)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: connect_proxy,
|
||||||
|
forward_proxy: forward_proxy,
|
||||||
|
socks_proxy: socks_proxy}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requests HTTPS directly on a non-standard port", %{origin: origin} do
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(origin.base_url <> "/final")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "follows redirects through an authenticated CONNECT proxy", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
|
||||||
|
request(origin.base_url <> "/redirect", proxy_opts, true)
|
||||||
|
|
||||||
|
expected = "Basic " <> Base.encode64("alice:secret")
|
||||||
|
assert_receive {:connect_proxy, ^expected, "127.0.0.1", origin_port}
|
||||||
|
assert origin_port == origin.port
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requests through an authenticated CONNECT proxy", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = request(origin.base_url <> "/final", proxy_opts)
|
||||||
|
|
||||||
|
expected = "Basic " <> Base.encode64("alice:secret")
|
||||||
|
assert_receive {:connect_proxy, ^expected, "127.0.0.1", origin_port}
|
||||||
|
assert origin_port == origin.port
|
||||||
|
assert worker_protocol(origin.port) == :http
|
||||||
|
assert_receive {:origin_request, "/final"}
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = result
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requests HTTP through a forwarding proxy", %{forward_proxy: proxy} do
|
||||||
|
proxy_opts = [proxy: {~c"127.0.0.1", proxy.port}]
|
||||||
|
url = "http://origin.example:8080/final?key=value"
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
|
||||||
|
|
||||||
|
assert_receive {:forward_proxy, ^url, "origin.example:8080", nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authenticates HTTP requests to a forwarding proxy", %{forward_proxy: proxy} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
url = "http://origin.example/final"
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
|
||||||
|
|
||||||
|
expected = "Basic " <> Base.encode64("alice:secret")
|
||||||
|
assert_receive {:forward_proxy, ^url, "origin.example", ^expected}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "opens a request-ready authenticated CONNECT tunnel", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
uri = URI.parse(origin.base_url <> "/final")
|
||||||
|
|
||||||
|
opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"},
|
||||||
|
tls_opts: [verify: :verify_none],
|
||||||
|
connect_timeout: 2_000
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {:ok, conn, :http, tunnel} = Pleroma.Gun.Conn.open(uri, opts)
|
||||||
|
stream = :gun.get(conn, "/final", [], %{tunnel: tunnel})
|
||||||
|
assert {:response, :nofin, 200, _headers} = :gun.await(conn, stream, 2_000)
|
||||||
|
assert {:ok, "ok"} = :gun.await_body(conn, stream, 2_000)
|
||||||
|
:gun.close(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "streams chunks through an authenticated CONNECT tunnel", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: client}} =
|
||||||
|
request_chunks(origin.base_url <> "/chunks", proxy_opts)
|
||||||
|
|
||||||
|
assert collect_chunks(client) == "hello"
|
||||||
|
assert :ok = Pleroma.Gun.ConnectionPool.release_conn(client.pid)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "discards an unfinished HTTP/1 redirect stream before following it", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: client}} =
|
||||||
|
request_chunks(origin.base_url <> "/redirect_chunks", proxy_opts, true)
|
||||||
|
|
||||||
|
assert collect_chunks(client) == "hello"
|
||||||
|
assert :ok = Pleroma.Gun.ConnectionPool.release_conn(client.pid)
|
||||||
|
|
||||||
|
expected = "Basic " <> Base.encode64("alice:secret")
|
||||||
|
assert_receive {:connect_proxy, ^expected, "127.0.0.1", _port}
|
||||||
|
assert_receive {:connect_proxy, ^expected, "127.0.0.1", _port}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uploads multipart bodies through an authenticated CONNECT tunnel", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
multipart = Tesla.Multipart.new() |> Tesla.Multipart.add_field("name", "value")
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200}} =
|
||||||
|
request_multipart(origin.base_url <> "/upload", multipart, proxy_opts)
|
||||||
|
|
||||||
|
assert_receive {:origin_headers, "/upload", headers}
|
||||||
|
assert String.downcase(headers) =~ "content-type: multipart/form-data;"
|
||||||
|
assert_receive {:origin_body, "/upload", body}
|
||||||
|
assert body =~ "name=\"name\""
|
||||||
|
assert body =~ "value"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uploads streamed bodies through an authenticated CONNECT tunnel", %{
|
||||||
|
origin: origin,
|
||||||
|
connect_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
body = Stream.map(["streamed", "-body"], & &1)
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200}} =
|
||||||
|
request_stream(origin.base_url <> "/upload", body, proxy_opts)
|
||||||
|
|
||||||
|
assert_receive {:origin_body, "/upload", "streamed-body"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "discards a partial HTTP/1 upload when body enumeration raises", %{origin: origin} do
|
||||||
|
body =
|
||||||
|
Stream.map(["partial", "raise"], fn
|
||||||
|
"raise" -> raise "upload failed"
|
||||||
|
part -> part
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_raise RuntimeError, "upload failed", fn ->
|
||||||
|
request_stream(origin.base_url <> "/upload", body, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_receive {:origin_headers, "/upload", _headers}
|
||||||
|
assert worker_protocol(origin.port) == nil
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(origin.base_url <> "/final")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "releases the lease when Gun rejects an invalid request header", %{origin: origin} do
|
||||||
|
adapter_opts = [
|
||||||
|
tls_opts: [verify: :verify_none],
|
||||||
|
connect_timeout: 2_000,
|
||||||
|
timeout: 2_000
|
||||||
|
]
|
||||||
|
|
||||||
|
client = Tesla.client([ConnectionPool], Tesla.Adapter.Gun)
|
||||||
|
|
||||||
|
assert {:invalid_request_header, "x-test", _message} =
|
||||||
|
catch_error(
|
||||||
|
Tesla.get(client, origin.base_url <> "/final",
|
||||||
|
headers: [{"x-test", "value\r\nx-injected: true"}],
|
||||||
|
opts: [adapter: adapter_opts]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert worker_clients(origin.port) == %{}
|
||||||
|
refute_receive {:origin_request, "/final"}
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(origin.base_url <> "/final")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requests through authenticated SOCKS5 with proxy-side DNS", %{
|
||||||
|
origin: origin,
|
||||||
|
socks_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [
|
||||||
|
proxy: {:socks5, ~c"127.0.0.1", proxy.port},
|
||||||
|
proxy_auth: {"alice", "secret"}
|
||||||
|
]
|
||||||
|
|
||||||
|
url = "https://localhost:#{origin.port}/final"
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
|
||||||
|
|
||||||
|
assert_receive {:socks5_proxy, "alice", "secret", "localhost", origin_port}
|
||||||
|
assert origin_port == origin.port
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requests through unauthenticated SOCKS5 with an IP proxy host", %{
|
||||||
|
origin: origin,
|
||||||
|
socks_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [proxy: {:socks5, {127, 0, 0, 1}, proxy.port}]
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
|
||||||
|
request(origin.base_url <> "/final", proxy_opts)
|
||||||
|
|
||||||
|
assert_receive {:socks5_proxy, nil, nil, "127.0.0.1", origin_port}
|
||||||
|
assert origin_port == origin.port
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requests through unauthenticated SOCKS5 with a localhost proxy host", %{
|
||||||
|
origin: origin,
|
||||||
|
socks_proxy: proxy
|
||||||
|
} do
|
||||||
|
proxy_opts = [proxy: {:socks5, ~c"localhost", proxy.port}]
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
|
||||||
|
request(origin.base_url <> "/final", proxy_opts)
|
||||||
|
|
||||||
|
assert_receive {:socks5_proxy, nil, nil, "127.0.0.1", origin_port}
|
||||||
|
assert origin_port == origin.port
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request(url, proxy_opts \\ [], follow_redirects \\ false) do
|
||||||
|
middleware =
|
||||||
|
if follow_redirects do
|
||||||
|
[Tesla.Middleware.FollowRedirects, ConnectionPool]
|
||||||
|
else
|
||||||
|
[ConnectionPool]
|
||||||
|
end
|
||||||
|
|
||||||
|
adapter_opts =
|
||||||
|
Keyword.merge(
|
||||||
|
[tls_opts: [verify: :verify_none], connect_timeout: 2_000, timeout: 2_000],
|
||||||
|
proxy_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
middleware
|
||||||
|
|> Tesla.client(Tesla.Adapter.Gun)
|
||||||
|
|> Tesla.get(url, opts: [adapter: adapter_opts])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_chunks(url, proxy_opts, follow_redirects \\ false) do
|
||||||
|
adapter_opts =
|
||||||
|
Keyword.merge(
|
||||||
|
[
|
||||||
|
body_as: :chunks,
|
||||||
|
tls_opts: [verify: :verify_none],
|
||||||
|
connect_timeout: 2_000,
|
||||||
|
timeout: 2_000
|
||||||
|
],
|
||||||
|
proxy_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
middleware =
|
||||||
|
if follow_redirects do
|
||||||
|
[Tesla.Middleware.FollowRedirects, ConnectionPool]
|
||||||
|
else
|
||||||
|
[ConnectionPool]
|
||||||
|
end
|
||||||
|
|
||||||
|
middleware
|
||||||
|
|> Tesla.client(Tesla.Adapter.Gun)
|
||||||
|
|> Tesla.get(url, opts: [adapter: adapter_opts])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_multipart(url, multipart, proxy_opts) do
|
||||||
|
adapter_opts =
|
||||||
|
Keyword.merge(
|
||||||
|
[tls_opts: [verify: :verify_none], connect_timeout: 2_000, timeout: 2_000],
|
||||||
|
proxy_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
[ConnectionPool]
|
||||||
|
|> Tesla.client(Tesla.Adapter.Gun)
|
||||||
|
|> Tesla.post(url, multipart, opts: [adapter: adapter_opts])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_stream(url, body, proxy_opts) do
|
||||||
|
adapter_opts =
|
||||||
|
Keyword.merge(
|
||||||
|
[tls_opts: [verify: :verify_none], connect_timeout: 2_000, timeout: 2_000],
|
||||||
|
proxy_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
[ConnectionPool]
|
||||||
|
|> Tesla.client(Tesla.Adapter.Gun)
|
||||||
|
|> Tesla.post(url, body, opts: [adapter: adapter_opts])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_chunks(client, acc \\ "") do
|
||||||
|
case Tesla.Adapter.Gun.read_chunk(client.pid, client.stream, client.opts) do
|
||||||
|
{:nofin, chunk} -> collect_chunks(client, acc <> chunk)
|
||||||
|
{:fin, chunk} -> acc <> chunk
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_tls_origin(parent) do
|
||||||
|
certfile = Path.expand("../../fixtures/server.pem", __DIR__)
|
||||||
|
keyfile = Path.expand("../../fixtures/private_key.pem", __DIR__)
|
||||||
|
|
||||||
|
{:ok, listener} =
|
||||||
|
:ssl.listen(0, [
|
||||||
|
:binary,
|
||||||
|
certfile: certfile,
|
||||||
|
keyfile: keyfile,
|
||||||
|
reuseaddr: true,
|
||||||
|
active: false,
|
||||||
|
packet: :raw,
|
||||||
|
ip: {127, 0, 0, 1}
|
||||||
|
])
|
||||||
|
|
||||||
|
{:ok, {{127, 0, 0, 1}, port}} = :ssl.sockname(listener)
|
||||||
|
{:ok, acceptor} = Task.start_link(fn -> accept_tls_loop(listener, parent) end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
listener: listener,
|
||||||
|
acceptor: acceptor,
|
||||||
|
transport: :ssl,
|
||||||
|
port: port,
|
||||||
|
base_url: "https://127.0.0.1:#{port}"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp accept_tls_loop(listener, parent) do
|
||||||
|
case :ssl.transport_accept(listener) do
|
||||||
|
{:ok, tcp_socket} ->
|
||||||
|
case :ssl.handshake(tcp_socket, 2_000) do
|
||||||
|
{:ok, socket} ->
|
||||||
|
pid = spawn(fn -> receive_and_serve_tls(socket, parent) end)
|
||||||
|
:ok = :ssl.controlling_process(socket, pid)
|
||||||
|
send(pid, :serve)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
:gen_tcp.close(tcp_socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
accept_tls_loop(listener, parent)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp receive_and_serve_tls(socket, parent) do
|
||||||
|
receive do
|
||||||
|
:serve -> serve_tls(socket, parent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serve_tls(socket, parent) do
|
||||||
|
case recv_headers(:ssl, socket) do
|
||||||
|
{:ok, request} ->
|
||||||
|
{headers, buffered_body} = split_headers(request)
|
||||||
|
path = request_path(headers)
|
||||||
|
send(parent, {:origin_request, path})
|
||||||
|
send(parent, {:origin_headers, path, headers})
|
||||||
|
|
||||||
|
case path do
|
||||||
|
"/redirect" ->
|
||||||
|
send_tls_response(socket, 302, "Found", [{"Location", "/final"}], "")
|
||||||
|
|
||||||
|
"/redirect_chunks" ->
|
||||||
|
send_tls_response(socket, 302, "Found", [{"Location", "/chunks"}], "discard-me")
|
||||||
|
|
||||||
|
"/final" ->
|
||||||
|
send_tls_response(socket, 200, "OK", [], "ok")
|
||||||
|
|
||||||
|
"/chunks" ->
|
||||||
|
send_tls_chunks(socket, ["hel", "lo"])
|
||||||
|
|
||||||
|
"/upload" ->
|
||||||
|
{:ok, body} = recv_request_body(socket, headers, buffered_body)
|
||||||
|
send(parent, {:origin_body, path, body})
|
||||||
|
send_tls_response(socket, 200, "OK", [], "uploaded")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
send_tls_response(socket, 404, "Not Found", [], "not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
serve_tls(socket, parent)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
:ssl.close(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_tls_response(socket, status, reason, headers, body) do
|
||||||
|
headers =
|
||||||
|
[
|
||||||
|
{"Content-Length", Integer.to_string(byte_size(body))},
|
||||||
|
{"Connection", "keep-alive"}
|
||||||
|
] ++ headers
|
||||||
|
|
||||||
|
:ssl.send(socket, [
|
||||||
|
"HTTP/1.1 ",
|
||||||
|
Integer.to_string(status),
|
||||||
|
" ",
|
||||||
|
reason,
|
||||||
|
"\r\n",
|
||||||
|
Enum.map(headers, fn {key, value} -> [key, ": ", value, "\r\n"] end),
|
||||||
|
"\r\n",
|
||||||
|
body
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_tls_chunks(socket, chunks) do
|
||||||
|
:ssl.send(socket, [
|
||||||
|
"HTTP/1.1 200 OK\r\n",
|
||||||
|
"Transfer-Encoding: chunked\r\n",
|
||||||
|
"Connection: keep-alive\r\n\r\n",
|
||||||
|
Enum.map(chunks, fn chunk ->
|
||||||
|
[Integer.to_string(byte_size(chunk), 16), "\r\n", chunk, "\r\n"]
|
||||||
|
end),
|
||||||
|
"0\r\n\r\n"
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_connect_proxy(parent) do
|
||||||
|
start_tcp_server(fn socket -> serve_connect_proxy(socket, parent) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serve_connect_proxy(socket, parent) do
|
||||||
|
with {:ok, headers} <- recv_headers(:gen_tcp, socket),
|
||||||
|
{:ok, host, port} <- parse_connect(headers),
|
||||||
|
auth when is_binary(auth) <- header(headers, "proxy-authorization"),
|
||||||
|
{:ok, upstream} <- tcp_connect(host, port) do
|
||||||
|
send(parent, {:connect_proxy, auth, host, port})
|
||||||
|
:ok = :gen_tcp.send(socket, "HTTP/1.1 200 Connection established\r\n\r\n")
|
||||||
|
tunnel(socket, upstream)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
:gen_tcp.send(
|
||||||
|
socket,
|
||||||
|
"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
:gen_tcp.close(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_forward_proxy(parent) do
|
||||||
|
start_tcp_server(fn socket -> serve_forward_proxy(socket, parent) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serve_forward_proxy(socket, parent) do
|
||||||
|
with {:ok, request} <- recv_headers(:gen_tcp, socket) do
|
||||||
|
{headers, _buffered_body} = split_headers(request)
|
||||||
|
target = request_path(headers)
|
||||||
|
host = header(headers, "host")
|
||||||
|
auth = header(headers, "proxy-authorization")
|
||||||
|
|
||||||
|
if String.starts_with?(target, "http://") do
|
||||||
|
send(parent, {:forward_proxy, target, host, auth})
|
||||||
|
|
||||||
|
:gen_tcp.send(
|
||||||
|
socket,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok"
|
||||||
|
)
|
||||||
|
|
||||||
|
serve_forward_proxy(socket, parent)
|
||||||
|
else
|
||||||
|
:gen_tcp.send(
|
||||||
|
socket,
|
||||||
|
"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
:gen_tcp.close(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_socks5_proxy(parent) do
|
||||||
|
start_tcp_server(fn socket -> serve_socks5_proxy(socket, parent) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serve_socks5_proxy(socket, parent) do
|
||||||
|
with {:ok, <<5, method_count>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
||||||
|
{:ok, methods} <- :gen_tcp.recv(socket, method_count, 2_000),
|
||||||
|
{:ok, username, password} <- negotiate_socks5_auth(socket, methods),
|
||||||
|
{:ok, host, port} <- recv_socks5_destination(socket),
|
||||||
|
{:ok, upstream} <- tcp_connect(host, port),
|
||||||
|
:ok <- :gen_tcp.send(socket, <<5, 0, 0, 1, 0, 0, 0, 0, 0, 0>>) do
|
||||||
|
send(parent, {:socks5_proxy, username, password, host, port})
|
||||||
|
tunnel(socket, upstream)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
:gen_tcp.close(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp negotiate_socks5_auth(socket, methods) do
|
||||||
|
cond do
|
||||||
|
:binary.match(methods, <<2>>) != :nomatch ->
|
||||||
|
with :ok <- :gen_tcp.send(socket, <<5, 2>>),
|
||||||
|
{:ok, username, password} <- recv_socks5_auth(socket),
|
||||||
|
:ok <- :gen_tcp.send(socket, <<1, 0>>) do
|
||||||
|
{:ok, username, password}
|
||||||
|
end
|
||||||
|
|
||||||
|
:binary.match(methods, <<0>>) != :nomatch ->
|
||||||
|
with :ok <- :gen_tcp.send(socket, <<5, 0>>) do
|
||||||
|
{:ok, nil, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:error, :unsupported_auth}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_socks5_auth(socket) do
|
||||||
|
with {:ok, <<1, username_length>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
||||||
|
{:ok, username} <- :gen_tcp.recv(socket, username_length, 2_000),
|
||||||
|
{:ok, <<password_length>>} <- :gen_tcp.recv(socket, 1, 2_000),
|
||||||
|
{:ok, password} <- :gen_tcp.recv(socket, password_length, 2_000) do
|
||||||
|
{:ok, username, password}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_socks5_destination(socket) do
|
||||||
|
with {:ok, <<5, 1, 0, address_type>>} <- :gen_tcp.recv(socket, 4, 2_000),
|
||||||
|
{:ok, host} <- recv_socks5_host(socket, address_type),
|
||||||
|
{:ok, <<port::16>>} <- :gen_tcp.recv(socket, 2, 2_000) do
|
||||||
|
{:ok, host, port}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_socks5_host(socket, 1) do
|
||||||
|
with {:ok, <<a, b, c, d>>} <- :gen_tcp.recv(socket, 4, 2_000) do
|
||||||
|
{:ok, Enum.join([a, b, c, d], ".")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_socks5_host(socket, 3) do
|
||||||
|
with {:ok, <<length>>} <- :gen_tcp.recv(socket, 1, 2_000),
|
||||||
|
{:ok, host} <- :gen_tcp.recv(socket, length, 2_000) do
|
||||||
|
{:ok, host}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_tcp_server(handler) do
|
||||||
|
{:ok, listener} =
|
||||||
|
:gen_tcp.listen(0, [
|
||||||
|
:binary,
|
||||||
|
active: false,
|
||||||
|
packet: :raw,
|
||||||
|
reuseaddr: true,
|
||||||
|
ip: {127, 0, 0, 1}
|
||||||
|
])
|
||||||
|
|
||||||
|
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
|
||||||
|
{:ok, acceptor} = Task.start_link(fn -> accept_tcp_loop(listener, handler) end)
|
||||||
|
%{listener: listener, acceptor: acceptor, transport: :gen_tcp, port: port}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp accept_tcp_loop(listener, handler) do
|
||||||
|
case :gen_tcp.accept(listener) do
|
||||||
|
{:ok, socket} ->
|
||||||
|
pid = spawn(fn -> receive_and_serve_tcp(socket, handler) end)
|
||||||
|
:ok = :gen_tcp.controlling_process(socket, pid)
|
||||||
|
send(pid, :serve)
|
||||||
|
accept_tcp_loop(listener, handler)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp receive_and_serve_tcp(socket, handler) do
|
||||||
|
receive do
|
||||||
|
:serve -> handler.(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_headers(transport, socket, acc \\ <<>>) do
|
||||||
|
case transport.recv(socket, 0, 2_000) do
|
||||||
|
{:ok, data} ->
|
||||||
|
acc = acc <> data
|
||||||
|
|
||||||
|
if :binary.match(acc, "\r\n\r\n") == :nomatch do
|
||||||
|
recv_headers(transport, socket, acc)
|
||||||
|
else
|
||||||
|
{:ok, acc}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_path(headers) do
|
||||||
|
headers
|
||||||
|
|> String.split("\r\n", parts: 2)
|
||||||
|
|> hd()
|
||||||
|
|> String.split(" ")
|
||||||
|
|> Enum.at(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp split_headers(request) do
|
||||||
|
{index, 4} = :binary.match(request, "\r\n\r\n")
|
||||||
|
split_at = index + 4
|
||||||
|
<<headers::binary-size(split_at), body::binary>> = request
|
||||||
|
{headers, body}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_request_body(socket, headers, buffered_body) do
|
||||||
|
cond do
|
||||||
|
String.contains?(String.downcase(headers), "transfer-encoding: chunked") ->
|
||||||
|
recv_chunked_body(socket, buffered_body, "")
|
||||||
|
|
||||||
|
content_length = header(headers, "content-length") ->
|
||||||
|
recv_exact(socket, buffered_body, String.to_integer(content_length))
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:ok, buffered_body}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_chunked_body(socket, buffer, acc) do
|
||||||
|
with {:ok, line, buffer} <- recv_line(socket, buffer),
|
||||||
|
{size, ""} <- Integer.parse(line, 16) do
|
||||||
|
if size == 0 do
|
||||||
|
{:ok, acc}
|
||||||
|
else
|
||||||
|
with {:ok, chunk_and_crlf, buffer} <- take_bytes(socket, buffer, size + 2),
|
||||||
|
<<chunk::binary-size(size), "\r\n">> <- chunk_and_crlf do
|
||||||
|
recv_chunked_body(socket, buffer, acc <> chunk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_line(socket, buffer) do
|
||||||
|
case :binary.match(buffer, "\r\n") do
|
||||||
|
{index, 2} ->
|
||||||
|
<<line::binary-size(index), "\r\n", rest::binary>> = buffer
|
||||||
|
{:ok, line, rest}
|
||||||
|
|
||||||
|
:nomatch ->
|
||||||
|
with {:ok, data} <- :ssl.recv(socket, 0, 2_000) do
|
||||||
|
recv_line(socket, buffer <> data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp take_bytes(_socket, buffer, size) when byte_size(buffer) >= size do
|
||||||
|
<<bytes::binary-size(size), rest::binary>> = buffer
|
||||||
|
{:ok, bytes, rest}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp take_bytes(socket, buffer, size) do
|
||||||
|
with {:ok, data} <- :ssl.recv(socket, 0, 2_000) do
|
||||||
|
take_bytes(socket, buffer <> data, size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_exact(socket, buffer, size) do
|
||||||
|
with {:ok, body, _rest} <- take_bytes(socket, buffer, size) do
|
||||||
|
{:ok, body}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_connect(headers) do
|
||||||
|
case headers |> String.split("\r\n", parts: 2) |> hd() |> String.split(" ") do
|
||||||
|
["CONNECT", authority, _protocol] ->
|
||||||
|
case String.split(authority, ":", parts: 2) do
|
||||||
|
[host, port] -> {:ok, host, String.to_integer(port)}
|
||||||
|
_ -> {:error, :invalid_authority}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, :invalid_connect}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp header(headers, wanted_name) do
|
||||||
|
headers
|
||||||
|
|> String.split("\r\n")
|
||||||
|
|> Enum.find_value(fn line ->
|
||||||
|
case String.split(line, ":", parts: 2) do
|
||||||
|
[name, value] ->
|
||||||
|
if String.downcase(name) == wanted_name, do: String.trim(value)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tcp_connect(host, port) do
|
||||||
|
:gen_tcp.connect(
|
||||||
|
String.to_charlist(host),
|
||||||
|
port,
|
||||||
|
[:binary, active: false, packet: :raw],
|
||||||
|
2_000
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp worker_protocol(port) do
|
||||||
|
case worker_state(port) do
|
||||||
|
nil -> nil
|
||||||
|
state -> state.protocol
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp worker_clients(port) do
|
||||||
|
case worker_state(port) do
|
||||||
|
nil -> nil
|
||||||
|
state -> state.clients
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp worker_state(port) do
|
||||||
|
prefix = "https:127.0.0.1:#{port}:"
|
||||||
|
|
||||||
|
Pleroma.Gun.ConnectionPool
|
||||||
|
|> Registry.select([{{:"$1", :"$2", :_}, [], [{{:"$1", :"$2"}}]}])
|
||||||
|
|> Enum.find_value(fn {key, worker} ->
|
||||||
|
if String.starts_with?(key, prefix), do: :sys.get_state(worker)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tunnel(client, upstream) do
|
||||||
|
:ok = :inet.setopts(client, active: :once)
|
||||||
|
:ok = :inet.setopts(upstream, active: :once)
|
||||||
|
tunnel_loop(client, upstream)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tunnel_loop(client, upstream) do
|
||||||
|
receive do
|
||||||
|
{:tcp, ^client, data} ->
|
||||||
|
:ok = :gen_tcp.send(upstream, data)
|
||||||
|
:ok = :inet.setopts(client, active: :once)
|
||||||
|
tunnel_loop(client, upstream)
|
||||||
|
|
||||||
|
{:tcp, ^upstream, data} ->
|
||||||
|
:ok = :gen_tcp.send(client, data)
|
||||||
|
:ok = :inet.setopts(upstream, active: :once)
|
||||||
|
tunnel_loop(client, upstream)
|
||||||
|
|
||||||
|
{:tcp_closed, _socket} ->
|
||||||
|
:gen_tcp.close(client)
|
||||||
|
:gen_tcp.close(upstream)
|
||||||
|
|
||||||
|
{:tcp_error, _socket, _reason} ->
|
||||||
|
:gen_tcp.close(client)
|
||||||
|
:gen_tcp.close(upstream)
|
||||||
|
after
|
||||||
|
10_000 ->
|
||||||
|
:gen_tcp.close(client)
|
||||||
|
:gen_tcp.close(upstream)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stop_server(%{listener: listener, acceptor: acceptor, transport: transport}) do
|
||||||
|
transport.close(listener)
|
||||||
|
|
||||||
|
if Process.alive?(acceptor), do: Process.exit(acceptor, :normal)
|
||||||
|
end
|
||||||
|
end
|
||||||
22
test/pleroma/reverse_proxy/client/tesla_test.exs
Normal file
22
test/pleroma/reverse_proxy/client/tesla_test.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.ReverseProxy.Client.TeslaTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
|
||||||
|
alias Pleroma.ReverseProxy.Client.Tesla
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
test "cancels an unfinished stream before releasing its connection" do
|
||||||
|
conn = self()
|
||||||
|
stream = [make_ref(), make_ref()]
|
||||||
|
|
||||||
|
expect(Pleroma.GunMock, :cancel, fn ^conn, ^stream -> :ok end)
|
||||||
|
|
||||||
|
assert :ok = Tesla.close(%{pid: conn, stream: stream})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -115,6 +115,7 @@ defmodule Pleroma.ReverseProxyTest do
|
||||||
describe "max_body" do
|
describe "max_body" do
|
||||||
test "length returns error if content-length more than option", %{conn: conn} do
|
test "length returns error if content-length more than option", %{conn: conn} do
|
||||||
request_mock(0)
|
request_mock(0)
|
||||||
|
expect(ClientMock, :close, fn _ -> :ok end)
|
||||||
|
|
||||||
assert capture_log(fn ->
|
assert capture_log(fn ->
|
||||||
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
|
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
|
||||||
|
|
@ -128,14 +129,31 @@ defmodule Pleroma.ReverseProxyTest do
|
||||||
end) == ""
|
end) == ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "closes streamed responses with invalid status", %{conn: conn} do
|
||||||
|
ClientMock
|
||||||
|
|> expect(:request, fn :get, "/invalid-status", _, _, _ ->
|
||||||
|
{:ok, 404, [], %{url: "/invalid-status"}}
|
||||||
|
end)
|
||||||
|
|> expect(:close, fn %{url: "/invalid-status"} -> :ok end)
|
||||||
|
|
||||||
|
ReverseProxy.call(conn, "/invalid-status")
|
||||||
|
end
|
||||||
|
|
||||||
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
|
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
|
||||||
stream_mock(3, true)
|
stream_mock(3, true)
|
||||||
|
|
||||||
assert capture_log(fn ->
|
assert capture_log(fn ->
|
||||||
ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30)
|
ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 29)
|
||||||
end) =~
|
end) =~
|
||||||
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
|
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "max_body_length accepts a body exactly at the limit", %{conn: conn} do
|
||||||
|
stream_mock(4)
|
||||||
|
|
||||||
|
conn = ReverseProxy.call(conn, "/stream-bytes/30", max_body_length: 30)
|
||||||
|
assert byte_size(conn.resp_body) == 30
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "HEAD requests" do
|
describe "HEAD requests" do
|
||||||
|
|
@ -200,7 +218,10 @@ defmodule Pleroma.ReverseProxyTest do
|
||||||
|
|
||||||
test "204", %{conn: conn} do
|
test "204", %{conn: conn} do
|
||||||
url = "/status/204"
|
url = "/status/204"
|
||||||
expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
|
|
||||||
|
ClientMock
|
||||||
|
|> expect(:request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
|
||||||
|
|> expect(:close, fn %{} -> :ok end)
|
||||||
|
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
conn = ReverseProxy.call(conn, url)
|
conn = ReverseProxy.call(conn, url)
|
||||||
|
|
|
||||||
99
test/pleroma/tesla/middleware/connection_pool_test.exs
Normal file
99
test/pleroma/tesla/middleware/connection_pool_test.exs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.Tesla.Middleware.ConnectionPoolTest do
|
||||||
|
use Pleroma.DataCase
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
|
||||||
|
alias Pleroma.Tesla.Middleware.ConnectionPool
|
||||||
|
|
||||||
|
defmodule FailingAdapter do
|
||||||
|
@behaviour Tesla.Adapter
|
||||||
|
|
||||||
|
@impl Tesla.Adapter
|
||||||
|
def call(_env, _opts), do: {:error, :request_failed}
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule EmptyAdapter do
|
||||||
|
@behaviour Tesla.Adapter
|
||||||
|
|
||||||
|
@impl Tesla.Adapter
|
||||||
|
def call(env, _opts), do: {:ok, %{env | status: 204, body: ""}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "discards the connection when the adapter cannot finish a request" do
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :open, fn _, _, _ ->
|
||||||
|
{:ok, conn} = Task.start_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
send(test_pid, {:gun_conn, conn})
|
||||||
|
{:ok, conn}
|
||||||
|
end)
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http} end)
|
||||||
|
|
||||||
|
client = Tesla.client([ConnectionPool], FailingAdapter)
|
||||||
|
|
||||||
|
assert {:error, :request_failed} =
|
||||||
|
Tesla.get(client, "http://discard.example/", opts: [adapter: []])
|
||||||
|
|
||||||
|
assert_receive {:gun_conn, conn}
|
||||||
|
|
||||||
|
assert eventually(fn -> not Process.alive?(conn) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "releases a chunks lease when the response has no stream" do
|
||||||
|
clear_config([:connections_pool, :max_idle_time], 5)
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :open, fn _, _, _ ->
|
||||||
|
{:ok, conn} = Task.start_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
send(test_pid, {:gun_conn, conn})
|
||||||
|
{:ok, conn}
|
||||||
|
end)
|
||||||
|
|
||||||
|
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http} end)
|
||||||
|
|
||||||
|
client = Tesla.client([ConnectionPool], EmptyAdapter)
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 204, body: ""}} =
|
||||||
|
Tesla.get(client, "http://empty-chunks.example/",
|
||||||
|
opts: [adapter: [body_as: :chunks]]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_receive {:gun_conn, conn}
|
||||||
|
worker = worker_for_conn(conn)
|
||||||
|
assert eventually(fn -> not Process.alive?(worker) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects unmanaged pooled streams" do
|
||||||
|
client = Tesla.client([ConnectionPool], EmptyAdapter)
|
||||||
|
|
||||||
|
assert {:error, :pooled_streaming_requires_chunks} =
|
||||||
|
Tesla.get(client, "http://stream.example/", opts: [adapter: [body_as: :stream]])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp worker_for_conn(conn) do
|
||||||
|
[worker] =
|
||||||
|
Registry.select(Pleroma.Gun.ConnectionPool, [
|
||||||
|
{{:_, :"$1", {:"$2", :_}}, [{:==, :"$2", conn}], [:"$1"]}
|
||||||
|
])
|
||||||
|
|
||||||
|
worker
|
||||||
|
end
|
||||||
|
|
||||||
|
defp eventually(predicate, attempts \\ 50)
|
||||||
|
|
||||||
|
defp eventually(_predicate, 0), do: false
|
||||||
|
|
||||||
|
defp eventually(predicate, attempts) do
|
||||||
|
if predicate.() do
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Process.sleep(10)
|
||||||
|
eventually(predicate, attempts - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue