Packages
LDAP / LDAPS directory monitor, with its dashboard panel bundled in the same package as a separate module (Integrations.Ldap.Display, #299) — one install, both halves; a release without raven_web simply runs the monitor headless.
Current section
Files
Jump to
Current section
Files
lib/integrations/ldap.ex
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