lib/remote_ip/debugger.ex

defmodule RemoteIp.Debugger do
  require Logger

  @moduledoc """
  Compile-time debugging facilities.

  `RemoteIp` uses the `debug/3` macro to instrument its implementation with
  *debug events* at compile time. If an event is enabled, the macro will expand
  into a `Logger.debug/2` call with a specific message. If an event is
  disabled, the logging will be purged, thus generating no extra code and
  having no impact on run time.

  ## Basic usage

  Events are fired on every call to `RemoteIp.call/2` or `RemoteIp.from/2`. To
  enable or disable all debug events at once, you can set a boolean in your
  `Config` file:

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

  By default, the debugger is turned off (i.e., `debug: false`).

  Because `RemoteIp.Debugger` works at compile time, you must make sure to
  recompile the `:remote_ip` dependency whenever you change the configuration:

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

  ## Advanced usage

  You may also pass a list of atoms into the `:debug` configuration naming
  which events to log.

  These are all the possible events:

  * `:options` - the keyword options *after* any runtime configuration has been
    evaluated (see `RemoteIp.Options`)

  * `:headers` - all incoming headers, either from the `Plug.Conn`'s
    `req_headers` or the list passed directly into `RemoteIp.from/2`; useful
    for seeing if you're even getting the forwarding headers you expect in the
    first place

  * `:forwarding` - the subset of headers (as configured by `RemoteIp.Options`)
    that contain forwarding information

  * `:ips` - the entire sequence of IP addresses parsed from the forwarding
    headers, in order

  * `:type` - for each IP (until we find the client), classifies the address
    either as a known client, a known proxy, a reserved address, or none of the
    above (and thus presumably a client)

  * `:ip` - the final result of the remote IP processing; when rewriting the
    `Plug.Conn`'s `remote_ip`, the message will tell you the original IP that
    is being replaced

  Therefore, `debug: true` is equivalent to passing in all of the above:

  ```elixir
  config :remote_ip, debug: [:options, :headers, :forwarding, :ips, :type, :ip]
  ```

  But you could disable certain events by removing them from the list. For
  example, to log only the incoming headers and resulting IP:

  ```elixir
  config :remote_ip, debug: [:headers, :ip]
  ```

  ## Interactions with `Logger`

  Since they both work at compile time, your configuration of `:logger` will
  also affect the operation of `RemoteIp.Debugger`. For example, it's possible
  to enable debugging but still purge all the resulting logs:

  ```elixir
  # All events *would* be logged...
  config :remote_ip, debug: true

  # ...But :debug logs will actually get purged at compile time
  config :logger, compile_time_purge_matching: [[level_lower_than: :info]]
  ```
  """

  @doc """
  An internal macro for generating debug logs.

  There is no reason for you to call this directly. It's used to instrument the
  `RemoteIp` module at compilation time.
  """

  @spec debug(atom(), [any()], do: any()) :: any()

  defmacro debug(id, inputs \\ [], do: output) do
    if debug?(id) do
      quote do
        inputs = unquote(inputs)
        output = unquote(output)
        unquote(__MODULE__).__log__(unquote(id), inputs, output)
        output
      end
    else
      output
    end
  end

  @debug Application.compile_env(:remote_ip, :debug, false)

  cond do
    is_list(@debug) ->
      defp debug?(id), do: Enum.member?(@debug, id)

    is_boolean(@debug) ->
      defp debug?(_), do: @debug
  end

  def __log__(id, inputs, output) do
    Logger.debug(__message__(id, inputs, output))
  end

  def __message__(:options, [], options) do
    headers = inspect(options[:headers])
    parsers = inspect(options[:parsers])
    proxies = inspect(options[:proxies] |> Enum.map(&to_string/1))
    clients = inspect(options[:clients] |> Enum.map(&to_string/1))

    [
      "Processing remote IP\n",
      "  headers: #{headers}\n",
      "  parsers: #{parsers}\n",
      "  proxies: #{proxies}\n",
      "  clients: #{clients}"
    ]
  end

  def __message__(:headers, [], headers) do
    "Taking forwarding headers from #{inspect(headers)}"
  end

  def __message__(:forwarding, [], headers) do
    "Parsing IPs from forwarding headers: #{inspect(headers)}"
  end

  def __message__(:ips, [], ips) do
    "Parsed IPs from forwarding headers: #{inspect(ips)}"
  end

  def __message__(:type, [ip], type) do
    case type do
      :client -> "#{inspect(ip)} is a known client IP"
      :proxy -> "#{inspect(ip)} is a known proxy IP"
      :reserved -> "#{inspect(ip)} is a reserved IP"
      :unknown -> "#{inspect(ip)} is an unknown IP, assuming it's the client"
    end
  end

  def __message__(:ip, [old_conn], new_conn) do
    origin = inspect(old_conn.remote_ip)
    client = inspect(new_conn.remote_ip)

    if client != origin do
      "Processed remote IP, found client #{client} to replace #{origin}"
    else
      "Processed remote IP, no client found to replace #{origin}"
    end
  end

  def __message__(:ip, [], ip) do
    if ip == nil do
      "Processed remote IP, no client found"
    else
      "Processed remote IP, found client #{inspect(ip)}"
    end
  end
end