towerops/lib/towerops/monitoring/executors/dns_executor.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

175 lines
4.9 KiB
Elixir

defmodule Towerops.Monitoring.Executors.DnsExecutor do
@moduledoc """
Executes DNS resolution checks using :inet_res.
Config format:
%{
"hostname" => "example.com",
"server" => "8.8.8.8", # optional, DNS server (uses system default if nil)
"record_type" => "A", # optional, default: A
"expected" => "93.184.216.34" # optional, expected result
}
"""
require Logger
@default_timeout 5000
@doc """
Executes a DNS check.
Returns:
- {:ok, response_time_ms, output} on success
- {:error, reason} on failure
"""
def execute(config, timeout_ms \\ @default_timeout) do
hostname = Map.fetch!(config, "hostname")
server = Map.get(config, "server")
record_type = Map.get(config, "record_type", "A")
expected = Map.get(config, "expected")
# Convert to Erlang atoms/charlists
hostname_charlist = String.to_charlist(hostname)
record_type_atom = record_type_to_atom(record_type)
start_time = System.monotonic_time(:millisecond)
result =
if server do
# Use specific DNS server
server_tuple = parse_server(server)
:inet_res.resolve(hostname_charlist, :in, record_type_atom, [
{:nameservers, [server_tuple]},
{:timeout, timeout_ms}
])
else
# Use system default DNS
:inet_res.resolve(hostname_charlist, :in, record_type_atom, [{:timeout, timeout_ms}])
end
case result do
{:ok, dns_rec} ->
response_time = System.monotonic_time(:millisecond) - start_time
process_dns_response(dns_rec, record_type_atom, expected, response_time)
{:error, :timeout} ->
{:error, "DNS query timeout after #{timeout_ms}ms"}
{:error, :nxdomain} ->
{:error, "Domain not found (NXDOMAIN)"}
{:error, :servfail} ->
{:error, "DNS server failure (SERVFAIL)"}
{:error, reason} ->
{:error, "DNS resolution failed: #{inspect(reason)}"}
end
rescue
e ->
Logger.error("DNS check exception: #{inspect(e)}")
{:error, "Exception: #{Exception.message(e)}"}
end
@doc false
def record_type_to_atom("A"), do: :a
def record_type_to_atom("AAAA"), do: :aaaa
def record_type_to_atom("CNAME"), do: :cname
def record_type_to_atom("MX"), do: :mx
def record_type_to_atom("TXT"), do: :txt
def record_type_to_atom("NS"), do: :ns
def record_type_to_atom("PTR"), do: :ptr
def record_type_to_atom(_), do: :a
@doc false
def parse_server(server_str) when is_binary(server_str) do
case String.split(server_str, ".") do
[a, b, c, d] ->
with {a_int, ""} <- Integer.parse(a),
{b_int, ""} <- Integer.parse(b),
{c_int, ""} <- Integer.parse(c),
{d_int, ""} <- Integer.parse(d),
true <- Enum.all?([a_int, b_int, c_int, d_int], &(&1 >= 0 and &1 <= 255)) do
{{a_int, b_int, c_int, d_int}, 53}
else
_ -> {{8, 8, 8, 8}, 53}
end
_ ->
{{8, 8, 8, 8}, 53}
end
end
def parse_server(_), do: {{8, 8, 8, 8}, 53}
defp process_dns_response(dns_rec, record_type, expected, response_time) do
answers = extract_answers(dns_rec, record_type)
cond do
Enum.empty?(answers) ->
{:error, "No #{record_type} records found"}
expected && !Enum.any?(answers, &(&1 == expected)) ->
{:error, "Expected '#{expected}', got: #{Enum.join(answers, ", ")}"}
true ->
{:ok, response_time, "Resolved to: #{Enum.join(answers, ", ")}"}
end
end
defp extract_answers(dns_rec, :a) do
dns_rec
|> :inet_dns.msg(:anlist)
|> Enum.filter(fn rr -> :inet_dns.rr(rr, :type) == :a end)
|> Enum.map(fn rr ->
{a, b, c, d} = :inet_dns.rr(rr, :data)
"#{a}.#{b}.#{c}.#{d}"
end)
end
defp extract_answers(dns_rec, :aaaa) do
dns_rec
|> :inet_dns.msg(:anlist)
|> Enum.filter(fn rr -> :inet_dns.rr(rr, :type) == :aaaa end)
|> Enum.map(fn rr ->
data = :inet_dns.rr(rr, :data)
format_ipv6(data)
end)
end
defp extract_answers(dns_rec, :cname) do
dns_rec
|> :inet_dns.msg(:anlist)
|> Enum.filter(fn rr -> :inet_dns.rr(rr, :type) == :cname end)
|> Enum.map(fn rr ->
rr |> :inet_dns.rr(:data) |> to_string()
end)
end
defp extract_answers(dns_rec, :mx) do
dns_rec
|> :inet_dns.msg(:anlist)
|> Enum.filter(fn rr -> :inet_dns.rr(rr, :type) == :mx end)
|> Enum.map(fn rr ->
{priority, exchange} = :inet_dns.rr(rr, :data)
"#{priority} #{exchange}"
end)
end
defp extract_answers(dns_rec, :txt) do
dns_rec
|> :inet_dns.msg(:anlist)
|> Enum.filter(fn rr -> :inet_dns.rr(rr, :type) == :txt end)
|> Enum.map(fn rr ->
rr |> :inet_dns.rr(:data) |> Enum.join("")
end)
end
defp extract_answers(_dns_rec, _type), do: []
defp format_ipv6({a, b, c, d, e, f, g, h}) do
[a, b, c, d, e, f, g, h]
|> Enum.map_join(":", &Integer.to_string(&1, 16))
|> String.downcase()
end
end