feat(http): rework Gun connection pooling
This commit is contained in:
parent
b0ae451942
commit
f11ffca7f5
22 changed files with 2133 additions and 192 deletions
144
test/pleroma/gun/conn_test.exs
Normal file
144
test/pleroma/gun/conn_test.exs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# 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 "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 "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
|
||||
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(: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)
|
||||
|
||||
:ok
|
||||
|
|
@ -20,40 +23,213 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
|
|||
|
||||
setup :gun_mock
|
||||
|
||||
test "gives the same connection to 2 concurrent requests" do
|
||||
Enum.map(
|
||||
[
|
||||
"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()
|
||||
test "opens sibling HTTP/1 connections while an existing connection is in use" do
|
||||
uri = URI.parse("http://www.korean-books.com.kp/document.pdf")
|
||||
|
||||
Task.start_link(fn ->
|
||||
{:ok, conn} = ConnectionPool.get_conn(uri, [])
|
||||
ConnectionPool.release_conn(conn)
|
||||
send(task_parent, conn)
|
||||
end)
|
||||
end
|
||||
)
|
||||
assert {:ok, first} = ConnectionPool.get_conn(uri, [])
|
||||
assert {:ok, second} = ConnectionPool.get_conn(uri, [])
|
||||
assert first != second
|
||||
|
||||
[pid, pid] =
|
||||
for _ <- 1..2 do
|
||||
receive do
|
||||
pid -> pid
|
||||
end
|
||||
assert :ok = ConnectionPool.release_conn(first)
|
||||
assert :ok = ConnectionPool.release_conn(second)
|
||||
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"}
|
||||
)
|
||||
|
||||
assert {:ok, other_credentials} =
|
||||
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
|
||||
:continue -> {:ok, :http2}
|
||||
end
|
||||
end)
|
||||
|
||||
uri = URI.parse("https://cold-h2.example/inbox")
|
||||
|
||||
tasks =
|
||||
for _ <- 1..10 do
|
||||
Task.async(fn -> ConnectionPool.get_conn(uri, []) 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
|
||||
|
||||
test "connection limit is respected with concurrent requests" do
|
||||
clear_config([:connections_pool, :max_connections]) do
|
||||
clear_config([:connections_pool, :max_connections], 1)
|
||||
# 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 ->
|
||||
Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill)
|
||||
restart_worker_supervisor()
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
@ -96,4 +272,41 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
|
|||
|> Enum.sort()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -73,5 +73,11 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do
|
|||
|
||||
assert opts[:proxy] == {~c"example.com", 4321}
|
||||
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
|
||||
|
|
|
|||
671
test/pleroma/http/gun_integration_test.exs
Normal file
671
test/pleroma/http/gun_integration_test.exs
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
# 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())
|
||||
socks_proxy = start_socks5_proxy(self())
|
||||
|
||||
on_exit(fn ->
|
||||
Process.sleep(20)
|
||||
stop_server(socks_proxy)
|
||||
stop_server(connect_proxy)
|
||||
stop_server(origin)
|
||||
end)
|
||||
|
||||
{:ok, origin: origin, connect_proxy: connect_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 "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 "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
|
||||
|
||||
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_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),
|
||||
true <- :binary.match(methods, <<2>>) != :nomatch,
|
||||
:ok <- :gen_tcp.send(socket, <<5, 2>>),
|
||||
{:ok, username, password} <- recv_socks5_auth(socket),
|
||||
:ok <- :gen_tcp.send(socket, <<1, 0>>),
|
||||
{: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 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
|
||||
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).protocol
|
||||
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
|
||||
test "length returns error if content-length more than option", %{conn: conn} do
|
||||
request_mock(0)
|
||||
expect(ClientMock, :close, fn _ -> :ok end)
|
||||
|
||||
assert capture_log(fn ->
|
||||
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
|
||||
|
|
@ -128,14 +129,31 @@ defmodule Pleroma.ReverseProxyTest do
|
|||
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
|
||||
stream_mock(3, true)
|
||||
|
||||
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) =~
|
||||
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
|
||||
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
|
||||
|
||||
describe "HEAD requests" do
|
||||
|
|
@ -200,7 +218,10 @@ defmodule Pleroma.ReverseProxyTest do
|
|||
|
||||
test "204", %{conn: conn} do
|
||||
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 ->
|
||||
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