lib/paddle.ex

defmodule Paddle do
  @moduledoc ~S"""
  Module handling ldap requests and translate them to the `:eldap` syntax.

  ## Configuration

  The configuration should be in the dev.secret.exs or prod.secret.exs depending
  on the environment you're working on. Here's an example config:

      config :penguin_paddle, Paddle,
        host: "ldap.my-organisation.org",
        base: "dc=myorganisation,dc=org",
        ssl: true,
        port: 636,
        ipv6: true,
        tcpopts: [],
        sslopts: [certfile: '/path/to/certificate.crt'],
        timeout: 3000,
        account_subdn: "ou=People",
        schema_files: Path.wildcard("/etc/openldap/schema/*.schema"),
        filter_passwords: true

  Option    | Description | Default
  --------- | ----------- | -------
  `:host`   | The host(s) containing the LDAP server(s). Can be a bitstring for a single host, or a list of bitstrings, which will make Paddle try to connect to each host in the specified order. See also the `:timeout` option. | **Mandatory**
  `:base`   | The base DN. | `""`
  `:ssl`    | When set to `true`, use SSL to connect to the LDAP server. | `false`
  `:port`   | The port the LDAP server listen to. | `389`
  `:ipv6`   | When set to `true`, connect to the LDAP server using IPv6. | `false`
  `:tcpopts`| Additionnal `:gen_tcp.connect/4` / `:ssl.connect/4` options.  Must not have the `:active`, `:binary`, `:deliver`, `:list`, `:mode` or `:packet` options. See [`:gen_tcp`'s option documentation](http://erlang.org/doc/man/gen_tcp.html#type-connect_option).  | `[]`
  `:sslopts`| Additionnal `:ssl.connect/4` options. Ineffective if the `:ssl` option is set to `false`. See [`:ssl`'s option documentation](http://erlang.org/doc/man/ssl.html).  | `[]`
  `:timeout`| The timeout in milliseconds, or `nil` for the default TCP stack timeout value (which may be very long), for each request to the LDAP server. | `nil`
  `:account_subdn` | The DN (without the base) where the accounts are located. Used by the `Paddle.authenticate/2` function. | `"ou=People"`
  `:account_identifier` |  The identifier by which users are identified. Used by the `Paddle.authenticate/2` function. | `:uid`
  `:schema_files` | Files which are to be parsed to help generate classes using [`Paddle.Class.Helper`](Paddle.Class.Helper.html#module-using-schema-files).  | `[]`
  `:filter_passwords` | Filter passwords from appearing in the logs | `true`

  ## Usage

  To check a user's credentials and/or authenticate the connection, simply do:

      Paddle.authenticate("username", "password")

  You can also specify the partial DN like so:

      Paddle.authenticate([cn: "admin"], "adminpassword")

  Many functions support passing both a base and a filter via a keyword list
  or a map like so:

      Paddle.get(filter: [uid: "testuser"], base: [ou: "People"])

  But you can also use structs which implements the `Paddle.Class` protocol
  (called [class objects](#module-class-objects)). If we take as example the
  classes defined in `test/support/classes.ex`, we could do:

      Paddle.get %MyApp.PosixAccount{uid: "user"}

  The previous example will return every accounts which are in a given subDN
  (defined in the `Paddle.Class` protocol), which have the right objectClass
  (also defined in the same protocol), and have an uid of "user".

  You can also specify an additional [filter](#module-filters) as second
  argument.

  ## Class objects

  A class object is simply a struct implementing the `Paddle.Class` protocol.

  If you're in need of some examples, you can see the `test/support/classes.ex`
  file which defines `MyApp.PosixAccount`, and `MyApp.PosixGroup` (but only
  in test mode, so you would have to define your own).

  For more informations, see the `Paddle.Class` module documentation.

  ## Filters

  A filter in Paddle is a keyword list or a map.

  This is equivalent to a filter where each attribute name (key from the map /
  keyword list) must have a corresponding value (value from the map / keyword
  list).

  Example:

      [uid: "user", cn: "User", homeDirectory: "/home/user"]

  If you are missing some filtering capabilities, you can always pass as
  argument an `:eldap` filter like so:

      Paddle.get(filter: :eldap.substrings('uid', initial: 'b'))

  For more informations and examples, see `Paddle.Filters.construct_filter/1`

  ## Bases

  A base in Paddle can be a Keyword list that will be converted to a charlist
  to be passed on to the `:eldap` module. A direct string can also be passed.

  For more informations and examples, see `Paddle.Filters.construct_dn/2`
  """

  use GenServer
  require Logger

  alias Paddle.Parsing
  alias Paddle.Filters
  alias Paddle.Attributes

  @typep ldap_conn :: :eldap.handle() | {:not_connected, binary}
  @type ldap_entry :: %{required(binary) => binary}
  @type auth_status :: :ok | {:error, atom}

  @type dn :: keyword | binary

  unless Application.get_env(:penguin_paddle, __MODULE__) do
    raise """
    Please configure the LDAP in the config files
    See the `Paddle` module documentation.
    """
  end

  @spec start_link(term) :: Genserver.on_start()

  @doc false
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @spec init([]) :: {:ok, ldap_conn}

  @impl GenServer
  def init(opts \\ []) do
    case do_connect(opts) do
      {:ok, ldap_conn} -> {:ok, ldap_conn}
      {:error, reason} -> {:ok, {:not_connected, reason}}
    end
  end

  @type reason :: :normal | :shutdown | {:shutdown, term} | term

  @spec terminate(reason, ldap_conn) :: :ok

  @impl GenServer
  def terminate(_shutdown_reason, {:not_connected, _reason}) do
    :ok
    Logger.info("Stopped LDAP, state was not connected")
  end

  @impl GenServer
  def terminate(_shutdown_reason, ldap_conn) do
    :eldap.close(ldap_conn)
    Logger.info("Stopped LDAP")
  end

  @spec handle_call(
          {:authenticate, charlist, charlist}
          | {:reconnect, list}
          | {:get, Paddle.Filters.t(), dn, atom}
          | {:get_single, Paddle.Filters.t(), dn, atom}
          | {:add, dn, attributes, atom}
          | {:delete, dn, atom}
          | {:modify, dn, atom, [mod]},
          GenServer.from(),
          ldap_conn
        ) ::
          {:reply, term, ldap_conn}

  @impl GenServer
  def handle_call({:reconnect, opts}, _from, ldap_conn) do
    case ldap_conn do
      {:not_connected, _reason} -> nil
      pid -> :eldap.close(pid)
    end

    Logger.info("Reconnecting")

    case do_connect(opts) do
      {:ok, ldap_conn} -> {:reply, {:ok, :connected}, ldap_conn}
      {:error, reason} -> {:reply, {:error, {:not_connected, reason}}, {:not_connected, reason}}
    end
  end

  @impl GenServer
  def handle_call(_message, _from, {:not_connected, _reason} = state) do
    {:reply, {:error, :not_connected}, state}
  end

  @impl GenServer
  def handle_call({:authenticate, dn, password}, _from, ldap_conn) do
    Logger.debug("Authenticating with dn: #{dn}")
    status = :eldap.simple_bind(ldap_conn, dn, password)

    case status do
      :ok -> {:reply, status, ldap_conn}
      {:error, _} -> {:reply, status, ldap_conn}
    end
  end

  @impl GenServer
  def handle_call({:get, filter, kwdn, base}, _from, ldap_conn) do
    base = config(base)
    dn = Parsing.construct_dn(kwdn, base)
    filter = Filters.construct_filter(filter)

    Logger.debug("Getting entries with dn: #{dn} and filter: #{inspect(filter, pretty: true)}")

    {:reply,
     :eldap.search(ldap_conn, base: dn, filter: filter)
     |> Parsing.clean_eldap_search_results(base), ldap_conn}
  end

  @impl GenServer
  def handle_call({:get_single, filter, kwdn, base}, _from, ldap_conn) do
    base = config(base)
    dn = Parsing.construct_dn(kwdn, base)
    filter = Filters.construct_filter(filter)

    Logger.debug(
      "Getting single entry with dn: #{dn} and filter: #{inspect(filter, pretty: true)}"
    )

    {:reply,
     :eldap.search(ldap_conn,
       base: dn,
       scope: :eldap.baseObject(),
       filter: filter
     )
     |> Parsing.clean_eldap_search_results(base)
     |> ensure_single_result, ldap_conn}
  end

  @impl GenServer
  def handle_call({:add, kwdn, attributes, base}, _from, ldap_conn) do
    dn = Parsing.construct_dn(kwdn, config(base))

    Logger.info("Adding entry with dn: #{dn}")

    attributes =
      attributes
      |> Enum.filter_map(
        fn {_key, value} -> value != nil end,
        fn {key, value} -> {'#{key}', Parsing.list_wrap(value)} end
      )

    {:reply, :eldap.add(ldap_conn, dn, attributes), ldap_conn}
  end

  @impl GenServer
  def handle_call({:delete, kwdn, base}, _from, ldap_conn) do
    dn = Parsing.construct_dn(kwdn, config(base))

    Logger.info("Deleting entry with dn: #{dn}")

    {:reply, :eldap.delete(ldap_conn, dn), ldap_conn}
  end

  @impl GenServer
  def handle_call({:modify, kwdn, base, mods}, _from, ldap_conn) do
    dn = Parsing.construct_dn(kwdn, config(base))

    Logger.info("Modifying entry: \"#{dn}\" with mods: #{inspect(mods)}")

    mods = mods |> Enum.map(&Parsing.mod_convert/1)

    {:reply, :eldap.modify(ldap_conn, dn, mods), ldap_conn}
  end

  @type authenticate_ldap_error ::
          :operationsError
          | :protocolError
          | :authMethodNotSupported
          | :strongAuthRequired
          | :referral
          | :saslBindInProgress
          | :inappropriateAuthentication
          | :invalidCredentials
          | :unavailable
          | :anonymous_auth
  @spec authenticate(dn, binary) :: :ok | {:error, authenticate_ldap_error}

  @doc ~S"""
  Check the given credentials and authenticate the current connection.

  When given the wrong credentials, returns `{:error, :invalidCredentials}`

  The user id can be passed as a binary, which will expand to
  `<account_identifier>=<id>,<account subdn>,<base>`, or with a keyword list if
  you want to specify the whole DN (but still without the base DN).

  Example:

      iex> Paddle.authenticate("testuser", "test")
      :ok
      iex> Paddle.authenticate("testuser", "wrong password")
      {:error, :invalidCredentials}
      iex> Paddle.authenticate([cn: "admin"], "test")
      :ok
  """
  def authenticate(kwdn, password) when is_list(kwdn) do
    dn = Parsing.construct_dn(kwdn, config(:base))
    GenServer.call(Paddle, {:authenticate, dn, :binary.bin_to_list(password)})
  end

  def authenticate(username, password) do
    dn = Parsing.construct_dn([{config(:account_identifier), username}], config(:account_base))
    GenServer.call(Paddle, {:authenticate, dn, :binary.bin_to_list(password)})
  end

  @doc ~S"""
  Closes the current connection and opens a new one.

  Accepts connection information as arguments.
  Not specified values will be fetched from the config.

  Example:

      iex> Paddle.reconnect(host: ['example.com'])
      {:error, {:not_connected, "connect failed"}}
      iex> Paddle.reconnect()
      {:ok, :connected}
  """
  def reconnect(opts \\ []) do
    GenServer.call(Paddle, {:reconnect, opts})
  end

  @spec get_dn(Paddle.Class.t()) :: {:ok, binary} | {:error, :missing_unique_identifier}

  @doc ~S"""
  Get the DN of an entry.

  Example:

      iex> Paddle.get_dn(%MyApp.PosixAccount{uid: "testuser"})
      {:ok, "uid=testuser,ou=People"}
  """
  def get_dn(object) do
    subdn = Paddle.Class.location(object)

    id_field = Paddle.Class.unique_identifier(object)
    id_value = Map.get(object, id_field)

    if id_value do
      id_value = Paddle.Parsing.ldap_escape(id_value)

      {:ok, "#{id_field}=#{id_value},#{subdn}"}
    else
      {:error, :missing_unique_identifier}
    end
  end

  # =============
  # == Getting ==
  # =============

  @type search_ldap_error ::
          :noSuchObject
          | :sizeLimitExceeded
          | :timeLimitExceeded
          | :undefinedAttributeType
          | :insufficientAccessRights

  @spec get(dn) :: {:ok, [ldap_entry]} | {:error, search_ldap_error}

  @doc ~S"""
  Get one or more LDAP entries given a partial DN and a filter.

  Example:

      iex> Paddle.get(base: [uid: "testuser", ou: "People"])
      {:ok,
       [%{"cn" => ["Test User"],
         "dn" => "uid=testuser,ou=People",
         "gecos" => ["Test User,,,,"], "gidNumber" => ["120"],
         "homeDirectory" => ["/home/testuser"],
         "loginShell" => ["/bin/bash"],
         "objectClass" => ["account", "posixAccount", "top"],
         "uid" => ["testuser"], "uidNumber" => ["500"],
         "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]}

      iex> Paddle.get(base: "uid=testuser,ou=People")
      {:ok,
       [%{"cn" => ["Test User"],
         "dn" => "uid=testuser,ou=People",
         "gecos" => ["Test User,,,,"], "gidNumber" => ["120"],
         "homeDirectory" => ["/home/testuser"],
         "loginShell" => ["/bin/bash"],
         "objectClass" => ["account", "posixAccount", "top"],
         "uid" => ["testuser"], "uidNumber" => ["500"],
         "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]}

      iex> Paddle.get(base: [uid: "nothing"])
      {:error, :noSuchObject}

      iex> Paddle.get(filter: [uid: "testuser"], base: [ou: "People"])
      {:ok,
       [%{"cn" => ["Test User"],
         "dn" => "uid=testuser,ou=People",
         "gecos" => ["Test User,,,,"], "gidNumber" => ["120"],
         "homeDirectory" => ["/home/testuser"],
         "loginShell" => ["/bin/bash"],
         "objectClass" => ["account", "posixAccount", "top"],
         "uid" => ["testuser"], "uidNumber" => ["500"],
         "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]}
  """
  def get(kwdn) when is_list(kwdn) do
    GenServer.call(
      Paddle,
      {:get, Keyword.get(kwdn, :filter), Keyword.get(kwdn, :base), :base}
    )
  end

  @spec get(Paddle.Class.t()) :: {:ok, [Paddle.Class.t()]} | {:error, search_ldap_error}
  @spec get(Paddle.Class.t(), Paddle.Filters.t()) ::
          {:ok, [Paddle.Class.t()]} | {:error, search_ldap_error}

  @doc ~S"""
  Get an entry in the LDAP given a class object. You can specify an optional
  additional filter as second argument.

  Example:

      iex> Paddle.get(%MyApp.PosixAccount{})
      {:ok,
       [%MyApp.PosixAccount{cn: ["Test User"], description: nil,
         gecos: ["Test User,,,,"], gidNumber: ["120"],
         homeDirectory: ["/home/testuser"], host: nil, l: nil,
         loginShell: ["/bin/bash"], o: nil,
         ou: nil, seeAlso: nil, uid: ["testuser"],
         uidNumber: ["500"],
         userPassword: ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]}

      iex> Paddle.get(%MyApp.PosixGroup{cn: "users"})
      {:ok,
       [%MyApp.PosixGroup{cn: ["users"], description: nil, gidNumber: ["2"],
         memberUid: ["testuser"], userPassword: nil}]}

      iex> Paddle.get(%MyApp.PosixGroup{}, :eldap.substrings('cn', initial: 'a'))
      {:ok,
       [%MyApp.PosixGroup{cn: ["adm"], description: nil, gidNumber: ["3"],
         memberUid: nil, userPassword: nil}]}
  """
  def get(object, additional_filter \\ nil) when is_map(object) do
    fields_filter =
      object
      |> Map.from_struct()
      |> Enum.filter(fn {_key, value} -> value != nil end)

    filter =
      object
      |> Paddle.Class.object_classes()
      |> Filters.class_filter()
      |> Filters.merge_filter(fields_filter)
      |> Filters.merge_filter(additional_filter)

    location = Paddle.Class.location(object)

    with {:ok, entries} <- GenServer.call(Paddle, {:get, filter, location, :base}) do
      {:ok,
       entries
       |> Enum.map(&Parsing.entry_to_class_object(&1, object))}
    end
  end

  @spec get!(dn) :: [ldap_entry]

  @doc ~S"""
  Same as `get/1` but throws in case of an error.
  """
  def get!(kwdn) when is_list(kwdn) do
    {:ok, result} = get(kwdn)
    result
  end

  @spec get!(Paddle.Class.t()) :: [Paddle.Class.t()]
  @spec get!(Paddle.Class.t(), Paddle.Filters.t()) :: [Paddle.Class.t()]

  @doc ~S"""
  Same as `get/2` but throws in case of an error.
  """
  def get!(object, additional_filter \\ []) do
    {:ok, result} = get(object, additional_filter)
    result
  end

  @spec get_single(dn) :: {:ok, ldap_entry} | {:error, search_ldap_error}

  @doc ~S"""
  Get a single LDAP entry given an optional partial DN and an optional filter.

  Example:

      iex> Paddle.get_single(base: [ou: "People"])
      {:ok,
       %{"dn" => "ou=People",
        "objectClass" => ["top", "organizationalUnit"], "ou" => ["People"]}}

      iex> Paddle.get_single(filter: [uid: "nothing"])
      {:error, :noSuchObject}
  """
  def get_single(kwdn) do
    GenServer.call(
      Paddle,
      {:get_single, Keyword.get(kwdn, :filter), Keyword.get(kwdn, :base), :base}
    )
  end

  # ============
  # == Adding ==
  # ============

  @type attributes :: keyword | %{required(binary) => binary} | [{binary, binary}]
  @type add_ldap_error ::
          :undefinedAttributeType
          | :objectClassViolation
          | :invalidAttributeSyntax
          | :noSuchObject
          | :insufficientAccessRights
          | :entryAlreadyExists

  @spec add(dn, attributes) :: :ok | {:error, add_ldap_error}

  @doc ~S"""
  Add an entry to the LDAP given a DN and a list of
  attributes.

  The first argument is the DN given as a string or keyword list. The second
  argument is the list of attributes in the new entry as a keyword list like
  so:

      [objectClass: ["account", "posixAccount"],
       cn: "User",
       loginShell: "/bin/bash",
       homeDirectory: "/home/user",
       uidNumber: 501,
       gidNumber: 100]

  Please note that due to the limitation of char lists you cannot pass directly
  a char list as an attribute value. But, you can wrap it in an array like
  this: `homeDirectory: ['/home/user']`
  """
  def add(kwdn, attributes), do: GenServer.call(Paddle, {:add, kwdn, attributes, :base})

  @spec add(Paddle.Class.t()) ::
          :ok
          | {:error, :missing_unique_identifier}
          | {:error, :missing_req_attributes, [atom]}
          | {:error, add_ldap_error}

  @doc ~S"""
  Add an entry to the LDAP given a class object.

  Example:

      Paddle.add(%MyApp.PosixAccount{uid: "myUser", cn: "My User", gidNumber: "501", homeDirectory: "/home/myUser"})
  """
  def add(class_object) do
    with {:ok, dn} <- get_dn(class_object),
         {:ok, attributes} <- Attributes.get(class_object) do
      add(dn, attributes)
    end
  end

  # ==============
  # == Deleting ==
  # ==============

  @type delete_ldap_error :: :noSuchObject | :notAllowedOnNonLeaf | :insufficientAccessRights

  @spec delete(Paddle.Class.t() | dn) :: :ok | {:error, delete_ldap_error}

  @doc ~S"""
  Delete a LDAP entry given a DN or a class object.

  Examples:

      Paddle.delete("uid=testuser,ou=People")
      Paddle.delete([uid: "testuser", ou: "People"])
      Paddle.delete(%MyApp.PosixAccount{uid: "testuser"})

  The three examples above do exactly the same thing (provided that the
  `MyApp.PosixAccount` is configured appropriately).
  """
  def delete(kwdn) when is_list(kwdn) or is_binary(kwdn) do
    GenServer.call(Paddle, {:delete, kwdn, :base})
  end

  def delete(class_object) when is_map(class_object) do
    with {:ok, dn} <- get_dn(class_object) do
      GenServer.call(Paddle, {:delete, dn, :base})
    end
  end

  # ===============
  # == Modifying ==
  # ===============

  @type mod ::
          {:add, {binary | atom, binary | [binary]}}
          | {:delete, binary}
          | {:replace, {binary | atom, binary | [binary]}}

  @type modify_ldap_error ::
          :noSuchObject
          | :undefinedAttributeType
          | :namingViolation
          | :attributeOrValueExists
          | :invalidAttributeSyntax
          | :notAllowedOnRDN
          | :objectClassViolation
          | :objectClassModsProhibited
          | :insufficientAccessRights

  @spec modify(Paddle.Class.t() | dn, [mod]) :: :ok | {:error, modify_ldap_error}

  @doc ~S"""
  Modify an LDAP entry given a DN or a class object and a list of
  modifications.

  A modification is specified like so:

      {action, {parameters...}}

  Available modifications:

  - `{:add, {field, value}}`
  - `{:delete, field}`
  - `{:replace, {field, value}}`

  For example, adding a "description" field:

      {:add, {"description", "This is a description"}}

  This allows you to do things like this:

      Paddle.modify([uid: "testuser", ou: "People"],
                    add: {"description", "This is a description"},
                    delete: "gecos",
                    replace: {"o", ["Club *Nix", "Linux Foundation"]})

  Or, using class objects:

      Paddle.modify(%MyApp.PosixAccount{uid: "testuser"},
                    add: {"description", "This is a description"},
                    delete: "gecos",
                    replace: {"o", ["Club *Nix", "Linux Foundation"]})

  """
  def modify(kwdn, mods) when is_list(kwdn) or is_binary(kwdn) do
    GenServer.call(Paddle, {:modify, kwdn, :base, mods})
  end

  def modify(class_object, mods) when is_map(class_object) do
    with {:ok, dn} <- get_dn(class_object) do
      GenServer.call(Paddle, {:modify, dn, :base, mods})
    end
  end

  # =======================
  # == Private Utilities ==
  # =======================

  @spec config :: keyword

  @doc ~S"""
  Get the whole configuration of the Paddle application.
  """
  def config, do: Application.get_env(:penguin_paddle, Paddle)

  @spec config(atom) :: any

  @doc ~S"""
  Get the environment configuration of the Paddle application under a
  certain key.
  """
  def config(:host) do
    case Keyword.get(config(), :host) do
      host when is_bitstring(host) -> [String.to_charlist(host)]
      hosts when is_list(hosts) -> Enum.map(hosts, &String.to_charlist/1)
    end
  end

  def config(:ssl), do: config(:ssl, false)
  def config(:ipv6), do: config(:ipv6, false)
  def config(:tcpopts), do: config(:tcpopts, [])
  def config(:sslopts), do: config(:sslopts, [])
  def config(:port), do: config(:port, 389)
  def config(:timeout), do: config(:timeout, nil)
  def config(:base), do: config(:base, "") |> :binary.bin_to_list()
  def config(:account_base), do: config(:account_subdn) ++ ',' ++ config(:base)
  def config(:account_subdn), do: config(:account_subdn, "ou=People") |> :binary.bin_to_list()
  def config(:account_identifier), do: config(:account_identifier, :uid)
  def config(:schema_files), do: config(:schema_files, [])

  @spec config(atom, any) :: any

  @doc ~S"""
  Same as `config/1` but allows you to specify a default value.
  """
  def config(key, default), do: Keyword.get(config(), key, default)

  @spec ensure_single_result({:ok, [ldap_entry]} | {:error, atom}) ::
          {:ok, ldap_entry} | {:error, :noSuchObject}

  defp ensure_single_result({:error, error}) do
    case error do
      :noSuchObject -> {:error, :noSuchObject}
    end
  end

  defp ensure_single_result({:ok, []}), do: {:error, :noSuchObject}
  defp ensure_single_result({:ok, [result]}), do: {:ok, result}

  @spec eldap_log_callback(pos_integer, charlist, [term]) :: :ok

  @doc false
  def eldap_log_callback(level, format_string, format_args) do
    message =
      case Application.get_env(:penguin_paddle, :filter_passwords, true) do
        true ->
          :io_lib.format(format_string, format_args)
          |> to_string()
          |> String.replace(~r/{simple,".*"}/, ~s({simple,"filtered"}))

        false ->
          :io_lib.format(format_string, format_args)
      end

    case level do
      # Level 1 seems unused by :eldap
      1 -> Logger.info(message)
      2 -> Logger.debug(message)
    end
  end

  defp do_connect(opts) do
    ssl = Keyword.get(opts, :ssl, config(:ssl))
    ipv6 = Keyword.get(opts, :ipv6, config(:ipv6))
    tcpopts = Keyword.get(opts, :tcpopts, config(:tcpopts))
    sslopts = Keyword.get(opts, :sslopts, config(:sslopts))
    host = Keyword.get(opts, :host, config(:host))
    port = Keyword.get(opts, :port, config(:port))
    timeout = Keyword.get(opts, :timeout, config(:timeout))

    Logger.info("Connecting to ldap#{if ssl, do: "s"}://#{inspect(host)}:#{port}")

    tcpopts =
      if ipv6 do
        [:inet6 | tcpopts]
      else
        tcpopts
      end

    options = [ssl: ssl, port: port, tcpopts: tcpopts, log: &eldap_log_callback/3]

    options =
      if timeout do
        Keyword.put(options, :timeout, timeout)
      else
        options
      end

    options =
      if ssl do
        Keyword.put(options, :sslopts, sslopts)
      else
        options
      end

    Logger.debug("Effective :eldap options: #{inspect(options)}")

    case :eldap.open(host, options) do
      {:ok, ldap_conn} ->
        :eldap.controlling_process(ldap_conn, self())
        Logger.info("Connected to LDAP")
        {:ok, ldap_conn}

      {:error, reason} ->
        Logger.info("Failed to connect to LDAP")
        {:error, Kernel.to_string(reason)}
    end
  end
end