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