This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

View File

@@ -0,0 +1,436 @@
defmodule Bandit do
@external_resource Path.join([__DIR__, "../README.md"])
@moduledoc """
Bandit is an HTTP server for Plug and WebSock apps.
As an HTTP server, Bandit's primary goal is to act as 'glue' between client connections managed
by [Thousand Island](https://github.com/mtrudel/thousand_island) and application code defined
via the [Plug](https://github.com/elixir-plug/plug) and/or
[WebSock](https://github.com/phoenixframework/websock) APIs. As such there really isn't a whole lot of
user-visible surface area to Bandit, and as a consequence the API documentation presented here
is somewhat sparse. This is by design! Bandit is intended to 'just work' in almost all cases;
the only thought users typically have to put into Bandit comes in the choice of which options (if
any) they would like to change when starting a Bandit server. The sparseness of the Bandit API
should not be taken as an indicator of the comprehensiveness or robustness of the project.
#{@external_resource |> File.read!() |> String.split("<!-- MDOC -->") |> Enum.fetch!(1)}
"""
@typedoc """
Possible top-level options to configure a Bandit server
* `plug`: The Plug to use to handle connections. Can be specified as `MyPlug` or `{MyPlug, plug_opts}`
* `scheme`: One of `:http` or `:https`. If `:https` is specified, you will also need to specify
valid `certfile` and `keyfile` values (or an equivalent value within
`thousand_island_options.transport_options`). Defaults to `:http`
* `port`: The TCP port to listen on. This option is offered as a convenience and actually sets
the option of the same name within `thousand_island_options`. If a string value is passed, it
will be parsed as an integer. Defaults to 4000 if `scheme` is `:http`, and 4040 if `scheme` is
`:https`
* `ip`: The interface(s) to listen on. This option is offered as a convenience and actually sets the
option of the same name within `thousand_island_options.transport_options`. Can be specified as:
* `{1, 2, 3, 4}` for IPv4 addresses
* `{1, 2, 3, 4, 5, 6, 7, 8}` for IPv6 addresses
* `:loopback` for local loopback (ie: `127.0.0.1`)
* `:any` for all interfaces (ie: `0.0.0.0`)
* `{:local, "/path/to/socket"}` for a Unix domain socket. If this option is used, the `port`
option *must* be set to `0`
* `inet`: Only bind to IPv4 interfaces. This option is offered as a convenience and actually sets the
option of the same name within `thousand_island_options.transport_options`. Must be specified
as a bare atom `:inet`
* `inet6`: Only bind to IPv6 interfaces. This option is offered as a convenience and actually sets the
option of the same name within `thousand_island_options.transport_options`. Must be specified
as a bare atom `:inet6`
* `keyfile`: The path to a file containing the SSL key to use for this server. This option is
offered as a convenience and actually sets the option of the same name within
`thousand_island_options.transport_options`. If a relative path is used here, you will also
need to set the `otp_app` parameter and ensure that the named file is part of your application
build
* `certfile`: The path to a file containing the SSL certificate to use for this server. This option is
offered as a convenience and actually sets the option of the same name within
`thousand_island_options.transport_options`. If a relative path is used here, you will also
need to set the `otp_app` parameter and ensure that the named file is part of your application
build
* `otp_app`: Provided as a convenience when using relative paths for `keyfile` and `certfile`
* `cipher_suite`: Used to define a pre-selected set of ciphers, as described by
`Plug.SSL.configure/1`. Optional, can be either `:strong` or `:compatible`
* `display_plug`: The plug to use when describing the connection in logs. Useful for situations
such as Phoenix code reloading where you have a 'wrapper' plug but wish to refer to the
connection by the endpoint name
* `startup_log`: The log level at which Bandit should log startup info.
Defaults to `:info` log level, can be set to false to disable it
* `thousand_island_options`: A list of options to pass to Thousand Island. Bandit sets some
default values in this list based on your top-level configuration; these values will be
overridden by values appearing here. A complete list can be found at
`t:ThousandIsland.options/0`
* `http_options`: A list of options to configure the shared aspects of Bandit's HTTP stack. A
complete list can be found at `t:http_options/0`
* `http_1_options`: A list of options to configure Bandit's HTTP/1 stack. A complete list can
be found at `t:http_1_options/0`
* `http_2_options`: A list of options to configure Bandit's HTTP/2 stack. A complete list can
be found at `t:http_2_options/0`
* `websocket_options`: A list of options to configure Bandit's WebSocket stack. A complete list can
be found at `t:websocket_options/0`
"""
@type options :: [
{:plug, module() | {module(), Plug.opts()}}
| {:scheme, :http | :https}
| {:port, :inet.port_number()}
| {:ip, :inet.socket_address()}
| :inet
| :inet6
| {:keyfile, binary()}
| {:certfile, binary()}
| {:otp_app, Application.app()}
| {:cipher_suite, :strong | :compatible}
| {:display_plug, module()}
| {:startup_log, Logger.level() | false}
| {:thousand_island_options, ThousandIsland.options()}
| {:http_options, http_options()}
| {:http_1_options, http_1_options()}
| {:http_2_options, http_2_options()}
| {:websocket_options, websocket_options()}
]
@typedoc """
Options to configure shared aspects of the HTTP stack in Bandit
* `compress`: Whether or not to attempt compression of responses via content-encoding
negotiation as described in
[RFC9110§8.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4). Defaults to true
* `response_encodings`: A list of compression encodings, expressed in order of preference.
Defaults to `~w(zstd gzip x-gzip deflate)`, with `zstd` only being present on platforms which
have the zstd library compiled in
* `deflate_options`: A keyword list of options to set on the deflate library. A complete list can
be found at `t:deflate_options/0`. Note that these options only affect the behaviour of the
'deflate' content encoding; 'gzip' does not have any configurable options (this is a
limitation of the underlying `:zlib` library)
* `zstd_options`: A map of options passed verbatim to :zstd, review the options [here](https://www.erlang.org/doc/apps/stdlib/zstd.html#t:compress_parameters/0)
* `log_exceptions_with_status_codes`: Which exceptions to log. Bandit will log only those
exceptions whose status codes (as determined by `Plug.Exception.status/1`) match the specified
list or range. Defaults to `500..599`
* `log_protocol_errors`: How to log protocol errors such as malformed requests. `:short` will
log a single-line summary, while `:verbose` will log full stack traces. The value of `false`
will disable protocol error logging entirely. Defaults to `:short`
* `log_client_closures`: How to log cases where the client closes the connection. These happen
routinely in the real world and so the handling of them is configured separately since they
can be quite noisy. Takes the same options as `log_protocol_errors`, but defaults to `false`
"""
@type http_options :: [
{:compress, boolean()}
| {:response_encodings, list()}
| {:deflate_options, deflate_options()}
| {:zstd_options, zstd_options()}
| {:log_exceptions_with_status_codes, list() | Range.t()}
| {:log_protocol_errors, :short | :verbose | false}
| {:log_client_closures, :short | :verbose | false}
]
@typedoc """
Options to configure the HTTP/1 stack in Bandit
* `enabled`: Whether or not to serve HTTP/1 requests. Defaults to true
* `max_request_line_length`: The maximum permitted length of the request line
(expressed as the number of bytes on the wire) in an HTTP/1.1 request. Defaults to 10_000 bytes
* `max_header_length`: The maximum permitted length of any single header (combined
key & value, expressed as the number of bytes on the wire) in an HTTP/1.1 request. Defaults to 10_000 bytes
* `max_header_count`: The maximum permitted number of headers in an HTTP/1.1 request.
Defaults to 50 headers
* `max_requests`: The maximum number of requests to serve in a single
HTTP/1.1 connection before closing the connection. Defaults to 0 (no limit)
* `clear_process_dict`: Whether to clear the process dictionary of all non-internal entries
between subsequent keepalive requests. If set, all keys not starting with `$` are removed from
the process dictionary between requests. Defaults to `true`
* `gc_every_n_keepalive_requests`: How often to run a full garbage collection pass between subsequent
keepalive requests on the same HTTP/1.1 connection. Defaults to 5 (garbage collect between
every 5 requests). This option is currently experimental, and may change at any time
* `log_unknown_messages`: Whether or not to log unknown messages sent to the handler process.
Defaults to `false`
"""
@type http_1_options :: [
{:enabled, boolean()}
| {:max_request_line_length, pos_integer()}
| {:max_header_length, pos_integer()}
| {:max_header_count, pos_integer()}
| {:max_requests, pos_integer()}
| {:clear_process_dict, boolean()}
| {:gc_every_n_keepalive_requests, pos_integer()}
| {:log_unknown_messages, boolean()}
]
@typedoc """
Options to configure the HTTP/2 stack in Bandit
* `enabled`: Whether or not to serve HTTP/2 requests. Defaults to true
* `max_header_block_size`: The maximum permitted length of a field block of an HTTP/2 request
(expressed as the number of compressed bytes). Includes any concatenated block fragments from
continuation frames. Defaults to 50_000 bytes
* `max_requests`: The maximum number of requests to serve in a single
HTTP/2 connection before closing the connection. Defaults to 0 (no limit)
* `max_reset_stream_rate`: The maximum rate of stream resets (RST_STREAM frames) allowed.
Specified as a tuple of `{count, milliseconds}` where `count` is the maximum number of
RST_STREAM frames allowed within the time window of `milliseconds`. Defaults to `{500, 10_000}`
(500 resets per 10 seconds). Setting this to `nil` disables rate limiting
* `sendfile_chunk_size`: The maximum number of bytes read per sendfile chunk when streaming
HTTP/2 responses. Defaults to 1_048_576 (1 MiB)
* `default_local_settings`: Options to override the default values for local HTTP/2
settings. Values provided here will override the defaults specified in RFC9113§6.5.2
"""
@type http_2_options :: [
{:enabled, boolean()}
| {:max_header_block_size, pos_integer()}
| {:max_requests, pos_integer()}
| {:max_reset_stream_rate, {pos_integer(), pos_integer()} | nil}
| {:sendfile_chunk_size, pos_integer()}
| {:default_local_settings, keyword()}
]
@typedoc """
Options to configure the WebSocket stack in Bandit
* `enabled`: Whether or not to serve WebSocket upgrade requests. Defaults to true
* `max_frame_size`: The maximum size of a single WebSocket frame (expressed as
a number of bytes on the wire). Defaults to 0 (no limit)
* `validate_text_frames`: Whether or not to validate text frames as being UTF-8. Strictly
speaking this is required per RFC6455§5.6, however it can be an expensive operation and one
that may be safely skipped in some situations. Defaults to true
* `compress`: Whether or not to allow per-message deflate compression globally. Note that
upgrade requests still need to set the `compress: true` option in `connection_opts` on
a per-upgrade basis for compression to be negotiated (see 'WebSocket Support' section below
for details). Defaults to `true`
* `deflate_options`: A keyword list of options to set on the deflate library when using the
per-message deflate extension. A complete list can be found at `t:deflate_options/0`.
`window_bits` is currently ignored and left to negotiation.
"""
@type websocket_options :: [
{:enabled, boolean()}
| {:max_frame_size, pos_integer()}
| {:validate_text_frames, boolean()}
| {:compress, boolean()}
| {:deflate_options, deflate_options()}
]
@typedoc """
Options to configure the deflate library used for HTTP and WebSocket compression
"""
@type deflate_options :: [
{:level, :zlib.zlevel()}
| {:window_bits, :zlib.zwindowbits()}
| {:memory_level, :zlib.zmemlevel()}
| {:strategy, :zlib.zstrategy()}
]
@typedoc """
Options to configure the zstd library used for HTTP compression
"""
@type zstd_options :: map
@typep scheme :: :http | :https
require Logger
@doc false
@spec child_spec(options()) :: Supervisor.child_spec()
def child_spec(arg) do
%{
id: {__MODULE__, make_ref()},
start: {__MODULE__, :start_link, [arg]},
type: :supervisor,
restart: :permanent
}
end
@top_level_keys ~w(plug scheme port ip keyfile certfile otp_app cipher_suite display_plug startup_log thousand_island_options http_options http_1_options http_2_options websocket_options)a
@http_keys ~w(compress response_encodings deflate_options zstd_options log_exceptions_with_status_codes log_protocol_errors log_client_closures)a
@http_1_keys ~w(enabled max_request_line_length max_header_length max_header_count max_requests clear_process_dict gc_every_n_keepalive_requests log_unknown_messages)a
@http_2_keys ~w(enabled max_header_block_size max_requests max_reset_stream_rate sendfile_chunk_size default_local_settings)a
@websocket_keys ~w(enabled max_frame_size validate_text_frames compress deflate_options primitive_ops_module)a
@thousand_island_keys ThousandIsland.ServerConfig.__struct__()
|> Map.from_struct()
|> Map.keys()
@doc """
Starts a Bandit server using the provided arguments. See `t:options/0` for specific options to
pass to this function.
"""
@spec start_link(options()) :: Supervisor.on_start()
def start_link(arg) do
# Special case top-level `:inet` and `:inet6` options so we can use keyword logic everywhere else
arg = arg |> special_case_inet_options() |> validate_options(@top_level_keys, "top level")
thousand_island_options =
Keyword.get(arg, :thousand_island_options, [])
|> validate_options(@thousand_island_keys, :thousand_island_options)
http_options =
Keyword.get(arg, :http_options, [])
|> validate_options(@http_keys, :http_options)
http_1_options =
Keyword.get(arg, :http_1_options, [])
|> validate_options(@http_1_keys, :http_1_options)
http_2_options =
Keyword.get(arg, :http_2_options, [])
|> validate_options(@http_2_keys, :http_2_options)
websocket_options =
Keyword.get(arg, :websocket_options, [])
|> validate_options(@websocket_keys, :websocket_options)
{plug_mod, _} = plug = plug(arg)
display_plug = Keyword.get(arg, :display_plug, plug_mod)
startup_log = Keyword.get(arg, :startup_log, :info)
{http_1_enabled, http_1_options} = Keyword.pop(http_1_options, :enabled, true)
{http_2_enabled, http_2_options} = Keyword.pop(http_2_options, :enabled, true)
handler_options = %{
plug: plug,
handler_module: Bandit.InitialHandler,
opts: %{
http: http_options,
http_1: http_1_options,
http_2: http_2_options,
websocket: websocket_options
},
http_1_enabled: http_1_enabled,
http_2_enabled: http_2_enabled
}
scheme = Keyword.get(arg, :scheme, :http)
{transport_module, transport_options, default_port} =
case scheme do
:http ->
transport_options =
Keyword.take(arg, [:ip])
|> then(&(Keyword.get(thousand_island_options, :transport_options, []) ++ &1))
{ThousandIsland.Transports.TCP, transport_options, 4000}
:https ->
supported_protocols =
if(http_2_enabled, do: ["h2"], else: []) ++
if http_1_enabled, do: ["http/1.1"], else: []
transport_options =
Keyword.take(arg, [:ip, :keyfile, :certfile, :otp_app, :cipher_suite])
|> Keyword.merge(alpn_preferred_protocols: supported_protocols)
|> then(&(Keyword.get(thousand_island_options, :transport_options, []) ++ &1))
|> Plug.SSL.configure()
|> case do
{:ok, options} -> options
{:error, message} -> raise "Plug.SSL.configure/1 encountered error: #{message}"
end
|> Enum.reject(&(is_tuple(&1) and elem(&1, 0) == :otp_app))
{ThousandIsland.Transports.SSL, transport_options, 4040}
end
port = Keyword.get(arg, :port, default_port) |> parse_as_number()
thousand_island_options
|> Keyword.put_new(:port, port)
|> Keyword.put_new(:transport_module, transport_module)
|> Keyword.put(:transport_options, transport_options)
|> Keyword.put_new(:handler_module, Bandit.DelegatingHandler)
|> Keyword.put_new(:handler_options, handler_options)
|> ThousandIsland.start_link()
|> case do
{:ok, pid} ->
startup_log &&
Logger.log(startup_log, info(scheme, display_plug, pid), domain: [:bandit], plug: plug)
{:ok, pid}
{:error, {:shutdown, {:failed_to_start_child, :listener, :eaddrinuse}}} = error ->
Logger.error([info(scheme, display_plug, nil), " failed, port #{port} already in use"],
domain: [:bandit],
plug: plug
)
error
{:error, _} = error ->
error
end
end
@spec special_case_inet_options(options()) :: options()
defp special_case_inet_options(opts) do
{inet_opts, opts} = Enum.split_with(opts, &(&1 in [:inet, :inet6]))
if inet_opts == [] do
opts
else
Keyword.update(
opts,
:thousand_island_options,
[transport_options: inet_opts],
fn thousand_island_opts ->
Keyword.update(thousand_island_opts, :transport_options, inet_opts, &(&1 ++ inet_opts))
end
)
end
end
@spec validate_options(Keyword.t(), [atom(), ...], String.t() | atom()) ::
Keyword.t() | no_return()
defp validate_options(options, valid_values, name) do
case Keyword.split(options, valid_values) do
{options, []} ->
options
{_, illegal_options} ->
raise "Unsupported key(s) in #{name} config: #{inspect(Keyword.keys(illegal_options))}"
end
end
@spec plug(options()) :: {module(), Plug.opts()}
defp plug(arg) do
arg
|> Keyword.get(:plug)
|> case do
nil -> raise "A value is required for :plug"
{plug_fn, plug_options} when is_function(plug_fn, 2) -> {plug_fn, plug_options}
plug_fn when is_function(plug_fn) -> {plug_fn, []}
{plug, plug_options} when is_atom(plug) -> validate_plug(plug, plug_options)
plug when is_atom(plug) -> validate_plug(plug, [])
other -> raise "Invalid value for plug: #{inspect(other)}"
end
end
defp validate_plug(plug, plug_options) do
Code.ensure_loaded!(plug)
if !function_exported?(plug, :init, 1), do: raise("plug module does not define init/1")
if !function_exported?(plug, :call, 2), do: raise("plug module does not define call/2")
{plug, plug.init(plug_options)}
end
@spec parse_as_number(binary() | integer()) :: integer()
defp parse_as_number(value) when is_binary(value), do: String.to_integer(value)
defp parse_as_number(value) when is_integer(value), do: value
@spec info(scheme(), module(), nil | pid()) :: String.t()
defp info(scheme, plug, pid) do
server_vsn = Application.spec(:bandit)[:vsn]
"Running #{inspect(plug)} with Bandit #{server_vsn} at #{bound_address(scheme, pid)}"
end
@spec bound_address(scheme(), nil | pid()) :: String.t() | scheme()
defp bound_address(scheme, nil), do: scheme
defp bound_address(scheme, pid) do
{:ok, {address, port}} = ThousandIsland.listener_info(pid)
case address do
:local -> "#{_unix_path = port} (#{scheme}+unix)"
:undefined -> "#{inspect(port)} (#{scheme}+undefined)"
:unspec -> "unspec (#{scheme})"
address -> "#{:inet.ntoa(address)}:#{port} (#{scheme})"
end
end
end

View File

@@ -0,0 +1,294 @@
defmodule Bandit.Adapter do
@moduledoc false
# Implements the Plug-facing `Plug.Conn.Adapter` behaviour. These functions provide the primary
# mechanism for Plug applications to interact with a client, including functions to read the
# client body (if sent) and send response information back to the client. The concerns in this
# module are broadly about the semantics of HTTP in general, and less about transport-specific
# concerns, which are managed by the underlying `Bandit.HTTPTransport` implementation
@behaviour Plug.Conn.Adapter
@already_sent {:plug_conn, :sent}
defstruct transport: nil,
owner_pid: nil,
method: nil,
status: nil,
content_encoding: nil,
compression_context: nil,
upgrade: nil,
metrics: %{},
opts: []
@typedoc "A struct for backing a Plug.Conn.Adapter"
@type t :: %__MODULE__{
transport: Bandit.HTTPTransport.t(),
owner_pid: pid() | nil,
method: Plug.Conn.method() | nil,
status: Plug.Conn.status() | nil,
content_encoding: String.t(),
compression_context: Bandit.Compression.t() | nil,
upgrade: nil | {:websocket, opts :: keyword(), websocket_opts :: keyword()},
metrics: %{},
opts: %{
required(:http) => Bandit.http_options(),
required(:websocket) => Bandit.websocket_options()
}
}
def init(owner_pid, transport, method, headers, opts) do
content_encoding =
Bandit.Compression.negotiate_content_encoding(
Bandit.Headers.get_header(headers, "accept-encoding"),
opts.http
)
%__MODULE__{
transport: transport,
owner_pid: owner_pid,
method: method,
content_encoding: content_encoding,
metrics: %{req_header_end_time: Bandit.Telemetry.monotonic_time()},
opts: opts
}
end
@impl Plug.Conn.Adapter
def read_req_body(%__MODULE__{} = adapter, opts) do
validate_calling_process!(adapter)
metrics =
adapter.metrics
|> Map.put_new_lazy(:req_body_start_time, &Bandit.Telemetry.monotonic_time/0)
case Bandit.HTTPTransport.read_data(adapter.transport, opts) do
{:ok, body, transport} ->
body = IO.iodata_to_binary(body)
metrics =
metrics
|> Map.update(:req_body_bytes, byte_size(body), &(&1 + byte_size(body)))
|> Map.put(:req_body_end_time, Bandit.Telemetry.monotonic_time())
{:ok, body, %{adapter | transport: transport, metrics: metrics}}
{:more, body, transport} ->
body = IO.iodata_to_binary(body)
metrics =
metrics
|> Map.update(:req_body_bytes, byte_size(body), &(&1 + byte_size(body)))
{:more, body, %{adapter | transport: transport, metrics: metrics}}
end
end
##################
# Response Sending
##################
@impl Plug.Conn.Adapter
def send_resp(%__MODULE__{} = adapter, status, headers, body) do
validate_calling_process!(adapter)
start_time = Bandit.Telemetry.monotonic_time()
# Save an extra iodata_length by checking common cases
empty_body? = Bandit.SocketHelpers.iodata_empty?(body)
{headers, compression_context} = Bandit.Compression.new(adapter, status, headers, empty_body?)
{compress_chunk, compression_context} =
Bandit.Compression.compress_chunk(body, compression_context)
{close_chunk, compression_metrics} = Bandit.Compression.close(compression_context)
encoded_body = [compress_chunk | close_chunk]
encoded_length = IO.iodata_length(encoded_body)
headers = Bandit.Headers.add_content_length(headers, encoded_length, status, adapter.method)
metrics =
adapter.metrics
|> Map.put(:resp_start_time, start_time)
|> Map.merge(compression_metrics)
adapter =
%{adapter | metrics: metrics}
|> send_headers(status, headers, :raw)
|> send_data(encoded_body, true)
send(adapter.owner_pid, @already_sent)
{:ok, nil, adapter}
end
@impl Plug.Conn.Adapter
def send_file(
%__MODULE__{} = adapter,
status,
headers,
path,
offset,
length
) do
validate_calling_process!(adapter)
start_time = Bandit.Telemetry.monotonic_time()
{:ok, fileinfo} = :file.read_file_info(path, [:raw, time: :universal])
%File.Stat{type: :regular, size: size} = File.Stat.from_record(fileinfo)
length = if length == :all, do: size - offset, else: length
if offset + length <= size do
headers = Bandit.Headers.add_content_length(headers, length, status, adapter.method)
adapter = send_headers(adapter, status, headers, :raw)
{socket, bytes_actually_written} =
if send_resp_body?(adapter),
do: {Bandit.HTTPTransport.sendfile(adapter.transport, path, offset, length), length},
else: {adapter.transport, 0}
metrics =
adapter.metrics
|> Map.put(:resp_body_bytes, bytes_actually_written)
|> Map.put(:resp_start_time, start_time)
|> Map.put(:resp_end_time, Bandit.Telemetry.monotonic_time())
send(adapter.owner_pid, @already_sent)
{:ok, nil, %{adapter | transport: socket, metrics: metrics}}
else
raise "Cannot read #{length} bytes starting at #{offset} as #{path} is only #{size} octets in length"
end
end
@impl Plug.Conn.Adapter
def send_chunked(%__MODULE__{} = adapter, status, headers) do
validate_calling_process!(adapter)
start_time = Bandit.Telemetry.monotonic_time()
metrics = Map.put(adapter.metrics, :resp_start_time, start_time)
{headers, compression_context} = Bandit.Compression.new(adapter, status, headers, false, true)
adapter = %{adapter | metrics: metrics, compression_context: compression_context}
send(adapter.owner_pid, @already_sent)
{:ok, nil, send_headers(adapter, status, headers, :chunk_encoded)}
end
@impl Plug.Conn.Adapter
def chunk(%__MODULE__{} = adapter, chunk) do
# Sending an empty chunk implicitly ends the response. This is a bit of an undefined corner of
# the Plug.Conn.Adapter behaviour (see https://github.com/elixir-plug/plug/pull/535 for
# details) and ending the response here carves closest to the underlying HTTP/1.1 behaviour
# (RFC9112§7.1). Since there is no notion of chunked encoding is in HTTP/2 anyway (RFC9113§8.1)
# this entire section of the API is a bit slanty regardless.
validate_calling_process!(adapter)
# chunk/2 is unique among Plug.Conn.Adapter's sending callbacks in that it can return an error
# tuple instead of just raising or dying on error. Rescue here to implement this
try do
if Bandit.SocketHelpers.iodata_empty?(chunk) do
{encoded_chunk, compression_metrics} =
Bandit.Compression.close(adapter.compression_context)
adapter = %{adapter | metrics: Map.merge(adapter.metrics, compression_metrics)}
adapter =
if encoded_chunk != [] do
send_data(adapter, encoded_chunk, false)
else
adapter
end
{:ok, nil, send_data(adapter, "", true)}
else
{encoded_chunk, compression_context} =
Bandit.Compression.compress_chunk(chunk, adapter.compression_context)
adapter = %{adapter | compression_context: compression_context}
{:ok, nil, send_data(adapter, encoded_chunk, false)}
end
rescue
error in Bandit.TransportError -> {:error, error.error}
error -> {:error, Exception.message(error)}
end
end
@impl Plug.Conn.Adapter
def inform(%__MODULE__{} = adapter, status, headers) do
validate_calling_process!(adapter)
# It's a bit weird to be casing on the underlying version here, but whether or not to send
# an informational response is actually defined in RFC9110§15.2 so we consider it as an aspect
# of semantics that belongs here and not in the underlying transport
if get_http_protocol(adapter) == :"HTTP/1.0" do
{:error, :not_supported}
else
# inform/3 is unique in that headers comes in as a keyword list
headers = Enum.map(headers, fn {header, value} -> {to_string(header), value} end)
{:ok, send_headers(adapter, status, headers, :inform)}
end
end
defp send_headers(adapter, status, headers, body_disposition) do
headers =
if is_nil(Bandit.Headers.get_header(headers, "date")) do
[Bandit.Clock.date_header() | headers]
else
headers
end
adapter = %{adapter | status: status}
body_disposition = if send_resp_body?(adapter), do: body_disposition, else: :no_body
socket =
Bandit.HTTPTransport.send_headers(adapter.transport, status, headers, body_disposition)
%{adapter | transport: socket}
end
defp send_data(adapter, data, end_request) do
socket =
if send_resp_body?(adapter),
do: Bandit.HTTPTransport.send_data(adapter.transport, data, end_request),
else: adapter.transport
data_size = IO.iodata_length(data)
metrics = Map.update(adapter.metrics, :resp_body_bytes, data_size, &(&1 + data_size))
metrics =
if end_request,
do: Map.put(metrics, :resp_end_time, Bandit.Telemetry.monotonic_time()),
else: metrics
%{adapter | transport: socket, metrics: metrics}
end
defp send_resp_body?(%{method: "HEAD"}), do: false
defp send_resp_body?(%{status: 204}), do: false
defp send_resp_body?(%{status: 304}), do: false
defp send_resp_body?(_adapter), do: true
@impl Plug.Conn.Adapter
def upgrade(%__MODULE__{} = adapter, protocol, opts) do
if Keyword.get(adapter.opts.websocket, :enabled, true) &&
Bandit.HTTPTransport.supported_upgrade?(adapter.transport, protocol),
do: {:ok, %{adapter | upgrade: {protocol, opts, adapter.opts.websocket}}},
else: {:error, :not_supported}
end
@impl Plug.Conn.Adapter
def push(_adapter, _path, _headers), do: {:error, :not_supported}
@impl Plug.Conn.Adapter
def get_peer_data(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.peer_data(adapter.transport)
@impl Plug.Conn.Adapter
def get_sock_data(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.sock_data(adapter.transport)
@impl Plug.Conn.Adapter
def get_ssl_data(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.ssl_data(adapter.transport)
@impl Plug.Conn.Adapter
def get_http_protocol(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.version(adapter.transport)
defp validate_calling_process!(%{owner_pid: owner}) when owner == self(), do: :ok
defp validate_calling_process!(_), do: raise("Adapter functions must be called by stream owner")
end

View File

@@ -0,0 +1,14 @@
defmodule Bandit.Application do
@moduledoc false
use Application
@impl Application
@spec start(Application.start_type(), start_args :: term) ::
{:ok, pid}
| {:error, {:already_started, pid} | {:shutdown, term} | term}
def start(_type, _args) do
children = [Bandit.Clock]
Supervisor.start_link(children, strategy: :one_for_one)
end
end

View File

@@ -0,0 +1,56 @@
defmodule Bandit.Clock do
@moduledoc false
# Task which updates an ETS table with the current pre-formatted HTTP header
# timestamp once a second. This saves the individual request processes from
# having to construct this themselves, since it is a surprisingly expensive
# operation
use Task, restart: :permanent
require Logger
@doc """
Returns the current timestamp according to RFC9110§5.6.7.
If the timestamp doesn't exist in the ETS table or the table doesn't exist
the timestamp is newly created for every request
"""
@spec date_header() :: {header :: binary(), date :: binary()}
def date_header do
date =
try do
:ets.lookup_element(__MODULE__, :date_header, 2)
rescue
ArgumentError ->
Logger.warning("Header timestamp couldn't be fetched from ETS cache", domain: [:bandit])
get_date_header()
end
{"date", date}
end
@spec start_link(any()) :: {:ok, pid()}
def start_link(_opts) do
Task.start_link(__MODULE__, :init, [])
end
@spec init :: no_return()
def init do
__MODULE__ = :ets.new(__MODULE__, [:set, :protected, :named_table, {:read_concurrency, true}])
run()
end
@spec run() :: no_return()
defp run do
_ = update_header()
Process.sleep(1_000)
run()
end
@spec get_date_header() :: String.t()
defp get_date_header, do: Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %X GMT")
@spec update_header() :: true
defp update_header, do: :ets.insert(__MODULE__, {:date_header, get_date_header()})
end

View File

@@ -0,0 +1,168 @@
defmodule Bandit.Compression do
@moduledoc false
defstruct method: nil, bytes_in: 0, lib_context: nil
@typedoc "A struct containing the context for response compression"
@type t :: %__MODULE__{
method: :deflate | :gzip | :identity | :zstd,
bytes_in: non_neg_integer(),
lib_context: term()
}
@accepted_encodings ~w(gzip x-gzip deflate)
if Code.ensure_loaded?(:zstd) do
@accepted_encodings ~w(zstd) ++ @accepted_encodings
end
@spec negotiate_content_encoding(nil | binary(), keyword()) :: String.t() | nil
def negotiate_content_encoding(nil, _), do: nil
def negotiate_content_encoding(accept_encoding, http_opts) do
if Keyword.get(http_opts, :compress, true) do
client_accept_encoding = Plug.Conn.Utils.list(accept_encoding)
Keyword.get(http_opts, :response_encodings, @accepted_encodings)
|> Enum.find(&(&1 in client_accept_encoding))
else
nil
end
end
def new(adapter, status, headers, empty_body?, streamable \\ false) do
response_content_encoding_header = Bandit.Headers.get_header(headers, "content-encoding")
headers = maybe_add_vary_header(adapter, status, headers)
if status not in [204, 304] && not is_nil(adapter.content_encoding) &&
is_nil(response_content_encoding_header) &&
!response_has_strong_etag(headers) && !response_indicates_no_transform(headers) &&
!empty_body? do
case start_stream(adapter.content_encoding, adapter.opts.http, streamable) do
{:ok, context} -> {[{"content-encoding", adapter.content_encoding} | headers], context}
{:error, :unsupported_encoding} -> {headers, %__MODULE__{method: :identity}}
end
else
{headers, %__MODULE__{method: :identity}}
end
end
defp maybe_add_vary_header(adapter, status, headers) do
if status != 204 && Keyword.get(adapter.opts.http, :compress, true),
do: [{"vary", "accept-encoding"} | headers],
else: headers
end
defp response_has_strong_etag(headers) do
case Bandit.Headers.get_header(headers, "etag") do
nil -> false
"\W" <> _rest -> false
_strong_etag -> true
end
end
defp response_indicates_no_transform(headers) do
case Bandit.Headers.get_header(headers, "cache-control") do
nil -> false
header -> "no-transform" in Plug.Conn.Utils.list(header)
end
end
defp start_stream("deflate", http_opts, _streamable) do
opts = Keyword.get(http_opts, :deflate_options, [])
deflate_context = :zlib.open()
:zlib.deflateInit(
deflate_context,
Keyword.get(opts, :level, :default),
:deflated,
Keyword.get(opts, :window_bits, 15),
Keyword.get(opts, :mem_level, 8),
Keyword.get(opts, :strategy, :default)
)
{:ok, %__MODULE__{method: :deflate, lib_context: deflate_context}}
end
defp start_stream("x-gzip", _opts, false), do: {:ok, %__MODULE__{method: :gzip}}
defp start_stream("gzip", _opts, false), do: {:ok, %__MODULE__{method: :gzip}}
if Code.ensure_loaded?(:zstd) do
defp start_stream("zstd", http_opts, false) do
opts = Keyword.get(http_opts, :zstd_options, %{})
{:ok, zstd_context} = :zstd.context(:compress, opts)
{:ok, %__MODULE__{method: :zstd, lib_context: zstd_context}}
end
end
defp start_stream(_encoding, _opts, _streamable), do: {:error, :unsupported_encoding}
def compress_chunk(chunk, %__MODULE__{method: :deflate} = context) do
result = :zlib.deflate(context.lib_context, chunk, :sync)
context =
context
|> Map.update!(:bytes_in, &(&1 + IO.iodata_length(chunk)))
{result, context}
end
if Code.ensure_loaded?(:zstd) do
def compress_chunk(chunk, %__MODULE__{method: :zstd} = context) do
result = :zstd.compress(chunk, context.lib_context)
context =
context
|> Map.update!(:bytes_in, &(&1 + IO.iodata_length(chunk)))
{result, context}
end
end
def compress_chunk(chunk, %__MODULE__{method: :gzip, lib_context: nil} = context) do
result = :zlib.gzip(chunk)
context =
context
|> Map.update!(:bytes_in, &(&1 + IO.iodata_length(chunk)))
|> Map.put(:lib_context, :done)
{result, context}
end
def compress_chunk(chunk, %__MODULE__{method: :identity} = context) do
{chunk, context}
end
def close(%__MODULE__{} = context) do
chunk = close_context(context)
if context.method == :identity do
{chunk, %{}}
else
{chunk,
%{
resp_compression_method: to_string(context.method),
resp_uncompressed_body_bytes: context.bytes_in
}}
end
end
defp close_context(%__MODULE__{method: :deflate, lib_context: lib_context}) do
last = :zlib.deflate(lib_context, [], :finish)
:ok = :zlib.deflateEnd(lib_context)
:zlib.close(lib_context)
last
end
if Code.ensure_loaded?(:zstd) do
defp close_context(%__MODULE__{method: :zstd, lib_context: lib_context}) do
:zstd.close(lib_context)
[]
end
end
defp close_context(_context), do: []
end

View File

@@ -0,0 +1,78 @@
defmodule Bandit.DelegatingHandler do
@moduledoc false
# Delegates all implementation of the ThousandIsland.Handler behaviour
# to an implementation specified in state. Allows for clean separation
# between protocol implementations & friction free protocol selection &
# upgrades.
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_connection(socket, %{handler_module: handler_module} = state) do
handler_module.handle_connection(socket, state)
|> handle_bandit_continuation(socket)
end
@impl ThousandIsland.Handler
def handle_data(data, socket, %{handler_module: handler_module} = state) do
handler_module.handle_data(data, socket, state)
|> handle_bandit_continuation(socket)
end
@impl ThousandIsland.Handler
def handle_shutdown(socket, %{handler_module: handler_module} = state) do
handler_module.handle_shutdown(socket, state)
end
@impl ThousandIsland.Handler
def handle_close(socket, %{handler_module: handler_module} = state) do
handler_module.handle_close(socket, state)
end
@impl ThousandIsland.Handler
def handle_timeout(socket, %{handler_module: handler_module} = state) do
handler_module.handle_timeout(socket, state)
end
@impl ThousandIsland.Handler
def handle_error(error, socket, %{handler_module: handler_module} = state) do
handler_module.handle_error(error, socket, state)
end
@impl GenServer
def handle_call(msg, from, {_socket, %{handler_module: handler_module}} = state) do
handler_module.handle_call(msg, from, state)
end
@impl GenServer
def handle_cast(msg, {_socket, %{handler_module: handler_module}} = state) do
handler_module.handle_cast(msg, state)
end
@impl GenServer
def handle_info(msg, {_socket, %{handler_module: handler_module}} = state) do
handler_module.handle_info(msg, state)
end
defp handle_bandit_continuation(continuation, socket) do
case continuation do
{:switch, next_handler, state} ->
handle_connection(socket, %{state | handler_module: next_handler})
{:switch, next_handler, data, state} ->
case handle_connection(socket, %{state | handler_module: next_handler}) do
{:continue, state} ->
handle_data(data, socket, state)
{:continue, state, _timeout} ->
handle_data(data, socket, state)
other ->
other
end
other ->
other
end
end
end

View File

@@ -0,0 +1,113 @@
defmodule Bandit.Extractor do
@moduledoc false
# A state machine for efficiently extracting full frames from received packets
@type deserialize_result :: any()
@callback header_and_payload_length(binary(), max_frame_size :: integer()) ::
{:ok, {header_length :: integer(), payload_length :: integer()}}
| {:error, term()}
| :more
@callback deserialize(binary(), primitive_ops_module :: module()) :: deserialize_result()
@type t :: %__MODULE__{
header: binary(),
payload: iodata(),
payload_length: non_neg_integer(),
required_length: non_neg_integer(),
mode: :header_parsing | :payload_parsing,
max_frame_size: non_neg_integer(),
frame_parser: atom(),
primitive_ops_module: module()
}
defstruct header: <<>>,
payload: [],
payload_length: 0,
required_length: 0,
mode: :header_parsing,
max_frame_size: 0,
frame_parser: nil,
primitive_ops_module: nil
@spec new(module(), module(), Keyword.t()) :: t()
def new(frame_parser, primitive_ops_module, opts) do
max_frame_size = Keyword.get(opts, :max_frame_size, 0)
%__MODULE__{
max_frame_size: max_frame_size,
frame_parser: frame_parser,
primitive_ops_module: primitive_ops_module
}
end
@spec push_data(t(), binary()) :: t()
def push_data(%__MODULE__{} = state, data) do
case state do
%{mode: :header_parsing} ->
%{state | header: state.header <> data}
%{mode: :payload_parsing, payload: payload, payload_length: length} ->
%{state | payload: [payload, data], payload_length: length + byte_size(data)}
end
end
@spec pop_frame(t()) :: {t(), :more | deserialize_result()}
def pop_frame(state)
def pop_frame(%__MODULE__{mode: :header_parsing} = state) do
case state.frame_parser.header_and_payload_length(state.header, state.max_frame_size) do
{:ok, {header_length, required_length}} ->
state
|> transition_to_payload_parsing(header_length, required_length)
|> pop_frame()
{:error, message} ->
{state, {:error, message}}
:more ->
{state, :more}
end
end
def pop_frame(
%__MODULE__{
mode: :payload_parsing,
payload_length: payload_length,
required_length: required_length
} = state
) do
if payload_length >= required_length do
<<payload::binary-size(required_length), rest::binary>> =
IO.iodata_to_binary(state.payload)
frame = state.frame_parser.deserialize(state.header <> payload, state.primitive_ops_module)
state = transition_to_header_parsing(state, rest)
{state, frame}
else
{state, :more}
end
end
defp transition_to_payload_parsing(state, header_length, required_length) do
payload_length = byte_size(state.header) - header_length
state
|> Map.put(:header, binary_part(state.header, 0, header_length))
|> Map.put(:payload, binary_part(state.header, header_length, payload_length))
|> Map.put(:payload_length, payload_length)
|> Map.put(:required_length, required_length)
|> Map.put(:mode, :payload_parsing)
end
defp transition_to_header_parsing(state, rest) do
state
|> Map.put(:header, rest)
|> Map.put(:payload, [])
|> Map.put(:payload_length, 0)
|> Map.put(:required_length, 0)
|> Map.put(:mode, :header_parsing)
end
end

View File

@@ -0,0 +1,123 @@
defmodule Bandit.Headers do
@moduledoc false
# Conveniences for dealing with headers.
@spec is_port_number(integer()) :: Macro.t()
defguardp is_port_number(port) when Bitwise.band(port, 0xFFFF) === port
@spec get_header(Plug.Conn.headers(), header :: binary()) :: binary() | nil
def get_header(headers, header) do
case List.keyfind(headers, header, 0) do
{_, value} -> value
nil -> nil
end
end
# Covers IPv6 addresses, like `[::1]:4000` as defined in RFC3986.
@spec parse_hostlike_header!(host_header :: binary()) ::
{Plug.Conn.host(), nil | Plug.Conn.port_number()}
def parse_hostlike_header!("[" <> _ = host_header) do
host_header
|> :binary.split("]:")
|> case do
[host, port] ->
case parse_integer(port) do
{port, ""} when is_port_number(port) -> {host <> "]", port}
_ -> raise Bandit.HTTPError, "Header contains invalid port"
end
[host] ->
{host, nil}
end
end
def parse_hostlike_header!(host_header) do
host_header
|> :binary.split(":")
|> case do
[host, port] ->
case parse_integer(port) do
{port, ""} when is_port_number(port) -> {host, port}
_ -> raise Bandit.HTTPError, "Header contains invalid port"
end
[host] ->
{host, nil}
end
end
@spec get_content_length(Plug.Conn.headers()) ::
{:ok, nil | non_neg_integer()} | {:error, String.t()}
def get_content_length(headers) do
case get_header(headers, "content-length") do
nil -> {:ok, nil}
value -> parse_content_length(value)
end
end
@spec parse_content_length(binary()) :: {:ok, non_neg_integer()} | {:error, String.t()}
defp parse_content_length(value) do
case parse_integer(value) do
{length, ""} ->
{:ok, length}
{length, _rest} ->
if value |> Plug.Conn.Utils.list() |> Enum.all?(&(&1 == to_string(length))),
do: {:ok, length},
else: {:error, "invalid content-length header (RFC9112§6.3.5)"}
:error ->
{:error, "invalid content-length header (RFC9112§6.3.5)"}
end
end
# Parses non-negative integers from strings. Return the valid portion of an
# integer and the remaining string as a tuple like `{123, ""}` or `:error`.
@spec parse_integer(String.t()) :: {non_neg_integer(), rest :: String.t()} | :error
defp parse_integer(<<digit::8, rest::binary>>) when digit >= ?0 and digit <= ?9 do
parse_integer(rest, digit - ?0)
end
defp parse_integer(_), do: :error
@spec parse_integer(String.t(), non_neg_integer()) :: {non_neg_integer(), String.t()}
defp parse_integer(<<digit::8, rest::binary>>, total) when digit >= ?0 and digit <= ?9 do
parse_integer(rest, total * 10 + digit - ?0)
end
defp parse_integer(rest, total), do: {total, rest}
@spec add_content_length(
headers :: Plug.Conn.headers(),
length :: non_neg_integer(),
status :: Plug.Conn.int_status(),
method :: Plug.Conn.method()
) ::
Plug.Conn.headers()
# Per RFC9110§8.6, we use the following logic:
#
# * If the response is 1xx or 204, content-length is NEVER sent
# * If the response is 304 or the method is HEAD AND the body length is zero, respect any
# content-length header the plug may have set on the assumption that it knows what it would
# have sent
# * For all other responses, use the length of the provided response body as the content-length,
# overwriting any content-length the plug may have set
def add_content_length(headers, _length, status, _method)
when status in 100..199 or status == 204 do
drop_content_length(headers)
end
def add_content_length(headers, 0, status, method) when status == 304 or method == "HEAD" do
headers
end
def add_content_length(headers, length, _status, _method) do
[{"content-length", to_string(length)} | drop_content_length(headers)]
end
@spec drop_content_length(Plug.Conn.headers()) :: Plug.Conn.headers()
defp drop_content_length(headers) do
Enum.reject(headers, &(elem(&1, 0) == "content-length"))
end
end

View File

@@ -0,0 +1,30 @@
# HTTP/1 Handler
Included in this folder is a complete `ThousandIsland.Handler` based implementation of HTTP/1.x as
defined in [RFC 9112](https://datatracker.ietf.org/doc/rfc9112).
## Process model
Within a Bandit server, an HTTP/1 connection is modeled as a single process.
This process is tied to the lifecycle of the underlying TCP connection; in the
case of an HTTP client which makes use of HTTP's keep-alive feature to make
multiple requests on the same connection, all of these requests will be serviced
by this same process.
The execution model to handle a given request is quite straightforward: the
underlying [Thousand Island](https://github.com/mtrudel/thousand_island) library
will call `Bandit.HTTP1.Handler.handle_data/3`, which will then construct a
`Bandit.HTTP1.Socket` struct that conforms to the `Bandit.HTTPTransport`
protocol. It will then call `Bandit.Pipeline.run/3`, which will go through the
process of reading the request (by calling functions on the
`Bandit.HTTPTransport` protocol), and constructing a `Plug.Conn` structure to
represent the request and subsequently pass it to the configured `Plug` module.
# Testing
All of this is exhaustively tested. Tests are located in `request_test.exs`, and
are broadly either concerned with testing network-facing aspects of the
implementation (ie: how well Bandit satisfies the relevant RFCs) or the Plug-facing
aspects of the implementation.
Unfortunately, there is no HTTP/1 equivalent to the external h2spec test suite.

View File

@@ -0,0 +1,96 @@
defmodule Bandit.HTTP1.Handler do
@moduledoc false
# An HTTP 1.0 & 1.1 Thousand Island Handler
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
transport = %Bandit.HTTP1.Socket{socket: socket, buffer: data, opts: state.opts}
connection_span = ThousandIsland.Socket.telemetry_span(socket)
conn_data = Bandit.SocketHelpers.conn_data(socket)
case Bandit.Pipeline.run(transport, state.plug, connection_span, conn_data, state.opts) do
{:ok, transport} -> maybe_keepalive(transport, state)
{:error, _reason} -> {:close, state}
{:upgrade, _transport, :websocket, opts} -> do_websocket_upgrade(opts, state)
end
end
defp maybe_keepalive(transport, state) do
requests_processed = Map.get(state, :requests_processed, 0) + 1
request_limit = Keyword.get(state.opts.http_1, :max_requests, 0)
under_limit = request_limit == 0 || requests_processed < request_limit
if under_limit && transport.keepalive do
if Keyword.get(state.opts.http_1, :clear_process_dict, true), do: clear_process_dict()
gc_every_n_requests = Keyword.get(state.opts.http_1, :gc_every_n_keepalive_requests, 5)
if rem(requests_processed, gc_every_n_requests) == 0, do: :erlang.garbage_collect()
state = Map.put(state, :requests_processed, requests_processed)
# We have bytes that we've read but haven't yet processed, tail call handle_data to start
# reading the next request
if Bandit.SocketHelpers.iodata_empty?(transport.buffer) do
{:continue, state}
else
handle_data(transport.buffer, transport.socket, state)
end
else
{:close, state}
end
end
defp clear_process_dict do
Process.get_keys()
|> Enum.each(
&if &1 not in ~w[$ancestors $initial_call $process_label]a, do: Process.delete(&1)
)
end
defp do_websocket_upgrade(upgrade_opts, state) do
:erlang.garbage_collect()
{:switch, Bandit.WebSocket.Handler, Map.put(state, :upgrade_opts, upgrade_opts)}
end
def handle_info({:plug_conn, :sent}, {socket, state}),
do: {:noreply, {socket, state}, socket.read_timeout}
def handle_info({:EXIT, _pid, :normal}, {socket, state}),
do: {:noreply, {socket, state}, socket.read_timeout}
def handle_info(msg, {socket, state}) do
if Keyword.get(state.opts.http_1, :log_unknown_messages, false), do: log_no_handle_info(msg)
{:noreply, {socket, state}, socket.read_timeout}
end
def handle_info(msg, state) do
log_no_handle_info(msg)
{:noreply, state}
end
defp log_no_handle_info(msg) do
# Copied verbatim from lib/elixir/lib/gen_server.ex
proc =
case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
:logger.error(
%{
label: {GenServer, :no_handle_info},
report: %{
module: __MODULE__,
message: msg,
name: proc
}
},
%{
domain: [:otp, :elixir],
error_logger: %{tag: :error_msg},
report_cb: &GenServer.format_report/1
}
)
end
end

View File

@@ -0,0 +1,503 @@
defmodule Bandit.HTTP1.Socket do
@moduledoc false
# This module implements the lower level parts of HTTP/1 (roughly, the aspects of the protocol
# described in RFC 9112 as opposed to RFC 9110). It is similar in spirit to
# `Bandit.HTTP2.Stream` for HTTP/2, and indeed both implement the `Bandit.HTTPTransport`
# behaviour. An instance of this struct is maintained as the state of a `Bandit.HTTP1.Handler`
# process, and it moves an HTTP/1 request through its lifecycle by calling functions defined on
# this module. This state is also tracked within the `Bandit.Adapter` instance that backs
# Bandit's Plug API.
defstruct socket: nil,
buffer: <<>>,
read_state: :unread,
write_state: :unsent,
unread_content_length: nil,
body_encoding: nil,
version: :"HTTP/1.0",
send_buffer: nil,
request_connection_header: nil,
keepalive: nil,
opts: %{}
@typedoc "An HTTP/1 read state"
@type read_state :: :unread | :headers_read | :read
@typedoc "An HTTP/1 write state"
@type write_state :: :unsent | :writing | :chunking | :chunk_streaming | :sent
@typedoc "The information necessary to communicate to/from a socket"
@type t :: %__MODULE__{
socket: ThousandIsland.Socket.t(),
buffer: iodata(),
read_state: read_state(),
write_state: write_state(),
unread_content_length: non_neg_integer() | :chunked | nil,
body_encoding: nil | binary(),
version: nil | :"HTTP/1.1" | :"HTTP/1.0",
send_buffer: iolist(),
request_connection_header: binary(),
keepalive: boolean(),
opts: %{
required(:http_1) => Bandit.http_1_options()
}
}
defimpl Bandit.HTTPTransport do
def peer_data(%@for{} = socket), do: Bandit.SocketHelpers.peer_data(socket.socket)
def sock_data(%@for{} = socket), do: Bandit.SocketHelpers.sock_data(socket.socket)
def ssl_data(%@for{} = socket), do: Bandit.SocketHelpers.ssl_data(socket.socket)
def version(%@for{} = socket), do: socket.version
def read_headers(%@for{read_state: :unread} = socket) do
{method, request_target, socket} = do_read_request_line!(socket)
{headers, socket} = do_read_headers!(socket)
content_length = get_content_length!(headers)
body_encoding = Bandit.Headers.get_header(headers, "transfer-encoding")
request_connection_header = safe_downcase(Bandit.Headers.get_header(headers, "connection"))
socket = %{socket | request_connection_header: request_connection_header}
case {content_length, body_encoding} do
{nil, nil} ->
# No body, so just go straight to 'read'
{:ok, method, request_target, headers, %{socket | read_state: :read}}
{content_length, nil} ->
socket = %{socket | read_state: :headers_read, unread_content_length: content_length}
{:ok, method, request_target, headers, socket}
{nil, body_encoding} ->
socket = %{socket | read_state: :headers_read, body_encoding: body_encoding}
{:ok, method, request_target, headers, socket}
{_content_length, _body_encoding} ->
request_error!(
"Request cannot contain both 'content-length' and 'transfer-encoding' (RFC9112§6.3.3)"
)
end
end
defp do_read_request_line!(socket, request_target \\ nil) do
packet_size = Keyword.get(socket.opts.http_1, :max_request_line_length, 10_000)
case :erlang.decode_packet(:http_bin, socket.buffer, packet_size: packet_size) do
{:more, _len} ->
chunk = read_available_for_header!(socket.socket)
do_read_request_line!(%{socket | buffer: socket.buffer <> chunk}, request_target)
{:ok, {:http_request, method, request_target, version}, rest} ->
version = get_version!(version)
# decode_packet is inconsistent about atom/string method returns
method = to_string(method)
request_target = resolve_request_target!(request_target, method)
socket = %{socket | buffer: rest, version: version}
{method, request_target, socket}
{:ok, {:http_error, reason}, _rest} ->
request_error!("Request line HTTP error: #{inspect(reason)}")
{:error, :invalid} ->
request_error!("Request URI is too long", :request_uri_too_long)
{:error, reason} ->
request_error!("Request line unknown error: #{inspect(reason)}")
end
end
defp get_version!({1, 1}), do: :"HTTP/1.1"
defp get_version!({1, 0}), do: :"HTTP/1.0"
defp get_version!(other), do: request_error!("Invalid HTTP version: #{inspect(other)}")
# Unwrap different request_targets returned by :erlang.decode_packet/3
defp resolve_request_target!({:abs_path, path}, _), do: {nil, nil, nil, path}
defp resolve_request_target!({:absoluteURI, scheme, host, :undefined, path}, _),
do: {to_string(scheme), host, nil, path}
defp resolve_request_target!({:absoluteURI, scheme, host, port, path}, _),
do: {to_string(scheme), host, port, path}
defp resolve_request_target!(:*, "OPTIONS"), do: {nil, nil, nil, :*}
defp resolve_request_target!({:scheme, scheme, port}, "CONNECT"),
do: {nil, scheme, port, nil}
defp resolve_request_target!(_request_target, _method),
do: request_error!("Unsupported request target (RFC9112§3.2)")
defp do_read_headers!(socket, headers \\ []) do
packet_size = Keyword.get(socket.opts.http_1, :max_header_length, 10_000)
case :erlang.decode_packet(:httph_bin, socket.buffer, packet_size: packet_size) do
{:more, _len} ->
chunk = read_available_for_header!(socket.socket)
socket = %{socket | buffer: socket.buffer <> chunk}
do_read_headers!(socket, headers)
{:ok, {:http_header, _, header, _, value}, rest} ->
socket = %{socket | buffer: rest}
headers = [{header |> to_string() |> String.downcase(:ascii), value} | headers]
if length(headers) <= Keyword.get(socket.opts.http_1, :max_header_count, 50) do
do_read_headers!(socket, headers)
else
request_error!("Too many headers", :request_header_fields_too_large)
end
{:ok, :http_eoh, rest} ->
socket = %{socket | read_state: :headers_read, buffer: rest}
{Enum.reverse(headers), socket}
{:ok, {:http_error, reason}, _rest} ->
request_error!("Header read HTTP error: #{inspect(reason)}")
{:error, :invalid} ->
request_error!("Header too long", :request_header_fields_too_large)
{:error, reason} ->
request_error!("Header read unknown error: #{inspect(reason)}")
end
end
defp get_content_length!(headers) do
case Bandit.Headers.get_content_length(headers) do
{:ok, content_length} -> content_length
{:error, reason} -> request_error!("Content length unknown error: #{inspect(reason)}")
end
end
def read_data(
%@for{read_state: :headers_read, unread_content_length: unread_content_length} = socket,
opts
)
when is_number(unread_content_length) do
{to_return, buffer, remaining_unread_content_length} =
do_read_content_length_data!(socket.socket, socket.buffer, unread_content_length, opts)
socket = %{socket | buffer: buffer, unread_content_length: remaining_unread_content_length}
if remaining_unread_content_length == 0 do
{:ok, to_return, %{socket | read_state: :read}}
else
{:more, to_return, socket}
end
end
def read_data(%@for{read_state: :headers_read, body_encoding: "chunked"} = socket, opts) do
read_size = Keyword.get(opts, :read_length, 1_000_000)
read_timeout = Keyword.get(opts, :read_timeout)
{body, buffer} =
do_read_chunked_data!(socket.socket, socket.buffer, <<>>, read_size, read_timeout)
body = IO.iodata_to_binary(body)
{:ok, body, %{socket | read_state: :read, buffer: buffer}}
end
def read_data(%@for{read_state: :headers_read, body_encoding: body_encoding}, _opts)
when not is_nil(body_encoding) do
request_error!("Unsupported transfer-encoding")
end
def read_data(%@for{} = socket, _opts), do: {:ok, <<>>, socket}
@dialyzer {:no_improper_lists, do_read_content_length_data!: 4}
defp do_read_content_length_data!(socket, buffer, unread_content_length, opts) do
max_to_return = min(unread_content_length, Keyword.get(opts, :length, 8_000_000))
cond do
max_to_return == 0 ->
# We have already satisfied our content length
{<<>>, buffer, unread_content_length}
byte_size(buffer) >= max_to_return ->
# We can satisfy the read request entirely from our buffer
<<to_return::binary-size(max_to_return), rest::binary>> = buffer
{to_return, rest, unread_content_length - max_to_return}
byte_size(buffer) < max_to_return ->
# We need to read off the wire
read_size = Keyword.get(opts, :read_length, 1_000_000)
read_timeout = Keyword.get(opts, :read_timeout)
to_return =
read!(socket, max_to_return - byte_size(buffer), [buffer], read_size, read_timeout)
|> IO.iodata_to_binary()
# We may have read more than we need to return
if byte_size(to_return) >= max_to_return do
<<to_return::binary-size(max_to_return), rest::binary>> = to_return
{to_return, rest, unread_content_length - max_to_return}
else
{to_return, <<>>, unread_content_length - byte_size(to_return)}
end
end
end
@dialyzer {:no_improper_lists, do_read_chunked_data!: 5}
defp do_read_chunked_data!(socket, buffer, body, read_size, read_timeout) do
case :binary.split(buffer, "\r\n") do
["0", "\r\n" <> rest] ->
# We should be reading (and ignoring) trailers here
{IO.iodata_to_binary(body), rest}
[chunk_size, rest] ->
chunk_size = String.to_integer(chunk_size, 16)
case rest do
<<next_chunk::binary-size(chunk_size), ?\r, ?\n, rest::binary>> ->
do_read_chunked_data!(socket, rest, [body, next_chunk], read_size, read_timeout)
_ ->
to_read = chunk_size - byte_size(rest)
if to_read > 0 do
iolist = read!(socket, to_read, [], read_size, read_timeout)
buffer = IO.iodata_to_binary([buffer | iolist])
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
else
chunk = read_available!(socket, read_timeout)
buffer = buffer <> chunk
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
end
end
_ ->
chunk = read_available!(socket, read_timeout)
buffer = buffer <> chunk
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
end
end
##################
# Internal Reading
##################
@compile {:inline, read_available_for_header!: 1}
@spec read_available_for_header!(ThousandIsland.Socket.t()) :: binary()
defp read_available_for_header!(socket) do
case ThousandIsland.Socket.recv(socket, 0) do
{:ok, chunk} -> chunk
{:error, reason} -> socket_error!(reason)
end
end
@compile {:inline, read_available!: 2}
@spec read_available!(ThousandIsland.Socket.t(), timeout()) :: binary()
defp read_available!(socket, read_timeout) do
case ThousandIsland.Socket.recv(socket, 0, read_timeout) do
{:ok, chunk} -> chunk
{:error, :timeout} -> <<>>
{:error, reason} -> socket_error!(reason)
end
end
@dialyzer {:no_improper_lists, read!: 5}
@spec read!(
ThousandIsland.Socket.t(),
non_neg_integer(),
iolist(),
non_neg_integer(),
timeout()
) ::
iolist()
defp read!(socket, to_read, already_read, read_size, read_timeout) do
case ThousandIsland.Socket.recv(socket, min(to_read, read_size), read_timeout) do
{:ok, chunk} ->
remaining_bytes = to_read - byte_size(chunk)
if remaining_bytes > 0 do
read!(socket, remaining_bytes, [already_read | chunk], read_size, read_timeout)
else
[already_read | chunk]
end
{:error, :timeout} ->
handle_timeout_with_disconnect_check!(socket)
{:error, reason} ->
socket_error!(reason)
end
end
# After a timeout, check if the peer is still connected. If not, this is
# likely a client disconnect that manifested as a timeout.
# We raise TransportError for disconnects and HTTPError for genuine timeouts.
# Use a non-blocking recv (timeout: 0) to detect closed connections.
@spec handle_timeout_with_disconnect_check!(ThousandIsland.Socket.t()) :: no_return()
defp handle_timeout_with_disconnect_check!(socket) do
case ThousandIsland.Socket.recv(socket, 0, 0) do
{:error, :timeout} ->
# Socket is still open but no data - genuine timeout
request_error!("Body read timeout", :request_timeout)
{:error, reason} ->
# Socket error (e.g., :closed) - client disconnected
socket_error!(reason)
{:ok, _data} ->
# Unexpected: data arrived just after timeout. Treat as timeout
# since we already committed to the timeout path.
request_error!("Body read timeout", :request_timeout)
end
end
def send_headers(%@for{write_state: :unsent} = socket, status, headers, body_disposition) do
resp_line = "#{socket.version} #{status} #{Plug.Conn.Status.reason_phrase(status)}\r\n"
{headers, socket} = handle_keepalive(status, headers, socket)
has_content_length = Bandit.Headers.get_header(headers, "content-length") != nil
case body_disposition do
:raw ->
# This is an optimization for the common case of sending a non-encoded body (or file),
# and coalesces the header and body send calls into a single ThousandIsland.Socket.send/2
# call. This makes a _substantial_ difference in practice
%{socket | write_state: :writing, send_buffer: [resp_line | encode_headers(headers)]}
:chunk_encoded when not has_content_length ->
headers = [{"transfer-encoding", "chunked"} | headers]
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :chunking}
:chunk_encoded when has_content_length ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :chunk_streaming}
:no_body ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :sent}
:inform ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :unsent}
end
end
defp handle_keepalive(status, headers, socket) do
response_connection_header = safe_downcase(Bandit.Headers.get_header(headers, "connection"))
# Per RFC9112§9.3
cond do
status in 100..199 ->
{headers, socket}
socket.request_connection_header == "close" || response_connection_header == "close" ->
{headers, %{socket | keepalive: false}}
socket.version == :"HTTP/1.1" ->
{headers, %{socket | keepalive: true}}
socket.version == :"HTTP/1.0" && socket.request_connection_header == "keep-alive" ->
{[{"connection", "keep-alive"} | headers], %{socket | keepalive: true}}
true ->
{[{"connection", "close"} | headers], %{socket | keepalive: false}}
end
end
defp safe_downcase(str) when is_binary(str), do: String.downcase(str, :ascii)
defp safe_downcase(str), do: str
defp encode_headers(headers) do
headers
|> Enum.map(fn {k, v} -> [k, ": ", v, "\r\n"] end)
|> then(&[&1 | ["\r\n"]])
end
def send_data(%@for{write_state: :writing} = socket, data, end_request) do
send!(socket.socket, [socket.send_buffer | data])
write_state = if end_request, do: :sent, else: :writing
%{socket | write_state: write_state, send_buffer: []}
end
def send_data(%@for{write_state: :chunking} = socket, data, end_request) do
byte_size = data |> IO.iodata_length()
send!(socket.socket, [Integer.to_string(byte_size, 16), "\r\n", data, "\r\n"])
write_state = if end_request, do: :sent, else: :chunking
%{socket | write_state: write_state}
end
def send_data(%@for{write_state: :chunk_streaming} = socket, data, end_request) do
send!(socket.socket, data)
write_state = if end_request, do: :sent, else: :chunk_streaming
%{socket | write_state: write_state}
end
def sendfile(%@for{write_state: :writing} = socket, path, offset, length) do
send!(socket.socket, socket.send_buffer)
case ThousandIsland.Socket.sendfile(socket.socket, path, offset, length) do
{:ok, _bytes_written} -> %{socket | write_state: :sent}
{:error, reason} -> socket_error!(reason)
end
end
@spec send!(ThousandIsland.Socket.t(), iolist()) :: :ok | no_return()
defp send!(socket, payload) do
case ThousandIsland.Socket.send(socket, payload) do
:ok ->
:ok
{:error, reason} ->
# Prevent error handlers from possibly trying to send again
send(self(), {:plug_conn, :sent})
socket_error!(reason)
end
end
def ensure_completed(%@for{read_state: :read} = socket), do: socket
def ensure_completed(%@for{keepalive: false} = socket), do: socket
def ensure_completed(%@for{} = socket) do
case read_data(socket, []) do
{:ok, _data, socket} -> socket
{:more, _data, _socket} -> request_error!("Unable to read remaining data in request body")
end
rescue
e in [Bandit.HTTPError] ->
# If we got a timeout during ensure_completed (draining the body),
# check if the client actually disconnected.
if e.plug_status == :request_timeout do
handle_timeout_with_disconnect_check!(socket.socket)
else
reraise e, __STACKTRACE__
end
end
def supported_upgrade?(%@for{} = _socket, protocol), do: protocol == :websocket
def send_on_error(%@for{}, %Bandit.TransportError{}), do: :ok
def send_on_error(%@for{} = socket, error) do
receive do
{:plug_conn, :sent} -> %{socket | write_state: :sent}
after
0 ->
status = error |> Plug.Exception.status() |> Plug.Conn.Status.code()
try do
send_headers(socket, status, [{"connection", "close"}], :no_body)
rescue
_e in [Bandit.TransportError, Bandit.HTTPError] -> :ok
end
end
end
@spec request_error!(term()) :: no_return()
@spec request_error!(term(), Plug.Conn.status()) :: no_return()
defp request_error!(reason, plug_status \\ :bad_request) do
raise Bandit.HTTPError, message: to_string(reason), plug_status: plug_status
end
@spec socket_error!(term()) :: no_return()
defp socket_error!(reason) do
raise Bandit.TransportError, message: "Unrecoverable error: #{reason}", error: reason
end
end
end

View File

@@ -0,0 +1,108 @@
# HTTP/2 Handler
Included in this folder is a complete `ThousandIsland.Handler` based implementation of HTTP/2 as
defined in [RFC 9110](https://datatracker.ietf.org/doc/rfc9110) & [RFC
9113](https://datatracker.ietf.org/doc/rfc9113)
## Process model
Within a Bandit server, an HTTP/2 connection is modeled as a set of processes:
* 1 process per connection, a `Bandit.HTTP2.Handler` module implementing the
`ThousandIsland.Handler` behaviour, and;
* 1 process per stream (i.e.: per HTTP request) within the connection, implemented as
a `Bandit.HTTP2.StreamProcess` process
Each of these processes model the majority of their state via a
`Bandit.HTTP2.Connection` & `Bandit.HTTP2.Stream` struct, respectively.
The lifetimes of these processes correspond to their role; a connection process lives for as long
as a client is connected, and a stream process lives only as long as is required to process
a single stream request within a connection.
Connection processes are the 'root' of each connection's process group, and are supervised by
Thousand Island in the same manner that `ThousandIsland.Handler` processes are usually supervised
(see the [project README](https://github.com/mtrudel/thousand_island) for details).
Stream processes are not supervised by design. The connection process starts new
stream processes as required, via a standard `start_link`
call, and manages the termination of the resultant linked stream processes by
handling `{:EXIT,...}` messages as described in the Elixir documentation. Each
stream process stays alive long enough to fully model an HTTP/2 stream,
beginning its life in the `:init` state and ending it in the `:closed` state (or
else by a stream or connection error being raised). This approach is aligned
with the realities of the HTTP/2 model, insofar as if a connection process
terminates there is no reason to keep its constituent stream processes around,
and if a stream process dies the connection should be able to handle this
without itself terminating. It also means that our process model is very
lightweight - there is no extra supervision overhead present because no such
supervision is required for the system to function in the desired way.
## Reading client data
The overall structure of the implementation is managed by the `Bandit.HTTP2.Handler` module, and
looks like the following:
1. Bytes are asynchronously received from ThousandIsland via the
`Bandit.HTTP2.Handler.handle_data/3` function
2. Frames are parsed from these bytes by calling the `Bandit.HTTP2.Frame.deserialize/2`
function. If successful, the parsed frame(s) are returned. We retain any unparsed bytes in
a buffer in order to attempt parsing them upon receipt of subsequent data from the client
3. Parsed frames are passed into the `Bandit.HTTP2.Connection` module along with a struct of
same module. Frames are processed via the `Bandit.HTTP2.Connection.handle_frame/3` function.
Connection-level frames are handled within the `Bandit.HTTP2.Connection`
struct, and stream-level frames are passed along to the corresponding stream
process, which is wholly responsible for managing all aspects of a stream's
state (which is tracked via the `Bandit.HTTP2.Stream` struct). The one
exception to this is the handling of frames sent to streams which have
already been closed (and whose corresponding processes have thus terminated).
Any such frames are discarded without effect.
4. This process is repeated every time we receive data from the client until the
`Bandit.HTTP2.Connection` module indicates that the connection should be closed, either
normally or due to error. Note that frame deserialization may end up returning a connection
error if the parsed frames fail specific criteria (generally, the frame parsing modules are
responsible for identifying errors as described in [section
6](https://datatracker.ietf.org/doc/html/rfc9113#section-6) of RFC 9113). In these cases, the
failure is passed through to the connection module for processing in order to coordinate an
orderly shutdown or client notification as appropriate
## Processing requests
The state of a particular stream are contained within a `Bandit.HTTP2.Stream`
struct, maintained within a `Bandit.HTTP2.StreamProcess` process. As part of the
stream's lifecycle, the server's configured Plug is called, with an instance of
the `Bandit.Adapter` struct being used to interface with the Plug. There
is a separation of concerns between the aspect of HTTP semantics managed by
`Bandit.Adapter` (roughly, those concerns laid out in
[RFC9110](https://datatracker.ietf.org/doc/html/rfc9110)) and the more
transport-specific HTTP/2 concerns managed by `Bandit.HTTP2.Stream` (roughly the
concerns specified in [RFC9113](https://datatracker.ietf.org/doc/html/rfc9113)).
# Testing
All of this is exhaustively tested. Tests are broken up primarily into `protocol_test.exs`, which
is concerned with aspects of the implementation relating to protocol conformance and
client-facing concerns, while `plug_test.exs` is concerned with aspects of the implementation
having to do with the Plug API and application-facing concerns. There are also more
unit-style tests covering frame serialization and deserialization.
In addition, the `h2spec` conformance suite is run via a `System` wrapper & executes the entirety
of the suite (in strict mode) against a running Bandit server.
## Limitations and Assumptions
Some limitations and assumptions of this implementation:
* This handler assumes that the HTTP/2 connection preface has already been consumed from the
client. The `Bandit.InitialHandler` module uses this preface to discriminate between various
HTTP versions when determining which handler to use
* Priority frames are parsed and validated, but do not induce any action on the part of the
server. There is no priority assigned to respective streams in terms of processing; all streams
are run in parallel as soon as they arrive
* While flow control is completely implemented here, the specific values used for upload flow
control (that is, the end that we control) are fixed. Specifically, we attempt to maintain
fairly large windows in order to not restrict client uploads (we 'slow-start' window changes
upon receipt of first byte, mostly to retain parity between connection and stream window
management since connection windows cannot be changed via settings). The majority of flow
control logic has been encapsulated in the `Bandit.HTTP2.FlowControl` module should future
refinement be required

View File

@@ -0,0 +1,473 @@
defmodule Bandit.HTTP2.Connection do
@moduledoc false
# Represents the state of an HTTP/2 connection, in a process-free manner. An instance of this
# struct is maintained as the state of a `Bandit.HTTP2.Handler` process, and it moves an HTTP/2
# connection through its lifecycle by calling functions defined on this module
require Logger
defstruct local_settings: %Bandit.HTTP2.Settings{},
remote_settings: %Bandit.HTTP2.Settings{},
fragment_frame: nil,
send_hpack_state: HPAX.new(4096),
recv_hpack_state: HPAX.new(4096),
send_window_size: 65_535,
recv_window_size: 65_535,
streams: %Bandit.HTTP2.StreamCollection{},
pending_sends: [],
conn_data: nil,
telemetry_span: nil,
plug: nil,
opts: %{},
reset_stream_timestamps: []
@typedoc "Encapsulates the state of an HTTP/2 connection"
@type t :: %__MODULE__{
local_settings: Bandit.HTTP2.Settings.t(),
remote_settings: Bandit.HTTP2.Settings.t(),
fragment_frame: Bandit.HTTP2.Frame.Headers.t() | nil,
send_hpack_state: term(),
recv_hpack_state: term(),
send_window_size: non_neg_integer(),
recv_window_size: non_neg_integer(),
streams: Bandit.HTTP2.StreamCollection.t(),
pending_sends: [{Bandit.HTTP2.Stream.stream_id(), iodata(), boolean(), fun()}],
conn_data: Bandit.Pipeline.conn_data(),
telemetry_span: ThousandIsland.Telemetry.t(),
plug: Bandit.Pipeline.plug_def(),
opts: %{
required(:http) => Bandit.http_options(),
required(:http_2) => Bandit.http_2_options()
},
reset_stream_timestamps: [integer()]
}
@spec init(ThousandIsland.Socket.t(), Bandit.Pipeline.plug_def(), map()) :: t()
def init(socket, plug, opts) do
connection = %__MODULE__{
local_settings:
struct!(Bandit.HTTP2.Settings, Keyword.get(opts.http_2, :default_local_settings, [])),
conn_data: Bandit.SocketHelpers.conn_data(socket),
telemetry_span: ThousandIsland.Socket.telemetry_span(socket),
plug: plug,
opts: opts
}
# Send SETTINGS frame per RFC9113§3.4
%Bandit.HTTP2.Frame.Settings{ack: false, settings: Map.from_struct(connection.local_settings)}
|> send_frame(socket, connection)
connection
end
#
# Receiving while expecting CONTINUATION frames is a special case (RFC9113§6.10); handle it first
#
@spec handle_frame(Bandit.HTTP2.Frame.frame(), ThousandIsland.Socket.t(), t()) :: t()
def handle_frame(
%Bandit.HTTP2.Frame.Continuation{end_headers: true, stream_id: stream_id} = frame,
socket,
%__MODULE__{fragment_frame: %Bandit.HTTP2.Frame.Headers{stream_id: stream_id}} =
connection
) do
header_block = connection.fragment_frame.fragment <> frame.fragment
header_frame = %{connection.fragment_frame | end_headers: true, fragment: header_block}
handle_frame(header_frame, socket, %{connection | fragment_frame: nil})
end
def handle_frame(
%Bandit.HTTP2.Frame.Continuation{end_headers: false, stream_id: stream_id} = frame,
_socket,
%__MODULE__{fragment_frame: %Bandit.HTTP2.Frame.Headers{stream_id: stream_id}} =
connection
) do
fragment = connection.fragment_frame.fragment <> frame.fragment
check_oversize_fragment!(fragment, connection)
fragment_frame = %{connection.fragment_frame | fragment: fragment}
%{connection | fragment_frame: fragment_frame}
end
def handle_frame(_frame, _socket, %__MODULE__{fragment_frame: %Bandit.HTTP2.Frame.Headers{}}) do
connection_error!("Expected CONTINUATION frame (RFC9113§6.10)")
end
#
# Connection-level receiving
#
def handle_frame(%Bandit.HTTP2.Frame.Settings{ack: true}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.Settings{ack: false} = frame, socket, connection) do
%Bandit.HTTP2.Frame.Settings{ack: true} |> send_frame(socket, connection)
# Merge whatever new settings were sent with our existing remote settings
remote_settings = struct(connection.remote_settings, frame.settings)
send_hpack_state = HPAX.resize(connection.send_hpack_state, remote_settings.header_table_size)
delta = remote_settings.initial_window_size - connection.remote_settings.initial_window_size
Bandit.HTTP2.StreamCollection.get_pids(connection.streams)
|> Enum.each(&Bandit.HTTP2.Stream.deliver_send_window_update(&1, delta))
do_pending_sends(socket, %{
connection
| remote_settings: remote_settings,
send_hpack_state: send_hpack_state
})
end
def handle_frame(%Bandit.HTTP2.Frame.Ping{ack: true}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.Ping{ack: false} = frame, socket, connection) do
%Bandit.HTTP2.Frame.Ping{ack: true, payload: frame.payload} |> send_frame(socket, connection)
connection
end
def handle_frame(%Bandit.HTTP2.Frame.Goaway{}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.WindowUpdate{stream_id: 0} = frame, socket, connection) do
case Bandit.HTTP2.FlowControl.update_send_window(
connection.send_window_size,
frame.size_increment
) do
{:ok, new_window} -> do_pending_sends(socket, %{connection | send_window_size: new_window})
{:error, error} -> connection_error!(error, Bandit.HTTP2.Errors.flow_control_error())
end
end
#
# Stream-level receiving
#
def handle_frame(%Bandit.HTTP2.Frame.WindowUpdate{} = frame, _socket, connection) do
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_send_window_update(stream, frame.size_increment)
end)
%{connection | streams: streams}
end
def handle_frame(%Bandit.HTTP2.Frame.Headers{end_headers: true} = frame, _socket, connection) do
check_oversize_fragment!(frame.fragment, connection)
case HPAX.decode(frame.fragment, connection.recv_hpack_state) do
{:ok, headers, recv_hpack_state} ->
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_headers(stream, headers, frame.end_stream)
end)
%{connection | recv_hpack_state: recv_hpack_state, streams: streams}
_ ->
connection_error!("Header decode error", Bandit.HTTP2.Errors.compression_error())
end
end
def handle_frame(%Bandit.HTTP2.Frame.Headers{end_headers: false} = frame, _socket, connection) do
check_oversize_fragment!(frame.fragment, connection)
%{connection | fragment_frame: frame}
end
def handle_frame(%Bandit.HTTP2.Frame.Continuation{}, _socket, _connection) do
connection_error!("Received unexpected CONTINUATION frame (RFC9113§6.10)")
end
def handle_frame(%Bandit.HTTP2.Frame.Data{} = frame, socket, connection) do
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_data(stream, frame.data, frame.end_stream)
end)
{recv_window_size, window_increment} =
Bandit.HTTP2.FlowControl.compute_recv_window(
connection.recv_window_size,
byte_size(frame.data)
)
if window_increment > 0 do
%Bandit.HTTP2.Frame.WindowUpdate{stream_id: 0, size_increment: window_increment}
|> send_frame(socket, connection)
end
%{connection | recv_window_size: recv_window_size, streams: streams}
end
def handle_frame(%Bandit.HTTP2.Frame.Priority{}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.RstStream{} = frame, _socket, connection) do
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_rst_stream(stream, frame.error_code)
end)
%{connection | streams: streams}
|> check_reset_stream_rate_limit!()
end
# Catch-all handler for unknown frame types
def handle_frame(%Bandit.HTTP2.Frame.Unknown{} = frame, _socket, connection) do
Logger.warning("Unknown frame (#{inspect(Map.from_struct(frame))})",
domain: [:bandit],
plug: connection.plug
)
connection
end
defp with_stream(connection, stream_id, fun) do
case Bandit.HTTP2.StreamCollection.get_pid(connection.streams, stream_id) do
pid when is_pid(pid) or pid == :closed ->
fun.(pid)
connection.streams
:new ->
new_stream!(connection, stream_id)
sendfile_chunk_size =
Keyword.get(connection.opts.http_2, :sendfile_chunk_size, 1_048_576)
stream =
Bandit.HTTP2.Stream.init(
self(),
stream_id,
connection.remote_settings.initial_window_size,
sendfile_chunk_size
)
case Bandit.HTTP2.StreamProcess.start_link(
stream,
connection.plug,
connection.telemetry_span,
connection.conn_data,
connection.opts
) do
{:ok, pid} ->
streams = Bandit.HTTP2.StreamCollection.insert(connection.streams, stream_id, pid)
with_stream(%{connection | streams: streams}, stream_id, fun)
_ ->
raise "Unable to start stream process"
end
:invalid ->
connection_error!("Received invalid stream identifier")
end
end
defp new_stream!(connection, stream_id) do
max_requests = Keyword.get(connection.opts.http_2, :max_requests, 0)
if max_requests != 0 and
max_requests <= Bandit.HTTP2.StreamCollection.stream_count(connection.streams) do
connection_error!("Connection count exceeded", Bandit.HTTP2.Errors.refused_stream())
end
if connection.local_settings.max_concurrent_streams <=
Bandit.HTTP2.StreamCollection.open_stream_count(connection.streams) do
stream_error!(
"Concurrent stream count exceeded",
stream_id,
Bandit.HTTP2.Errors.refused_stream()
)
end
end
defp check_oversize_fragment!(fragment, connection) do
if byte_size(fragment) > Keyword.get(connection.opts.http_2, :max_header_block_size, 50_000),
do: connection_error!("Received overlong headers")
end
@spec check_reset_stream_rate_limit!(t()) :: t()
defp check_reset_stream_rate_limit!(connection) do
case Keyword.get(connection.opts.http_2, :max_reset_stream_rate, {500, 10_000}) do
nil ->
connection
{intensity, period} ->
now = :erlang.monotonic_time(:millisecond)
threshold = now - period
resets = connection.reset_stream_timestamps
recent_timestamps = can_reset(intensity - 1, threshold, resets, [], intensity, period)
%{connection | reset_stream_timestamps: [now | recent_timestamps]}
end
end
defp can_reset(_, _, [], acc, _, _),
do: :lists.reverse(acc)
defp can_reset(_, threshold, [restart | _], acc, _, _) when restart < threshold,
do: :lists.reverse(acc)
defp can_reset(0, _, [_ | _], _acc, intensity, period),
do:
connection_error!(
"Stream resets rate exceeded #{intensity} resets in #{period}ms",
Bandit.HTTP2.Errors.enhance_your_calm()
)
defp can_reset(n, threshold, [restart | restarts], acc, intensity, period),
do: can_reset(n - 1, threshold, restarts, [restart | acc], intensity, period)
# Shared logic to send any pending frames upon adjustment of our send window
defp do_pending_sends(socket, connection) do
connection.pending_sends
|> Enum.reverse()
|> Enum.reduce(connection, fn pending_send, connection ->
connection = connection |> Map.update!(:pending_sends, &List.delete(&1, pending_send))
{stream_id, rest, end_stream, on_unblock} = pending_send
send_data(stream_id, rest, end_stream, on_unblock, socket, connection)
end)
end
#
# Sending logic
#
# All callers of functions below will be from stream processes
#
#
# Stream-level sending
#
@spec send_headers(
Bandit.HTTP2.Stream.stream_id(),
Plug.Conn.headers(),
boolean(),
ThousandIsland.Socket.t(),
t()
) :: t()
def send_headers(stream_id, headers, end_stream, socket, connection) do
with enc_headers <- Enum.map(headers, fn {key, value} -> {:store, key, value} end),
{block, send_hpack_state} <- HPAX.encode(enc_headers, connection.send_hpack_state) do
%Bandit.HTTP2.Frame.Headers{
stream_id: stream_id,
end_stream: end_stream,
fragment: block
}
|> send_frame(socket, connection)
%{connection | send_hpack_state: send_hpack_state}
end
end
@spec send_data(
Bandit.HTTP2.Stream.stream_id(),
iodata(),
boolean(),
fun(),
ThousandIsland.Socket.t(),
t()
) :: t()
def send_data(stream_id, data, end_stream, on_unblock, socket, connection) do
with connection_window_size <- connection.send_window_size,
max_bytes_to_send <- max(connection_window_size, 0),
{data_to_send, bytes_to_send, rest} <- split_data(data, max_bytes_to_send),
connection <- %{connection | send_window_size: connection_window_size - bytes_to_send},
end_stream_to_send <- end_stream && byte_size(rest) == 0 do
if end_stream_to_send || bytes_to_send > 0 do
%Bandit.HTTP2.Frame.Data{
stream_id: stream_id,
end_stream: end_stream_to_send,
data: data_to_send
}
|> send_frame(socket, connection)
end
if byte_size(rest) == 0 do
on_unblock.()
connection
else
pending_sends = [{stream_id, rest, end_stream, on_unblock} | connection.pending_sends]
%{connection | pending_sends: pending_sends}
end
end
end
defp split_data(data, desired_length) do
data_length = IO.iodata_length(data)
if data_length <= desired_length do
{data, data_length, <<>>}
else
<<to_send::binary-size(desired_length), rest::binary>> = IO.iodata_to_binary(data)
{to_send, desired_length, rest}
end
end
@spec send_recv_window_update(
Bandit.HTTP2.Stream.stream_id(),
non_neg_integer(),
ThousandIsland.Socket.t(),
t()
) :: term()
def send_recv_window_update(stream_id, size_increment, socket, connection) do
%Bandit.HTTP2.Frame.WindowUpdate{stream_id: stream_id, size_increment: size_increment}
|> send_frame(socket, connection)
end
@spec send_rst_stream(
Bandit.HTTP2.Stream.stream_id(),
Bandit.HTTP2.Errors.error_code(),
ThousandIsland.Socket.t(),
t()
) :: term()
def send_rst_stream(stream_id, error_code, socket, connection) do
%Bandit.HTTP2.Frame.RstStream{stream_id: stream_id, error_code: error_code}
|> send_frame(socket, connection)
end
@spec stream_terminated(pid(), t()) :: t()
def stream_terminated(pid, connection) do
%{connection | streams: Bandit.HTTP2.StreamCollection.delete(connection.streams, pid)}
end
#
# Helper functions
#
@spec close_connection(Bandit.HTTP2.Errors.error_code(), term(), ThousandIsland.Socket.t(), t()) ::
{:close, t()} | {:error, term(), t()}
def close_connection(error_code, reason, socket, connection) do
last_stream_id = Bandit.HTTP2.StreamCollection.last_stream_id(connection.streams)
%Bandit.HTTP2.Frame.Goaway{last_stream_id: last_stream_id, error_code: error_code}
|> send_frame(socket, connection)
if error_code == Bandit.HTTP2.Errors.no_error(),
do: {:close, connection},
else: {:error, reason, connection}
end
@spec connection_error!(term()) :: no_return()
@spec connection_error!(term(), Bandit.HTTP2.Errors.error_code()) :: no_return()
defp connection_error!(message, error_code \\ Bandit.HTTP2.Errors.protocol_error()) do
raise Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code
end
@spec stream_error!(
String.t(),
Bandit.HTTP2.Stream.stream_id(),
Bandit.HTTP2.Errors.error_code()
) ::
no_return()
defp stream_error!(message, stream_id, error_code) do
raise Bandit.HTTP2.Errors.StreamError,
message: message,
error_code: error_code,
stream_id: stream_id
end
defp send_frame(frame, socket, connection) do
_ =
ThousandIsland.Socket.send(
socket,
Bandit.HTTP2.Frame.serialize(frame, connection.remote_settings.max_frame_size)
)
:ok
end
end

View File

@@ -0,0 +1,59 @@
defmodule Bandit.HTTP2.Errors do
@moduledoc false
# Errors as defined in RFC9113§7
@typedoc "An error code as defined for GOAWAY and RST_STREAM errors"
@type error_code() ::
(no_error :: 0x0)
| (protocol_error :: 0x1)
| (internal_error :: 0x2)
| (flow_control_error :: 0x3)
| (settings_timeout :: 0x4)
| (stream_closed :: 0x5)
| (frame_size_error :: 0x6)
| (refused_stream :: 0x7)
| (cancel :: 0x8)
| (compression_error :: 0x9)
| (connect_error :: 0xA)
| (enhance_your_calm :: 0xB)
| (inadequate_security :: 0xC)
| (http_1_1_requires :: 0xD)
error_codes = %{
no_error: 0x0,
protocol_error: 0x1,
internal_error: 0x2,
flow_control_error: 0x3,
settings_timeout: 0x4,
stream_closed: 0x5,
frame_size_error: 0x6,
refused_stream: 0x7,
cancel: 0x8,
compression_error: 0x9,
connect_error: 0xA,
enhance_your_calm: 0xB,
inadequate_security: 0xC,
http_1_1_requires: 0xD
}
@spec to_reason(integer()) :: atom()
for {name, value} <- error_codes do
@spec unquote(name)() :: unquote(Macro.var(name, Elixir)) :: unquote(value)
def unquote(name)(), do: unquote(value)
def to_reason(unquote(value)), do: unquote(name)
end
def to_reason(_), do: :unknown
# Represents a stream error as defined in RFC9113§5.4.2
defmodule StreamError do
defexception [:message, :error_code, :stream_id]
end
# Represents a stream error as defined in RFC9113§5.4.3
defmodule ConnectionError do
defexception [:message, :error_code]
end
end

View File

@@ -0,0 +1,43 @@
defmodule Bandit.HTTP2.FlowControl do
@moduledoc false
# Helpers for working with flow control window calculations
import Bitwise
@max_window_increment (1 <<< 31) - 1
@max_window_size (1 <<< 31) - 1
@min_window_size 1 <<< 30
@spec compute_recv_window(non_neg_integer(), non_neg_integer()) ::
{non_neg_integer(), non_neg_integer()}
def compute_recv_window(recv_window_size, data_size) do
# This is what our window size will be after receiving data_size bytes
recv_window_size = recv_window_size - data_size
if recv_window_size > @min_window_size do
# We have room to go before we need to update our window
{recv_window_size, 0}
else
# We want our new window to be as large as possible, but are limited by both the maximum size
# of the window (2^31-1) and the maximum size of the increment we can send to the client, both
# per RFC9113§6.9. Be careful about handling cases where we have a negative window due to
# misbehaving clients or network races
new_recv_window_size = min(recv_window_size + @max_window_increment, @max_window_size)
# Finally, determine what increment to send to the client
increment = new_recv_window_size - recv_window_size
{new_recv_window_size, increment}
end
end
@spec update_send_window(non_neg_integer(), non_neg_integer()) ::
{:ok, non_neg_integer()} | {:error, String.t()}
def update_send_window(current_send_window, increment) do
if current_send_window + increment > @max_window_size do
{:error, "Invalid WINDOW_UPDATE increment RFC9113§6.9.1"}
else
{:ok, current_send_window + increment}
end
end
end

View File

@@ -0,0 +1,106 @@
defmodule Bandit.HTTP2.Frame do
@moduledoc false
@typedoc "Indicates a frame type"
@type frame_type :: non_neg_integer()
@typedoc "The flags passed along with a frame"
@type flags :: byte()
@typedoc "A valid HTTP/2 frame"
@type frame ::
Bandit.HTTP2.Frame.Data.t()
| Bandit.HTTP2.Frame.Headers.t()
| Bandit.HTTP2.Frame.Priority.t()
| Bandit.HTTP2.Frame.RstStream.t()
| Bandit.HTTP2.Frame.Settings.t()
| Bandit.HTTP2.Frame.Ping.t()
| Bandit.HTTP2.Frame.Goaway.t()
| Bandit.HTTP2.Frame.WindowUpdate.t()
| Bandit.HTTP2.Frame.Continuation.t()
| Bandit.HTTP2.Frame.Unknown.t()
@spec deserialize(binary(), non_neg_integer()) ::
{{:ok, frame()}, iodata()}
| {{:more, iodata()}, <<>>}
| {{:error, Bandit.HTTP2.Errors.error_code(), binary()}, iodata()}
| nil
def deserialize(
<<length::24, type::8, flags::8, _reserved::1, stream_id::31,
payload::binary-size(length), rest::binary>>,
max_frame_size
)
when length <= max_frame_size do
type
|> case do
0x0 -> Bandit.HTTP2.Frame.Data.deserialize(flags, stream_id, payload)
0x1 -> Bandit.HTTP2.Frame.Headers.deserialize(flags, stream_id, payload)
0x2 -> Bandit.HTTP2.Frame.Priority.deserialize(flags, stream_id, payload)
0x3 -> Bandit.HTTP2.Frame.RstStream.deserialize(flags, stream_id, payload)
0x4 -> Bandit.HTTP2.Frame.Settings.deserialize(flags, stream_id, payload)
0x5 -> Bandit.HTTP2.Frame.PushPromise.deserialize(flags, stream_id, payload)
0x6 -> Bandit.HTTP2.Frame.Ping.deserialize(flags, stream_id, payload)
0x7 -> Bandit.HTTP2.Frame.Goaway.deserialize(flags, stream_id, payload)
0x8 -> Bandit.HTTP2.Frame.WindowUpdate.deserialize(flags, stream_id, payload)
0x9 -> Bandit.HTTP2.Frame.Continuation.deserialize(flags, stream_id, payload)
unknown -> Bandit.HTTP2.Frame.Unknown.deserialize(unknown, flags, stream_id, payload)
end
|> then(&{&1, rest})
end
# This is a little more aggressive than necessary. RFC9113§4.2 says we only need
# to treat frame size violations as connection level errors if the frame in
# question would affect the connection as a whole, so we could be more surgical
# here and send stream level errors in some cases. However, we are well within
# our rights to consider such errors as connection errors
def deserialize(
<<length::24, _type::8, _flags::8, _reserved::1, _stream_id::31,
_payload::binary-size(length), rest::binary>>,
max_frame_size
)
when length > max_frame_size do
{{:error, Bandit.HTTP2.Errors.frame_size_error(), "Payload size too large (RFC9113§4.2)"},
rest}
end
# nil is used to indicate for Stream.unfold/2 that the frame deserialization is finished
def deserialize(<<>>, _max_frame_size) do
nil
end
def deserialize(msg, _max_frame_size) do
{{:more, msg}, <<>>}
end
defmodule Flags do
@moduledoc false
import Bitwise
defguard set?(flags, bit) when band(flags, bsl(1, bit)) != 0
defguard clear?(flags, bit) when band(flags, bsl(1, bit)) == 0
@spec set([0..255]) :: 0..255
def set([]), do: 0x0
def set([bit | rest]), do: bor(bsl(1, bit), set(rest))
end
defprotocol Serializable do
@moduledoc false
@spec serialize(any(), non_neg_integer()) :: [
{Bandit.HTTP2.Frame.frame_type(), Bandit.HTTP2.Frame.flags(),
Bandit.HTTP2.Stream.stream_id(), iodata()}
]
def serialize(frame, max_frame_size)
end
@spec serialize(frame(), non_neg_integer()) :: iolist()
def serialize(frame, max_frame_size) do
frame
|> Serializable.serialize(max_frame_size)
|> Enum.map(fn {type, flags, stream_id, payload} ->
[<<IO.iodata_length(payload)::24, type::8, flags::8, 0::1, stream_id::31>>, payload]
end)
end
end

View File

@@ -0,0 +1,57 @@
defmodule Bandit.HTTP2.Frame.Continuation do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct stream_id: nil,
end_headers: false,
fragment: nil
@typedoc "An HTTP/2 CONTINUATION frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
end_headers: boolean(),
fragment: iodata()
}
@end_headers_bit 2
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"CONTINUATION frame with zero stream_id (RFC9113§6.10)"}
end
def deserialize(flags, stream_id, <<fragment::binary>>) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_headers: set?(flags, @end_headers_bit),
fragment: fragment
}}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@end_headers_bit 2
def serialize(%Bandit.HTTP2.Frame.Continuation{} = frame, max_frame_size) do
fragment_length = IO.iodata_length(frame.fragment)
if fragment_length <= max_frame_size do
[{0x9, set([@end_headers_bit]), frame.stream_id, frame.fragment}]
else
<<this_frame::binary-size(max_frame_size), rest::binary>> =
IO.iodata_to_binary(frame.fragment)
[
{0x9, 0x00, frame.stream_id, this_frame}
| Bandit.HTTP2.Frame.Serializable.serialize(
%Bandit.HTTP2.Frame.Continuation{stream_id: frame.stream_id, fragment: rest},
max_frame_size
)
]
end
end
end
end

View File

@@ -0,0 +1,78 @@
defmodule Bandit.HTTP2.Frame.Data do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct stream_id: nil,
end_stream: false,
data: nil
@typedoc "An HTTP/2 DATA frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
end_stream: boolean(),
data: iodata()
}
@end_stream_bit 0
@padding_bit 3
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(), "DATA frame with zero stream_id (RFC9113§6.1)"}
end
def deserialize(flags, stream_id, <<padding_length::8, rest::binary>>)
when set?(flags, @padding_bit) and byte_size(rest) >= padding_length do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
data: binary_part(rest, 0, byte_size(rest) - padding_length)
}}
end
def deserialize(flags, stream_id, <<data::binary>>) when clear?(flags, @padding_bit) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
data: data
}}
end
def deserialize(flags, _stream_id, <<_padding_length::8, _rest::binary>>)
when set?(flags, @padding_bit) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"DATA frame with invalid padding length (RFC9113§6.1)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@end_stream_bit 0
def serialize(%Bandit.HTTP2.Frame.Data{} = frame, max_frame_size) do
data_length = IO.iodata_length(frame.data)
if data_length <= max_frame_size do
flags = if frame.end_stream, do: [@end_stream_bit], else: []
[{0x0, set(flags), frame.stream_id, frame.data}]
else
<<this_frame::binary-size(max_frame_size), rest::binary>> =
IO.iodata_to_binary(frame.data)
[
{0x0, 0x00, frame.stream_id, this_frame}
| Bandit.HTTP2.Frame.Serializable.serialize(
%Bandit.HTTP2.Frame.Data{
stream_id: frame.stream_id,
end_stream: frame.end_stream,
data: rest
},
max_frame_size
)
]
end
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule Bandit.HTTP2.Frame.Goaway do
@moduledoc false
defstruct last_stream_id: 0, error_code: 0, debug_data: <<>>
@typedoc "An HTTP/2 GOAWAY frame"
@type t :: %__MODULE__{
last_stream_id: Bandit.HTTP2.Stream.stream_id(),
error_code: Bandit.HTTP2.Errors.error_code(),
debug_data: iodata()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(
_flags,
0,
<<_reserved::1, last_stream_id::31, error_code::32, debug_data::binary>>
) do
{:ok,
%__MODULE__{last_stream_id: last_stream_id, error_code: error_code, debug_data: debug_data}}
end
def deserialize(_flags, stream_id, _payload) when stream_id != 0 do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"Invalid stream ID in GOAWAY frame (RFC9113§6.8)"}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"GOAWAY frame with invalid payload size (RFC9113§6.8)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.Goaway{} = frame, _max_frame_size) do
[
{0x7, 0x0, 0,
<<0x0::1, frame.last_stream_id::31, frame.error_code::32, frame.debug_data::binary>>}
]
end
end
end

View File

@@ -0,0 +1,140 @@
defmodule Bandit.HTTP2.Frame.Headers do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct stream_id: nil,
end_stream: false,
end_headers: false,
exclusive_dependency: false,
stream_dependency: nil,
weight: nil,
fragment: nil
@typedoc "An HTTP/2 HEADERS frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
end_stream: boolean(),
end_headers: boolean(),
exclusive_dependency: boolean(),
stream_dependency: Bandit.HTTP2.Stream.stream_id() | nil,
weight: non_neg_integer() | nil,
fragment: iodata()
}
@end_stream_bit 0
@end_headers_bit 2
@padding_bit 3
@priority_bit 5
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"HEADERS frame with zero stream_id (RFC9113§6.2)"}
end
# Padding and priority
def deserialize(
flags,
stream_id,
<<padding_length::8, exclusive_dependency::1, stream_dependency::31, weight::8,
rest::binary>>
)
when set?(flags, @padding_bit) and set?(flags, @priority_bit) and
byte_size(rest) >= padding_length do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
exclusive_dependency: exclusive_dependency == 0x01,
stream_dependency: stream_dependency,
weight: weight,
fragment: binary_part(rest, 0, byte_size(rest) - padding_length)
}}
end
# Padding but not priority
def deserialize(flags, stream_id, <<padding_length::8, rest::binary>>)
when set?(flags, @padding_bit) and clear?(flags, @priority_bit) and
byte_size(rest) >= padding_length do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
fragment: binary_part(rest, 0, byte_size(rest) - padding_length)
}}
end
# Any other case where padding is set
def deserialize(flags, _stream_id, <<_padding_length::8, _rest::binary>>)
when set?(flags, @padding_bit) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"HEADERS frame with invalid padding length (RFC9113§6.2)"}
end
def deserialize(
flags,
stream_id,
<<exclusive_dependency::1, stream_dependency::31, weight::8, fragment::binary>>
)
when set?(flags, @priority_bit) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
exclusive_dependency: exclusive_dependency == 0x01,
stream_dependency: stream_dependency,
weight: weight,
fragment: fragment
}}
end
def deserialize(flags, stream_id, <<fragment::binary>>)
when clear?(flags, @priority_bit) and clear?(flags, @padding_bit) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
fragment: fragment
}}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@end_stream_bit 0
@end_headers_bit 2
def serialize(
%Bandit.HTTP2.Frame.Headers{
exclusive_dependency: false,
stream_dependency: nil,
weight: nil
} =
frame,
max_frame_size
) do
flags = if frame.end_stream, do: [@end_stream_bit], else: []
fragment_length = IO.iodata_length(frame.fragment)
if fragment_length <= max_frame_size do
[{0x1, set([@end_headers_bit | flags]), frame.stream_id, frame.fragment}]
else
<<this_frame::binary-size(max_frame_size), rest::binary>> =
IO.iodata_to_binary(frame.fragment)
[
{0x1, set(flags), frame.stream_id, this_frame}
| Bandit.HTTP2.Frame.Serializable.serialize(
%Bandit.HTTP2.Frame.Continuation{stream_id: frame.stream_id, fragment: rest},
max_frame_size
)
]
end
end
end
end

View File

@@ -0,0 +1,45 @@
defmodule Bandit.HTTP2.Frame.Ping do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct ack: false, payload: nil
@typedoc "An HTTP/2 PING frame"
@type t :: %__MODULE__{
ack: boolean(),
payload: iodata()
}
@ack_bit 0
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(flags, 0, <<payload::binary-size(8)>>) when set?(flags, @ack_bit) do
{:ok, %__MODULE__{ack: true, payload: payload}}
end
def deserialize(flags, 0, <<payload::binary-size(8)>>) when clear?(flags, @ack_bit) do
{:ok, %__MODULE__{ack: false, payload: payload}}
end
def deserialize(_flags, stream_id, _payload) when stream_id != 0 do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"Invalid stream ID in PING frame (RFC9113§6.7)"}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"PING frame with invalid payload size (RFC9113§6.7)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@ack_bit 0
def serialize(%Bandit.HTTP2.Frame.Ping{ack: true} = frame, _max_frame_size),
do: [{0x6, set([@ack_bit]), 0, frame.payload}]
def serialize(%Bandit.HTTP2.Frame.Ping{ack: false} = frame, _max_frame_size),
do: [{0x6, 0x0, 0, frame.payload}]
end
end

View File

@@ -0,0 +1,35 @@
defmodule Bandit.HTTP2.Frame.Priority do
@moduledoc false
defstruct stream_id: nil, dependent_stream_id: nil, weight: nil
@typedoc "An HTTP/2 PRIORITY frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
dependent_stream_id: Bandit.HTTP2.Stream.stream_id(),
weight: non_neg_integer()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"PRIORITY frame with zero stream_id (RFC9113§6.3)"}
end
def deserialize(_flags, stream_id, <<_reserved::1, dependent_stream_id::31, weight::8>>) do
{:ok,
%__MODULE__{stream_id: stream_id, dependent_stream_id: dependent_stream_id, weight: weight}}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"Invalid payload size in PRIORITY frame (RFC9113§6.3)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.Priority{} = frame, _max_frame_size) do
[{0x2, 0x0, frame.stream_id, <<0::1, frame.dependent_stream_id::31, frame.weight::8>>}]
end
end
end

View File

@@ -0,0 +1,9 @@
defmodule Bandit.HTTP2.Frame.PushPromise do
@moduledoc false
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, _stream, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(), "PUSH_PROMISE frame received (RFC9113§8.4)"}
end
end

View File

@@ -0,0 +1,33 @@
defmodule Bandit.HTTP2.Frame.RstStream do
@moduledoc false
defstruct stream_id: nil, error_code: nil
@typedoc "An HTTP/2 RST_STREAM frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
error_code: Bandit.HTTP2.Errors.error_code()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"RST_STREAM frame with zero stream_id (RFC9113§6.4)"}
end
def deserialize(_flags, stream_id, <<error_code::32>>) do
{:ok, %__MODULE__{stream_id: stream_id, error_code: error_code}}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"Invalid payload size in RST_STREAM frame (RFC9113§6.4)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.RstStream{} = frame, _max_frame_size) do
[{0x3, 0x0, frame.stream_id, <<frame.error_code::32>>}]
end
end
end

View File

@@ -0,0 +1,117 @@
defmodule Bandit.HTTP2.Frame.Settings do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
import Bitwise
@max_window_size (1 <<< 31) - 1
@min_frame_size 1 <<< 14
@max_frame_size (1 <<< 24) - 1
defstruct ack: false, settings: nil
@typedoc "An HTTP/2 SETTINGS frame"
@type t :: %__MODULE__{ack: true, settings: nil} | %__MODULE__{ack: false, settings: map()}
@ack_bit 0
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(flags, 0, payload) when clear?(flags, @ack_bit) do
payload
|> Stream.unfold(fn
<<>> -> nil
<<setting::16, value::32, rest::binary>> -> {{:ok, {setting, value}}, rest}
<<rest::binary>> -> {{:error, rest}, <<>>}
end)
|> Enum.reduce_while({:ok, %{}}, fn
{:ok, {0x01, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :header_table_size, value)}}
{:ok, {0x02, val}}, {:ok, acc} when val in [0x00, 0x01] ->
{:cont, {:ok, acc}}
{:ok, {0x02, _value}}, {:ok, _acc} ->
{:halt,
{:error, Bandit.HTTP2.Errors.protocol_error(), "Invalid enable_push value (RFC9113§6.5)"}}
{:ok, {0x03, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :max_concurrent_streams, value)}}
{:ok, {0x04, value}}, {:ok, _acc} when value > @max_window_size ->
{:halt,
{:error, Bandit.HTTP2.Errors.flow_control_error(), "Invalid window_size (RFC9113§6.5)"}}
{:ok, {0x04, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :initial_window_size, value)}}
{:ok, {0x05, value}}, {:ok, _acc} when value < @min_frame_size ->
{:halt,
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid max_frame_size (RFC9113§6.5)"}}
{:ok, {0x05, value}}, {:ok, _acc} when value > @max_frame_size ->
{:halt,
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid max_frame_size (RFC9113§6.5)"}}
{:ok, {0x05, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :max_frame_size, value)}}
{:ok, {0x06, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :max_header_list_size, value)}}
{:ok, {_setting, _value}}, {:ok, acc} ->
{:cont, {:ok, acc}}
{:error, _rest}, _acc ->
{:halt,
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid SETTINGS size (RFC9113§6.5)"}}
end)
|> case do
{:ok, settings} -> {:ok, %__MODULE__{ack: false, settings: settings}}
{:error, error_code, reason} -> {:error, error_code, reason}
end
end
def deserialize(flags, 0, <<>>) when set?(flags, @ack_bit) do
{:ok, %__MODULE__{ack: true}}
end
def deserialize(flags, 0, _payload) when set?(flags, @ack_bit) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"SETTINGS ack frame with non-empty payload (RFC9113§6.5)"}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(), "Invalid SETTINGS frame (RFC9113§6.5)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@ack_bit 0
def serialize(%Bandit.HTTP2.Frame.Settings{ack: true}, _max_frame_size),
do: [{0x4, set([@ack_bit]), 0, <<>>}]
def serialize(%Bandit.HTTP2.Frame.Settings{ack: false} = frame, _max_frame_size) do
# Encode default settings values as empty binaries so that we do not send
# them. This means we can't restore settings back to default values if we
# change them, but since we don't ever change our settings this is fine
payload =
frame.settings
|> Enum.uniq_by(fn {setting, _} -> setting end)
|> Enum.map(fn
{:header_table_size, 4_096} -> <<>>
{:header_table_size, value} -> <<0x01::16, value::32>>
{:max_concurrent_streams, :infinity} -> <<>>
{:max_concurrent_streams, value} -> <<0x03::16, value::32>>
{:initial_window_size, 65_535} -> <<>>
{:initial_window_size, value} -> <<0x04::16, value::32>>
{:max_frame_size, 16_384} -> <<>>
{:max_frame_size, value} -> <<0x05::16, value::32>>
{:max_header_list_size, :infinity} -> <<>>
{:max_header_list_size, value} -> <<0x06::16, value::32>>
end)
[{0x4, 0x0, 0, payload}]
end
end
end

View File

@@ -0,0 +1,27 @@
defmodule Bandit.HTTP2.Frame.Unknown do
@moduledoc false
defstruct type: nil,
flags: nil,
stream_id: nil,
payload: nil
@typedoc "An HTTP/2 frame of unknown type"
@type t :: %__MODULE__{
type: Bandit.HTTP2.Frame.frame_type(),
flags: Bandit.HTTP2.Frame.flags(),
stream_id: Bandit.HTTP2.Stream.stream_id(),
payload: iodata()
}
# Note this is arity 4
@spec deserialize(
Bandit.HTTP2.Frame.frame_type(),
Bandit.HTTP2.Frame.flags(),
Bandit.HTTP2.Stream.stream_id(),
iodata()
) :: {:ok, t()}
def deserialize(type, flags, stream_id, payload) do
{:ok, %__MODULE__{type: type, flags: flags, stream_id: stream_id, payload: payload}}
end
end

View File

@@ -0,0 +1,33 @@
defmodule Bandit.HTTP2.Frame.WindowUpdate do
@moduledoc false
defstruct stream_id: nil,
size_increment: nil
@typedoc "An HTTP/2 WINDOW_UPDATE frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
size_increment: non_neg_integer()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, _stream_id, <<_reserved::1, 0::31>>) do
{:error, Bandit.HTTP2.Errors.flow_control_error(),
"Invalid WINDOW_UPDATE size increment (RFC9113§6.9)"}
end
def deserialize(_flags, stream_id, <<_reserved::1, size_increment::31>>) do
{:ok, %__MODULE__{stream_id: stream_id, size_increment: size_increment}}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid WINDOW_UPDATE frame (RFC9113§6.9)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.WindowUpdate{} = frame, _max_frame_size) do
[{0x8, 0, frame.stream_id, <<0::1, frame.size_increment::31>>}]
end
end
end

View File

@@ -0,0 +1,186 @@
defmodule Bandit.HTTP2.Handler do
@moduledoc false
# An HTTP/2 handler, this module comprises the primary interface between Thousand Island and an
# HTTP connection. It is responsible for:
#
# * All socket-level sending and receiving from the client
# * Coordinating the parsing of frames & attendant error handling
# * Tracking connection state as represented by a `Bandit.HTTP2.Connection` struct
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_connection(socket, state) do
connection = Bandit.HTTP2.Connection.init(socket, state.plug, state.opts)
{:continue, Map.merge(state, %{buffer: <<>>, connection: connection})}
rescue
error -> rescue_connection_error(error, __STACKTRACE__, socket, state)
end
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
(state.buffer <> data)
|> Stream.unfold(
&Bandit.HTTP2.Frame.deserialize(&1, state.connection.local_settings.max_frame_size)
)
|> Enum.reduce_while(state, fn
{:ok, frame}, state ->
connection = Bandit.HTTP2.Connection.handle_frame(frame, socket, state.connection)
{:cont, %{state | connection: connection, buffer: <<>>}}
{:more, rest}, state ->
{:halt, %{state | buffer: rest}}
{:error, error_code, message}, _state ->
# We encountered an error while deserializing the frame. Let the connection figure out
# how to respond to it
raise Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code
end)
|> then(&{:continue, &1})
rescue
error in Bandit.HTTP2.Errors.StreamError -> rescue_stream_error(error, socket, state)
error -> rescue_connection_error(error, __STACKTRACE__, socket, state)
end
@impl ThousandIsland.Handler
def handle_shutdown(socket, state) do
Bandit.HTTP2.Connection.close_connection(
Bandit.HTTP2.Errors.no_error(),
"Server shutdown",
socket,
state.connection
)
end
@impl ThousandIsland.Handler
def handle_timeout(socket, state) do
Bandit.HTTP2.Connection.close_connection(
Bandit.HTTP2.Errors.no_error(),
"Client timeout",
socket,
state.connection
)
end
def handle_call({:peer_data, _stream_id}, _from, {socket, state}) do
{:reply, Bandit.SocketHelpers.peer_data(socket), {socket, state}, socket.read_timeout}
end
def handle_call({:sock_data, _stream_id}, _from, {socket, state}) do
{:reply, Bandit.SocketHelpers.sock_data(socket), {socket, state}, socket.read_timeout}
end
def handle_call({:ssl_data, _stream_id}, _from, {socket, state}) do
{:reply, Bandit.SocketHelpers.ssl_data(socket), {socket, state}, socket.read_timeout}
end
def handle_call({{:send_data, data, end_stream}, stream_id}, from, {socket, state}) do
# In 'normal' cases where there is sufficient space in the send windows for this message to be
# sent, Connection will call `unblock` synchronously in the `Connection.send_data` call below.
# In cases where there is not enough space in the connection window, Connection will call
# `unblock` at some point in the future once space opens up in the window. This
# keeps this code simple in that we can blindly send noreply here and let Connection handle
# the separate cases. This ensures that we have backpressure all the way back to the
# stream's handler process in the event of window overruns.
#
# Note that the above only applies to the connection-level send window; stream-level windows
# are managed internally by the stream and are not considered here at all. If the stream has
# managed to send this message, it is because there was enough room in the stream's send
# window to do so.
unblock = fn -> GenServer.reply(from, :ok) end
connection =
Bandit.HTTP2.Connection.send_data(
stream_id,
data,
end_stream,
unblock,
socket,
state.connection
)
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:send_headers, headers, end_stream}, stream_id}, {socket, state}) do
connection =
Bandit.HTTP2.Connection.send_headers(
stream_id,
headers,
end_stream,
socket,
state.connection
)
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:send_recv_window_update, size_increment}, stream_id}, {socket, state}) do
Bandit.HTTP2.Connection.send_recv_window_update(
stream_id,
size_increment,
socket,
state.connection
)
{:noreply, {socket, state}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:send_rst_stream, error_code}, stream_id}, {socket, state}) do
Bandit.HTTP2.Connection.send_rst_stream(stream_id, error_code, socket, state.connection)
{:noreply, {socket, state}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:close_connection, error_code, msg}, _stream_id}, {socket, state}) do
_ = Bandit.HTTP2.Connection.close_connection(error_code, msg, socket, state.connection)
{:stop, :normal, {socket, state}}
end
def handle_info({:EXIT, pid, _reason}, {socket, state}) do
connection = Bandit.HTTP2.Connection.stream_terminated(pid, state.connection)
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
end
defp rescue_stream_error(error, socket, state) do
Bandit.HTTP2.Connection.send_rst_stream(
error.stream_id,
error.error_code,
socket,
state.connection
)
{:continue, state}
end
defp rescue_connection_error(error, stacktrace, socket, state) do
do_rescue_error(error, stacktrace, socket, state)
{:close, state}
end
defp rescue_error_handle_info(error, stacktrace, socket, state) do
do_rescue_error(error, stacktrace, socket, state)
{:stop, :normal}
end
defp do_rescue_error(error, stacktrace, socket, state) do
_ =
if state[:connection] do
Bandit.HTTP2.Connection.close_connection(
error.error_code,
error.message,
socket,
state[:connection]
)
end
Bandit.Logger.maybe_log_protocol_error(error, stacktrace, state.opts, plug: state.plug)
end
end

View File

@@ -0,0 +1,20 @@
defmodule Bandit.HTTP2.Settings do
@moduledoc """
Settings as defined in RFC9113§6.5.2
"""
defstruct header_table_size: 4_096,
max_concurrent_streams: :infinity,
initial_window_size: 65_535,
max_frame_size: 16_384,
max_header_list_size: :infinity
@typedoc "A collection of settings as defined in RFC9113§6.5"
@type t :: %__MODULE__{
header_table_size: non_neg_integer(),
max_concurrent_streams: non_neg_integer() | :infinity,
initial_window_size: non_neg_integer(),
max_frame_size: non_neg_integer(),
max_header_list_size: non_neg_integer() | :infinity
}
end

View File

@@ -0,0 +1,634 @@
defmodule Bandit.HTTP2.Stream do
@moduledoc false
# This module implements an HTTP/2 stream as described in RFC 9113, without concern for the higher-level
# HTTP semantics described in RFC 9110. It is similar in spirit to `Bandit.HTTP1.Socket` for
# HTTP/1, and indeed both implement the `Bandit.HTTPTransport` behaviour. An instance of this
# struct is maintained as the state of a `Bandit.HTTP2.StreamProcess` process, and it moves an
# HTTP/2 stream through its lifecycle by calling functions defined on this module. This state is
# also tracked within the `Bandit.Adapter` instance that backs Bandit's Plug API.
#
# A note about naming:
#
# This module has several intended callers, and due to its nature as a coordinator, needs to be
# careful about how it uses terms like 'read', 'send', 'receive', etc. To that end, there are
# some conventions in place:
#
# * Functions on this module which are intended to be called internally by the containing
# `Bandit.HTTP2.Connection` to pass information received from the client (such as headers or
# request data) to this stream. These functions are named `deliver_*`, and are intended to be
# called by the connection process. As such, they take a `stream_handle()` argument, which
# corresponds either to a pid (in the case of an active stream), or the value `:closed` (in the
# case of a stream which has already completed processing)
#
# * Functions on this module which are intended to be called by the higher-level implementation
# that is processing this stream are implemented via the `Bandit.HTTPTransport` protocol
#
# * In order for this stream to receive information from the containing connection process, we
# use carefully crafted `receive` calls (we do this in a manner that is safe to do within a
# GenServer). This work is handled internally by a number of functions named `do_recv_*`, which
# generally present a blocking interface in order to align with the expectations of the
# `Plug.Conn.Adapter` behaviour.
#
# This module also uses exceptions by convention rather than error tuples since many
# of these functions are called within `Plug.Conn.Adapter` calls, which makes it
# difficult to properly unwind many error conditions back to a place where we can properly shut
# down the stream by sending a RstStream frame to the client and terminating our process. The
# pattern here is to raise exceptions, and have the `Bandit.HTTP2.StreamProcess`'s `terminate/2`
# callback take care of calling back into us via the `reset_stream/2` and `close_connection/2`
# functions here, with the luxury of a nicely unwound stack and a process that is guaranteed to
# be terminated as soon as these functions are called
require Logger
defstruct connection_pid: nil,
stream_id: nil,
state: :idle,
recv_window_size: 65_535,
send_window_size: nil,
sendfile_chunk_size: nil,
bytes_remaining: nil,
read_timeout: 15_000
@typedoc "An HTTP/2 stream identifier"
@type stream_id :: non_neg_integer()
@typedoc "A handle to a stream, suitable for passing to the `deliver_*` functions on this module"
@type stream_handle :: pid() | :closed
@typedoc "An HTTP/2 stream state"
@type state :: :idle | :open | :local_closed | :remote_closed | :closed
@typedoc "The information necessary to communicate to/from a stream"
@type t :: %__MODULE__{
connection_pid: pid(),
stream_id: non_neg_integer(),
state: state(),
recv_window_size: non_neg_integer(),
send_window_size: non_neg_integer(),
sendfile_chunk_size: pos_integer(),
bytes_remaining: non_neg_integer() | nil,
read_timeout: timeout()
}
def init(connection_pid, stream_id, initial_send_window_size, sendfile_chunk_size) do
%__MODULE__{
connection_pid: connection_pid,
stream_id: stream_id,
send_window_size: initial_send_window_size,
sendfile_chunk_size: sendfile_chunk_size
}
end
# Collection API - Delivery
#
# These functions are intended to be called by the connection process which contains this
# stream. All of these start with `deliver_`
@spec deliver_headers(stream_handle(), Plug.Conn.headers(), boolean()) :: term()
def deliver_headers(:closed, _headers, _end_stream), do: :ok
def deliver_headers(pid, headers, end_stream),
do: send(pid, {:bandit, {:headers, headers, end_stream}})
@spec deliver_data(stream_handle(), iodata(), boolean()) :: term()
def deliver_data(:closed, _data, _end_stream), do: :ok
def deliver_data(pid, data, end_stream), do: send(pid, {:bandit, {:data, data, end_stream}})
@spec deliver_send_window_update(stream_handle(), non_neg_integer()) :: term()
def deliver_send_window_update(:closed, _delta), do: :ok
def deliver_send_window_update(pid, delta),
do: send(pid, {:bandit, {:send_window_update, delta}})
@spec deliver_rst_stream(stream_handle(), Bandit.HTTP2.Errors.error_code()) :: term()
def deliver_rst_stream(:closed, _error_code), do: :ok
def deliver_rst_stream(pid, error_code), do: send(pid, {:bandit, {:rst_stream, error_code}})
defimpl Bandit.HTTPTransport do
def peer_data(%@for{} = stream), do: call(stream, :peer_data, :infinity)
def sock_data(%@for{} = stream), do: call(stream, :sock_data, :infinity)
def ssl_data(%@for{} = stream), do: call(stream, :ssl_data, :infinity)
def version(%@for{}), do: :"HTTP/2"
def read_headers(%@for{state: :idle} = stream) do
case do_recv(stream, stream.read_timeout) do
{:headers, headers, stream} ->
method = Bandit.Headers.get_header(headers, ":method")
request_target = build_request_target!(headers, stream)
{pseudo_headers, headers} = split_headers!(headers, stream)
pseudo_headers_all_request!(pseudo_headers, stream)
exactly_one_instance_of!(pseudo_headers, ":scheme", stream)
exactly_one_instance_of!(pseudo_headers, ":method", stream)
exactly_one_instance_of!(pseudo_headers, ":path", stream)
headers_all_lowercase!(headers, stream)
no_connection_headers!(headers, stream)
valid_te_header!(headers, stream)
content_length = get_content_length!(headers, stream)
headers = combine_cookie_crumbs(headers)
stream = %{stream | bytes_remaining: content_length}
{:ok, method, request_target, headers, stream}
:timeout ->
stream_error!("Timed out waiting for HEADER", stream)
%@for{} = stream ->
read_headers(stream)
end
end
defp build_request_target!(headers, stream) do
scheme = Bandit.Headers.get_header(headers, ":scheme")
{host, port} = get_host_and_port!(headers)
path = get_path!(headers, stream)
{scheme, host, port, path}
end
defp get_host_and_port!(headers) do
case Bandit.Headers.get_header(headers, ":authority") do
authority when not is_nil(authority) -> Bandit.Headers.parse_hostlike_header!(authority)
nil -> {nil, nil}
end
end
# RFC9113§8.3.1 - path should be non-empty and absolute
defp get_path!(headers, stream) do
headers
|> Bandit.Headers.get_header(":path")
|> case do
nil -> stream_error!("Received empty :path", stream)
"*" -> :*
"/" <> _ = path -> split_path!(path, stream)
_ -> stream_error!("Path does not start with /", stream)
end
end
# RFC9113§8.3.1 - path should match the path-absolute production from RFC3986
defp split_path!(path, stream) do
if path |> String.split("/") |> Enum.all?(&(&1 not in [".", ".."])),
do: path,
else: stream_error!("Path contains dot segment", stream)
end
# RFC9113§8.3 - pseudo headers must appear first
defp split_headers!(headers, stream) do
{pseudo_headers, headers} =
Enum.split_while(headers, fn {key, _value} -> String.starts_with?(key, ":") end)
if Enum.any?(headers, fn {key, _value} -> String.starts_with?(key, ":") end),
do: stream_error!("Received pseudo headers after regular one", stream),
else: {pseudo_headers, headers}
end
# RFC9113§8.3.1 - only request pseudo headers may appear
defp pseudo_headers_all_request!(headers, stream) do
if Enum.any?(headers, fn {key, _value} ->
key not in ~w[:method :scheme :authority :path]
end),
do: stream_error!("Received invalid pseudo header", stream)
end
# RFC9113§8.3.1 - method, scheme, path pseudo headers must appear exactly once
defp exactly_one_instance_of!(headers, header, stream) do
if Enum.count(headers, fn {key, _value} -> key == header end) != 1,
do: stream_error!("Expected 1 #{header} headers", stream)
end
# RFC9113§8.2 - all headers name fields must be lowercsae
defp headers_all_lowercase!(headers, stream) do
if !Enum.all?(headers, fn {key, _value} -> lowercase?(key) end),
do: stream_error!("Received uppercase header", stream)
end
defp lowercase?(<<char, _rest::bits>>) when char >= ?A and char <= ?Z, do: false
defp lowercase?(<<_char, rest::bits>>), do: lowercase?(rest)
defp lowercase?(<<>>), do: true
# RFC9113§8.2.2 - no hop-by-hop headers
# Note that we do not filter out the TE header here, since it is allowed in
# specific cases by RFC9113§8.2.2. We check those cases in a separate filter
defp no_connection_headers!(headers, stream) do
connection_headers =
~w[connection keep-alive proxy-authenticate proxy-authorization trailers transfer-encoding upgrade]
if Enum.any?(headers, fn {key, _value} -> key in connection_headers end),
do: stream_error!("Received connection-specific header", stream)
end
# RFC9113§8.2.2 - TE header may be present if it contains exactly 'trailers'
defp valid_te_header!(headers, stream) do
if Bandit.Headers.get_header(headers, "te") not in [nil, "trailers"],
do: stream_error!("Received invalid TE header", stream)
end
defp get_content_length!(headers, stream) do
case Bandit.Headers.get_content_length(headers) do
{:ok, content_length} -> content_length
{:error, reason} -> stream_error!(reason, stream)
end
end
# RFC9113§8.2.3 - cookie headers may be split during transmission
defp combine_cookie_crumbs(headers) do
{crumbs, other_headers} =
headers |> Enum.split_with(fn {header, _} -> header == "cookie" end)
case Enum.map_join(crumbs, "; ", fn {"cookie", crumb} -> crumb end) do
"" -> other_headers
combined_cookie -> [{"cookie", combined_cookie} | other_headers]
end
end
def read_data(%@for{} = stream, opts) do
max_bytes = Keyword.get(opts, :length, 8_000_000)
timeout = Keyword.get(opts, :read_timeout, 15_000)
do_read_data(stream, max_bytes, timeout, [])
end
defp do_read_data(%@for{state: state} = stream, max_bytes, timeout, acc)
when state in [:open, :local_closed] do
case do_recv(stream, timeout) do
{:headers, trailers, stream} ->
no_pseudo_headers!(trailers, stream)
Logger.warning("Ignoring trailers #{inspect(trailers)}", domain: [:bandit])
do_read_data(stream, max_bytes, timeout, acc)
{:data, data, stream} ->
acc = [data | acc]
max_bytes = max_bytes - byte_size(data)
if max_bytes >= 0 do
do_read_data(stream, max_bytes, timeout, acc)
else
{:more, Enum.reverse(acc), stream}
end
:timeout ->
{:more, Enum.reverse(acc), stream}
%@for{} = stream ->
do_read_data(stream, max_bytes, timeout, acc)
end
end
defp do_read_data(%@for{state: :remote_closed} = stream, _max_bytes, _timeout, acc) do
{:ok, Enum.reverse(acc), stream}
end
defp no_pseudo_headers!(headers, stream) do
if Enum.any?(headers, fn {key, _value} -> String.starts_with?(key, ":") end),
do: stream_error!("Received trailers with pseudo headers", stream)
end
defp do_recv(%@for{state: :idle} = stream, timeout) do
receive do
{:bandit, {:headers, headers, end_stream}} ->
{:headers, headers, stream |> do_recv_headers() |> do_recv_end_stream(end_stream)}
{:bandit, {:data, _data, _end_stream}} ->
connection_error!("Received DATA in idle state")
{:bandit, {:send_window_update, _delta}} ->
connection_error!("Received WINDOW_UPDATE in idle state")
{:bandit, {:rst_stream, _error_code}} ->
connection_error!("Received RST_STREAM in idle state")
after
timeout -> :timeout
end
end
defp do_recv(%@for{state: state} = stream, timeout)
when state in [:open, :local_closed] do
receive do
{:bandit, {:headers, headers, end_stream}} ->
{:headers, headers, stream |> do_recv_headers() |> do_recv_end_stream(end_stream)}
{:bandit, {:data, data, end_stream}} ->
{:data, data,
stream |> do_recv_data(data, end_stream) |> do_recv_end_stream(end_stream)}
{:bandit, {:send_window_update, delta}} ->
do_recv_send_window_update(stream, delta)
{:bandit, {:rst_stream, error_code}} ->
do_recv_rst_stream!(stream, error_code)
after
timeout -> :timeout
end
end
defp do_recv(%@for{state: :remote_closed} = stream, timeout) do
receive do
{:bandit, {:headers, _headers, _end_stream}} ->
do_stream_closed_error!("Received HEADERS in remote_closed state", stream)
{:bandit, {:data, _data, _end_stream}} ->
do_stream_closed_error!("Received DATA in remote_closed state", stream)
{:bandit, {:send_window_update, delta}} ->
do_recv_send_window_update(stream, delta)
{:bandit, {:rst_stream, error_code}} ->
do_recv_rst_stream!(stream, error_code)
after
timeout -> :timeout
end
end
defp do_recv(%@for{state: :closed} = stream, timeout) do
receive do
{:bandit, {:headers, _headers, _end_stream}} -> stream
{:bandit, {:data, _data, _end_stream}} -> stream
{:bandit, {:send_window_update, _delta}} -> stream
{:bandit, {:rst_stream, _error_code}} -> stream
after
timeout -> :timeout
end
end
defp do_recv_headers(%@for{state: :idle} = stream), do: %{stream | state: :open}
defp do_recv_headers(stream), do: stream
defp do_recv_data(stream, data, end_stream) do
{new_window, increment} =
Bandit.HTTP2.FlowControl.compute_recv_window(stream.recv_window_size, byte_size(data))
if increment > 0 && !end_stream, do: do_send(stream, {:send_recv_window_update, increment})
bytes_remaining =
case stream.bytes_remaining do
nil -> nil
bytes_remaining -> bytes_remaining - byte_size(data)
end
%{stream | recv_window_size: new_window, bytes_remaining: bytes_remaining}
end
defp do_recv_end_stream(stream, false), do: stream
defp do_recv_end_stream(stream, true) do
next_state =
case stream.state do
:open -> :remote_closed
:local_closed -> :closed
end
if stream.bytes_remaining not in [nil, 0],
do: stream_error!("Received END_STREAM with byte still pending", stream)
%{stream | state: next_state}
end
defp do_recv_send_window_update(stream, delta) do
case Bandit.HTTP2.FlowControl.update_send_window(stream.send_window_size, delta) do
{:ok, new_window} ->
%{stream | send_window_size: new_window}
{:error, reason} ->
stream_error!(reason, stream, Bandit.HTTP2.Errors.flow_control_error())
end
end
@spec do_recv_rst_stream!(term(), term()) :: no_return()
defp do_recv_rst_stream!(_stream, error_code) do
case Bandit.HTTP2.Errors.to_reason(error_code) do
reason when reason in [:no_error, :cancel] ->
raise(Bandit.TransportError, message: "Client reset stream normally", error: :closed)
reason ->
raise(Bandit.TransportError,
message: "Received RST_STREAM from client: #{reason} (#{error_code})",
error: reason
)
end
end
@spec do_stream_closed_error!(String.t(), Bandit.HTTP2.Stream.t()) :: no_return()
defp do_stream_closed_error!(msg, stream),
do: stream_error!(msg, stream, Bandit.HTTP2.Errors.stream_closed())
# Stream API - Sending
def send_headers(%@for{state: state} = stream, status, headers, body_disposition)
when state in [:open, :remote_closed] do
# We need to map body_disposition into the state model of HTTP/2. This turns out to be really
# easy, since HTTP/2 only has one way to send data. The only bit we need from the disposition
# is whether there will be any data forthcoming (ie: whether or not to end the stream). That
# will possibly walk us to a different state per RFC9113§5.1, as determined by the tail call
# to set_state_on_send_end_stream/2
end_stream = body_disposition == :no_body
headers = [{":status", to_string(status)} | split_cookies(headers)]
do_send(stream, {:send_headers, headers, end_stream})
set_state_on_send_end_stream(stream, end_stream)
end
# RFC9113§8.2.3 - cookie headers may be split during transmission
defp split_cookies(headers) do
headers
|> Enum.flat_map(fn
{"cookie", cookie} ->
cookie |> String.split("; ") |> Enum.map(fn crumb -> {"cookie", crumb} end)
{header, value} ->
[{header, value}]
end)
end
def send_data(%@for{state: state} = stream, data, end_stream)
when state in [:open, :remote_closed] do
stream =
receive do
{:bandit, {:send_window_update, delta}} -> do_recv_send_window_update(stream, delta)
{:bandit, {:rst_stream, error_code}} -> do_recv_rst_stream!(stream, error_code)
after
0 -> stream
end
max_bytes_to_send = max(stream.send_window_size, 0)
{data_to_send, bytes_to_send, rest} = split_data(data, max_bytes_to_send)
stream =
if end_stream || bytes_to_send > 0 do
end_stream_to_send = end_stream && byte_size(rest) == 0
call(stream, {:send_data, data_to_send, end_stream_to_send}, :infinity)
%{stream | send_window_size: stream.send_window_size - bytes_to_send}
else
stream
end
if byte_size(rest) == 0 do
set_state_on_send_end_stream(stream, end_stream)
else
receive do
{:bandit, {:send_window_update, delta}} ->
stream
|> do_recv_send_window_update(delta)
|> send_data(rest, end_stream)
after
stream.read_timeout ->
stream_error!(
"Timeout waiting for space in the send_window",
stream,
Bandit.HTTP2.Errors.flow_control_error()
)
end
end
end
def sendfile(%@for{} = stream, path, offset, length) do
case :file.open(path, [:raw, :binary]) do
{:ok, fd} ->
try do
if length == 0 do
send_data(stream, "", true)
else
sendfile_loop(stream, fd, offset, length, 0)
end
after
:file.close(fd)
end
{:error, reason} ->
raise "Error opening file for sendfile: #{inspect(reason)}"
end
end
defp sendfile_loop(stream, _fd, _offset, length, sent) when sent >= length do
stream
end
defp sendfile_loop(stream, fd, offset, length, sent) do
read_size = min(length - sent, sendfile_chunk_size(stream))
case :file.pread(fd, offset + sent, read_size) do
{:ok, data} ->
now_sent = byte_size(data)
end_stream = sent + now_sent >= length
stream = send_data(stream, data, end_stream)
if end_stream do
stream
else
sendfile_loop(stream, fd, offset, length, sent + now_sent)
end
:eof ->
raise "Error reading file for sendfile: :eof"
{:error, reason} ->
raise "Error reading file for sendfile: #{inspect(reason)}"
end
end
defp sendfile_chunk_size(%@for{sendfile_chunk_size: sendfile_chunk_size}) do
max(sendfile_chunk_size, 1)
end
defp split_data(data, desired_length) do
data_length = IO.iodata_length(data)
if data_length <= desired_length do
{data, data_length, <<>>}
else
<<to_send::binary-size(desired_length), rest::binary>> = IO.iodata_to_binary(data)
{to_send, desired_length, rest}
end
end
defp set_state_on_send_end_stream(stream, false), do: stream
defp set_state_on_send_end_stream(%@for{state: :open} = stream, true),
do: %{stream | state: :local_closed}
defp set_state_on_send_end_stream(%@for{state: :remote_closed} = stream, true),
do: %{stream | state: :closed}
# Closing off the stream upon completion or error
def ensure_completed(%@for{state: :closed} = stream), do: stream
def ensure_completed(%@for{state: :local_closed} = stream) do
receive do
{:bandit, {:headers, _headers, true}} ->
do_recv_end_stream(stream, true)
{:bandit, {:data, data, true}} ->
do_recv_data(stream, data, true) |> do_recv_end_stream(true)
after
# RFC9113§8.1 - hint the client to stop sending data
0 -> do_send(stream, {:send_rst_stream, Bandit.HTTP2.Errors.no_error()})
end
end
def ensure_completed(%@for{state: state} = stream) do
stream_error!(
"Terminating stream in #{state} state",
stream,
Bandit.HTTP2.Errors.internal_error()
)
end
def supported_upgrade?(%@for{} = _stream, _protocol), do: false
def send_on_error(%@for{} = stream, %Bandit.HTTP2.Errors.StreamError{} = error) do
do_send(stream, {:send_rst_stream, error.error_code})
%{stream | state: :closed}
end
def send_on_error(%@for{} = stream, %Bandit.HTTP2.Errors.ConnectionError{} = error) do
do_send(stream, {:close_connection, error.error_code, error.message})
stream
end
def send_on_error(%@for{state: state} = stream, error) when state in [:idle, :open] do
stream = maybe_send_error(%{stream | state: :open}, error)
%{stream | state: :local_closed}
end
def send_on_error(%@for{state: :remote_closed} = stream, error) do
stream = maybe_send_error(%{stream | state: :open}, error)
%{stream | state: :closed}
end
def send_on_error(%@for{} = stream, _error), do: stream
defp maybe_send_error(stream, error) do
receive do
{:plug_conn, :sent} -> stream
after
0 ->
status = error |> Plug.Exception.status() |> Plug.Conn.Status.code()
send_headers(stream, status, [], :no_body)
end
end
# Helpers
defp do_send(stream, msg), do: send(stream.connection_pid, {msg, stream.stream_id})
defp call(stream, msg, timeout),
do: GenServer.call(stream.connection_pid, {msg, stream.stream_id}, timeout)
@spec stream_error!(String.t(), Bandit.HTTP2.Stream.t()) :: no_return()
@spec stream_error!(
String.t(),
Bandit.HTTP2.Stream.t(),
Bandit.HTTP2.Errors.error_code()
) :: no_return()
defp stream_error!(message, stream, error_code \\ Bandit.HTTP2.Errors.protocol_error()),
do:
raise(Bandit.HTTP2.Errors.StreamError,
message: message,
error_code: error_code,
stream_id: stream.stream_id
)
@spec connection_error!(term()) :: no_return()
@spec connection_error!(term(), Bandit.HTTP2.Errors.error_code()) :: no_return()
defp connection_error!(message, error_code \\ Bandit.HTTP2.Errors.protocol_error()),
do: raise(Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code)
end
end

View File

@@ -0,0 +1,78 @@
defmodule Bandit.HTTP2.StreamCollection do
@moduledoc false
# Represents a collection of stream IDs and what process IDs are running them. An instance of
# this struct is contained within each `Bandit.HTTP2.Connection` struct and is responsible for
# encapsulating the data about the streams which are currently active within the connection.
#
# This collection has a number of useful properties:
#
# * Process IDs are accessible by stream id
# * Process IDs are deletable by themselves (ie: deletion is via PID)
# * The collection is able to determine if a stream not currently contained in this collection
# represents a previously seen stream (in which case it is considered to be in a 'closed'
# state), or if it is a stream ID of a stream that has yet to be created
require Integer
defstruct last_stream_id: 0,
stream_count: 0,
id_to_pid: %{},
pid_to_id: %{}
@typedoc "A map from stream id to pid"
@type t :: %__MODULE__{
last_stream_id: Bandit.HTTP2.Stream.stream_id(),
stream_count: non_neg_integer(),
id_to_pid: %{Bandit.HTTP2.Stream.stream_id() => pid()},
pid_to_id: %{pid() => Bandit.HTTP2.Stream.stream_id()}
}
@spec get_pids(t()) :: [pid()]
def get_pids(collection), do: Map.values(collection.id_to_pid)
@spec get_pid(t(), Bandit.HTTP2.Stream.stream_id()) :: pid() | :new | :closed | :invalid
def get_pid(_collection, stream_id) when Integer.is_even(stream_id), do: :invalid
def get_pid(collection, stream_id) when stream_id > collection.last_stream_id, do: :new
def get_pid(collection, stream_id) do
case Map.get(collection.id_to_pid, stream_id) do
pid when is_pid(pid) -> pid
nil -> :closed
end
end
@spec insert(t(), Bandit.HTTP2.Stream.stream_id(), pid()) :: t()
def insert(collection, stream_id, pid) do
%__MODULE__{
last_stream_id: stream_id,
stream_count: collection.stream_count + 1,
id_to_pid: Map.put(collection.id_to_pid, stream_id, pid),
pid_to_id: Map.put(collection.pid_to_id, pid, stream_id)
}
end
# Dialyzer insists on the atom() here even though it doesn't make sense
@spec delete(t(), pid()) :: t() | atom()
def delete(collection, pid) do
case Map.pop(collection.pid_to_id, pid) do
{nil, _} ->
collection
{stream_id, new_pid_to_id} ->
%{
collection
| id_to_pid: Map.delete(collection.id_to_pid, stream_id),
pid_to_id: new_pid_to_id
}
end
end
@spec stream_count(t()) :: non_neg_integer()
def stream_count(collection), do: collection.stream_count
@spec open_stream_count(t()) :: non_neg_integer()
def open_stream_count(collection), do: collection.pid_to_id |> map_size()
@spec last_stream_id(t()) :: Bandit.HTTP2.Stream.stream_id()
def last_stream_id(collection), do: collection.last_stream_id
end

View File

@@ -0,0 +1,31 @@
defmodule Bandit.HTTP2.StreamProcess do
@moduledoc false
# This process runs the lifecycle of an HTTP/2 stream, which is modeled by a
# `Bandit.HTTP2.Stream` struct that this process maintains in its state
#
# As part of this lifecycle, the execution of a Plug to handle this stream's request
# takes place here; the entirety of the Plug lifecycle takes place in a single
# `c:handle_continue/2` call.
use GenServer, restart: :temporary
@spec start_link(
Bandit.HTTP2.Stream.t(),
Bandit.Pipeline.plug_def(),
Bandit.Telemetry.t(),
Bandit.Pipeline.conn_data(),
keyword()
) :: GenServer.on_start()
def start_link(stream, plug, connection_span, conn_data, opts) do
GenServer.start_link(__MODULE__, {stream, plug, connection_span, conn_data, opts})
end
@impl GenServer
def init(state), do: {:ok, state, {:continue, :start_stream}}
@impl GenServer
def handle_continue(:start_stream, {stream, plug, connection_span, conn_data, opts} = state) do
_ = Bandit.Pipeline.run(stream, plug, connection_span, conn_data, opts)
{:stop, :normal, state}
end
end

View File

@@ -0,0 +1,6 @@
defmodule Bandit.HTTPError do
# Represents an error suitable for return as an HTTP status. Note that these may be surfaced
# from anywhere that such a message is well defined, including within HTTP/1 transport concerns
# and also within shared HTTP semantics (ie: within Bandit.Adapter or Bandit.Pipeline)
defexception message: nil, plug_status: :bad_request
end

View File

@@ -0,0 +1,47 @@
defprotocol Bandit.HTTPTransport do
@moduledoc false
# A protocol implemented by the lower level transports (HTTP/1 and HTTP/2) to encapsulate the
# low-level mechanics needed to complete an HTTP request/response cycle. Implementations of this
# protocol should be broadly concerned with the protocol-specific aspects of a connection, and
# can rely on higher-level code taking care of shared HTTP semantics
@typedoc "How the response body is to be delivered"
@type body_disposition :: :raw | :chunk_encoded | :no_body | :inform
@spec peer_data(t()) :: Plug.Conn.Adapter.peer_data()
def peer_data(transport)
@spec sock_data(t()) :: Plug.Conn.Adapter.sock_data()
def sock_data(transport)
@spec ssl_data(t()) :: Plug.Conn.Adapter.ssl_data()
def ssl_data(transport)
@spec version(t()) :: Plug.Conn.Adapter.http_protocol()
def version(transport)
@spec read_headers(t()) ::
{:ok, Plug.Conn.method(), Bandit.Pipeline.request_target(), Plug.Conn.headers(), t()}
def read_headers(transport)
@spec read_data(t(), opts :: keyword()) :: {:ok, iodata(), t()} | {:more, iodata(), t()}
def read_data(transport, opts)
@spec send_headers(t(), Plug.Conn.status(), Plug.Conn.headers(), body_disposition()) :: t()
def send_headers(transport, status, heeaders, disposition)
@spec send_data(t(), data :: iodata(), end_request :: boolean()) :: t()
def send_data(transport, data, end_request)
@spec sendfile(t(), Path.t(), offset :: integer(), length :: integer() | :all) :: t()
def sendfile(transport, path, offset, length)
@spec ensure_completed(t()) :: t()
def ensure_completed(transport)
@spec supported_upgrade?(t(), atom()) :: boolean()
def supported_upgrade?(transport, protocol)
@spec send_on_error(t(), struct()) :: t()
def send_on_error(transport, error)
end

View File

@@ -0,0 +1,86 @@
defmodule Bandit.InitialHandler do
@moduledoc false
# The initial protocol implementation used for all connections. Switches to a
# specific protocol implementation based on configuration, ALPN negotiation, and
# line heuristics.
use ThousandIsland.Handler
require Logger
@type on_switch_handler ::
{:switch, bandit_http_handler(), data :: term(), state :: term()}
| {:switch, bandit_http_handler(), state :: term()}
@type bandit_http_handler :: Bandit.HTTP1.Handler | Bandit.HTTP2.Handler
# Attempts to guess the protocol in use, returning the applicable next handler and any
# data consumed in the course of guessing which must be processed by the actual protocol handler
@impl ThousandIsland.Handler
@spec handle_connection(ThousandIsland.Socket.t(), state :: term()) ::
ThousandIsland.Handler.handler_result() | on_switch_handler()
def handle_connection(socket, state) do
case {state.http_1_enabled, state.http_2_enabled, alpn_protocol(socket), sniff_wire(socket)} do
{_, _, _, :likely_tls} ->
Logger.warning("Connection that looks like TLS received on a clear channel",
domain: [:bandit],
plug: state.plug
)
{:close, state}
{_, true, Bandit.HTTP2.Handler, Bandit.HTTP2.Handler} ->
{:switch, Bandit.HTTP2.Handler, state}
{true, _, Bandit.HTTP1.Handler, {:no_match, data}} ->
{:switch, Bandit.HTTP1.Handler, data, state}
{_, true, :no_match, Bandit.HTTP2.Handler} ->
{:switch, Bandit.HTTP2.Handler, state}
{true, _, :no_match, {:no_match, data}} ->
{:switch, Bandit.HTTP1.Handler, data, state}
_other ->
{:close, state}
end
end
# Returns the protocol as negotiated via ALPN, if applicable
@spec alpn_protocol(ThousandIsland.Socket.t()) ::
Bandit.HTTP2.Handler | Bandit.HTTP1.Handler | :no_match
defp alpn_protocol(socket) do
case ThousandIsland.Socket.negotiated_protocol(socket) do
{:ok, "h2"} -> Bandit.HTTP2.Handler
{:ok, "http/1.1"} -> Bandit.HTTP1.Handler
_ -> :no_match
end
end
# Returns the protocol as suggested by received data, if possible.
# We do this in two phases so that we don't hang on *really* short HTTP/1
# requests that are less than 24 bytes
@spec sniff_wire(ThousandIsland.Socket.t()) ::
Bandit.HTTP2.Handler
| :likely_tls
| {:no_match, binary()}
| {:error, :closed | :timeout | :inet.posix()}
defp sniff_wire(socket) do
case ThousandIsland.Socket.recv(socket, 3) do
{:ok, "PRI" = buffer} -> sniff_wire_for_http2(socket, buffer)
{:ok, <<22::8, 3::8, minor::8>>} when minor in [1, 3] -> :likely_tls
{:ok, data} -> {:no_match, data}
{:error, :timeout} -> {:no_match, <<>>}
{:error, error} -> {:error, error}
end
end
defp sniff_wire_for_http2(socket, buffer) do
case ThousandIsland.Socket.recv(socket, 21) do
{:ok, " * HTTP/2.0\r\n\r\nSM\r\n\r\n"} -> Bandit.HTTP2.Handler
{:ok, data} -> {:no_match, buffer <> data}
{:error, :timeout} -> {:no_match, buffer}
{:error, error} -> {:error, error}
end
end
end

View File

@@ -0,0 +1,46 @@
defmodule Bandit.Logger do
@moduledoc false
require Logger
def maybe_log_protocol_error(error, stacktrace, opts, metadata) do
logging_verbosity =
case error do
%Bandit.TransportError{error: :closed} ->
Keyword.get(opts.http, :log_client_closures, false)
_error ->
Keyword.get(opts.http, :log_protocol_errors, :short)
end
case logging_verbosity do
:short ->
logger_metadata = logger_metadata_for(:error, error, stacktrace, metadata)
Logger.error(Exception.format_banner(:error, error, stacktrace), logger_metadata)
:verbose ->
logger_metadata = logger_metadata_for(:error, error, stacktrace, metadata)
Logger.error(Exception.format(:error, error, stacktrace), logger_metadata)
false ->
:ok
end
end
def logger_metadata_for(kind, reason, stacktrace, metadata) do
crash_reason = crash_reason(kind, reason, stacktrace)
case reason do
%Bandit.HTTP2.Errors.StreamError{stream_id: stream_id} when is_integer(stream_id) ->
[stream_id: stream_id, domain: [:bandit], crash_reason: crash_reason]
|> Keyword.merge(metadata)
_ ->
[domain: [:bandit], crash_reason: crash_reason]
|> Keyword.merge(metadata)
end
end
defp crash_reason(:throw, reason, stacktrace), do: {{:nocatch, reason}, stacktrace}
defp crash_reason(_, reason, stacktrace), do: {reason, stacktrace}
end

View File

@@ -0,0 +1,116 @@
defmodule Bandit.PhoenixAdapter do
@moduledoc """
A Bandit adapter for Phoenix.
This adapter provides out-of-the-box support for all aspects of Phoenix 1.7 and later. Earlier
versions of Phoenix will work with this adapter, but without support for WebSockets.
To use this adapter, your project will need to include Bandit as a dependency:
```elixir
{:bandit, "~> 1.0"}
```
Once Bandit is included as a dependency of your Phoenix project, add the following `adapter:`
line to your endpoint configuration in `config/config.exs`, as in the following example:
```
# config/config.exs
config :your_app, YourAppWeb.Endpoint,
adapter: Bandit.PhoenixAdapter, # <---- ADD THIS LINE
url: [host: "localhost"],
render_errors: ...
```
That's it! **After restarting Phoenix you should see the startup message indicate that it is being
served by Bandit**, and everything should 'just work'. Note that if you have set any exotic
configuration options within your endpoint, you may need to update that configuration to work
with Bandit; see below for details.
## Endpoint configuration
This adapter supports the standard Phoenix structure for endpoint configuration. Top-level keys for
`:http` and `:https` are supported, and configuration values within each of those are interpreted
as raw Bandit configuration as specified by `t:Bandit.options/0`. Bandit's configuration supports
all values used in a standard out-of-the-box Phoenix application, so if you haven't made any
substantial changes to your endpoint configuration things should 'just work' for you.
In the event that you *have* made advanced changes to your endpoint configuration, you may need
to update this config to work with Bandit. Consult Bandit's documentation at
`t:Bandit.options/0` for details.
It can be difficult to know exactly *where* to put the options that you may need to set from the
ones available at `t:Bandit.options/0`. The general idea is that anything inside the `http:` or
`https:` keyword lists in your configuration are passed directly to `Bandit.start_link/1`, so an
example may look like so:
```elixir
# config/{dev,prod,etc}.exs
config :your_app, YourAppWeb.Endpoint,
http: [
ip: {127, 0, 0, 1},
port: 4000,
thousand_island_options: [num_acceptors: 123],
http_options: [log_protocol_errors: false],
http_1_options: [max_requests: 1],
websocket_options: [compress: false]
],
```
Note that, unlike the `adapter: Bandit.PhoenixAdapter` configuration change outlined previously,
configuration of specific `http:` and `https:` values is done on a per-environment basis in
Phoenix, so these changes will typically be in your `config/dev.exs`, `config/prod.exs` and
similar files.
"""
@doc """
Returns the Bandit server process for the provided scheme within the given Phoenix Endpoint
"""
@spec bandit_pid(module()) ::
{:ok, Supervisor.child() | :restarting | :undefined} | {:error, :no_server_found}
def bandit_pid(endpoint, scheme \\ :http) do
endpoint
|> Supervisor.which_children()
|> Enum.find(fn {id, _, _, _} -> id == {endpoint, scheme} end)
|> case do
{_, pid, _, _} -> {:ok, pid}
nil -> {:error, :no_server_found}
end
end
@doc """
Returns the bound address and port of the Bandit server process for the provided
scheme within the given Phoenix Endpoint
"""
def server_info(endpoint, scheme) do
case bandit_pid(endpoint, scheme) do
{:ok, pid} -> ThousandIsland.listener_info(pid)
{:error, reason} -> {:error, reason}
end
end
@doc false
def child_specs(endpoint, config) do
otp_app = Keyword.fetch!(config, :otp_app)
plug = resolve_plug(config[:code_reloader], endpoint)
for scheme <- [:http, :https], opts = config[scheme] do
([plug: plug, display_plug: endpoint, scheme: scheme, otp_app: otp_app] ++ opts)
|> Bandit.child_spec()
|> Supervisor.child_spec(id: {endpoint, scheme})
end
end
defp resolve_plug(code_reload?, endpoint) do
if code_reload? &&
Code.ensure_loaded?(Phoenix.Endpoint.SyncCodeReloadPlug) &&
function_exported?(Phoenix.Endpoint.SyncCodeReloadPlug, :call, 2) do
{Phoenix.Endpoint.SyncCodeReloadPlug, {endpoint, []}}
else
endpoint
end
end
end

View File

@@ -0,0 +1,248 @@
defmodule Bandit.Pipeline do
@moduledoc false
# Provides a common pipeline for HTTP/1.1 and h2 adapters, factoring together shared
# functionality relating to `Plug.Conn` management
@type plug_def :: {function() | module(), Plug.opts()}
@type conn_data :: {boolean(), :inet.ip_address()}
@type request_target ::
{scheme(), nil | Plug.Conn.host(), nil | Plug.Conn.port_number(), path()}
@type scheme :: String.t() | nil
@type path :: String.t() | :*
require Logger
@spec run(
Bandit.HTTPTransport.t(),
plug_def(),
ThousandIsland.Telemetry.t() | Bandit.Telemetry.t(),
conn_data(),
map()
) ::
{:ok, Bandit.HTTPTransport.t()}
| {:upgrade, Bandit.HTTPTransport.t(), :websocket, tuple()}
| {:error, term()}
def run(transport, plug, connection_span, conn_data, opts) do
measurements = %{monotonic_time: Bandit.Telemetry.monotonic_time()}
metadata = %{
connection_telemetry_span_context: connection_span.telemetry_span_context,
plug: plug
}
try do
{:ok, method, request_target, headers, transport} =
Bandit.HTTPTransport.read_headers(transport)
conn = build_conn!(transport, method, request_target, headers, conn_data, opts)
span = Bandit.Telemetry.start_span(:request, measurements, Map.put(metadata, :conn, conn))
try do
conn
|> call_plug!(plug)
|> maybe_upgrade!()
|> case do
{:no_upgrade, conn} ->
%Plug.Conn{adapter: {_mod, adapter}} = conn = commit_response!(conn)
Bandit.Telemetry.stop_span(span, adapter.metrics, %{conn: conn})
{:ok, adapter.transport}
{:upgrade, %Plug.Conn{adapter: {_mod, adapter}} = conn, protocol, opts} ->
conn = Plug.Conn.put_status(conn, 101)
Bandit.Telemetry.stop_span(span, adapter.metrics, %{conn: conn})
{:upgrade, adapter.transport, protocol, opts}
end
catch
kind, value ->
handle_error(kind, value, __STACKTRACE__, transport, span, opts, plug: plug, conn: conn)
end
rescue
exception ->
span = Bandit.Telemetry.start_span(:request, measurements, metadata)
handle_error(:error, exception, __STACKTRACE__, transport, span, opts, plug: plug)
end
end
@spec build_conn!(
Bandit.HTTPTransport.t(),
Plug.Conn.method(),
request_target(),
Plug.Conn.headers(),
conn_data(),
map()
) :: Plug.Conn.t()
defp build_conn!(transport, method, request_target, headers, {secure?, peer_address}, opts) do
adapter = Bandit.Adapter.init(self(), transport, method, headers, opts)
scheme = determine_scheme(secure?, request_target)
version = Bandit.HTTPTransport.version(transport)
{host, port} = determine_host_and_port!(scheme, version, request_target, headers)
{path, query} = determine_path_and_query(request_target)
uri = %URI{scheme: scheme, host: host, port: port, path: path, query: query}
Plug.Conn.Adapter.conn({Bandit.Adapter, adapter}, method, uri, peer_address, headers)
end
@spec determine_scheme(boolean(), request_target()) :: String.t() | nil
defp determine_scheme(secure?, {scheme, _, _, _}) do
case {secure?, scheme} do
{true, nil} -> "https"
{false, nil} -> "http"
{_, scheme} -> scheme
end
end
@spec determine_host_and_port!(binary(), atom(), request_target(), Plug.Conn.headers()) ::
{Plug.Conn.host(), Plug.Conn.port_number()}
defp determine_host_and_port!(scheme, version, {_, nil, nil, _}, headers) do
case {Bandit.Headers.get_header(headers, "host"), version} do
{nil, :"HTTP/1.0"} ->
{"", URI.default_port(scheme)}
{nil, _} ->
request_error!("Unable to obtain host and port: No host header")
{host_header, _} ->
{host, port} = Bandit.Headers.parse_hostlike_header!(host_header)
{host, port || URI.default_port(scheme)}
end
end
defp determine_host_and_port!(scheme, _version, {_, host, port, _}, _headers),
do: {to_string(host), port || URI.default_port(scheme)}
@spec determine_path_and_query(request_target()) :: {String.t(), nil | String.t()}
defp determine_path_and_query({_, _, _, :*}), do: {"*", nil}
defp determine_path_and_query({_, _, _, path}), do: split_path(path)
@spec split_path(String.t()) :: {String.t(), nil | String.t()}
defp split_path(path) do
path
|> to_string()
|> :binary.split("#")
|> hd()
|> :binary.split("?")
|> case do
[path, query] -> {path, query}
[path] -> {path, nil}
end
end
@spec call_plug!(Plug.Conn.t(), plug_def()) :: Plug.Conn.t() | no_return()
defp call_plug!(%Plug.Conn{} = conn, {plug, plug_opts}) when is_atom(plug) do
case plug.call(conn, plug_opts) do
%Plug.Conn{} = conn -> conn
other -> raise("Expected #{plug}.call/2 to return %Plug.Conn{} but got: #{inspect(other)}")
end
end
defp call_plug!(%Plug.Conn{} = conn, {plug_fn, plug_opts}) when is_function(plug_fn) do
case plug_fn.(conn, plug_opts) do
%Plug.Conn{} = conn -> conn
other -> raise("Expected Plug function to return %Plug.Conn{} but got: #{inspect(other)}")
end
end
@spec maybe_upgrade!(Plug.Conn.t()) ::
{:no_upgrade, Plug.Conn.t()} | {:upgrade, Plug.Conn.t(), :websocket, tuple()}
defp maybe_upgrade!(
%Plug.Conn{
state: :upgraded,
adapter:
{_,
%{upgrade: {:websocket, {websock, websock_opts, connection_opts}, websocket_opts}}}
} = conn
) do
# We can safely unset the state, since we match on :upgraded above
case Bandit.WebSocket.Handshake.handshake(
%{conn | state: :unset},
connection_opts,
websocket_opts
) do
{:ok, conn, connection_opts} ->
{:upgrade, conn, :websocket, {websock, websock_opts, connection_opts}}
{:error, reason} ->
request_error!(reason)
end
end
defp maybe_upgrade!(conn), do: {:no_upgrade, conn}
@spec commit_response!(Plug.Conn.t()) :: Plug.Conn.t() | no_return()
defp commit_response!(conn) do
case conn do
%Plug.Conn{state: :unset} ->
raise(Plug.Conn.NotSentError)
%Plug.Conn{state: :set} ->
Plug.Conn.send_resp(conn)
%Plug.Conn{state: :chunked, adapter: {mod, adapter}} ->
adapter =
case mod.chunk(adapter, "") do
{:ok, _, adapter} -> adapter
_ -> adapter
end
%{conn | adapter: {mod, adapter}}
%Plug.Conn{} ->
conn
end
|> then(fn %Plug.Conn{adapter: {mod, adapter}} = conn ->
transport = Bandit.HTTPTransport.ensure_completed(adapter.transport)
%{conn | adapter: {mod, %{adapter | transport: transport}}}
end)
end
@spec request_error!(term()) :: no_return()
@spec request_error!(term(), Plug.Conn.status()) :: no_return()
defp request_error!(reason, plug_status \\ :bad_request) do
raise Bandit.HTTPError, message: reason, plug_status: plug_status
end
@spec handle_error(
:error | :throw | :exit,
Exception.t() | term(),
Exception.stacktrace(),
Bandit.HTTPTransport.t(),
Bandit.Telemetry.t(),
map(),
keyword()
) :: {:ok, Bandit.HTTPTransport.t()} | {:error, term()}
defp handle_error(:error, %Plug.Conn.WrapperError{} = error, _, transport, span, opts, metadata) do
# Unwrap the inner error and handle it
handle_error(error.kind, error.reason, error.stack, transport, span, opts, metadata)
end
defp handle_error(:error, %type{} = error, stacktrace, transport, span, opts, metadata)
when type in [
Bandit.HTTPError,
Bandit.TransportError,
Bandit.HTTP2.Errors.StreamError,
Bandit.HTTP2.Errors.ConnectionError
] do
Bandit.Telemetry.stop_span(span, %{}, Enum.into(metadata, %{error: error.message}))
Bandit.Logger.maybe_log_protocol_error(error, stacktrace, opts, metadata)
# We want to do this at the end of the function, since the HTTP2 stack may kill this process
# in the course of handling a ConnectionError
Bandit.HTTPTransport.send_on_error(transport, error)
{:error, error}
end
defp handle_error(kind, reason, stacktrace, transport, span, opts, metadata) do
reason = Exception.normalize(kind, reason, stacktrace)
Bandit.Telemetry.span_exception(span, kind, reason, stacktrace)
status = reason |> Plug.Exception.status() |> Plug.Conn.Status.code()
if status in Keyword.get(opts.http, :log_exceptions_with_status_codes, 500..599) do
logger_metadata = Bandit.Logger.logger_metadata_for(kind, reason, stacktrace, metadata)
Logger.error(Exception.format(kind, reason, stacktrace), logger_metadata)
end
Bandit.HTTPTransport.send_on_error(transport, reason)
{:error, reason}
end
end

View File

@@ -0,0 +1,34 @@
defmodule Bandit.PrimitiveOps.WebSocket do
@moduledoc """
WebSocket primitive operations behaviour and default implementation
"""
@doc """
WebSocket masking according to [RFC6455§5.3](https://www.rfc-editor.org/rfc/rfc6455#section-5.3)
"""
@callback ws_mask(payload :: binary(), mask :: integer()) :: binary()
@behaviour __MODULE__
# Note that masking is an involution, so we don't need a separate unmask function
@impl true
def ws_mask(payload, mask)
when is_binary(payload) and is_integer(mask) and mask >= 0x00000000 and mask <= 0xFFFFFFFF do
ws_mask(<<>>, payload, mask)
end
defp ws_mask(acc, <<h::32, rest::binary>>, mask) do
ws_mask(<<acc::binary, (<<Bitwise.bxor(h, mask)::32>>)>>, rest, mask)
end
for size <- [24, 16, 8] do
defp ws_mask(acc, <<h::unquote(size)>>, mask) do
<<mask::unquote(size), _::binary>> = <<mask::32>>
<<acc::binary, (<<Bitwise.bxor(h, mask)::unquote(size)>>)>>
end
end
defp ws_mask(acc, <<>>, _mask) do
acc
end
end

View File

@@ -0,0 +1,74 @@
defmodule Bandit.SocketHelpers do
@moduledoc false
def iodata_empty?(""), do: true
def iodata_empty?([]), do: true
def iodata_empty?([head | tail]), do: iodata_empty?(head) and iodata_empty?(tail)
def iodata_empty?(_), do: false
@spec conn_data(ThousandIsland.Socket.t()) :: Bandit.Pipeline.conn_data()
def conn_data(socket) do
secure? = ThousandIsland.Socket.secure?(socket)
{peer_address, _port} =
case ThousandIsland.Socket.peername(socket) do
{:ok, peername} -> map_address(peername)
{:error, reason} -> transport_error!("Unable to obtain conn_data", reason)
end
{secure?, peer_address}
end
@spec peer_data(ThousandIsland.Socket.t()) :: Plug.Conn.Adapter.peer_data()
def peer_data(socket) do
with {:ok, peername} <- ThousandIsland.Socket.peername(socket),
{address, port} <- map_address(peername),
{:ok, ssl_cert} <- peercert(socket) do
%{address: address, port: port, ssl_cert: ssl_cert}
else
{:error, reason} -> transport_error!("Unable to obtain peer_data", reason)
end
end
@spec sock_data(ThousandIsland.Socket.t()) :: Plug.Conn.Adapter.sock_data()
def sock_data(socket) do
with {:ok, sockname} <- ThousandIsland.Socket.sockname(socket),
{address, port} <- map_address(sockname) do
%{address: address, port: port}
else
{:error, reason} -> transport_error!("Unable to obtain sock_data", reason)
end
end
@spec ssl_data(ThousandIsland.Socket.t()) :: Plug.Conn.Adapter.ssl_data()
def ssl_data(socket) do
case ThousandIsland.Socket.connection_information(socket) do
{:ok, connection_information} -> connection_information
{:error, :not_secure} -> nil
{:error, reason} -> transport_error!("Unable to obtain ssl_data", reason)
end
end
defp map_address(address) do
case address do
{:local, path} -> {{:local, path}, 0}
{:unspec, <<>>} -> {:unspec, 0}
{:undefined, term} -> {{:undefined, term}, 0}
{ip, port} -> {ip, port}
end
end
defp peercert(socket) do
case ThousandIsland.Socket.peercert(socket) do
{:ok, cert} -> {:ok, cert}
{:error, :no_peercert} -> {:ok, nil}
{:error, :not_secure} -> {:ok, nil}
{:error, reason} -> {:error, reason}
end
end
@spec transport_error!(term(), term()) :: no_return()
defp transport_error!(message, error) do
raise Bandit.TransportError, message: message, error: error
end
end

View File

@@ -0,0 +1,250 @@
defmodule Bandit.Telemetry do
@moduledoc """
The following telemetry spans are emitted by bandit
## `[:bandit, :request, *]`
Represents Bandit handling a specific client HTTP request
This span is started by the following event:
* `[:bandit, :request, :start]`
Represents the start of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `conn`: The `Plug.Conn` representing this connection. Not present in cases where `error`
is also set and the nature of error is such that Bandit was unable to successfully build
the conn
* `plug`: The Plug which is being used to serve this request. Specified as `{plug_module, plug_opts}`
This span is ended by the following event:
* `[:bandit, :request, :stop]`
Represents the end of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `duration`: The span duration, in `:native` units
* `req_header_end_time`: The time that header reading completed, in `:native` units
* `req_body_start_time`: The time that request body reading started, in `:native` units.
* `req_body_end_time`: The time that request body reading completed, in `:native` units
* `req_body_bytes`: The length of the request body, in octets
* `resp_start_time`: The time that the response started, in `:native` units
* `resp_end_time`: The time that the response completed, in `:native` units
* `resp_body_bytes`: The length of the response body, in octets. If the response is
compressed, this is the size of the compressed payload as sent on the wire
* `resp_uncompressed_body_bytes`: The length of the original, uncompressed body. Only
included for responses which are compressed
* `resp_compression_method`: The method of compression, as sent in the `Content-Encoding`
header of the response. Only included for responses which are compressed
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `conn`: The `Plug.Conn` representing this connection. Not present in cases where `error`
is also set and the nature of error is such that Bandit was unable to successfully build
the conn
* `plug`: The Plug which is being used to serve this request. Specified as `{plug_module, plug_opts}`
* `error`: The error that caused the span to end, if it ended in error
The following events may be emitted within this span:
* `[:bandit, :request, :exception]`
The request for this span ended unexpectedly
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `conn`: The `Plug.Conn` representing this connection. Not present in cases where `error`
is also set and the nature of error is such that Bandit was unable to successfully build
the conn
* `plug`: The Plug which is being used to serve this request. Specified as `{plug_module, plug_opts}`
* `kind`: The kind of unexpected condition, typically `:exit`
* `exception`: The exception which caused this unexpected termination. May be an exception
or an arbitrary value when the event was an uncaught throw or an exit
* `stacktrace`: The stacktrace of the location which caused this unexpected termination
## `[:bandit, :websocket, *]`
Represents Bandit handling a WebSocket connection
This span is started by the following event:
* `[:bandit, :websocket, :start]`
Represents the start of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `compress`: Details about the compression configuration for this connection
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `websock`: The WebSock which is being used to serve this request. Specified as `websock_module`
This span is ended by the following event:
* `[:bandit, :websocket, :stop]`
Represents the end of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `duration`: The span duration, in `:native` units
* `recv_text_frame_count`: The number of text frames received
* `recv_text_frame_bytes`: The total number of bytes received in the payload of text frames
* `recv_binary_frame_count`: The number of binary frames received
* `recv_binary_frame_bytes`: The total number of bytes received in the payload of binary frames
* `recv_ping_frame_count`: The number of ping frames received
* `recv_ping_frame_bytes`: The total number of bytes received in the payload of ping frames
* `recv_pong_frame_count`: The number of pong frames received
* `recv_pong_frame_bytes`: The total number of bytes received in the payload of pong frames
* `recv_connection_close_frame_count`: The number of connection close frames received
* `recv_connection_close_frame_bytes`: The total number of bytes received in the payload of connection close frames
* `recv_continuation_frame_count`: The number of continuation frames received
* `recv_continuation_frame_bytes`: The total number of bytes received in the payload of continuation frames
* `send_text_frame_count`: The number of text frames sent
* `send_text_frame_bytes`: The total number of bytes sent in the payload of text frames
* `send_binary_frame_count`: The number of binary frames sent
* `send_binary_frame_bytes`: The total number of bytes sent in the payload of binary frames
* `send_ping_frame_count`: The number of ping frames sent
* `send_ping_frame_bytes`: The total number of bytes sent in the payload of ping frames
* `send_pong_frame_count`: The number of pong frames sent
* `send_pong_frame_bytes`: The total number of bytes sent in the payload of pong frames
* `send_connection_close_frame_count`: The number of connection close frames sent
* `send_connection_close_frame_bytes`: The total number of bytes sent in the payload of connection close frames
* `send_continuation_frame_count`: The number of continuation frames sent
* `send_continuation_frame_bytes`: The total number of bytes sent in the payload of continuation frames
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `origin_telemetry_span_context`: The span context of the Bandit `:request` span from which
this connection originated
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `websock`: The WebSock which is being used to serve this request. Specified as `websock_module`
* `error`: The error that caused the span to end, if it ended in error
"""
defstruct span_name: nil, telemetry_span_context: nil, start_time: nil, start_metadata: nil
@typep span_name :: atom()
@opaque t :: %__MODULE__{
span_name: span_name(),
telemetry_span_context: reference(),
start_time: integer(),
start_metadata: :telemetry.event_metadata()
}
@app_name :bandit
@doc false
@spec start_span(span_name(), :telemetry.event_measurements(), :telemetry.event_metadata()) ::
t()
def start_span(span_name, measurements \\ %{}, metadata \\ %{}) do
measurements = Map.put_new_lazy(measurements, :monotonic_time, &monotonic_time/0)
telemetry_span_context = make_ref()
metadata = Map.put(metadata, :telemetry_span_context, telemetry_span_context)
event([span_name, :start], measurements, metadata)
%__MODULE__{
span_name: span_name,
telemetry_span_context: telemetry_span_context,
start_time: measurements[:monotonic_time],
start_metadata: metadata
}
end
@doc false
@spec stop_span(t(), :telemetry.event_measurements(), :telemetry.event_metadata()) :: :ok
def stop_span(span, measurements \\ %{}, metadata \\ %{}) do
measurements = Map.put_new_lazy(measurements, :monotonic_time, &monotonic_time/0)
measurements =
Map.put(measurements, :duration, measurements[:monotonic_time] - span.start_time)
metadata = Map.merge(span.start_metadata, metadata)
untimed_span_event(span, :stop, measurements, metadata)
end
@spec span_exception(t(), Exception.kind(), Exception.t() | term(), Exception.stacktrace()) ::
:ok
def span_exception(span, kind, reason, stacktrace) do
# Using :exit for backwards-compatibility with Bandit =< 1.5.7
kind = if kind == :error, do: :exit, else: kind
metadata =
Map.merge(span.start_metadata, %{
kind: kind,
exception: reason,
stacktrace: stacktrace
})
span_event(span, :exception, %{}, metadata)
end
@doc false
@spec span_event(t(), span_name(), :telemetry.event_measurements(), :telemetry.event_metadata()) ::
:ok
def span_event(span, name, measurements \\ %{}, metadata \\ %{}) do
measurements = Map.put_new_lazy(measurements, :monotonic_time, &monotonic_time/0)
untimed_span_event(span, name, measurements, metadata)
end
@doc false
@spec untimed_span_event(
t(),
span_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata()
) :: :ok
def untimed_span_event(span, name, measurements \\ %{}, metadata \\ %{}) do
metadata = Map.put(metadata, :telemetry_span_context, span.telemetry_span_context)
event([span.span_name, name], measurements, metadata)
end
@spec monotonic_time :: integer()
defdelegate monotonic_time, to: System
@spec event(
:telemetry.event_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata()
) :: :ok
defp event(suffix, measurements, metadata) do
:telemetry.execute([@app_name | suffix], measurements, metadata)
end
@doc false
@spec telemetry_span_context(t()) :: reference()
def telemetry_span_context(span) do
span.telemetry_span_context
end
end

View File

@@ -0,0 +1,150 @@
defmodule Bandit.Trace do
@moduledoc """
**THIS MODULE IS EXPERIMENTAL AND SUBJECT TO CHANGE**
Helper functions to provide visibility into runtime errors within a running Bandit instance
Can be used within an IEx session attached to a running Bandit instance, as follows:
```
iex> Bandit.Trace.start_tracing()
... # Wait for traces to show up whenever exceptions are raised
iex> Bandit.Trace.stop_tracing()
```
It can also be started within your application by adding `Bandit.Trace` to your process tree.
`Bandit.Trace` will emit a trace on every exception that Bandit sees (both those emitted from
within your Plug as well as internal ones due to protocol violations and the like). These traces
consist of a complete dump of all telemetry events that occur in the offending request's parent
connection.
Tracing imposes a modest but non-zero load; it *should* be safe to run in most production
environments, but it is not intended to run on an ongoing basis.
By default, `Bandit.Trace` maintains a FIFO log of the last 10000 telemetry events that Bandit
has emitted. Events which correlate to the parent connection which have been evicted from this
queue will not be included in this output.
**WARNING** The emitted logs contains a *complete* copy of your request's Plug data, as well as *all* data
sent and received on all requests which are contained in the output. It is therefore of the utmost
importance that you carefully redact the output before sharing it publicly.
"""
defstruct queue: nil, size: 0, max_size: 10_000, trace_on_exception: true
use GenServer
@events [
[:bandit, :request, :start],
[:bandit, :request, :stop],
[:bandit, :request, :exception],
[:bandit, :websocket, :start],
[:bandit, :websocket, :stop],
[:thousand_island, :connection, :start],
[:thousand_island, :connection, :stop],
[:thousand_island, :connection, :ready],
[:thousand_island, :connection, :async_recv],
[:thousand_island, :connection, :recv],
[:thousand_island, :connection, :recv_error],
[:thousand_island, :connection, :send],
[:thousand_island, :connection, :send_error],
[:thousand_island, :connection, :sendfile],
[:thousand_island, :connection, :sendfile_error],
[:thousand_island, :connection, :socket_shutdown]
]
@doc """
Start tracing of all Bandit requests
See module documentation for intended usage. Accepts the following options:
* `max_size`: The size of the telemetry event queue to maintain. By default, `Bandit.Trace` maintains a
queue of the last 10000 telemetry events
* `trace_on_exception`: Whether or not to emit traces when an error is raised within
Bandit. Defaults to `true`
"""
def start_tracing(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@doc """
Stop any active trace session
"""
def stop_tracing, do: GenServer.stop(__MODULE__)
def handle_event(event, measurements, metadata, pid),
do: GenServer.cast(pid, {:event, {event, measurements, metadata, :os.perf_counter()}})
@doc """
Return the complete queue of telemetry events that `Bandit.Trace` is currently tracking
"""
def get_events, do: GenServer.call(__MODULE__, :get_events)
@impl GenServer
def init(opts) do
_ = :telemetry.attach_many(self(), @events, &__MODULE__.handle_event/4, self())
{:ok, struct!(%__MODULE__{queue: :queue.new()}, opts)}
end
@impl GenServer
def terminate(_, _), do: :telemetry.detach(self())
@impl GenServer
def handle_cast({:event, event}, state) do
state
|> maybe_pop()
|> push(event)
|> tap(&maybe_trace(&1, event))
|> then(&{:noreply, &1})
end
defp maybe_pop(%{size: size, max_size: max_size} = state) when size >= max_size,
do: maybe_pop(%{state | queue: :queue.drop(state.queue), size: size - 1})
defp maybe_pop(state), do: state
defp push(state, event),
do: %{state | queue: :queue.in(event, state.queue), size: state.size + 1}
defp maybe_trace(
%{trace_on_exception: true} = state,
{[:bandit, :request, :exception], _, metadata, _}
) do
connection_span_context = Map.get(metadata, :connection_telemetry_span_context)
IO.puts("======================================")
IO.puts("Starting telemetry trace for exception")
IO.puts("======================================")
:queue.to_list(state.queue)
|> Enum.filter(fn {_, _, metadata, _} ->
Map.get(metadata, :telemetry_span_context) == connection_span_context ||
Map.get(metadata, :connection_telemetry_span_context) == connection_span_context
end)
|> format_list()
|> inspect(limit: :infinity, pretty: true, printable_limit: :infinity)
|> IO.puts()
IO.puts("=======================================")
IO.puts("Completed telemetry trace for exception")
IO.puts("=======================================")
:ok
end
defp maybe_trace(_state, _event), do: :ok
@impl GenServer
def handle_call(:get_events, _from, state),
do: {:reply, :queue.to_list(state.queue) |> format_list(), state}
defp format_list([]), do: :ok
defp format_list([{_, _, _, start_time} | _] = events),
do: Enum.map(events, &format_tuple(&1, start_time))
defp format_tuple({event, measurements, metadata, time}, start_time) do
time = :erlang.convert_time_unit(time - start_time, :perf_counter, :microsecond)
%{telemetry_span_context: span_id} = metadata
{time, span_id, event, measurements, metadata}
end
end

View File

@@ -0,0 +1,6 @@
defmodule Bandit.TransportError do
# Represents an error coming from the underlying transport which cannot be signalled back to the
# client by conventional means within the request. Examples include TCP socket closures and
# errors in the case of HTTP/1, and stream resets in HTTP/2
defexception message: nil, error: nil
end

View File

@@ -0,0 +1,58 @@
# WebSocket Handler
Included in this folder is a complete `ThousandIsland.Handler` based implementation of WebSockets
as defined in [RFC 6455](https://datatracker.ietf.org/doc/rfc6455).
## Upgrade mechanism
A good overview of this process is contained in this [ElixirConf EU
talk](https://www.youtube.com/watch?v=usKLrYl4zlY).
Upgrading an HTTP connection to a WebSocket connection is coordinated by code
contained within several libraries, including Bandit,
[WebSockAdapter](https://github.com/phoenixframework/websock_adapter), and
[Plug](https://github.com/elixir-plug/plug).
The HTTP request containing the upgrade request is first passed to the user's
application as a standard Plug call. After inspecting the request and deeming it
a suitable upgrade candidate (via whatever policy the application dictates), the
user indicates a desire to upgrade the connection to a WebSocket by calling
`WebSockAdapter.upgrade/4`, which checks that the request is a valid WebSocket
upgrade request, and then calls `Plug.Conn.upgrade_adapter/3` to signal to
Bandit that the connection should be upgraded at the conclusion of the request.
At the conclusion of the `Plug.call/2` callback, `Bandit.Pipeline` will then
attempt to upgrade the underlying connection. As part of this upgrade process,
`Bandit.DelegatingHandler` will switch the Handler for the connection to be
`Bandit.WebSocket.Handler`. This will cause any future communication after the
upgrade process to be handled directly by Bandit's WebSocket stack.
## Process model
Within a Bandit server, a WebSocket connection is modeled as a single process.
This process is directly tied to the lifecycle of the underlying WebSocket
connection; when upgrading from HTTP/1, the existing HTTP/1 handler process
'magically' becomes a WebSocket process by changing which Handler the
`Bandit.DelegatingHandler` delegates to.
The execution model to handle a given request is quite straightforward: at
upgrade time, the `Bandit.DelegatingHandler` will call `handle_connection/2` to
allow the WebSocket handler to initialize any startup state. Connection state is
modeled by the `Bandit.WebSocket.Connection` struct and module.
All data subsequently received by the underlying [Thousand
Island](https://github.com/mtrudel/thousand_island) library will result in
a call to `Bandit.WebSocket.Handler.handle_data/3`, which will then attempt to
parse the data into one or more WebSocket frames. Once a frame has been
constructed, it is them passed through to the configured `WebSock` handler by
way of the underlying `Bandit.WebSocket.Connection`.
# Testing
All of this is exhaustively tested. Tests are broken up primarily into `protocol_test.exs`, which
is concerned with aspects of the implementation relating to protocol conformance and
client-facing concerns, while `sock_test.exs` is concerned with aspects of the implementation
having to do with the WebSock API and application-facing concerns. There are also more
unit-style tests covering frame serialization and deserialization.
In addition, the `autobahn` conformance suite is run via a `System` wrapper & executes the entirety
of the suite against a running Bandit server.

View File

@@ -0,0 +1,315 @@
defmodule Bandit.WebSocket.Connection do
@moduledoc false
# Implementation of a WebSocket lifecycle, implemented using a Socket protocol for communication
alias Bandit.WebSocket.{Frame, PerMessageDeflate, Socket}
defstruct websock: nil,
websock_state: nil,
state: :open,
compress: nil,
opts: [],
fragment_frame: nil,
span: nil,
metrics: %{}
@typedoc "Connection state"
@type state :: :open | :closing | :closed
@typedoc "Encapsulates the state of a WebSocket connection"
@type t :: %__MODULE__{
websock: WebSock.impl(),
websock_state: WebSock.state(),
state: state(),
compress: PerMessageDeflate.t() | nil,
opts: keyword(),
fragment_frame: Frame.Text.t() | Frame.Binary.t() | nil,
span: Bandit.Telemetry.t(),
metrics: map()
}
def init(websock, websock_state, connection_opts, socket) do
compress = Keyword.get(connection_opts, :compress)
connection_telemetry_span_context =
ThousandIsland.Socket.telemetry_span(socket).telemetry_span_context
span =
Bandit.Telemetry.start_span(:websocket, %{compress: compress}, %{
connection_telemetry_span_context: connection_telemetry_span_context,
websock: websock
})
instance = %__MODULE__{
websock: websock,
websock_state: websock_state,
compress: compress,
opts: connection_opts,
span: span
}
websock.init(websock_state) |> handle_continutation(socket, instance)
end
def handle_frame(frame, socket, %{fragment_frame: nil} = connection) do
connection = do_recv_metrics(frame, connection)
case frame do
%Frame.Continuation{} ->
do_error(1002, "Received unexpected continuation frame (RFC6455§5.4)", socket, connection)
%Frame.Text{fin: true, compressed: true} = frame ->
do_inflate(frame, socket, connection)
%Frame.Text{fin: true} = frame ->
if !Keyword.get(connection.opts, :validate_text_frames, true) || String.valid?(frame.data) do
connection.websock.handle_in({frame.data, opcode: :text}, connection.websock_state)
|> handle_continutation(socket, connection)
else
do_error(1007, "Received non UTF-8 text frame (RFC6455§8.1)", socket, connection)
end
%Frame.Text{fin: false} = frame ->
{:continue, %{connection | fragment_frame: frame}}
%Frame.Binary{fin: true, compressed: true} = frame ->
do_inflate(frame, socket, connection)
%Frame.Binary{fin: true} = frame ->
connection.websock.handle_in({frame.data, opcode: :binary}, connection.websock_state)
|> handle_continutation(socket, connection)
%Frame.Binary{fin: false} = frame ->
{:continue, %{connection | fragment_frame: frame}}
frame ->
handle_control_frame(frame, socket, connection)
end
end
def handle_frame(frame, socket, %{fragment_frame: fragment_frame} = connection)
when not is_nil(fragment_frame) do
connection = do_recv_metrics(frame, connection)
case frame do
%Frame.Continuation{fin: true} = frame ->
data = IO.iodata_to_binary([connection.fragment_frame.data | frame.data])
frame = %{connection.fragment_frame | fin: true, data: data}
handle_frame(frame, socket, %{connection | fragment_frame: nil})
%Frame.Continuation{fin: false} = frame ->
data = [connection.fragment_frame.data | frame.data]
frame = %{connection.fragment_frame | fin: true, data: data}
{:continue, %{connection | fragment_frame: frame}}
%Frame.Text{} ->
do_error(1002, "Received unexpected text frame (RFC6455§5.4)", socket, connection)
%Frame.Binary{} ->
do_error(1002, "Received unexpected binary frame (RFC6455§5.4)", socket, connection)
frame ->
handle_control_frame(frame, socket, connection)
end
end
defp handle_control_frame(frame, socket, connection) do
case frame do
%Frame.ConnectionClose{} = frame ->
# This is a bit of a subtle case, see RFC6455§7.4.1-2
reply_code =
case frame.code do
code when code in 1000..1003 or code in 1007..1011 or code > 2999 -> 1000
_code -> 1002
end
{:continue, connection} = do_stop(reply_code, :remote, socket, connection)
{:close, %{connection | state: :closed, compress: nil}}
%Frame.Ping{} = frame ->
connection =
Socket.send_frame(socket, {:pong, frame.data}, false)
|> do_send_metrics(connection)
if function_exported?(connection.websock, :handle_control, 2) do
connection.websock.handle_control({frame.data, opcode: :ping}, connection.websock_state)
|> handle_continutation(socket, connection)
else
{:continue, connection}
end
%Frame.Pong{} = frame ->
if function_exported?(connection.websock, :handle_control, 2) do
connection.websock.handle_control({frame.data, opcode: :pong}, connection.websock_state)
|> handle_continutation(socket, connection)
else
{:continue, connection}
end
end
end
defp do_recv_metrics(frame, connection) do
metrics =
Bandit.WebSocket.Frame.recv_metrics(frame)
|> Enum.reduce(connection.metrics, fn {key, value}, metrics ->
Map.update(metrics, key, value, &(&1 + value))
end)
%{connection | metrics: metrics}
end
defp do_send_metrics(metrics, connection) do
metrics =
metrics
|> Enum.reduce(connection.metrics, fn {key, value}, metrics ->
Map.update(metrics, key, value, &(&1 + value))
end)
%{connection | metrics: metrics}
end
def handle_close(socket, connection), do: do_error(1006, :closed, socket, connection)
# Some uncertainty if this should be 1000 or 1001 @ https://github.com/mtrudel/bandit/issues/89
def handle_shutdown(socket, connection), do: do_stop(1000, :shutdown, socket, connection)
def handle_error({:deserializing, :max_frame_size_exceeded = reason}, socket, connection),
do: do_error(1009, reason, socket, connection)
def handle_error({:deserializing, reason}, socket, connection),
do: do_error(1002, reason, socket, connection)
def handle_error(reason, socket, connection), do: do_error(1011, reason, socket, connection)
def handle_timeout(socket, connection), do: do_error(1002, :timeout, socket, connection)
def handle_info(msg, socket, connection) do
connection.websock.handle_info(msg, connection.websock_state)
|> handle_continutation(socket, connection)
end
defp handle_continutation(continutation, socket, connection) do
case continutation do
{:ok, websock_state} ->
{:continue, %{connection | websock_state: websock_state}}
{:reply, _status, msg, websock_state} ->
do_deflate(msg, socket, %{connection | websock_state: websock_state})
{:push, msg, websock_state} ->
do_deflate(msg, socket, %{connection | websock_state: websock_state})
{:stop, :normal, websock_state} ->
do_stop(1000, :normal, socket, %{connection | websock_state: websock_state})
{:stop, :normal, code, websock_state} ->
do_stop(code, :normal, socket, %{connection | websock_state: websock_state})
{:stop, :normal, code, msg, websock_state} ->
case do_deflate(msg, socket, %{connection | websock_state: websock_state}) do
{:continue, connection} -> do_stop(code, :normal, socket, connection)
other -> other
end
{:stop, {:shutdown, :disconnected}, websock_state} ->
do_stop(1000, :normal, socket, %{connection | websock_state: websock_state})
{:stop, {:shutdown, :restart}, websock_state} ->
do_stop(1012, :normal, socket, %{connection | websock_state: websock_state})
{:stop, reason, websock_state} ->
do_error(1011, reason, socket, %{connection | websock_state: websock_state})
{:stop, reason, code, websock_state} ->
do_error(code, reason, socket, %{connection | websock_state: websock_state})
{:stop, reason, code, msg, websock_state} ->
case do_deflate(msg, socket, %{connection | websock_state: websock_state}) do
{:continue, connection} -> do_error(code, reason, socket, connection)
other -> other
end
end
end
defp do_stop(code, reason, socket, connection) do
if connection.state == :open do
if function_exported?(connection.websock, :terminate, 2) do
connection.websock.terminate(reason, connection.websock_state)
end
_ = Socket.close(socket, code)
if connection.compress, do: PerMessageDeflate.close(connection.compress)
Bandit.Telemetry.stop_span(connection.span, connection.metrics)
end
{:continue, %{connection | state: :closing, compress: nil}}
end
defp do_error(code, reason, socket, connection) do
if connection.state == :open do
if function_exported?(connection.websock, :terminate, 2) do
connection.websock.terminate(maybe_wrap_reason(reason), connection.websock_state)
end
_ = Socket.close(socket, code)
if connection.compress, do: PerMessageDeflate.close(connection.compress)
Bandit.Telemetry.stop_span(connection.span, connection.metrics, %{error: reason})
end
{:error, reason, %{connection | state: :closed, compress: nil}}
end
defp maybe_wrap_reason(:timeout), do: :timeout
defp maybe_wrap_reason(reason), do: {:error, reason}
defp do_deflate(msgs, socket, connection) when is_list(msgs) do
Enum.reduce(msgs, {:continue, connection}, fn
msg, {:continue, connection} -> do_deflate(msg, socket, connection)
_msg, other -> other
end)
end
defp do_deflate({opcode, data} = msg, socket, connection) when opcode in [:text, :binary] do
case PerMessageDeflate.deflate(data, connection.compress) do
{:ok, data, compress} ->
connection =
Socket.send_frame(socket, {opcode, data}, true)
|> do_send_metrics(connection)
{:continue, %{connection | compress: compress}}
{:error, :no_compress} ->
connection =
Socket.send_frame(socket, msg, false)
|> do_send_metrics(connection)
{:continue, connection}
{:error, reason} ->
do_error(1007, "Deflation error: #{inspect(reason)}", socket, connection)
end
end
defp do_deflate({opcode, _data} = msg, socket, connection) when opcode in [:ping, :pong] do
connection =
Socket.send_frame(socket, msg, false)
|> do_send_metrics(connection)
{:continue, connection}
end
defp do_inflate(frame, socket, connection) do
case PerMessageDeflate.inflate(frame.data, connection.compress) do
{:ok, data, compress} ->
frame = %{frame | data: data, compressed: false}
connection = %{connection | compress: compress}
handle_frame(frame, socket, connection)
{:error, :no_compress} ->
do_error(1002, "Received unexpected compressed frame (RFC6455§5.2)", socket, connection)
{:error, _reason} ->
do_error(1007, "Inflation error", socket, connection)
end
end
end

View File

@@ -0,0 +1,205 @@
defmodule Bandit.WebSocket.Frame do
@moduledoc false
alias Bandit.WebSocket.Frame
@behaviour Bandit.Extractor
@typedoc "Indicates an opcode"
@type opcode ::
(binary :: 0x2)
| (connection_close :: 0x8)
| (continuation :: 0x0)
| (ping :: 0x9)
| (pong :: 0xA)
| (text :: 0x1)
@typedoc "A valid WebSocket frame"
@type frame ::
Frame.Continuation.t()
| Frame.Text.t()
| Frame.Binary.t()
| Frame.ConnectionClose.t()
| Frame.Ping.t()
| Frame.Pong.t()
@impl Bandit.Extractor
@spec header_and_payload_length(binary(), non_neg_integer()) ::
{:ok, {header_length :: integer(), payload_length :: integer()}}
| {:error, :max_frame_size_exceeded | :client_frame_without_mask}
| :more
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 1::1, 127::7, length::64, _mask::32,
_rest::binary>>,
max_frame_size
) do
validate_max_frame_size(14, length, max_frame_size)
end
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 1::1, 126::7, length::16, _mask::32,
_rest::binary>>,
max_frame_size
) do
validate_max_frame_size(8, length, max_frame_size)
end
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 1::1, length::7, _mask::32,
_rest::binary>>,
max_frame_size
)
when length <= 125 do
validate_max_frame_size(6, length, max_frame_size)
end
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 0::1, _rest::binary>>,
_max_frame_size
) do
{:error, :client_frame_without_mask}
end
def header_and_payload_length(_msg, _max_frame_size) do
:more
end
defp validate_max_frame_size(header_length, payload_length, max_frame_size) do
if max_frame_size != 0 and header_length + payload_length > max_frame_size do
{:error, :max_frame_size_exceeded}
else
{:ok, {header_length, payload_length}}
end
end
@impl Bandit.Extractor
@spec deserialize(binary(), module()) :: {:ok, frame()} | {:error, term()}
def deserialize(
<<fin::1, compressed::1, rsv::2, opcode::4, 1::1, 127::7, length::64, mask::32,
payload::binary-size(length)>>,
primitive_ops_module
) do
to_frame(fin, compressed, rsv, opcode, mask, payload, primitive_ops_module)
end
def deserialize(
<<fin::1, compressed::1, rsv::2, opcode::4, 1::1, 126::7, length::16, mask::32,
payload::binary-size(length)>>,
primitive_ops_module
) do
to_frame(fin, compressed, rsv, opcode, mask, payload, primitive_ops_module)
end
def deserialize(
<<fin::1, compressed::1, rsv::2, opcode::4, 1::1, length::7, mask::32,
payload::binary-size(length)>>,
primitive_ops_module
) do
to_frame(fin, compressed, rsv, opcode, mask, payload, primitive_ops_module)
end
def deserialize(_msg, _primitive_ops_module) do
{:error, :deserialization_failed}
end
def recv_metrics(%frame_type{} = frame) do
case frame_type do
Frame.Continuation ->
[
recv_continuation_frame_count: 1,
recv_continuation_frame_bytes: IO.iodata_length(frame.data)
]
Frame.Text ->
[recv_text_frame_count: 1, recv_text_frame_bytes: IO.iodata_length(frame.data)]
Frame.Binary ->
[recv_binary_frame_count: 1, recv_binary_frame_bytes: IO.iodata_length(frame.data)]
Frame.ConnectionClose ->
[
recv_connection_close_frame_count: 1,
recv_connection_close_frame_bytes: IO.iodata_length(frame.reason)
]
Frame.Ping ->
[recv_ping_frame_count: 1, recv_ping_frame_bytes: IO.iodata_length(frame.data)]
Frame.Pong ->
[recv_pong_frame_count: 1, recv_pong_frame_bytes: IO.iodata_length(frame.data)]
end
end
def send_metrics(%frame_type{} = frame) do
case frame_type do
Frame.Continuation ->
[
send_continuation_frame_count: 1,
send_continuation_frame_bytes: IO.iodata_length(frame.data)
]
Frame.Text ->
[send_text_frame_count: 1, send_text_frame_bytes: IO.iodata_length(frame.data)]
Frame.Binary ->
[send_binary_frame_count: 1, send_binary_frame_bytes: IO.iodata_length(frame.data)]
Frame.ConnectionClose ->
[
send_connection_close_frame_count: 1,
send_connection_close_frame_bytes: IO.iodata_length(frame.reason)
]
Frame.Ping ->
[send_ping_frame_count: 1, send_ping_frame_bytes: IO.iodata_length(frame.data)]
Frame.Pong ->
[send_pong_frame_count: 1, send_pong_frame_bytes: IO.iodata_length(frame.data)]
end
end
defp to_frame(_fin, _compressed, rsv, _opcode, _mask, _payload, _primitive_ops_module)
when rsv != 0x0 do
{:error, "Received unsupported RSV flags #{rsv}"}
end
defp to_frame(fin, compressed, 0x0, opcode, mask, payload, primitive_ops_module) do
fin = fin == 0x1
compressed = compressed == 0x1
unmasked_payload = primitive_ops_module.ws_mask(payload, mask)
opcode
|> case do
0x0 -> Frame.Continuation.deserialize(fin, compressed, unmasked_payload)
0x1 -> Frame.Text.deserialize(fin, compressed, unmasked_payload)
0x2 -> Frame.Binary.deserialize(fin, compressed, unmasked_payload)
0x8 -> Frame.ConnectionClose.deserialize(fin, compressed, unmasked_payload)
0x9 -> Frame.Ping.deserialize(fin, compressed, unmasked_payload)
0xA -> Frame.Pong.deserialize(fin, compressed, unmasked_payload)
unknown -> {:error, "unknown opcode #{unknown}"}
end
end
defprotocol Serializable do
@moduledoc false
@spec serialize(any()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(frame)
end
@spec serialize(frame()) :: iolist()
def serialize(frame) do
frame
|> Serializable.serialize()
|> Enum.map(fn {opcode, fin, compressed, payload} ->
fin = if fin, do: 0x1, else: 0x0
compressed = if compressed, do: 0x1, else: 0x0
mask_and_length = payload |> IO.iodata_length() |> mask_and_length()
[<<fin::1, compressed::1, 0x0::2, opcode::4>>, mask_and_length, payload]
end)
end
defp mask_and_length(length) when length <= 125, do: <<0::1, length::7>>
defp mask_and_length(length) when length <= 65_535, do: <<0::1, 126::7, length::16>>
defp mask_and_length(length), do: <<0::1, 127::7, length::64>>
end

View File

@@ -0,0 +1,20 @@
defmodule Bandit.WebSocket.Frame.Binary do
@moduledoc false
defstruct fin: nil, compressed: false, data: <<>>
@typedoc "A WebSocket binary frame"
@type t :: %__MODULE__{fin: boolean(), compressed: boolean(), data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()}
def deserialize(fin, compressed, payload) do
{:ok, %__MODULE__{fin: fin, compressed: compressed, data: payload}}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x2, frame.fin, frame.compressed, frame.data}]
end
end

View File

@@ -0,0 +1,49 @@
defmodule Bandit.WebSocket.Frame.ConnectionClose do
@moduledoc false
defstruct code: nil, reason: <<>>
@typedoc "A WebSocket status code, or none at all"
@type status_code :: non_neg_integer() | nil
@typedoc "A WebSocket connection close frame"
@type t :: %__MODULE__{code: status_code(), reason: binary()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(true, false, <<>>) do
{:ok, %__MODULE__{}}
end
def deserialize(true, false, <<code::16>>) do
{:ok, %__MODULE__{code: code}}
end
def deserialize(true, false, <<code::16, reason::binary>>) when byte_size(reason) <= 123 do
if String.valid?(reason) do
{:ok, %__MODULE__{code: code, reason: reason}}
else
{:error, "Received non UTF-8 connection close frame (RFC6455§5.5.1)"}
end
end
def deserialize(true, false, _payload) do
{:error, "Invalid connection close payload (RFC6455§5.5)"}
end
def deserialize(false, false, _payload) do
{:error, "Cannot have a fragmented connection close frame (RFC6455§5.5)"}
end
def deserialize(true, true, _payload) do
{:error, "Cannot have a compressed connection close frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{code: nil}), do: [{0x8, true, false, <<>>}]
def serialize(%@for{reason: nil} = frame), do: [{0x8, true, false, <<frame.code::16>>}]
def serialize(%@for{} = frame), do: [{0x8, true, false, [<<frame.code::16>>, frame.reason]}]
end
end

View File

@@ -0,0 +1,24 @@
defmodule Bandit.WebSocket.Frame.Continuation do
@moduledoc false
defstruct fin: nil, data: <<>>
@typedoc "A WebSocket continuation frame"
@type t :: %__MODULE__{fin: boolean(), data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(fin, false, payload) do
{:ok, %__MODULE__{fin: fin, data: payload}}
end
def deserialize(_fin, true, _payload) do
{:error, "Cannot have a compressed continuation frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x0, frame.fin, false, frame.data}]
end
end

View File

@@ -0,0 +1,32 @@
defmodule Bandit.WebSocket.Frame.Ping do
@moduledoc false
defstruct data: <<>>
@typedoc "A WebSocket ping frame"
@type t :: %__MODULE__{data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(true, false, <<data::binary>>) when byte_size(data) <= 125 do
{:ok, %__MODULE__{data: data}}
end
def deserialize(true, false, _payload) do
{:error, "Invalid ping payload (RFC6455§5.5.2)"}
end
def deserialize(false, false, _payload) do
{:error, "Cannot have a fragmented ping frame (RFC6455§5.5.2)"}
end
def deserialize(true, true, _payload) do
{:error, "Cannot have a compressed ping frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x9, true, false, frame.data}]
end
end

View File

@@ -0,0 +1,32 @@
defmodule Bandit.WebSocket.Frame.Pong do
@moduledoc false
defstruct data: <<>>
@typedoc "A WebSocket pong frame"
@type t :: %__MODULE__{data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(true, false, <<data::binary>>) when byte_size(data) <= 125 do
{:ok, %__MODULE__{data: data}}
end
def deserialize(true, false, _payload) do
{:error, "Invalid pong payload (RFC6455§5.5.3)"}
end
def deserialize(false, false, _payload) do
{:error, "Cannot have a fragmented pong frame (RFC6455§5.5.3)"}
end
def deserialize(true, true, _payload) do
{:error, "Cannot have a compressed pong frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0xA, true, false, frame.data}]
end
end

View File

@@ -0,0 +1,20 @@
defmodule Bandit.WebSocket.Frame.Text do
@moduledoc false
defstruct fin: nil, compressed: false, data: <<>>
@typedoc "A WebSocket text frame"
@type t :: %__MODULE__{fin: boolean(), compressed: boolean(), data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()}
def deserialize(fin, compressed, payload) do
{:ok, %__MODULE__{fin: fin, compressed: compressed, data: payload}}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x1, frame.fin, frame.compressed, frame.data}]
end
end

View File

@@ -0,0 +1,97 @@
defmodule Bandit.WebSocket.Handler do
@moduledoc false
# A WebSocket handler conforming to RFC6455, structured as a ThousandIsland.Handler
use ThousandIsland.Handler
alias Bandit.Extractor
alias Bandit.WebSocket.{Connection, Frame}
@impl ThousandIsland.Handler
def handle_connection(socket, state) do
{websock, websock_opts, connection_opts} = state.upgrade_opts
connection_opts
|> Keyword.take([:fullsweep_after, :max_heap_size])
|> Enum.each(fn {key, value} -> :erlang.process_flag(key, value) end)
connection_opts = Keyword.merge(state.opts.websocket, connection_opts)
primitive_ops_module =
Keyword.get(state.opts.websocket, :primitive_ops_module, Bandit.PrimitiveOps.WebSocket)
state =
state
|> Map.take([:handler_module])
|> Map.put(:extractor, Extractor.new(Frame, primitive_ops_module, connection_opts))
case Connection.init(websock, websock_opts, connection_opts, socket) do
{:continue, connection} ->
case Keyword.get(connection_opts, :timeout) do
nil -> {:continue, Map.put(state, :connection, connection)}
timeout -> {:continue, Map.put(state, :connection, connection), {:persistent, timeout}}
end
{:error, reason, connection} ->
{:error, reason, Map.put(state, :connection, connection)}
end
end
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
state.extractor
|> Extractor.push_data(data)
|> pop_frame(socket, state)
end
defp pop_frame(extractor, socket, state) do
case Extractor.pop_frame(extractor) do
{extractor, {:ok, frame}} ->
case Connection.handle_frame(frame, socket, state.connection) do
{:continue, connection} ->
pop_frame(extractor, socket, %{state | extractor: extractor, connection: connection})
{:close, connection} ->
{:close, %{state | extractor: extractor, connection: connection}}
{:error, reason, connection} ->
{:error, reason, %{state | extractor: extractor, connection: connection}}
end
{extractor, {:error, reason}} ->
{:error, {:deserializing, reason}, %{state | extractor: extractor}}
{extractor, :more} ->
{:continue, %{state | extractor: extractor}}
end
end
@impl ThousandIsland.Handler
def handle_close(socket, %{connection: connection}),
do: Connection.handle_close(socket, connection)
def handle_close(_socket, _state), do: :ok
@impl ThousandIsland.Handler
def handle_shutdown(socket, state), do: Connection.handle_shutdown(socket, state.connection)
@impl ThousandIsland.Handler
def handle_error(reason, socket, state),
do: Connection.handle_error(reason, socket, state.connection)
@impl ThousandIsland.Handler
def handle_timeout(socket, state), do: Connection.handle_timeout(socket, state.connection)
def handle_info({:plug_conn, :sent}, {socket, state}),
do: {:noreply, {socket, state}, socket.read_timeout}
def handle_info(msg, {socket, state}) do
case Connection.handle_info(msg, socket, state.connection) do
{:continue, connection_state} ->
{:noreply, {socket, %{state | connection: connection_state}}, socket.read_timeout}
{:error, reason, connection_state} ->
{:stop, reason, {socket, %{state | connection: connection_state}}}
end
end
end

View File

@@ -0,0 +1,105 @@
defmodule Bandit.WebSocket.Handshake do
@moduledoc false
# Functions to support WebSocket handshaking as described in RFC6455§4.2 & RFC7692
import Plug.Conn
@type extensions :: [{String.t(), [{String.t(), String.t() | true}]}]
@spec handshake(Plug.Conn.t(), keyword(), keyword()) ::
{:ok, Plug.Conn.t(), Keyword.t()} | {:error, String.t()}
def handshake(%Plug.Conn{} = conn, opts, websocket_opts) do
with :ok <- Bandit.WebSocket.UpgradeValidation.validate_upgrade(conn) do
do_handshake(conn, opts, websocket_opts)
end
end
@spec do_handshake(Plug.Conn.t(), keyword(), keyword()) :: {:ok, Plug.Conn.t(), keyword()}
defp do_handshake(conn, opts, websocket_opts) do
requested_extensions = requested_extensions(conn)
{negotiated_params, returned_data} =
if Keyword.get(opts, :compress) && Keyword.get(websocket_opts, :compress, true) do
Bandit.WebSocket.PerMessageDeflate.negotiate(requested_extensions, websocket_opts)
else
{nil, []}
end
conn = send_handshake(conn, returned_data)
{:ok, conn, Keyword.put(opts, :compress, negotiated_params)}
end
@spec requested_extensions(Plug.Conn.t()) :: extensions()
defp requested_extensions(%Plug.Conn{} = conn) do
conn
|> get_req_header("sec-websocket-extensions")
|> Enum.flat_map(&Plug.Conn.Utils.list/1)
|> Enum.map(fn extension ->
[name | params] =
extension
|> String.split(";", trim: true)
|> Enum.map(&String.trim/1)
params = split_params(params)
{name, params}
end)
end
@spec split_params([String.t()]) :: [{String.t(), String.t() | true}]
defp split_params(params) do
params
|> Enum.map(fn param ->
param
|> String.split("=", trim: true)
|> Enum.map(&String.trim/1)
|> case do
[key, value] -> {key, value}
[key] -> {key, true}
end
end)
end
@spec send_handshake(Plug.Conn.t(), extensions()) :: Plug.Conn.t()
defp send_handshake(%Plug.Conn{} = conn, extensions) do
# Taken from RFC6455§4.2.2/5. Note that we can take for granted the existence of the
# sec-websocket-key header in the request, since we check for it in the handshake? call above
[client_key] = get_req_header(conn, "sec-websocket-key")
concatenated_key = client_key <> "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
hashed_key = :crypto.hash(:sha, concatenated_key)
server_key = Base.encode64(hashed_key)
headers =
[
{:upgrade, "websocket"},
{:connection, "Upgrade"},
{:"sec-websocket-accept", server_key}
] ++
websocket_extension_header(extensions) ++
conn.resp_headers
inform(conn, 101, headers)
end
@spec websocket_extension_header(extensions()) :: keyword()
defp websocket_extension_header([]), do: []
defp websocket_extension_header(extensions) do
extensions =
extensions
|> Enum.map_join(",", fn {extension, params} ->
params =
params
|> Enum.flat_map(fn
{_param, false} -> []
{param, true} -> [to_string(param)]
{param, value} -> [to_string(param) <> "=" <> to_string(value)]
end)
[to_string(extension) | params]
|> Enum.join(";")
end)
[{:"sec-websocket-extensions", extensions}]
end
end

View File

@@ -0,0 +1,151 @@
defmodule Bandit.WebSocket.PerMessageDeflate do
@moduledoc false
# Support for per-message deflate extension, per RFC7692§7
@typedoc "Encapsulates the state of a WebSocket permessage-deflate context"
@type t :: %__MODULE__{
server_no_context_takeover: boolean(),
client_no_context_takeover: boolean(),
server_max_window_bits: 8..15,
client_max_window_bits: 8..15,
inflate_context: :zlib.zstream(),
deflate_context: :zlib.zstream()
}
defstruct server_no_context_takeover: false,
client_no_context_takeover: false,
server_max_window_bits: 15,
client_max_window_bits: 15,
inflate_context: nil,
deflate_context: nil
@valid_params ~w[server_no_context_takeover client_no_context_takeover server_max_window_bits client_max_window_bits]
def negotiate(requested_extensions, opts) do
:proplists.get_all_values("permessage-deflate", requested_extensions)
|> Enum.find_value(&do_negotiate/1)
|> case do
nil -> {nil, []}
params -> {init(params, opts), "permessage-deflate": params}
end
end
defp do_negotiate(params) do
with params <- normalize_params(params),
true <- validate_params(params) do
resolve_params(params)
else
_ -> nil
end
end
defp normalize_params(params) do
params
|> Enum.map(fn
{"server_max_window_bits", true} -> {"server_max_window_bits", true}
{"server_max_window_bits", value} -> {"server_max_window_bits", parse(value)}
{"client_max_window_bits", true} -> {"client_max_window_bits", 15}
{"client_max_window_bits", value} -> {"client_max_window_bits", parse(value)}
value -> value
end)
end
defp parse(value) do
case Integer.parse(value) do
{int_value, ""} -> int_value
:error -> value
end
end
defp validate_params(params) do
no_invalid_params = params |> :proplists.split(@valid_params) |> elem(1) == []
no_repeat_params = params |> :proplists.get_keys() |> length() == length(params)
no_invalid_values =
:proplists.get_value("server_no_context_takeover", params) in [:undefined, true] &&
:proplists.get_value("client_no_context_takeover", params) in [:undefined, true] &&
:proplists.get_value("server_max_window_bits", params, 15) in 8..15 &&
:proplists.get_value("client_max_window_bits", params, 15) in 8..15
no_invalid_params && no_repeat_params && no_invalid_values
end
# This is where we finally determine which parameters to accept. Note that we don't convert to
# atoms until this stage to avoid potential atom exhaustion
defp resolve_params(params) do
@valid_params
|> Enum.flat_map(fn param_name ->
case :proplists.get_value(param_name, params) do
:undefined -> []
param -> [{String.to_existing_atom(param_name), param}]
end
end)
end
defp init(params, opts) do
instance = struct(__MODULE__, params)
inflate_context = :zlib.open()
:ok = :zlib.inflateInit(inflate_context, fix_bits(-instance.client_max_window_bits))
deflate_context = :zlib.open()
deflate_opts = Keyword.get(opts, :deflate_options, [])
:ok =
:zlib.deflateInit(
deflate_context,
Keyword.get(deflate_opts, :level, :default),
:deflated,
fix_bits(-instance.server_max_window_bits),
Keyword.get(deflate_opts, :mem_level, 8),
Keyword.get(deflate_opts, :strategy, :default)
)
%{instance | inflate_context: inflate_context, deflate_context: deflate_context}
end
# https://www.erlang.org/doc/man/zlib.html#deflateInit-6
defp fix_bits(-8), do: -9
defp fix_bits(other), do: other
# Note that we pass back the context to the caller even though it is unmodified locally
def inflate(data, %__MODULE__{} = context) do
inflated_data =
context.inflate_context
|> :zlib.inflate(<<data::binary, 0x00, 0x00, 0xFF, 0xFF>>)
|> IO.iodata_to_binary()
if context.client_no_context_takeover, do: :zlib.inflateReset(context.inflate_context)
{:ok, inflated_data, context}
rescue
e -> {:error, "Error encountered #{inspect(e)}"}
end
def inflate(_data, nil), do: {:error, :no_compress}
def deflate(data, %__MODULE__{} = context) do
deflated_data =
context.deflate_context
|> :zlib.deflate(data, :sync)
|> IO.iodata_to_binary()
deflated_size = byte_size(deflated_data) - 4
deflated_data =
case deflated_data do
<<deflated_data::binary-size(deflated_size), 0x00, 0x00, 0xFF, 0xFF>> -> deflated_data
deflated -> deflated
end
if context.server_no_context_takeover, do: :zlib.deflateReset(context.deflate_context)
{:ok, deflated_data, context}
rescue
e -> {:error, "Error encountered #{inspect(e)}"}
end
def deflate(_data, nil), do: {:error, :no_compress}
def close(%__MODULE__{} = context) do
:zlib.close(context.inflate_context)
:zlib.close(context.deflate_context)
end
end

View File

@@ -0,0 +1,75 @@
defprotocol Bandit.WebSocket.Socket do
@moduledoc false
#
# A protocol defining the low-level functionality of a WebSocket
#
@type t :: term()
@type frame_type :: :text | :binary | :ping | :pong
@type send_frame_stats :: [
send_binary_frame_bytes: non_neg_integer(),
send_binary_frame_count: non_neg_integer(),
send_ping_frame_bytes: non_neg_integer(),
send_ping_frame_count: non_neg_integer(),
send_pong_frame_bytes: non_neg_integer(),
send_pong_frame_count: non_neg_integer(),
send_text_frame_bytes: non_neg_integer(),
send_text_frame_count: non_neg_integer()
]
@spec send_frame(t(), {frame_type :: frame_type(), data :: iodata()}, boolean()) ::
send_frame_stats()
def send_frame(socket, data_and_frame_type, compressed)
@spec close(t(), code :: WebSock.close_detail()) :: :ok | {:error, :inet.posix()}
def close(socket, code)
end
defimpl Bandit.WebSocket.Socket, for: ThousandIsland.Socket do
@moduledoc false
#
# An implementation of Bandit.WebSocket.Socket for use with ThousandIsland.Socket instances
#
alias Bandit.WebSocket.Frame
@spec send_frame(@for.t(), {frame_type :: @protocol.frame_type(), data :: iodata()}, boolean()) ::
@protocol.send_frame_stats()
def send_frame(socket, {:text, data}, compressed) do
_ = do_send_frame(socket, %Frame.Text{fin: true, data: data, compressed: compressed})
[send_text_frame_count: 1, send_text_frame_bytes: IO.iodata_length(data)]
end
def send_frame(socket, {:binary, data}, compressed) do
_ = do_send_frame(socket, %Frame.Binary{fin: true, data: data, compressed: compressed})
[send_binary_frame_count: 1, send_binary_frame_bytes: IO.iodata_length(data)]
end
def send_frame(socket, {:ping, data}, false) do
_ = do_send_frame(socket, %Frame.Ping{data: data})
[send_ping_frame_count: 1, send_ping_frame_bytes: IO.iodata_length(data)]
end
def send_frame(socket, {:pong, data}, false) do
_ = do_send_frame(socket, %Frame.Pong{data: data})
[send_pong_frame_count: 1, send_pong_frame_bytes: IO.iodata_length(data)]
end
@spec close(@for.t(), non_neg_integer() | {non_neg_integer(), binary()}) ::
:ok | {:error, :inet.posix()}
def close(socket, {code, detail}) when is_integer(code) do
_ = do_send_frame(socket, %Frame.ConnectionClose{code: code, reason: detail})
@for.shutdown(socket, :write)
end
def close(socket, code) when is_integer(code) do
_ = do_send_frame(socket, %Frame.ConnectionClose{code: code})
@for.shutdown(socket, :write)
end
@spec do_send_frame(@for.t(), Frame.frame()) ::
:ok | {:error, :closed | :timeout | :inet.posix()}
defp do_send_frame(socket, frame) do
@for.send(socket, Frame.serialize(frame))
end
end

View File

@@ -0,0 +1,65 @@
defmodule Bandit.WebSocket.UpgradeValidation do
@moduledoc false
# Provides validation of WebSocket upgrade requests as described in RFC6455§4.2
# Validates that the request satisfies the requirements to issue a WebSocket upgrade response.
# Validations are performed based on the clauses laid out in RFC6455§4.2
#
# This function does not actually perform an upgrade or change the connection in any way
#
# Returns `:ok` if the connection satisfies the requirements for a WebSocket upgrade, and
# `{:error, reason}` if not
#
@spec validate_upgrade(Plug.Conn.t()) :: :ok | {:error, String.t()}
def validate_upgrade(conn) do
case Plug.Conn.get_http_protocol(conn) do
:"HTTP/1.1" -> validate_upgrade_http1(conn)
other -> {:error, "HTTP version #{other} unsupported"}
end
end
# Validate the conn per RFC6455§4.2.1
defp validate_upgrade_http1(conn) do
with :ok <- assert_method(conn, "GET"),
:ok <- assert_header_nonempty(conn, "host"),
:ok <- assert_header_contains(conn, "connection", "upgrade"),
:ok <- assert_header_contains(conn, "upgrade", "websocket"),
:ok <- assert_header_nonempty(conn, "sec-websocket-key"),
:ok <- assert_header_equals(conn, "sec-websocket-version", "13") do
:ok
end
end
defp assert_method(conn, verb) do
case conn.method do
^verb -> :ok
other -> {:error, "HTTP method #{other} unsupported"}
end
end
defp assert_header_nonempty(conn, header) do
case Plug.Conn.get_req_header(conn, header) do
[] -> {:error, "'#{header}' header is absent"}
_ -> :ok
end
end
defp assert_header_equals(conn, header, expected) do
case Plug.Conn.get_req_header(conn, header) |> Enum.map(&String.downcase(&1, :ascii)) do
[^expected] -> :ok
value -> {:error, "'#{header}' header must equal '#{expected}', got #{inspect(value)}"}
end
end
defp assert_header_contains(conn, header, needle) do
haystack = Plug.Conn.get_req_header(conn, header)
haystack
|> Enum.flat_map(&Plug.Conn.Utils.list/1)
|> Enum.any?(&(String.downcase(&1, :ascii) == needle))
|> case do
true -> :ok
false -> {:error, "'#{header}' header must contain '#{needle}', got #{inspect(haystack)}"}
end
end
end