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
* `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)
* `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()}
| {:deflate_options, deflate_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)
* `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()}
| {: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`
"""
@type websocket_options :: [
{:enabled, boolean()}
| {:max_frame_size, pos_integer()}
| {:validate_text_frames, boolean()}
| {:compress, boolean()}
]
@typedoc """
Options to configure the deflate library used for HTTP compression
"""
@type deflate_options :: [
{:level, :zlib.zlevel()}
| {:window_bits, :zlib.zwindowbits()}
| {:memory_level, :zlib.zmemlevel()}
| {:strategy, :zlib.zstrategy()}
]
@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 deflate_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 default_local_settings)a
@websocket_keys ~w(enabled max_frame_size validate_text_frames compress 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