lib/remote_ip.ex

defmodule RemoteIp do
  import RemoteIp.Debugger

  @behaviour Plug

  @moduledoc """
  A plug to rewrite the `Plug.Conn`'s `remote_ip` based on forwarding headers.

  Generic comma-separated headers like `X-Forwarded-For`, `X-Real-Ip`, and
  `X-Client-Ip` are all recognized, as well as the [RFC
  7239](https://tools.ietf.org/html/rfc7239) `Forwarded` header. IPs are
  processed last-to-first to prevent IP spoofing. Read more in the
  documentation for [the algorithm](algorithm.md).

  This plug is highly configurable, giving you the power to adapt it to your
  particular networking infrastructure:

  * IPs can come from any header(s) you want. You can even implement your own
    custom parser if you're using a special format.

  * You can configure the IPs of known proxies & clients so that you never get
    the wrong results.

  * All options are configurable at runtime, so you can deploy a single release
    but still customize it using environment variables, the `Application`
    environment, or any other arbitrary mechanism.

  * Still not getting the right IP? You can recompile the plug with debugging
    enabled to generate logs, and even fine-tune the verbosity by selecting
    which events to track.

  ## Usage

  This plug should be early in your pipeline, or else the `remote_ip` might not
  get rewritten before your route's logic executes.

  In [Phoenix](https://hexdocs.pm/phoenix), this might mean plugging `RemoteIp`
  into your endpoint before the router:

  ```elixir
  defmodule MyApp.Endpoint do
    use Phoenix.Endpoint, otp_app: :my_app

    plug RemoteIp
    # plug ...
    # plug ...
    plug MyApp.Router
  end
  ```

  But if you only want to rewrite IPs in a narrower part of your app, you could
  of course put it in an individual pipeline of your router.

  In an ordinary `Plug.Router`, you should make sure `RemoteIp` comes before
  the `:match`/`:dispatch` plugs:

  ```elixir
  defmodule MyApp do
    use Plug.Router

    plug RemoteIp
    plug :match
    plug :dispatch

    # get "/" do ...
  end
  ```

  You can also use `RemoteIp.from/2` to determine an IP from a list of headers.
  This is useful outside of the plug pipeline, where you may not have access to
  the `Plug.Conn`. For example, you might only be getting the `x_headers` from
  [`Phoenix.Socket`](https://hexdocs.pm/phoenix/Phoenix.Socket.html):

  ```elixir
  defmodule MySocket do
    use Phoenix.Socket

    def connect(params, socket, connect_info) do
      ip = RemoteIp.from(connect_info[:x_headers])
      # ...
    end
  end
  ```

  ## Configuration

  Options may be passed as a keyword list via `RemoteIp.init/1` or directly
  into `RemoteIp.from/2`. At a high level, the following options are available:

  * `:headers` - a list of header names to consider
  * `:parsers` - a map from header names to custom parser modules
  * `:clients` - a list of known client IPs, either plain or in CIDR notation
  * `:proxies` - a list of known proxy IPs, either plain or in CIDR notation

  You can specify any option using a tuple of `{module, function_name,
  arguments}`, which will be called dynamically at runtime to get the
  equivalent value.

  For more details about these options, see `RemoteIp.Options`.

  ## Troubleshooting

  Getting the right configuration can be tricky. Requests might come in with
  unexpected headers, or maybe you didn't account for certain proxies, or any
  number of other issues.

  Luckily, you can debug `RemoteIp.call/2` and `RemoteIp.from/2` by updating
  your `Config` file:

  ```elixir
  config :remote_ip, debug: true
  ```

  and recompiling the `:remote_ip` dependency:

  ```console
  $ mix deps.clean --build remote_ip
  $ mix deps.compile
  ```

  Then it will generate log messages showing how the IP gets computed. For more
  details about these messages, as well advanced usage, see
  `RemoteIp.Debugger`.

  ## Metadata

  When you use this plug, `RemoteIp.call/2` will populate the `Logger` metadata
  under the key `:remote_ip`. This will be the string representation of the
  final value of the `Plug.Conn`'s `remote_ip`. Even if no client was found in
  the headers, we still set the metadata to the original IP.

  You can use this in your logs by updating your `Config` file:

  ```elixir
  config :logger,
    message: "$metadata[$level] $message\\n",
    metadata: [:remote_ip]
  ```

  Then your logs will look something like this:

  ```log
  [info] Running ExampleWeb.Endpoint with cowboy 2.8.0 at 0.0.0.0:4000 (http)
  [info] Access ExampleWeb.Endpoint at http://localhost:4000
  remote_ip=1.2.3.4 [info] GET /
  remote_ip=1.2.3.4 [debug] Processing with ExampleWeb.PageController.index/2
    Parameters: %{}
    Pipelines: [:browser]
  remote_ip=1.2.3.4 [info] Sent 200 in 21ms
  ```

  Note that metadata will *not* be set by `RemoteIp.from/2`.
  """

  @impl Plug

  @doc """
  The `c:Plug.init/1` callback.

  This accepts the keyword options described by `RemoteIp.Options`. Because
  plug initialization typically happens at compile time, we make sure not to
  evaluate runtime options until `call/2`.
  """

  def init(opts) do
    RemoteIp.Options.pack(opts)
  end

  @impl Plug

  @doc """
  The `c:Plug.call/2` callback.

  Rewrites the `Plug.Conn`'s `remote_ip` based on its forwarding headers. Each
  call will re-evaluate all runtime options. See `RemoteIp.Options` for
  details.
  """

  def call(conn, opts) do
    debug :ip, [conn] do
      ip = ip_from(conn.req_headers, opts) || conn.remote_ip
      add_metadata(ip)
      %{conn | remote_ip: ip}
    end
  end

  @doc """
  Extracts the remote IP from a list of headers.

  In cases where you don't have access to a full `Plug.Conn` struct, you can
  use this function to process the remote IP from a list of key-value pairs
  representing the headers.

  You may specify the same options as if you were using the plug. Runtime
  options are evaluated each time you call this function. See
  `RemoteIp.Options` for details.

  If no client IP can be found in the given headers, this function will return
  `nil`.

  ## Examples

      iex> RemoteIp.from([{"x-forwarded-for", "1.2.3.4"}])
      {1, 2, 3, 4}

      iex> [{"x-foo", "1.2.3.4"}, {"x-bar", "2.3.4.5"}]
      ...> |> RemoteIp.from(headers: ~w[x-foo])
      {1, 2, 3, 4}

      iex> [{"x-foo", "1.2.3.4"}, {"x-bar", "2.3.4.5"}]
      ...> |> RemoteIp.from(headers: ~w[x-bar])
      {2, 3, 4, 5}

      iex> [{"x-foo", "1.2.3.4"}, {"x-bar", "2.3.4.5"}]
      ...> |> RemoteIp.from(headers: ~w[x-baz])
      nil
  """

  @spec from(Plug.Conn.headers(), keyword()) :: :inet.ip_address() | nil

  def from(headers, opts \\ []) do
    debug :ip do
      ip_from(headers, init(opts))
    end
  end

  defp ip_from(headers, opts) do
    opts = options_from(opts)
    client_from(ips_from(headers, opts), opts)
  end

  defp options_from(opts) do
    debug :options do
      RemoteIp.Options.unpack(opts)
    end
  end

  defp ips_from(headers, opts) do
    debug :ips do
      headers = forwarding_from(headers, opts)
      RemoteIp.Headers.parse(headers, opts[:parsers])
    end
  end

  defp forwarding_from(headers, opts) do
    debug :forwarding do
      debug(:headers, do: headers) |> RemoteIp.Headers.take(opts[:headers])
    end
  end

  defp client_from(ips, opts) do
    Enum.reverse(ips) |> Enum.find(&client?(&1, opts))
  end

  defp client?(ip, opts) do
    type(ip, opts) in [:client, :unknown]
  end

  # https://en.wikipedia.org/wiki/Loopback
  # https://en.wikipedia.org/wiki/Private_network
  # https://en.wikipedia.org/wiki/Reserved_IP_addresses
  @reserved ~w[
    127.0.0.0/8
    ::1/128
    fc00::/7
    10.0.0.0/8
    172.16.0.0/12
    192.168.0.0/16
  ] |> Enum.map(&RemoteIp.Block.parse!/1)

  defp type(ip, opts) do
    debug :type, [ip] do
      ip = RemoteIp.Block.encode(ip)

      cond do
        opts[:clients] |> contains?(ip) -> :client
        opts[:proxies] |> contains?(ip) -> :proxy
        @reserved |> contains?(ip) -> :reserved
        true -> :unknown
      end
    end
  end

  defp contains?(blocks, ip) do
    Enum.any?(blocks, &RemoteIp.Block.contains?(&1, ip))
  end

  defp add_metadata(remote_ip) do
    case :inet.ntoa(remote_ip) do
      {:error, _} -> :ok
      ip -> Logger.metadata(remote_ip: to_string(ip))
    end
  end
end