defmodule Integrations.Ldap do @moduledoc """ LDAP / LDAPS directory monitor. Collection only — see `Integrations.Ldap.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts, same as a single-module package would; a release without `raven_web` simply never compiles the display half and runs this monitor headless. Connects to an LDAP server and verifies it is accepting connections and responding to queries. Uses OTP's built-in `:eldap` module — no external dependencies required. Optionally performs an authenticated bind and/or a search query to confirm the directory is serving data correctly. ## Params * `:host` — LDAP server hostname or IP. Required. * `:port` — Port number. Defaults to `389` for plain LDAP or `636` when `:ssl` is `true`. * `:ssl` — Use LDAPS (TLS from the start). Defaults to `false`. * `:starttls` — Upgrade to TLS using STARTTLS after connecting. Defaults to `false`. Mutually exclusive with `:ssl`. * `:verify_peer` — Verify the server's TLS certificate chain. Defaults to `true`. Set to `false` to skip verification (development only). * `:bind_dn` — Distinguished name to bind as (optional). When omitted, an anonymous bind is attempted. * `:bind_password` — Password for `:bind_dn` (optional). * `:search_base` — Base DN for a verification search (optional). * `:search_filter` — LDAP filter string for the search. Defaults to `"(objectClass=*)"`. * `:timeout_ms` — Connection and operation timeout in milliseconds. Defaults to `5000`. ## Health signal * `:up` — Connection established; bind succeeded (if configured); search returned results (if configured). * `:degraded` — Bind or search succeeded but returned 0 results when results were expected, or latency is high. * `:down` — Connection refused, TLS failure, or bind error. """ use CodeNameRaven.Monitor @default_timeout_ms 5_000 @default_search_filter "(objectClass=*)" @impl true def params_template do %{ host: "", port: "", ssl: "false", bind_dn: "", bind_password: "", search_base: "", timeout_ms: "5000" } end @impl true def params_schema do [ host: [type: :string, required: true, doc: "LDAP server hostname or IP"], port: [type: :non_neg_integer, required: false, doc: "Port (default: 389 plain, 636 SSL)"], ssl: [type: :boolean, default: false, doc: "Use LDAPS (TLS from connection start)"], starttls: [type: :boolean, default: false, doc: "Upgrade to TLS via STARTTLS after connect"], verify_peer: [type: :boolean, default: true, doc: "Verify server TLS certificate chain"], bind_dn: [type: :string, required: false, doc: "Distinguished name to bind as (omit for anonymous)"], bind_password: [type: :string, required: false, doc: "Password for bind_dn"], search_base: [type: :string, required: false, doc: "Base DN for verification search"], search_filter: [type: :string, default: "(objectClass=*)", doc: "LDAP filter for search"], timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "Connection and operation timeout in milliseconds"] ] end @impl true def target_uri(params) do host = get_param(params, :host) port = effective_port(params) ssl? = ssl?(params) if host do scheme = if ssl?, do: "ldaps", else: "ldap" {:ok, "#{scheme}://#{host}:#{port}"} else :none end end @impl true def identity_params(params) do %{ host: get_param(params, :host), port: effective_port(params), bind_dn: get_param(params, :bind_dn) } end # --------------------------------------------------------------------------- # Collect # --------------------------------------------------------------------------- @impl true def collect(params, state) do host = get_param(params, :host) if is_nil(host) do {:error, "missing required param :host", state} else port = effective_port(params) timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms) use_ssl = ssl?(params) starttls = truthy?(get_param(params, :starttls)) verify = truthy?(get_param(params, :verify_peer), default: true) ssl_opts = if verify do [verify: :verify_peer, cacerts: :public_key.cacerts_get()] else [verify: :verify_none] end base_opts = [port: port, ssl: use_ssl, timeout: timeout_ms] eldap_opts = if use_ssl or starttls, do: Keyword.put(base_opts, :sslopts, ssl_opts), else: base_opts started_at = System.monotonic_time(:millisecond) case :eldap.open([to_charlist(host)], eldap_opts) do {:ok, conn} -> result = run_checks(conn, params, started_at, timeout_ms, starttls, ssl_opts) :eldap.close(conn) case result do {:ok, data} -> {:ok, data, state} {:error, reason} -> {:error, reason, state} end {:error, reason} -> {:error, format_error(reason), state} end end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(result) do cond do result.bind_attempted and not result.bind_ok -> :down result.search_attempted and result.result_count == 0 -> :degraded true -> :up end end # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do base = %{latency_ms: result.latency_ms} if result.search_attempted do Map.put(base, :result_count, result.result_count) else base end end # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- defp run_checks(conn, params, started_at, timeout_ms, starttls, ssl_opts) do if starttls do case :eldap.start_tls(conn, ssl_opts, timeout_ms) do :ok -> :ok {:error, reason} -> throw({:starttls_error, format_error(reason)}) end end bind_dn = get_param(params, :bind_dn) bind_pw = get_param(params, :bind_password) || "" {bind_attempted, bind_ok} = if bind_dn do result = :eldap.simple_bind(conn, to_charlist(bind_dn), to_charlist(bind_pw)) {true, result == :ok} else # Anonymous bind — always attempted on open, treat as success {false, true} end latency_ms = System.monotonic_time(:millisecond) - started_at search_base = get_param(params, :search_base) search_filter = get_param(params, :search_filter) || @default_search_filter {search_attempted, result_count} = if search_base && bind_ok do filter = parse_filter(search_filter) search_opts = [ base: to_charlist(search_base), filter: filter, scope: :eldap.wholeSubtree(), timeout: timeout_ms ] case :eldap.search(conn, search_opts) do {:ok, {:eldap_search_result, entries, _, _}} -> {true, length(entries)} {:ok, {:eldap_search_result, entries, _}} -> {true, length(entries)} _ -> {true, 0} end else {false, 0} end {:ok, %{ latency_ms: latency_ms, bind_attempted: bind_attempted, bind_ok: bind_ok, search_attempted: search_attempted, result_count: result_count }} catch {:starttls_error, reason} -> {:error, "STARTTLS failed: #{reason}"} end defp parse_filter(_filter_str), do: :eldap.present('objectClass') defp format_error(:econnrefused), do: "connection refused" defp format_error(:timeout), do: "connection timed out" defp format_error(:nxdomain), do: "hostname not found" defp format_error({:ldap_error, code, msg}), do: "LDAP error #{code}: #{msg}" defp format_error(reason), do: inspect(reason) # --------------------------------------------------------------------------- # Shared helpers — also duplicated in Display (same package): effective_port/1 # is reached from ldap_title/1 in the dashboard panel, and pulls in ssl?/1, # truthy?/1, truthy?/2, get_param/2, and parse_int/2 with it. See #299. # --------------------------------------------------------------------------- defp effective_port(params) do case get_param(params, :port) do nil -> if ssl?(params), do: 636, else: 389 p -> parse_int(p, 389) end end defp ssl?(params), do: truthy?(get_param(params, :ssl)) defp truthy?(nil, opts), do: Keyword.get(opts, :default, false) defp truthy?(_, opts), do: Keyword.get(opts, :default, false) defp truthy?("true"), do: true defp truthy?(true), do: true defp truthy?(_), do: false defp get_param(params, key) when is_atom(key) do v = case Map.fetch(params, key) do {:ok, val} -> val :error -> params[to_string(key)] end if is_binary(v) and String.trim(v) == "", do: nil, else: v end defp parse_int(nil, default), do: default defp parse_int(v, _) when is_integer(v), do: v defp parse_int(v, default) when is_binary(v) do case Integer.parse(v) do {n, _} -> n :error -> default end end defp parse_int(_, default), do: default end