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.
175 lines
4.9 KiB
Elixir
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
|