diff --git a/.dialyzer_ignore b/.dialyzer_ignore index 646c68fe..f0d1175e 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,4 +1,4 @@ -lib/mint/tunnel_proxy.ex:49 +lib/mint/tunnel_proxy.ex:50 lib/mint/http1.ex:999 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 diff --git a/docker-compose.yml b/docker-compose.yml index 5f55e0d8..65235d3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,13 @@ services: ports: - "${TINYPROXY_AUTH_PORT:-8889}:8888" + proxy-https: + build: ./test/docker/squid-https + ports: + # Bound to loopback: this is an unauthenticated open proxy, it must not + # be reachable from other hosts while the test suite runs. + - "127.0.0.1:${SQUID_HTTPS_PORT:-8890}:3129" + httpbin: image: docker.io/kennethreitz/httpbin:latest platform: linux/amd64 diff --git a/lib/mint/core/transport/ssl.ex b/lib/mint/core/transport/ssl.ex index d4cbf1b1..f004d7b5 100644 --- a/lib/mint/core/transport/ssl.ex +++ b/lib/mint/core/transport/ssl.ex @@ -293,6 +293,8 @@ defmodule Mint.Core.Transport.SSL do @default_versions [:"tlsv1.3", :"tlsv1.2"] @default_timeout 30_000 + @tunnel_cb_info {Mint.Core.Transport.SSL.Tunnel, :ssl, :ssl_closed, :ssl_error, :ssl_passive} + Record.defrecordp( :certificate, :Certificate, @@ -347,6 +349,10 @@ defmodule Mint.Core.Transport.SSL do end @impl true + # :ssl.connect/3's spec doesn't cover upgrading an :ssl socket, but the + # function supports any socket type that the :cb_info callback module can + # handle. + @dialyzer {:nowarn_function, upgrade: 5} def upgrade(socket, :http, hostname, _port, opts) do hostname = String.to_charlist(hostname) timeout = Keyword.get(opts, :timeout, @default_timeout) @@ -357,8 +363,21 @@ defmodule Mint.Core.Transport.SSL do wrap_err(:ssl.connect(socket, ssl_opts(hostname, opts), timeout)) end - def upgrade(_socket, :https, _hostname, _port, _opts) do - raise "nested SSL sessions are not supported" + def upgrade(socket, :https, hostname, _port, opts) do + hostname = String.to_charlist(hostname) + timeout = Keyword.get(opts, :timeout, @default_timeout) + + with :ok <- setopts(socket, active: false) do + # The TLS session to the host is nested inside the TLS session to the + # proxy: the established :ssl socket acts as the transport for the new + # connection, so :ssl needs a callback module that speaks :ssl instead + # of :gen_tcp. Forced with Keyword.put/3 so that user-provided transport + # options cannot override it. + opts = Keyword.delete(opts, :hostname) + opts = Keyword.put(ssl_opts(hostname, opts), :cb_info, @tunnel_cb_info) + + wrap_err(:ssl.connect(socket, opts, timeout)) + end end @impl true diff --git a/lib/mint/core/transport/ssl/tunnel.ex b/lib/mint/core/transport/ssl/tunnel.ex new file mode 100644 index 00000000..32196197 --- /dev/null +++ b/lib/mint/core/transport/ssl/tunnel.ex @@ -0,0 +1,28 @@ +defmodule Mint.Core.Transport.SSL.Tunnel do + @moduledoc false + + # The transport callback module given to `:ssl.connect/3` through the + # `:cb_info` option when nesting a TLS session inside an established + # `:ssl` socket. `:ssl` requires the callback module to behave like + # `:gen_tcp`, with functions corresponding to `:inet.setopts/2`, + # `:inet.getopts/2`, `:inet.peername/1`, `:inet.sockname/1`, and + # `:inet.port/1`. + + defdelegate setopts(socket, opts), to: :ssl + defdelegate getopts(socket, opts), to: :ssl + defdelegate send(socket, data), to: :ssl + defdelegate recv(socket, length), to: :ssl + defdelegate recv(socket, length, timeout), to: :ssl + defdelegate controlling_process(socket, pid), to: :ssl + defdelegate close(socket), to: :ssl + defdelegate shutdown(socket, how), to: :ssl + defdelegate getstat(socket, options), to: :ssl + defdelegate peername(socket), to: :ssl + defdelegate sockname(socket), to: :ssl + + def port(socket) do + with {:ok, {_address, port}} <- :ssl.sockname(socket) do + {:ok, port} + end + end +end diff --git a/lib/mint/http.ex b/lib/mint/http.ex index e0bbafeb..d86d2bed 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -230,9 +230,6 @@ defmodule Mint.HTTP do **bytes**, of an HTTP/1 response header section or chunked trailer section. Defaults to 256 KiB. This option is only used for HTTP/1 connections. *Available since 1.9.2*. - The following options are HTTP/1-specific and will force the connection - to be an HTTP/1 connection. - * `:proxy` - a `{scheme, address, port, opts}` tuple that identifies a proxy to connect to. See the "Proxying" section below for more information. @@ -278,15 +275,17 @@ defmodule Mint.HTTP do You can set up proxying through the `:proxy` option, which is a tuple `{scheme, address, port, opts}` that identifies the proxy to connect to. Once a proxied connection is returned, the proxy is transparent to you and you - can use the connection like a normal HTTP/1 connection. + can use the connection like a normal connection. - If the `scheme` is `:http`, we will connect to the host in the most compatible - way, supporting older proxy servers. Data will be sent in clear text. + If the connection scheme is `:http`, requests are forwarded through the proxy + in the most compatible way, supporting older proxy servers. Data will be sent + in clear text. - If the connection scheme is `:https`, we will connect to the host with a tunnel - through the proxy. Using `:https` for both the proxy and the connection scheme - is not supported, it is recommended to use `:https` for the end host connection - instead of the proxy. + If the connection scheme is `:https`, a tunnel through the proxy is established + with the `CONNECT` method. If the proxy `scheme` is `:http`, the `CONNECT` + request is sent in clear text; if it is `:https`, the connection to the proxy + uses TLS and the TLS session to the host is nested inside it. *HTTPS proxies + for HTTPS connections are available since v1.10.0*. ## Transport options @@ -415,6 +414,11 @@ defmodule Mint.HTTP do proxy = {:http, "myproxy.example.com", 80, []} {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, proxy: proxy) + Using an HTTPS proxy: + + proxy = {:https, "myproxy.example.com", 443, []} + {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, proxy: proxy) + Forcing the connection to be an HTTP/2 connection: {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, protocols: [:http2]) diff --git a/lib/mint/negotiate.ex b/lib/mint/negotiate.ex index a059b04d..092d559d 100644 --- a/lib/mint/negotiate.ex +++ b/lib/mint/negotiate.ex @@ -117,6 +117,9 @@ defmodule Mint.Negotiate do {:ok, transport_state} -> alpn_negotiate(new_scheme, transport_state, hostname, port, opts) + {:error, %TransportError{} = error} -> + {:error, error} + {:error, reason} -> {:error, %TransportError{reason: reason}} end diff --git a/lib/mint/tunnel_proxy.ex b/lib/mint/tunnel_proxy.ex index 7116c265..2ce502c4 100644 --- a/lib/mint/tunnel_proxy.ex +++ b/lib/mint/tunnel_proxy.ex @@ -46,8 +46,12 @@ defmodule Mint.TunnelProxy do # Note that we may leak messages if the server sent data after the CONNECT response case Negotiate.upgrade(proxy_scheme, socket, scheme, hostname, port, opts) do - {:ok, conn} -> {:ok, HTTP.put_proxy_headers(conn, proxy_headers)} - {:error, reason} -> {:error, wrap_in_proxy_error(reason)} + {:ok, conn} -> + {:ok, HTTP.put_proxy_headers(conn, proxy_headers)} + + {:error, reason} -> + _ = Mint.Core.Util.scheme_to_transport(proxy_scheme).close(socket) + {:error, wrap_in_proxy_error(reason)} end end diff --git a/test/docker/squid-https/Dockerfile b/test/docker/squid-https/Dockerfile new file mode 100644 index 00000000..942b9877 --- /dev/null +++ b/test/docker/squid-https/Dockerfile @@ -0,0 +1,16 @@ +FROM ubuntu:24.04 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends squid-openssl openssl && \ + rm -rf /var/lib/apt/lists/* + +# Self-signed certificate for the TLS listener. Tests connect to the proxy +# with verify: :verify_none. +RUN openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout /etc/squid/key.pem -out /etc/squid/cert.pem -subj "/CN=localhost" + +COPY squid.conf /etc/squid/squid.conf + +EXPOSE 3129 + +CMD ["squid", "-N", "-d", "1"] diff --git a/test/docker/squid-https/squid.conf b/test/docker/squid-https/squid.conf new file mode 100644 index 00000000..b635b40b --- /dev/null +++ b/test/docker/squid-https/squid.conf @@ -0,0 +1,3 @@ +visible_hostname squid-https +https_port 3129 tls-cert=/etc/squid/cert.pem tls-key=/etc/squid/key.pem +http_access allow all diff --git a/test/mint/core/transport/ssl_test.exs b/test/mint/core/transport/ssl_test.exs index 6da52f69..82ee7abc 100644 --- a/test/mint/core/transport/ssl_test.exs +++ b/test/mint/core/transport/ssl_test.exs @@ -384,14 +384,6 @@ defmodule Mint.Core.Transport.SSLTest do end end - describe "upgrade/4" do - test "raises an error if the scheme is :https" do - assert_raise RuntimeError, "nested SSL sessions are not supported", fn -> - SSL.upgrade(_fake_socket = nil, :https, ~c"localhost", _port = 0, _timeout = 5000) - end - end - end - defp cn_cert(_context) do [cert: load_cert(@cn_cert)] end diff --git a/test/mint/https_proxy_test.exs b/test/mint/https_proxy_test.exs new file mode 100644 index 00000000..8695597c --- /dev/null +++ b/test/mint/https_proxy_test.exs @@ -0,0 +1,291 @@ +defmodule Mint.HTTPSProxyTest do + use ExUnit.Case, async: true + + import Mint.HTTP1.TestHelpers + import Mint.HTTP2.Frame, only: [settings: 1, ping: 1] + + alias Mint.{HTTP, HTTP2, TransportError} + alias Mint.HTTP2.Frame + alias Mint.HTTP2.TestServer + alias Mint.TunnelProxyServer + + @cert_opts [ + certfile: Path.absname("../support/mint/certificate.pem", __DIR__), + keyfile: Path.absname("../support/mint/key.pem", __DIR__) + ] + + describe "HTTPS proxies for HTTPS connections" do + test "HTTP/1 request through the tunnel" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, origin_ref} = start_http1_origin() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none] + ) + + assert HTTP.protocol(conn) == :http1 + + assert_receive {^proxy_ref, :connect, head}, 1000 + assert head =~ "CONNECT localhost:#{origin_port} HTTP/1.1\r\n" + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert_receive {^origin_ref, :origin_request, origin_request}, 1000 + assert origin_request =~ "GET / HTTP/1.1\r\n" + + assert {:ok, _conn, responses} = receive_stream(conn) + assert [{:status, ^request, 200}, {:headers, ^request, _headers} | rest] = responses + assert merge_body(rest, request) == "hello" + end + + test "HTTP/1 forced through the :protocols option" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, origin_ref} = start_http1_origin() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none], + protocols: [:http1] + ) + + assert HTTP.protocol(conn) == :http1 + assert_receive {^proxy_ref, :connect, _head}, 1000 + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert_receive {^origin_ref, :origin_request, _origin_request}, 1000 + + assert {:ok, _conn, responses} = receive_stream(conn) + assert [{:status, ^request, 200}, {:headers, ^request, _headers} | rest] = responses + assert merge_body(rest, request) == "hello" + end + + test "HTTP/2 negotiated through ALPN in the tunnel" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, server_socket_task} = TestServer.listen_and_accept() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none] + ) + + assert HTTP.protocol(conn) == :http2 + assert_receive {^proxy_ref, :connect, _head}, 1000 + + {conn, server_socket} = exchange_server_settings(conn, server_socket_task) + + assert {:ok, conn, ping_ref} = HTTP2.ping(conn) + server = TestServer.new(server_socket) + assert [ping(opaque_data: opaque_data)] = TestServer.recv_next_frames(server, 1) + + ping_ack_flags = Frame.set_flags(:ping, [:ack]) + ping_ack = ping(flags: ping_ack_flags, opaque_data: opaque_data) + :ok = :ssl.send(server_socket, Frame.encode(ping_ack)) + + client_socket = HTTP.get_socket(conn) + assert_receive {:ssl, ^client_socket, _data} = message, 1000 + assert {:ok, _conn, [{:pong, ^ping_ref}]} = HTTP.stream(conn, message) + end + + test "HTTP/2 forced through the :protocols option" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, server_socket_task} = TestServer.listen_and_accept() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none], + protocols: [:http2] + ) + + assert HTTP.protocol(conn) == :http2 + assert_receive {^proxy_ref, :connect, _head}, 1000 + + {_conn, _server_socket} = exchange_server_settings(conn, server_socket_task) + end + + test "verifies the host certificate through the tunnel" do + %{server_config: server_config, client_config: client_config} = pkix_test_chain() + + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start(server_config) + {:ok, origin_port, _origin_ref} = start_http1_origin(server_config) + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, + [transport_opts: [cacerts: client_config[:cacerts]]]}, + transport_opts: [cacerts: client_config[:cacerts]] + ) + + assert_receive {^proxy_ref, :connect, _head}, 1000 + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert {:ok, _conn, responses} = receive_stream(conn) + assert [{:status, ^request, 200}, {:headers, ^request, _headers} | rest] = responses + assert merge_body(rest, request) == "hello" + end + + @tag :capture_log + test "fails when the host certificate is not trusted" do + %{server_config: server_config, client_config: client_config} = pkix_test_chain() + + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start(server_config) + {:ok, origin_port, _origin_ref} = start_http1_origin() + + assert {:error, %TransportError{reason: reason}} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, + [transport_opts: [cacerts: client_config[:cacerts]]]}, + transport_opts: [cacerts: client_config[:cacerts]] + ) + + assert {:tls_alert, _alert} = reason + + # The tunnel to the proxy must be torn down when the nested handshake fails. + assert_receive {^proxy_ref, :connect, _head}, 1000 + assert_receive {^proxy_ref, :closed}, 1000 + end + + @tag :capture_log + test "returns an error when the TLS handshake inside the tunnel fails" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port} = start_garbage_origin() + + assert {:error, %TransportError{reason: reason}} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none] + ) + + refute match?(%TransportError{}, reason) + + # The tunnel to the proxy must be torn down when the nested handshake fails. + assert_receive {^proxy_ref, :connect, _head}, 1000 + assert_receive {^proxy_ref, :closed}, 1000 + end + + test ":cb_info in transport options cannot override the tunnel transport" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, _origin_ref} = start_http1_origin() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [ + verify: :verify_none, + cb_info: {:gen_tcp, :tcp, :tcp_closed, :tcp_error} + ] + ) + + assert HTTP.open?(conn) + assert_receive {^proxy_ref, :connect, _head}, 1000 + end + end + + # Completes the connection setup like a real server would: receives the + # client preface, sends the server SETTINGS and the ack of the client + # SETTINGS, and waits for the client to ack the server SETTINGS. + defp exchange_server_settings(conn, server_socket_task) do + settings_ack_flags = Frame.set_flags(:settings, [:ack]) + + {:ok, server_socket} = Task.await(server_socket_task) + assert :ok = TestServer.perform_http2_handshake(server_socket) + + :ok = + :ssl.send(server_socket, [ + Frame.encode(settings(params: [])), + Frame.encode(settings(flags: settings_ack_flags, params: [])) + ]) + + client_socket = HTTP.get_socket(conn) + assert_receive {:ssl, ^client_socket, _data} = message, 1000 + assert {:ok, conn, []} = HTTP.stream(conn, message) + + {:ok, data} = :ssl.recv(server_socket, 0, 1000) + assert {:ok, frame, ""} = Frame.decode_next(data) + assert settings(flags: ^settings_ack_flags, params: []) = frame + + :ok = :ssl.setopts(server_socket, active: true) + + {conn, server_socket} + end + + defp start_http1_origin(cert_opts \\ @cert_opts) do + socket_opts = [mode: :binary, packet: :raw, active: false, reuseaddr: true] + {:ok, listen_socket} = :ssl.listen(0, socket_opts ++ cert_opts) + {:ok, {_address, port}} = :ssl.sockname(listen_socket) + parent = self() + ref = make_ref() + + spawn_link(fn -> + {:ok, socket} = :ssl.transport_accept(listen_socket) + + case :ssl.handshake(socket, 10_000) do + {:ok, socket} -> + request = recv_until_blank_line(socket, "") + send(parent, {ref, :origin_request, request}) + :ok = :ssl.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello") + + receive do + :stop -> :ok + end + + {:error, _reason} -> + :ok + end + end) + + {:ok, port, ref} + end + + defp start_garbage_origin do + {:ok, listen_socket} = :gen_tcp.listen(0, mode: :binary, active: false) + {:ok, port} = :inet.port(listen_socket) + + spawn_link(fn -> + {:ok, socket} = :gen_tcp.accept(listen_socket) + {:ok, _client_hello} = :gen_tcp.recv(socket, 0, 10_000) + :ok = :gen_tcp.send(socket, "HTTP/1.1 502 Bad Gateway\r\n\r\n") + :gen_tcp.close(socket) + end) + + {:ok, port} + end + + defp recv_until_blank_line(socket, buffer) do + if String.contains?(buffer, "\r\n\r\n") do + buffer + else + {:ok, data} = :ssl.recv(socket, 0, 10_000) + recv_until_blank_line(socket, buffer <> data) + end + end + + defp pkix_test_chain do + san_extension = {:Extension, {2, 5, 29, 17}, false, [dNSName: ~c"localhost"]} + cert_opts = [digest: :sha256, key: {:rsa, 2048, 17}] + + :public_key.pkix_test_data(%{ + server_chain: %{ + root: cert_opts, + intermediates: [], + peer: cert_opts ++ [extensions: [san_extension]] + }, + client_chain: %{ + root: cert_opts, + intermediates: [], + peer: cert_opts + } + }) + end +end diff --git a/test/mint/tunnel_proxy_test.exs b/test/mint/tunnel_proxy_test.exs index 2d7b9864..df24bfa0 100644 --- a/test/mint/tunnel_proxy_test.exs +++ b/test/mint/tunnel_proxy_test.exs @@ -114,18 +114,36 @@ defmodule Mint.TunnelProxyTest do assert merge_body(responses, request) =~ "mint/" end - test "200 response without explicit http2 - https://httpbin.org" do + test "200 response through an HTTPS proxy - https://httpbin.org" do assert {:ok, conn} = - Mint.TunnelProxy.connect( - {:http, "localhost", HttpBin.proxy_port(), []}, - {:https, HttpBin.proxy_host(), HttpBin.https_port(), - [protocols: [:http1, :http2], transport_opts: HttpBin.https_transport_opts()]} + Mint.HTTP.connect(:https, HttpBin.proxy_host(), HttpBin.https_port(), + proxy: + {:https, "localhost", HttpBin.https_proxy_port(), + [transport_opts: [verify: :verify_none]]}, + transport_opts: HttpBin.https_transport_opts() ) - assert conn.__struct__ == Mint.HTTP2 + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert {:ok, _conn, responses} = receive_stream(conn) - assert [{"proxy-agent", <<"tinyproxy/", _version::binary>>}] = - Mint.HTTP.get_proxy_headers(conn) + assert [status, headers | responses] = responses + assert {:status, ^request, 200} = status + assert {:headers, ^request, headers} = headers + assert is_list(headers) + assert merge_body(responses, request) =~ "httpbin" + end + + test "200 response through an HTTPS proxy with explicit http2 - https://httpbin.org" do + assert {:ok, conn} = + Mint.HTTP.connect(:https, HttpBin.proxy_host(), HttpBin.https_port(), + proxy: + {:https, "localhost", HttpBin.https_proxy_port(), + [transport_opts: [verify: :verify_none]]}, + transport_opts: HttpBin.https_transport_opts(), + protocols: [:http2] + ) + + assert conn.__struct__ == Mint.HTTP2 assert {:ok, conn, request} = HTTP.request(conn, "GET", "/user-agent", [], nil) assert {:ok, _conn, responses} = receive_stream(conn) @@ -137,27 +155,26 @@ defmodule Mint.TunnelProxyTest do assert merge_body(responses, request) =~ "mint/" end - @tag :skip - test "do not support nested HTTPS connections - https://httpbin.org" do + test "200 response without explicit http2 - https://httpbin.org" do assert {:ok, conn} = Mint.TunnelProxy.connect( - {:https, "localhost", HttpBin.proxy_port(), []}, + {:http, "localhost", HttpBin.proxy_port(), []}, {:https, HttpBin.proxy_host(), HttpBin.https_port(), - [transport_opts: HttpBin.https_transport_opts()]} + [protocols: [:http1, :http2], transport_opts: HttpBin.https_transport_opts()]} ) - assert conn.__struct__ == Mint.HTTP1 + assert conn.__struct__ == Mint.HTTP2 assert [{"proxy-agent", <<"tinyproxy/", _version::binary>>}] = Mint.HTTP.get_proxy_headers(conn) - assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/user-agent", [], nil) assert {:ok, _conn, responses} = receive_stream(conn) assert [status, headers | responses] = responses assert {:status, ^request, 200} = status assert {:headers, ^request, headers} = headers assert is_list(headers) - assert merge_body(responses, request) =~ "httpbin" + assert merge_body(responses, request) =~ "mint/" end end diff --git a/test/support/mint/http_bin.ex b/test/support/mint/http_bin.ex index 0a0c19d3..93962f1b 100644 --- a/test/support/mint/http_bin.ex +++ b/test/support/mint/http_bin.ex @@ -29,6 +29,10 @@ defmodule Mint.HttpBin do get_env_port("TINYPROXY_AUTH_PORT", 8889) end + def https_proxy_port() do + get_env_port("SQUID_HTTPS_PORT", 8890) + end + def https_transport_opts() do [cacertfile: "caddy_storage/pki/authorities/local/root.crt"] end diff --git a/test/support/mint/tunnel_proxy_server.ex b/test/support/mint/tunnel_proxy_server.ex new file mode 100644 index 00000000..3bd5a1c3 --- /dev/null +++ b/test/support/mint/tunnel_proxy_server.ex @@ -0,0 +1,107 @@ +defmodule Mint.TunnelProxyServer do + # A minimal HTTPS CONNECT proxy: it terminates TLS, accepts a CONNECT + # request, connects to the requested host, and then blindly relays bytes + # in both directions. The head of each CONNECT request is sent to the + # process that started the server as {server_ref, :connect, head}. + + @socket_opts [ + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true + ] + + @cert_opts [ + certfile: Path.absname("certificate.pem", __DIR__), + keyfile: Path.absname("key.pem", __DIR__) + ] + + def start(cert_opts \\ @cert_opts) do + {:ok, listen_socket} = :ssl.listen(0, @socket_opts ++ cert_opts) + {:ok, {_address, port}} = :ssl.sockname(listen_socket) + server_ref = make_ref() + parent = self() + + spawn_link(fn -> loop(listen_socket, parent, server_ref) end) + + {:ok, port, server_ref} + end + + defp loop(listen_socket, parent, server_ref) do + case :ssl.transport_accept(listen_socket) do + {:ok, socket} -> + case :ssl.handshake(socket, 10_000) do + {:ok, socket} -> + handler = spawn_link(fn -> handle_connection(parent, server_ref) end) + :ok = :ssl.controlling_process(socket, handler) + send(handler, {:socket, socket}) + + {:error, _reason} -> + :ok + end + + loop(listen_socket, parent, server_ref) + + {:error, :closed} -> + :ok + end + end + + defp handle_connection(parent, server_ref) do + socket = + receive do + {:socket, socket} -> socket + end + + head = recv_until_blank_line(socket, "") + send(parent, {server_ref, :connect, head}) + + [request_line, _rest] = String.split(head, "\r\n", parts: 2) + ["CONNECT", authority, _version] = String.split(request_line, " ") + [host, port] = String.split(authority, ":") + + {:ok, tcp_socket} = + :gen_tcp.connect(String.to_charlist(host), String.to_integer(port), + mode: :binary, + active: true + ) + + :ok = :ssl.send(socket, "HTTP/1.1 200 OK\r\n\r\n") + :ok = :ssl.setopts(socket, active: true) + relay(socket, tcp_socket) + send(parent, {server_ref, :closed}) + end + + defp recv_until_blank_line(socket, buffer) do + if String.contains?(buffer, "\r\n\r\n") do + buffer + else + {:ok, data} = :ssl.recv(socket, 0, 10_000) + recv_until_blank_line(socket, buffer <> data) + end + end + + defp relay(ssl_socket, tcp_socket) do + receive do + {:ssl, ^ssl_socket, data} -> + _ = :gen_tcp.send(tcp_socket, data) + relay(ssl_socket, tcp_socket) + + {:tcp, ^tcp_socket, data} -> + _ = :ssl.send(ssl_socket, data) + relay(ssl_socket, tcp_socket) + + {:ssl_closed, ^ssl_socket} -> + :gen_tcp.close(tcp_socket) + + {:tcp_closed, ^tcp_socket} -> + :ssl.close(ssl_socket) + + {:ssl_error, ^ssl_socket, _reason} -> + :gen_tcp.close(tcp_socket) + + {:tcp_error, ^tcp_socket, _reason} -> + :ssl.close(ssl_socket) + end + end +end