Packages

DNS resolution monitor, with its dashboard panel bundled in the same package as a separate module (Integrations.Dns.Display) — one install, both halves; a release without raven_web simply runs the monitor headless.

Current section

Files

Jump to
raven_integration_dns lib integrations dns.ex
Raw

lib/integrations/dns.ex

defmodule Integrations.Dns do
@moduledoc """
DNS resolution monitor.
Queries a DNS nameserver and checks that a name resolves correctly within
an acceptable time. Uses OTP's built-in `:inet_res` — no external
dependencies required.
Useful for monitoring authoritative nameservers, recursive resolvers,
cluster-internal DNS (e.g. CoreDNS), and split-horizon configurations.
Collection only — see `Integrations.Dns.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.
## Params
* `:nameserver` — IP address of the DNS server to query. Required.
Port 53 is used unless `:nameserver_port` is set.
* `:nameserver_port` — DNS server port. Defaults to `53`.
* `:query` — Name to resolve. Required.
* `:type` — Record type: `"A"`, `"AAAA"`, `"MX"`, `"TXT"`,
`"CNAME"`, `"NS"`, `"PTR"`. Defaults to `"A"`.
* `:expected` — Expected value in the answer (optional). For A/AAAA
records, an IP string. For MX, the exchange hostname.
For TXT, a substring that must appear in any record.
When set, a mismatch triggers `:degraded`.
* `:timeout_ms` — Query timeout in milliseconds. Defaults to `5000`.
* `:latency_degraded_ms` — RTT threshold for `:degraded`. Defaults to
`200`.
## Health signal
* `:up` — Query resolved; answer matches `:expected` (if set);
latency within threshold.
* `:degraded` — Query resolved but answer does not match `:expected`,
or latency exceeded `:latency_degraded_ms`.
* `:down` — Query failed (NXDOMAIN, SERVFAIL, timeout, unreachable).
"""
use CodeNameRaven.Monitor
@default_port 53
@default_type "A"
@default_timeout_ms 5_000
@default_lat_degraded 200
@type_map %{
"A" => :a,
"AAAA" => :aaaa,
"MX" => :mx,
"TXT" => :txt,
"CNAME" => :cname,
"NS" => :ns,
"PTR" => :ptr,
"SOA" => :soa
}
@impl true
def params_template do
%{nameserver: "", query: "", type: "A", expected: "", timeout_ms: "5000"}
end
@impl true
def params_schema do
[
nameserver: [type: :string, required: true, doc: "IP address of DNS server to query"],
nameserver_port: [type: :non_neg_integer, default: 53, doc: "DNS server port"],
query: [type: :string, required: true, doc: "Name to resolve"],
type: [type: {:in, ["A", "AAAA", "MX", "TXT", "CNAME", "NS", "PTR", "SOA"]}, default: "A", doc: "DNS record type"],
expected: [type: :string, required: false, doc: "Expected answer value (mismatch triggers degraded)"],
timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "Query timeout in milliseconds"],
latency_degraded_ms: [type: :non_neg_integer, default: 200, doc: "RTT threshold for degraded"]
]
end
@impl true
def target_uri(params) do
ns = get_param(params, :nameserver)
query = get_param(params, :query)
type = get_param(params, :type) || @default_type
if ns && query, do: {:ok, "dns://#{ns}/#{query}?type=#{type}"}, else: :none
end
@impl true
def identity_params(params) do
%{
nameserver: get_param(params, :nameserver),
query: get_param(params, :query),
type: get_param(params, :type) || @default_type
}
end
# ---------------------------------------------------------------------------
# Collect
# ---------------------------------------------------------------------------
@impl true
def collect(params, state) do
nameserver = get_param(params, :nameserver)
query = get_param(params, :query)
cond do
is_nil(nameserver) -> {:error, "missing required param :nameserver", state}
is_nil(query) -> {:error, "missing required param :query", state}
true ->
ns_port = parse_int(get_param(params, :nameserver_port), @default_port)
type_str = get_param(params, :type) || @default_type
type_atom = Map.get(@type_map, String.upcase(type_str), :a)
timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms)
expected = get_param(params, :expected)
ns_ip = parse_ip(nameserver)
opts = [
nameservers: [{ns_ip, ns_port}],
timeout: timeout_ms,
retry: 1
]
started_at = System.monotonic_time(:millisecond)
case :inet_res.resolve(to_charlist(query), :in, type_atom, opts) do
{:ok, dns_record} ->
latency_ms = System.monotonic_time(:millisecond) - started_at
answers = extract_answers(dns_record, type_atom)
matched = answers_match?(answers, expected, type_atom)
result = %{
latency_ms: latency_ms,
answers: answers,
matched: matched,
expected: expected,
type: type_str,
query: query
}
{:ok, result, state}
{:error, reason} ->
{:error, format_dns_error(reason), state}
end
end
end
# ---------------------------------------------------------------------------
# Healthy?
# ---------------------------------------------------------------------------
@impl true
def healthy?(result) do
lat_degraded = result[:latency_degraded_ms] || @default_lat_degraded
cond do
result.expected != nil and not result.matched -> :degraded
result.latency_ms >= lat_degraded -> :degraded
true -> :up
end
end
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@impl true
def metrics(result) do
%{
latency_ms: result.latency_ms,
answer_count: length(result.answers)
}
end
# ---------------------------------------------------------------------------
# Answer extraction
# ---------------------------------------------------------------------------
defp extract_answers(dns_rec, type_atom) do
# dns_rec is a :dns_rec tuple from :inet_res
# Answers are in element index 3 (0-indexed: type, id, qdlist, anlist, ...)
anlist = elem(dns_rec, 3)
Enum.flat_map(anlist, fn rr ->
data = elem(rr, 6)
format_rdata(type_atom, data)
end)
end
defp format_rdata(:a, {a, b, c, d}), do: ["#{a}.#{b}.#{c}.#{d}"]
defp format_rdata(:aaaa, addr) when is_tuple(addr), do: [:inet.ntoa(addr) |> to_string() |> List.wrap()]
defp format_rdata(:mx, {pref, name}), do: ["#{pref} #{to_string(name)}"]
defp format_rdata(:txt, data) when is_list(data), do: [data |> Enum.map(&to_string/1) |> Enum.join()]
defp format_rdata(:txt, data), do: [to_string(data)]
defp format_rdata(:cname, name), do: [to_string(name)]
defp format_rdata(:ns, name), do: [to_string(name)]
defp format_rdata(:ptr, name), do: [to_string(name)]
defp format_rdata(_, data), do: [inspect(data)]
defp answers_match?(_answers, nil, _type), do: true
defp answers_match?(answers, expected, :txt) do
Enum.any?(answers, &String.contains?(&1, expected))
end
defp answers_match?(answers, expected, _type) do
Enum.any?(answers, &String.contains?(&1, expected))
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp parse_ip(str) do
case :inet.parse_address(to_charlist(str)) do
{:ok, ip} -> ip
{:error, _} -> to_charlist(str)
end
end
defp format_dns_error(:nxdomain), do: "NXDOMAIN — name does not exist"
defp format_dns_error(:servfail), do: "SERVFAIL — server returned failure"
defp format_dns_error(:timeout), do: "query timed out"
defp format_dns_error(:refused), do: "query refused by server"
defp format_dns_error({:noquery, reason}), do: "no answer: #{inspect(reason)}"
defp format_dns_error(reason), do: inspect(reason)
defp get_param(params, key) when is_atom(key) do
v = params[key] || params[to_string(key)]
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