Sponsored by AppSignal
Would you like to see your link here? Contact us
Notesclub

DNS Zone Explorer

notebooks/DNS-zone-explorer.livemd

DNS Zone Explorer

Mix.install([
  {:kino, "~> 0.12.3"},
  {:kino_shell, "~> 0.1.2"},
  {:jason, "~> 1.4"},
  {:domainname, "~> 0.1.5"},
  {:whois, "~> 0.2.1"}
])

Overview

Domain entry, parsing and querying

To begin, we need user input to know which domain to interrogate, storing the result into the domain variable.

domain = Kino.Input.text("Enter your domain name")

The following will read and attempt to construct a valid domain name and parrot the user input back to us, as a simple form of feedback. If it fails, then an error will warn us of this at this stage.

{:ok, verified_domain} =
  DomainName.new(Kino.Input.read(domain),
    must_be_hostname: true
  )
defmodule DomainName.Querying do
  def domain_name_query(domain_name) do
    domain_name
    |> DomainName.name()
    |> Whois.lookup()
  end

  def get_critical_domain_info(domain_name) do
    case domain_name_query(domain_name) do
      {:ok, whois_record} -> whois_record
      {_, _} -> {:error}
    end
  end
end

Defining special variables

Email authentication is a collection of techniques aimed at providing verifiable information about the origin of email messages. Three authentication methods have been widely adopted: DKIM, SPF and DMARC. DKIM and DMARC are specified as DNS records, using specific subdomain labels, which we define in this section.

BIMI is a branding specification, and as it is bound to be more widely used, we will specify its own subdomain label requirement as well.

dkim_subdomain_label = "_domainkey"
dmarc_subdomain_label = "_dmarc"
bimi_subdomain_label = "_bimi"

Let’s define a set of DKIM selectors in dkim_selector_list, as a valid selector is needed to query for a DKIM key.

known_dkim_selector_list = [
  # Generic
  "x",
  # Google
  "google",
  # Microsoft
  "selector1",
  # Microsoft
  "selector2",
  # MailChimp
  "k1",
  # Mandrill
  "mandrill",
  # Everlytic
  "everlytickey1",
  # Everlytic
  "everlytickey2",
  # Hetzner
  "dkim",
  # MxVault 
  "mxvault",
  # MailJet
  "mailjet",
  # Avoccado.ai
  "pic"
]

Let’s get locations of all subdomain enumeration tools we may need, into variables we can use.

amass_binary = System.get_env("LB_AMASS_BIN")
assetfinder_binary = System.get_env("LB_ASSETFINDER_BIN")
dnsrecon_binary = System.get_env("LB_DNSRECON_BIN")
puredns_binary = System.get_env("LB_PUREDNS_BIN")
subfinder_binary = System.get_env("LB_SUBFINDER_BIN")
sublist3r_binary = System.get_env("LB_SUBLIST3R_BIN")
dss_binary = System.get_env("LB_DSS_BIN")
:ok

Using Erlang’s :inet_res builtin functionality

defmodule Dns.Query do
  def get_record_info(dns_name, type, options \\ []) when is_atom(type) do
    answer_source =
      Keyword.get(options, :answer_source, "#{String.upcase(Kernel.to_string(type))} query")

    dns_name
    |> DomainName.name()
    |> String.to_charlist()
    |> :inet_res.resolve(:in, type)
    |> case do
      {:ok, res} ->
        {:ok,
         res
         |> :inet_dns.msg()
         |> Keyword.fetch!(:anlist)
         |> Enum.map(fn i -> Enum.into(:inet_dns.rr(i), %{}) end)
         |> Enum.map(fn i ->
           Map.merge(i, %{
             query_tool: "inet_res",
             answer_source: answer_source
           })
         end)}

      {:error, reason} ->
        {:error, reason}
    end
  end

  def construct_subdomain_host(fqdn, subdomain_label),
    do: DomainName.join!(subdomain_label, fqdn)

  def filter_for_matching_type(response, type) do
    case response do
      {:ok, msg} ->
        msg
        |> Enum.reverse()
        |> case do
          [%{type: ^type} | _tail] -> msg
          _ -> []
        end

      {:error, _} ->
        []
    end
  end

  def get_a_records(fqdn) do
    get_record_info(fqdn, :a) |> filter_for_matching_type(:a)
  end

  def get_aaaa_records(fqdn) do
    get_record_info(fqdn, :aaaa) |> filter_for_matching_type(:aaaa)
  end

  def get_cname_record(fqdn) do
    get_record_info(fqdn, :cname) |> filter_for_matching_type(:cname)
  end

  def resolves_to_already_seen?(record_list, domains_seen)
      when is_list(record_list) and is_list(domains_seen) do
    Enum.map(record_list, fn rec ->
      Enum.map(domains_seen, fn domain ->
        rec.data
        |> DomainName.new!()
        |> DomainName.equal?(domain)
      end)
    end)
    |> List.flatten()
    |> Enum.any?(fn i -> i end)
  end

  def get_ns_records(fqdn) do
    get_record_info(fqdn, :ns) |> filter_for_matching_type(:ns)
  end

  def get_mx_records(fqdn) do
    get_record_info(fqdn, :mx) |> filter_for_matching_type(:mx)
  end

  def get_ptr_records(fqdn) do
    get_record_info(fqdn, :ptr) |> filter_for_matching_type(:ptr)
  end

  def get_soa_records(fqdn) do
    get_record_info(fqdn, :soa) |> filter_for_matching_type(:soa)
  end

  def get_txt_records(fqdn) do
    get_record_info(fqdn, :txt) |> filter_for_matching_type(:txt)
  end

  def get_dmarc_record(fqdn, dmarc_subdomain_label) do
    construct_subdomain_host(fqdn, dmarc_subdomain_label)
    |> get_record_info(:txt, answer_source: "DMARC query")

    # |> filter_for_matching_type(:txt)
  end

  def get_dkim_records(fqdn, dkim_subdomain_label, selector_list)
      when is_list(selector_list) do
    dkim_subdomain = construct_subdomain_host(fqdn, dkim_subdomain_label)

    Enum.map(selector_list, fn sel ->
      construct_subdomain_host(dkim_subdomain, sel)
      |> get_record_info(:txt, answer_source: "DKIM query")
      |> case do
        {:ok, payload} -> payload
        {:error, reason} -> {:error, reason}
      end
    end)
    # |> filter_for_matching_type(:txt)
    |> Enum.filter(fn
      {:error, _} -> false
      _ -> true
    end)
  end
end

As any record query may return a CNAME—or a _canonical name_—response, which the client will immediately query all the way until it encounters a non-CNAME response, we will receive a response list. This possibility needs to be checked, as it means getting multiple answer records, and this needs to be handled appropriately.

Two ways present themselves, the quick one being to discard all but the last record in the list, as this is likely to be the final one, and the one we actually want. Doing this, however, means eliding potentially useful information, at least for debugging, as it would not be clear how we came to the final record, without seeing the alias traversals within. In cases of DNS cache poisoning, or even Man-in-the-Middle (MITM) attacks, discovering unexpected CNAME records is critical in helping spot vulnerabilities, so they should be kept and reported on for a full picture of the state of the zone. Showing the full redirection picture aids in upholding the principle of least astonishment, in that it shouldn’t cause surprise or consternation to systems administrators, or those developers who are not aware of every infrastructure decision taken to date.

On DNS zone enumeration

For security reasons, it is difficult to get a true state of a DNS zone, as this is information which would be used by attackers in finding exploits.

To enumerate subdomains of a domain, we will turn to several penetration testing tools.

Subdomain enumeration

We now call out to System.cmd to run subfinder on our domain. Note that ease of parsing is most important here, thus the tool is run with a few additional options:

  • -silent so that the masthead message, progress and other messages are not printed to stdout
  • -json to return a list of JSON-formatted results, for each enumerated subdomain
  • -active to display active subdomains only

PLEASE NOTE This command is run on the network, querying multiple internet sources, and as such is likely to take some time, typically needing 30 seconds or more. Please be patient and don’t interrupt it unnecessarily. For additional informative use, stderr has been redirected to stdout to show error messages, if any.

# {subfinder_query_result, 0} =
#   System.cmd(
#     "bash",
#     ["-lc", "#{subfinder_binary} -d #{DomainName.name(verified_domain)} -silent -json -active"],
#     stderr_to_stdout: true
#   )

subfinder_query_result =
  {"{\"host\":\"api.magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"leakix\"}\n{\"host\":\"school.magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"leakix\"}\n{\"host\":\"magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"digitorus\"}\n{\"host\":\"watch.magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"hackertarget\"}\n{\"host\":\"email.reply.magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"leakix\"}\n{\"host\":\"www.magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"leakix\"}\n{\"host\":\"go.magicpillsmovie.com\",\"input\":\"magicpillsmovie.com\",\"source\":\"leakix\"}\n",
   0}

With query results saved to subfinder_query_results, we now need to clean up the results by splitting away newline characters (\n), filtering out empty strings, and parsing the JSON results.

# enumerated_subdomains =
#   subfinder_query_result
#   |> String.split("\n")
#   |> Enum.filter(fn rec -> rec != "" end)
#   |> Enum.map(fn rec -> Jason.decode!(rec) end)
#   |> Enum.map(fn m ->
#     m
#     |> Map.new(fn {k, v} -> {String.to_atom(k), v} end)
#     |> Map.merge(%{tool: "subfinder"})
#   end)
#   |> Enum.filter(fn %{host: host} -> host != DomainName.name(verified_domain) end)

enumerated_subdomains = [
  %{
    input: "magicpillsmovie.com",
    host: "api.magicpillsmovie.com",
    source: "leakix",
    tool: "subfinder"
  },
  %{
    input: "magicpillsmovie.com",
    host: "school.magicpillsmovie.com",
    source: "leakix",
    tool: "subfinder"
  },
  %{
    input: "magicpillsmovie.com",
    host: "watch.magicpillsmovie.com",
    source: "hackertarget",
    tool: "subfinder"
  },
  %{
    input: "magicpillsmovie.com",
    host: "email.reply.magicpillsmovie.com",
    source: "leakix",
    tool: "subfinder"
  },
  %{
    input: "magicpillsmovie.com",
    host: "www.magicpillsmovie.com",
    source: "leakix",
    tool: "subfinder"
  },
  %{
    input: "magicpillsmovie.com",
    host: "go.magicpillsmovie.com",
    source: "leakix",
    tool: "subfinder"
  }
]

Core functionality

defmodule Dns.Core do
  def get_host_name_without_domain(fqdn, zone_root_domain)
      when is_struct(fqdn, DomainName) and is_struct(zone_root_domain, DomainName) do
    fqdn
    |> DomainName.without_suffix(zone_root_domain)
    |> case do
      {:ok, name} -> name
      {:error, _} -> fqdn
    end
  end

  def is_subdomain_of_zone?(fqdn, zone_root_domain) when is_struct(fqdn, DomainName) do
    DomainName.ends_with?(fqdn, zone_root_domain)
  end

  def is_subdomain_of_zone?(fqdn, zone_root_domain) when is_bitstring(fqdn) do
    fqdn
    |> DomainName.new!()
    |> DomainName.ends_with?(zone_root_domain)
  end

  def get_subdomain_labels(subdomain) do
    DomainName.labels(subdomain) |> Enum.reverse()
  end

  def classify_txt_record_type(record, options \\ []) do
    data_string =
      cond do
        is_list(record.data) -> Enum.join(record.data, " || ")
        true -> record.data
      end

    cond do
      is_record_spf?(data_string) -> :spf
      is_record_payload_dmarc?(data_string) -> :dmarc
      is_record_payload_dkim?(data_string) -> :dkim
      true -> nil
    end
  end

  defp is_record_spf?(data_string) when is_bitstring(data_string) do
    spf_re = ~r/^v=spf1.*/
    String.match?(data_string, spf_re)
  end

  defp is_record_dmarc?(data_string, fqdn, options \\ [])
       when is_bitstring(data_string) and is_struct(fqdn, DomainName) do
    case is_record_payload_dmarc?(data_string) do
      true -> is_record_domain_dmarc?(fqdn)
      false -> nil
    end
  end

  def is_record_payload_dmarc?(data_string) when is_bitstring(data_string) do
    String.match?(data_string, ~r/^v=DMARC1.*/)
  end

  def is_record_domain_dmarc?(fqdn) when is_struct(fqdn, DomainName) do
    fqdn
    |> DomainName.labels()
    |> Enum.find(fn l -> l == "_dmarc" end)
    |> case do
      nil -> nil
      _ -> :dmarc
    end
  end

  defp is_record_dkim?(data_string, fqdn, options \\ [])
       when is_bitstring(data_string) and is_struct(fqdn, DomainName) do
    case is_record_payload_dkim?(data_string) do
      true -> is_record_domain_dkim?(fqdn)
      false -> nil
    end
  end

  def is_record_payload_dkim?(data_string) when is_bitstring(data_string) do
    String.match?(data_string, ~r/.*p=.*/)
  end

  def is_record_domain_dkim?(fqdn) when is_struct(fqdn, DomainName) do
    fqdn
    |> DomainName.labels()
    |> Enum.find(fn l -> l == "_domainkey" end)
    |> case do
      nil -> nil
      _ -> :dkim
    end
  end

  def classify_record_chain_type(record_chain) when is_list(record_chain) do
    case record_chain do
      [head_link | []] ->
        case Dns.Core.classify_txt_record_type(head_link) do
          :dkim -> Map.merge(head_link, %{specialised_type: :dkim})
          :dmarc -> Map.merge(head_link, %{specialised_type: :dmarc})
          :spf -> Map.merge(head_link, %{specialised_type: :spf})
          _ -> head_link
        end

      [head_link | tail_links] ->
        cond do
          Dns.Core.is_record_domain_dkim?(head_link.name) &&
              Dns.Core.is_record_payload_dkim?(List.last(tail_links).data) ->
            Enum.map(record_chain, fn link -> Map.merge(link, %{specialised_type: :dkim}) end)

          Dns.Core.is_record_domain_dmarc?(head_link.name) &&
              Dns.Core.is_record_payload_dmarc?(List.last(tail_links).data) ->
            Enum.map(record_chain, fn link -> Map.merge(link, %{specialised_type: :dmarc}) end)

          true ->
            record_chain
        end
    end
  end

  def handle_query_result_chain(record_chain, options \\ [])
      when is_list(record_chain) do
    record_chain
    |> Enum.map(fn rec -> construct_dns_record_dispatcher(rec, options) end)
    |> classify_record_chain_type()
    |> convert_record_chain_to_tree()
  end

  def construct_dns_record_dispatcher(record, options \\ [])
      when is_map(record) do
    IO.inspect(record, label: "Dispatcher record")

    record
    |> case do
      %{type: :soa} = record ->
        IO.inspect("SOA type")
        construct_soa_record(record, options)

      %{type: :ns} = record ->
        IO.inspect("NS type")
        construct_ns_record(record, options)

      %{type: :mx} = record ->
        IO.inspect("MX type")
        construct_mx_record(record, options)

      %{type: :a} = record ->
        IO.inspect("A type")
        construct_a_record(record, options)

      %{type: :aaaa} = record ->
        IO.inspect("AAAA type")
        construct_a_record(record, options)

      %{type: :cname} = record ->
        IO.inspect("CNAME type")
        construct_cname_record(record, options)

      %{type: :txt} = record ->
        IO.inspect("TXT type")
        construct_txt_record(record, options)

      _ ->
        IO.inspect("Other type")
    end
  end

  def index_record_dispatcher(record, index) do
    record
    |> Map.get(:type)
    |> case do
      :ns -> index_ns_record(record, index)
      :mx -> index_mx_record(record, index)
      :a -> index_a_record(record, index)
      :aaaa -> index_a_record(record, index)
      _ -> record
    end
  end

  def convert_record_chain_to_tree(record_chain)
      when is_struct(record_chain, DnsRecord) do
    IO.inspect("Single map, no list")
    IO.inspect(record_chain, label: "One record")
    record_chain
  end

  def convert_record_chain_to_tree(record_chain) when is_list(record_chain) do
    case record_chain do
      [
        %DnsRecord{name: _name_first, data: _data_first} = first_record,
        %DnsRecord{name: _name_second} = second_record | _tail
      ] ->
        IO.inspect("At least two maps in list")
        IO.inspect(first_record, label: "First record")
        IO.inspect(second_record, label: "Second record")

        Q.resolve_chain(record_chain, [])

      [%DnsRecord{name: _name} = record | []] ->
        IO.inspect("Single map in list")
        IO.inspect(record, label: "One record")
        record

      [[]] ->
        []

      [] ->
        []
    end
  end

  @doc """
  Expects to receive a map, shaped as in the following example:

  %{data: {~c"ns-586.awsdns-09.net", ~c"awsdns-hostmaster.amazon.com", 1, 7200,
   900, 1209600, 86400},
  id: 2, type: :soa, ttl: 855, domain: ~c"domain.org",
  class: :in, query_tool: "...", answer_source: "..."}
  """
  defp construct_soa_record(record, options \\ []) do
    fqdn = DomainName.new!(record.domain)
    data = Dns.Core.handle_soa_record_data(record.data)

    DnsRecord.construct_dns_record(%{
      id: "SOA-#{record.domain}",
      type: record.type,
      weight: 0,
      name: fqdn,
      display_name: "SOA #{record.domain} zone",
      inside_zone: is_subdomain_of_zone?(fqdn, options[:zone_root_domain]),
      ttl: record.ttl,
      data:
        "Master name: #{data.mname}, Responsible contact: #{data.rname}, Serial number: #{data.serial}, Refresh time: #{data.refresh}, Retry time: #{data.retry}, Expire time: #{data.expire}, Minimum TTL: #{data.minimum}",
      structured_data: data,
      query_tool: record.query_tool,
      answer_source: record.answer_source
    })
  end

  @doc """
  Expects to receive a map, shaped as in the following example:

  %{data: ~c"dns4.p04.nsone.net", id: 4, type: :ns, ttl: 3600,
  domain: ~c"domain.com", class: :in, query_tool: "...",
  answer_source: "..."}
  """
  def construct_ns_record(record, options \\ []) do
    fqdn = DomainName.new!(record.domain)

    DnsRecord.construct_dns_record(%{
      id: "NS #{record.data}",
      type: record.type,
      weight: 5,
      name: fqdn,
      display_name: "NS",
      inside_zone: is_subdomain_of_zone?(fqdn, options[:zone_root_domain]),
      ttl: record.ttl,
      data: "#{record.data}",
      structured_data: %{fqdn: DomainName.new!(record.data)},
      query_tool: record.query_tool,
      answer_source: record.answer_source
    })
  end

  def index_ns_record(record, index) when is_struct(record, DnsRecord) and is_number(index) do
    Map.merge(record, %{
      id: Enum.join(["NS", index, record.name], "-"),
      display_name: "NS #{index}"
    })
  end

  @doc """
  Expects to receive a map, shaped as in the following example:

  %{data: {0, ~c"mx.mail.com"}, id: 1, type: :mx, ttl: 3600,
  domain: ~c"domain.com", class: :in,
  query_tool: "...", answer_source: "..."}
  """
  def construct_mx_record(record, options \\ []) do
    fqdn = DomainName.new!(record.domain)
    parsed_data = Dns.Core.handle_mx_record_data(record.data)

    DnsRecord.construct_dns_record(%{
      id: "MX-#{parsed_data.data}",
      type: record.type,
      weight: 10,
      name: fqdn,
      display_name: "MX #{record.domain}",
      inside_zone: is_subdomain_of_zone?(fqdn, options[:zone_root_domain]),
      ttl: record.ttl,
      data: to_string(parsed_data.data),
      structured_data: %{priority: parsed_data.priority, fqdn: DomainName.new!(parsed_data.data)},
      query_tool: record.query_tool,
      answer_source: record.answer_source
    })
  end

  def index_mx_record(record, index) when is_struct(record, DnsRecord) and is_number(index) do
    Map.merge(record, %{
      id: Enum.join(["MX", index, record.name], "-"),
      display_name: Enum.join(["MX", index, record.name], " ")
    })
  end

  @doc """
  Expects to receive a map, shaped as in the following AAAA query example:

  %{data: {9734, 18176, 16, 0, 0, 0, 26644, 63309}, id: 5, type: :aaaa,
  ttl: 300, domain: ~c"subdomain.domain.com", class: :in,
  query_tool: "...", answer_source: "..."}

  where the only difference for an A query is the shape of the `data` tuple:

  data: {104, 20, 246, 77}
  """
  def construct_a_record(record, options \\ []) do
    fqdn = DomainName.new!(record.domain)

    data =
      case record.type do
        :a -> handle_a_record_data(record.data)
        :aaaa -> handle_aaaa_record_data(record.data)
        _ -> nil
      end

    DnsRecord.construct_dns_record(%{
      id: "A-#{data}",
      type: record.type,
      weight: 15,
      name: fqdn,
      display_name: "#{record.domain}",
      inside_zone: is_subdomain_of_zone?(fqdn, options[:zone_root_domain]),
      ttl: record.ttl,
      data: data,
      structured_data: %{raw_data: record.data},
      query_tool: record.query_tool,
      answer_source: record.answer_source
    })
  end

  def index_a_record(record, index) when is_struct(record, DnsRecord) and is_number(index) do
    Map.merge(record, %{
      id: Enum.join(["A", index, record.name], "-")
    })
  end

  @doc """
  Expects to receive a map, shaped as in the following example:

  %{data: ~c"domain.org", id: 1, type: :cname, ttl: 2322,
  domain: ~c"subdomain.domain.com", class: :in,
  query_tool: "...", answer_source: "..."
  }
  """
  defp construct_cname_record(record, options \\ []) do
    fqdn = DomainName.new!(record.domain)
    children = Keyword.get(options, :children, [])

    DnsRecord.construct_dns_record(%{
      id: "CNAME-#{record.domain}",
      type: record.type,
      weight: 20,
      name: fqdn,
      display_name: "#{record.domain}",
      inside_zone: is_subdomain_of_zone?(fqdn, options[:zone_root_domain]),
      ttl: record.ttl,
      data: handle_record_data(record.data),
      structured_data: %{fqdn: DomainName.new!(record.data)},
      child_records: children,
      query_tool: record.query_tool,
      answer_source: record.answer_source
    })
  end

  @doc """
  Expects to receive a map, shaped as in the following example:

  %{
    data: [~c"google-site-verification=..."], type: :txt, ttl: 900,
    domain: ~c"domain.org", class: :in,
    query_tool: "...", answer_source: "..."}
  """
  defp construct_txt_record(record, options \\ []) do
    fqdn = DomainName.new!(record.domain)
    id_prefix = Keyword.get(options, :id_prefix, "")

    specialised_type =
      Keyword.get(
        options,
        :id_prefix,
        Map.get(record, :specialised_type, classify_txt_record_type(record, options))
      )

    id_construction =
      ["TXT", id_prefix, specialised_type, record.domain]
      |> Enum.reject(fn i -> i == "" end)
      |> Enum.join("-")

    display_name_construction =
      [String.upcase(Kernel.to_string(specialised_type)), "[TXT]", record.domain]
      |> Enum.reject(fn i -> i == "" end)
      |> Enum.join(" ")

    DnsRecord.construct_dns_record(%{
      id: id_construction,
      type: record.type,
      specialised_type: specialised_type,
      weight: 30,
      name: fqdn,
      display_name: display_name_construction,
      inside_zone: is_subdomain_of_zone?(fqdn, options[:zone_root_domain]),
      ttl: record.ttl,
      data: Enum.join(record.data, " || "),
      structured_data: %{raw_data: record.data},
      query_tool: record.query_tool,
      answer_source: record.answer_source
    })
  end

  def handle_record_payload(payload, options \\ []) do
    case payload do
      {:ok, []} ->
        IO.inspect("", label: "1 - {:ok, []}")
        []

      {:ok, payload} ->
        IO.inspect(payload, label: "2 - {:ok, payload}")
        # payload |> Enum.with_index(fn rec, index -> Map.merge(rec, %{id: index + 1}) end)
        handle_query_result_chain(payload, options)

      {:error, _} ->
        IO.inspect("", label: "3 - {:error, _}")
        []

      [ok: []] ->
        IO.inspect("", label: "4 - [:ok, []]")
        []

      [ok: payload] ->
        IO.inspect(payload, label: "5 - [:ok, payload]")
        # payload |> Enum.with_index(fn rec, index -> Map.merge(rec, %{id: index + 1}) end)
        handle_query_result_chain(payload, options)

      [error: _] ->
        IO.inspect("", label: "6 - [:error, _]")
        []

      [head | tail] = record_list when is_list(record_list) and is_list(head) ->
        IO.inspect(record_list, label: "7 - [[record_list]]")
        Enum.map(record_list, fn item -> handle_record_payload(item, options) end)

      [head | tail] = record_list when is_list(record_list) and is_map(head) ->
        IO.inspect(record_list, label: "8 - [%{record_list}]")
        # payload |> Enum.with_index(fn rec, index -> Map.merge(rec, %{id: index + 1}) end)
        handle_query_result_chain(payload, options)

      [] ->
        IO.inspect("", label: "8 - []")
        []
    end
  end

  def handle_soa_record_data({mname, rname, serial, refresh, retry, expire, minimum} = data)
      when is_tuple(data) do
    %{
      mname: mname,
      rname: rname,
      serial: serial,
      refresh: refresh,
      retry: retry,
      expire: expire,
      minimum: minimum
    }
  end

  def handle_mx_record_data({priority, remaining_data} = data) when is_tuple(data) do
    %{priority: priority, data: remaining_data}
  end

  def handle_aaaa_record_data(data) when is_tuple(data) do
    data
    |> Tuple.to_list()
    |> Enum.map(fn int -> Integer.to_string(int, 16) end)
    |> Enum.join(".")
  end

  def handle_a_record_data(data) when is_tuple(data) do
    data
    |> Tuple.to_list()
    |> Enum.join(".")
  end

  def handle_record_data(data), do: Kernel.to_string(data)
end

Get DKIM, DMARC and SPF records

# {dss_domain_scan, 0} =
#   System.cmd(
#     "bash",
#     [
#       "-lc",
#       "#{dss_binary} scan #{DomainName.name(verified_domain)} --advise --checkTls --format json"
#     ],
#     stderr_to_stdout: true
#   )

{dss_domain_scan, 0} =
  {"{\"scanResult\":{\"domain\":\"magicpillsmovie.com\",\"elapsed\":1422,\"dkim\":\"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1QuexI9cwivkCoREvb6obK6BjbNnY4j/oqkEHZQSwyojtVL1EMjcuDk5vobxnIq+jsbhJjkm3CSA9KHDcberkxxXliewzdxF77QAbUvdU5eNxexEcXdGMsrjnCevlg4k4Kjcrq0Htw+R9jFMM52lW4eJ3pnCsG1Cf9JOK73T9iN1UIOin6E0I2y/1KHsWwXv2fm0Sb7A6PRUoYSCdKITPQV2U6Dz2kusvzB94mA5bVdzCbwhzZ6DpUzmy1PlNWbXBodyZIwoFKoUJ1GpWoUTVhZ97c3aHZ4O1LQwX571o/KZgagurzX9vkmegT6GsOL3BLI46kktHgt3iA3C5oKdKwIDAQABv=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1QuexI9cwivkCoREvb6obK6BjbNnY4j/oqkEHZQSwyojtVL1EMjcuDk5vobxnIq+jsbhJjkm3CSA9KHDcberkxxXliewzdxF77QAbUvdU5eNxexEcXdGMsrjnCevlg4k4Kjcrq0Htw+R9jFMM52lW4eJ3pnCsG1Cf9JOK73T9iN1UIOin6E0I2y/1KHsWwXv2fm0Sb7A6PRUoYSCdKITPQV2U6Dz2kusvzB94mA5bVdzCbwhzZ6DpUzmy1PlNWbXBodyZIwoFKoUJ1GpWoUTVhZ97c3aHZ4O1LQwX571o/KZgagurzX9vkmegT6GsOL3BLI46kktHgt3iA3C5oKdKwIDAQAB\",\"dmarc\":\"v=DMARC1;p=reject TTL: 1 hour\",\"mx\":[\"mx.runbox.com.\"],\"ns\":[\"dns1.p04.nsone.net.\",\"dns2.p04.nsone.net.\",\"dns3.p04.nsone.net.\",\"dns4.p04.nsone.net.\"],\"spf\":\"v=spf1 include:spf.runbox.com include:spf.mailjet.com +mx ~all\"},\"advice\":{\"bimi\":[\"We couldn't detect any active BIMI record for your domain. Please visit https://dmarcguide.globalcyberalliance.org to fix this.\"],\"dkim\":[\"DKIM is setup for this email server. However, if you have other 3rd party systems, please send a test email to confirm DKIM is setup properly.\"],\"dmarc\":[\"Invalid DMARC policy specified, the record must be p=none/p=quarantine/p=reject.\",\"Consider specifying a 'rua' tag for aggregate reporting.\",\"Consider specifying an 'fo' tag to define the condition for generating failure reports. Default is '0' (report if both SPF and DKIM fail).\",\"Consider specifying a 'ruf' tag for forensic reporting.\",\"Subdomain policy isn't specified, they'll default to the main policy instead.\"],\"domain\":[\"Your domain is using TLS 1.3, no further action needed!\"],\"mx\":[\"You have a single mail server setup, but it's recommended that you have at least two setup in case the first one fails.\",\"mx.runbox.com: Failed to reach domain before timeout\"],\"spf\":[\"SPF seems to be setup correctly! No further action needed.\"]}}\n",
   0}
parsed_dss_domain_scan =
  dss_domain_scan
  |> Jason.decode!()
%{
  "advice" => %{
    "bimi" => bimi_advice,
    "dkim" => dkim_advice,
    "dmarc" => dmarc_advice,
    "domain" => domain_advice,
    "mx" => mx_advice,
    "spf" => spf_advice
  },
  "scanResult" => %{
    "dkim" => dkim_scan_result,
    "dmarc" => dmarc_scan_result,
    "spf" => spf_scan_result
  }
} = parsed_dss_domain_scan

Structuring records in a subdomain tree

The domain name is not a standard DNS record (from our point of view), so we will give it a unique structure, holding the most important information we wish to report about it. The DomainName.Information structure tas care of organising this information for later retrieval.

defmodule ZoneRootDomain do
  defstruct name: "",
            display_name: "",
            registrar: "",
            nameservers: [],
            created_at: nil,
            updated_at: nil,
            expires_at: nil,
            query_tool: "",
            zone_tree: []
end

To make reading and traversal of our zone easier, a DnsRecord structure will store all the relevant information for each record.

defmodule DnsRecord do
  defstruct id: nil,
            name: nil,
            display_name: "",
            inside_zone: false,
            weight: 0,
            type: nil,
            specialised_type: nil,
            data: "",
            structured_data: %{},
            ttl: nil,
            query_tool: "",
            answer_source: "",
            child_records: []

  def construct_dns_record(dns_record) when is_map(dns_record) do
    %DnsRecord{
      id: Map.get(dns_record, :id, nil),
      name: Map.get(dns_record, :name, nil),
      display_name: Map.get(dns_record, :display_name, ""),
      inside_zone: dns_record.inside_zone,
      weight: Map.get(dns_record, :weight, 0),
      type: dns_record.type,
      specialised_type: Map.get(dns_record, :specialised_type, nil),
      data: Map.get(dns_record, :data),
      structured_data: Map.get(dns_record, :structured_data, %{}),
      ttl: dns_record.ttl,
      child_records: Map.get(dns_record, :child_records, []),
      query_tool: Map.get(dns_record, :query_tool, ""),
      answer_source: Map.get(dns_record, :answer_source, "")
    }
  end
end
defmodule Dns.ZoneTree do
  def define_root_of_tree(parent_domain) do
    %Whois.Record{
      domain: domain,
      nameservers: nameservers,
      registrar: registrar,
      created_at: created_at,
      updated_at: updated_at,
      expires_at: expires_at
    } =
      DomainName.Querying.get_critical_domain_info(parent_domain)

    %ZoneRootDomain{
      name: DomainName.new!(domain),
      display_name: DomainName.name(DomainName.new!(domain)),
      registrar: registrar,
      created_at: created_at,
      updated_at: updated_at,
      expires_at: expires_at,
      nameservers: nameservers,
      query_tool: "whois: Elixir WHOIS client and parser",
      zone_tree: []
    }
  end

  def construct_dns_zone_tree(fqdn, options \\ []) do
    IO.inspect(options, label: "### Options")

    [
      (
        IO.inspect("Getting SOA records")
        get_soa_zone_record(fqdn, options)
      ),
      (
        IO.inspect("Getting NS records")
        get_ns_zone_records(fqdn, options)
      ),
      (
        IO.inspect("Getting A records")
        get_a_zone_records(fqdn, options)
      ),
      (
        IO.inspect("Getting AAAA records")
        get_aaaa_zone_records(fqdn, options)
      ),
      (
        IO.inspect("Getting MX records")
        get_mx_zone_records(fqdn, options)
      ),
      (
        IO.inspect("Getting TXT records")
        get_txt_zone_records(fqdn, options)
      ),
      (
        IO.inspect("Getting DMARC records")

        get_dmarc_record(fqdn, options)
      ),
      (
        IO.inspect("Getting DKIM records")

        get_dkim_records(fqdn, options)
      )
    ]

    # |> Enum.sort_by(fn rec -> {rec.weight, rec.type, rec.name, rec.data} end)
  end

  def get_soa_zone_record(fqdn, options \\ []) do
    fqdn
    |> Dns.Query.get_soa_records()
    |> Dns.Core.handle_record_payload(options)
  end

  def get_ns_zone_records(fqdn, options \\ []) do
    fqdn
    |> Dns.Query.get_ns_records()
    |> Dns.Core.handle_record_payload(options)
  end

  def get_mx_zone_records(fqdn, options \\ []) do
    fqdn
    |> Dns.Query.get_mx_records()
    |> Dns.Core.handle_record_payload(options)
  end

  def get_a_zone_records(fqdn, options \\ []) do
    fqdn
    |> Dns.Query.get_a_records()
    |> Dns.Core.handle_record_payload(options)
  end

  def get_aaaa_zone_records(fqdn, options \\ []) do
    fqdn
    |> Dns.Query.get_aaaa_records()
    |> Dns.Core.handle_record_payload(options)
  end

  def get_cname_zone_records(
        subdomain_list,
        options \\ []
      )
      when is_list(subdomain_list) do
    subdomain_list
    |> Enum.map(fn %{host: host} = rec ->
      Map.merge(rec, %{
        domain: host,
        host_labels:
          Dns.Core.get_host_name_without_domain(DomainName.new!(host), options[:zone_root_domain])
          |> Dns.Core.get_subdomain_labels()
      })
    end)
    |> Enum.map(fn rec ->
      construct_cname_tree(rec, rec.host_labels, [], "", options)
    end)

    # |> Enum.map(fn %{host: host} = rec ->
    #   resolve_for_cname_or_a(host) |> Enum.map(fn irec -> Map.merge(irec, rec) end)
    # end)
    # |> List.flatten()
    # |> Enum.with_index(fn rec, index -> Map.merge(rec, %{id: index + 1}) end)

    # |> Enum.map(fn res ->
    #   construct_cname_record(
    #     res,
    #     host_name: Dns.Core.get_host_name_without_domain(parent_domain, host),
    #     source: source,
    #     tool: tool
    #   )
    # end)
  end

  def construct_cname_tree(_rec, [], [], _acc, _options \\ []), do: []

  def construct_cname_tree(rec, [head | []], child_records, acc, options) do
    host_name = DomainName.join!([head, acc])
    fqdn = DomainName.join!(DomainName.name(host_name), options[:zone_root_domain])

    IO.inspect(acc, label: "Acc")
    IO.inspect(host_name, label: "Host")
    IO.inspect(fqdn, label: "FQDN")

    Map.merge(rec, %{
      host_name: host_name,
      fqdn: fqdn,
      child_records:
        Dns.Query.get_cname_record(fqdn)
        |> Dns.Query.resolves_to_already_seen?([options[:zone_root_domain]])
        |> case do
          true ->
            [
              fqdn
              |> Dns.Query.get_cname_record()
              |> Dns.Core.handle_record_payload(options)
            ]

          false ->
            (Dns.ZoneTree.construct_dns_zone_tree(fqdn, options) ++
               [
                 [
                   construct_cname_tree(
                     rec,
                     child_records,
                     [],
                     DomainName.name(host_name),
                     options
                   )
                 ]
               ])
            |> Enum.reject(fn i -> i == [] end)
        end
    })
  end

  def construct_cname_tree(rec, [head | tail], child_records, acc, options),
    do: construct_cname_tree(rec, [head], tail, acc, options)

  # Deprecated
  def resolve_for_cname_or_a(host) do
    parsed_host = DomainName.new!(host)

    cname_res = Dns.Query.get_record_info(parsed_host, :cname)

    case cname_res do
      [] ->
        a_res = Dns.Query.get_record_info(parsed_host, :a)

        case a_res do
          [] -> nil
          _ -> a_res
        end

      _ ->
        cname_res
    end
  end

  def resolve_chain(parent_domain, [record | []], child_records) do
    record
  end

  def resolve_chain(parent_domain, [head | tail], child_records),
    do: resolve_chain(parent_domain, [head], tail)

  # Deprecated
  def resolve_cname_chain(parent_domain, record, []) do
    case record.type do
      _ ->
        nil
        #     :cname ->
        #       construct_cname_record(record,
        #         host_name: Dns.Core.get_host_name_without_domain(parent_domain, record.domain),
        #         child_records: []
        #       )

        #     :txt ->
        #       record
        #       |> Map.merge(%{id: record.domain, specialised_type: "DKIM"})
        #       |> construct_txt_record(id_prefix: "")

        #     _ ->
        #       nil
        #   end
    end
  end

  def resolve_cname_chain(parent_domain, record, next_resolution) do
    [next_hd | next_tl] = next_resolution

    # case record.type do
    #   :cname ->
    #     construct_cname_record(record,
    #       host_name: Dns.Core.get_host_name_without_domain(parent_domain, record.domain),
    #       child_records: resolve_cname_chain(parent_domain, next_hd, next_tl)
    #     )

    #   _ ->
    #     nil
    # end
  end

  def get_txt_zone_records(fqdn, options \\ []) do
    IO.inspect(options, label: "### Options")

    fqdn
    |> Dns.Query.get_txt_records()
    |> Dns.Core.handle_record_payload(options)

    # |> Enum.with_index(fn rec, index ->
    #   Map.merge(rec, %{id: index + 1, special_type: Dns.Core.classify_txt_record_type(rec)})
    # end)
    # |> Enum.map(fn rec -> construct_txt_record(rec) end)
  end

  def get_dmarc_record(fqdn, options \\ []) do
    options = Keyword.put(options, :query_intent, :dmarc)

    fqdn
    |> Dns.Query.get_dmarc_record(options[:dmarc_subdomain_label])
    |> Dns.Core.handle_record_payload(options)

    # |> Enum.with_index(fn rec, index ->
    #   Map.merge(rec, %{id: index + 1, special_type: "DMARC"})
    # end)
    # |> Enum.map(fn rec ->
    #   construct_txt_record(rec, id_prefix: "dmarc-")
    # end)
  end

  def get_dkim_records(fqdn, options \\ []) do
    options = Keyword.put(options, :query_intent, :dkim)
    IO.inspect(options, label: "Options")

    fqdn
    |> Dns.Query.get_dkim_records(
      options[:dkim_subdomain_label],
      options[:known_dkim_selector_list]
    )
    |> Dns.Core.handle_record_payload(options)

    # |> Enum.map(fn
    #   [head | []] ->
    #     head
    #     |> Map.merge(%{id: head.domain, special_type: "DKIM"})
    #     |> construct_txt_record(id_prefix: "")

    #   [head | tail] ->
    #     case head.type do
    #       :cname ->
    #         resolve_cname_chain(parent_domain, head, tail)

    #       _ ->
    #         nil
    #     end

    #   [] ->
    #     []
    # end)
    # |> Enum.filter(fn i -> not is_nil(i) end)
  end
end
# zone_root_record = Dns.ZoneTree.define_root_of_tree(verified_domain)
zone_root_record =
  %ZoneRootDomain{
    name: DomainName.new!("magicpillsmovie.com"),
    display_name: "magicpillsmovie.com",
    registrar: "GoDaddy.com, LLC",
    nameservers: [],
    created_at: ~N[2015-09-24 23:12:58],
    updated_at: ~N[2023-09-25 10:45:43],
    expires_at: ~N[2024-09-25 04:12:58],
    query_tool: "whois: Elixir WHOIS client and parser",
    zone_tree: []
  }
x_zone_tree =
  zone_root_record
  |> Map.merge(%{
    zone_tree: [
      Dns.ZoneTree.construct_dns_zone_tree(
        verified_domain,
        zone_root_domain: verified_domain,
        parent_domain: verified_domain,
        dmarc_subdomain_label: dmarc_subdomain_label,
        dkim_subdomain_label: dkim_subdomain_label,
        known_dkim_selector_list: known_dkim_selector_list
      ),
      Dns.ZoneTree.get_cname_zone_records(
        enumerated_subdomains,
        zone_root_domain: verified_domain,
        parent_domain: verified_domain,
        dmarc_subdomain_label: dmarc_subdomain_label,
        dkim_subdomain_label: dkim_subdomain_label,
        known_dkim_selector_list: known_dkim_selector_list
      )
    ]
  })
defmodule Q do
  # The next children list is empty, return current record and end
  def resolve_chain(current_node, [] = _subsequent_nodes)
      when is_struct(current_node, DnsRecord) do
    IO.inspect("==> Resolve chain: 0. no next records")
    current_node
  end

  # Worker, placing the (head of) tail node into the current child's child_nodes
  # recursively call on the new next children list
  def resolve_chain(current_node, [next_node | subsequent_nodes])
      when is_struct(current_node, DnsRecord) and is_struct(next_node, DnsRecord) do
    IO.inspect("==> Resolve chain: 1. working step")

    cond do
      current_node.type not in [:a, :aaaa, :soa, :txt] ->
        current_node
        |> Map.merge(%{
          child_records: [
            resolve_chain(next_node, subsequent_nodes)
          ]
        })

      true ->
        current_node
    end
  end

  # Parent and current child domains match
  def resolve_chain(
        [
          %{structured_data: %{fqdn: next_domain}} = current_node,
          %{name: next_domain} = next_node | tail
        ],
        []
      ) do
    IO.inspect("==> Resolve chain: 2. parent-child nodes")

    resolve_chain(current_node, [next_node | tail])
  end

  # The current and next child domains match, enter sibling mode
  # Since the next two records are siblings, all remaining records are siblings
  # Sort by the `data` field and apply an index function, providing them with a unique ID
  def resolve_chain([%{name: name}, %{name: name} | _tail] = sibling_nodes, []) do
    IO.inspect("==> Resolve chain: 3. sibling mode")

    Enum.map(sibling_nodes, fn node -> node end)
    |> List.flatten()
    |> Enum.sort_by(fn rec -> rec.data end)
    |> Enum.with_index(fn rec, index ->
      Dns.Core.index_record_dispatcher(rec, index + 1)
    end)
  end
end
q_list = [1, 2, 3]
list when is_list(q_list) = q_list
list = [[%{a: 1}], %{a: 2}]

[head | tail] = list

List.pop_at(list, 0)

Reaching in to each map, we can take out the value for each host key, returning a simple list of strings.

subdomain_list =
  enumerated_subdomains
  |> Enum.map(fn %{host: host} -> host end)
  |> Enum.map(fn i -> i |> DomainName.new!() |> Dns.Query.get_a_records() end)

# |> Enum.map(fn host -> DomainName.new!(host) |> Dns.Query.get_soa_records() end)

What we’ve now got is a list of subdomains, with fully-qualified domain names (FQDN). This is very wordy, as each subdomain is fully specified, containing the domain name in full. We don’t want that much information, stripping the zone parent domain name from each subdomain, leaving only the actual subdomain name itself.

subdomains =
  subdomain_list

# |> Enum.map(fn d ->
#   d
#   |> DomainName.new!()
#   |> DomainName.without_suffix(verified_domain)
#   |> case do
#     {:ok, s} -> DomainName.name(s)
#   end
# end)
# |> Enum.filter(fn i -> i != "" end)
dns_zone_tree =
  Dns.ZoneTree.construct_dns_zone_tree(
    verified_domain,
    enumerated_subdomains,
    dmarc_subdomain_label,
    dkim_subdomain_label,
    known_dkim_selector_list
  )

DNS record list

defmodule DnsZone.RecordList do
  def dns_zone_table_header() do
    """
    | Name |  TTL | Type | Value |
    | :--- | ---: | :--- | :---  | 
    """
  end

  def construct_dns_table_records(dns_zone_tree) do
    dns_zone_tree
    |> Enum.map(fn rec ->
      "| #{rec.name} | #{rec.ttl} | #{String.upcase(Kernel.to_string(rec.type))} | #{rec.data} |"
    end)
    |> Enum.join("\n")
  end

  def construct_dns_zone_table(table_header, tabular_records) do
    table_header <> tabular_records
  end
end
Kino.Markdown.new(
  DnsZone.RecordList.construct_dns_zone_table(
    DnsZone.RecordList.dns_zone_table_header(),
    DnsZone.RecordList.construct_dns_table_records(dns_zone_tree)
  )
)

Visualising the DNS zone

With the information now gathered, we can create a usable flowchart diagram, presenting the zone visually. We will use Mermaid for this, as it is an easy to use tool for programmatic diagram creation.

To begin with, let’s define invariant sections of the diagram definition: initialisation configuration, diagram type and title, and styling. By doing this up front, we’re developing the diagram definition in easily managable segments, and defining the diagram in a literate programming style.

dns_zone_diagram_configuration =
  """
  %%{ init: { 
    'theme': 'base',
    'themeVariables': {
      'clusterBkg': '#81a1c144;'
    },
    'fontFamily': 'Calluna Sans, Helvetica, Trebuchet MS, Verdana, Arial, Sans-Serif',
    'flowchart': { 
      'curve': 'linear',
      'useMaxWidth': 'true'
     } 
    } }%%
  """
diagram_type = "flowchart LR"

In the following step, we insert the list of edges by order of definition.

diagram_styles =
  """
  classDef parent-node fill:turquoise,stroke:#b48ead,stroke-width:1px;
  classDef parent-zone fill:#81a1c144,stroke:#b48ead,stroke-width:1px;
  classDef cname-record fill:#88c0d088,stroke:#b48ead,stroke-width:1px;
  classDef ns-record fill:#bf616a44,stroke:#b48ead,stroke-width:1px;
  classDef mx-record fill:#d0877099,stroke:#b48ead,stroke-width:1px;
  classDef txt-record fill:#ebcb8b88,stroke:#b48ead,stroke-width:1px;
  classDef a-record fill:#a3be8c99,stroke:#b48ead,stroke-width:1px;
  """

First, we need a module, ZoneTree to convert this data into Mermaid flowchart definition code.

defmodule ZoneTree do
  def define_parent_node(parent_domain) do
    strf_format_string = "%Y-%m-%d"

    domain_critical_info = DomainNameQuerying.get_critical_domain_info(parent_domain)
    date_created = domain_critical_info.created_at |> Calendar.strftime(strf_format_string)
    date_updated = domain_critical_info.updated_at |> Calendar.strftime(strf_format_string)
    date_expires = domain_critical_info.expires_at |> Calendar.strftime(strf_format_string)

    "parent-node([\"`#{DomainName.name(parent_domain)}
    —
    Created #{date_created}
    Updated #{date_updated}
    Expiry #{date_expires}`\"]):::parent-node;"
  end

  def define_dns_zone(
        parent_domain,
        dmarc_subdomain_label,
        dkim_subdomain_label,
        known_dkim_selector_list
      ) do
    zone_tree =
      Dns.ZoneTree.construct_zone_tree(
        parent_domain,
        dmarc_subdomain_label,
        dkim_subdomain_label,
        known_dkim_selector_list
      )

    zone_records =
      for rec <- zone_tree do
        construct_zone_records("parent-node", rec)
        #     "parent-node #{define_link_type(rec.type)} #{rec.id}([\"`#{rec.display_name} 
        #     —
        #     TTL #{rec.ttl}
        #     #{if rec.type === :mx, do: rec.priority} #{rec.data}`\"])#{attach_style_class(rec.type)}"
      end

    # zone_records
    # |> Enum.join(";")
  end

  defp construct_zone_records(parent_node, record) do
    "#{parent_node} #{define_link_type(record.type)} #{record.id}([\"`#{record.display_name} 
        —
        TTL #{record.ttl}
        #{if record.type === :mx, do: record.priority} #{if record.special_type == "DKIM", do: String.slice(record.data, 0..25) <> "...", else: record.data}`\"])#{attach_style_class(record.type)};
        #{if Map.get(record, :children, []) != [], do: construct_zone_records(record.id, record.children)}"
  end

  def define_dns_sub_zones(parent_domain, subdomain_list) do
    zone_tree = Dns.ZoneTree.construct_cname_zone_trees(parent_domain, subdomain_list)

    zone_records =
      for rec <- zone_tree do
        "parent-node #{define_link_type(rec.type)} #{rec.id}([\"`#{rec.display_name} #{if rec.type === :mx, do: ": " <> rec.name}
        —
        TTL #{rec.ttl}
        #{if rec.type === :mx, do: rec.priority} #{if rec.special_type == "DKIM", do: String.slice(rec.data, 0..25) <> "...", else: rec.data}`\"])#{attach_style_class(rec.type)}"
      end

    zone_records
    |> Enum.join(";")
  end

  def define_link_type(type) when is_atom(type) do
    case type do
      :a -> "---"
      :ns -> "-.-"
      :mx -> "---"
      :txt -> "---"
      :cname -> "---"
      _ -> "---"
    end
  end

  def attach_style_class(type) when is_atom(type) do
    case type do
      :a -> ":::a-record"
      :ns -> ":::ns-record"
      :mx -> ":::mx-record"
      :txt -> ":::txt-record"
      :cname -> ":::cname-record"
      _ -> ""
    end
  end

  def enumerate_relationships(zone_tree) do
    edges =
      for i <- 0..(length(zone_tree) - 1) do
        "#{i}"
      end

    Enum.join(edges, ",")
  end
end

The construct_tree/1 function takes the parent domain and the list of subdomains, converting them to a node-and-relationship definition string.

parent_node = ZoneTree.define_parent_node(verified_domain)

parent_zone =
  ZoneTree.define_dns_zone(
    verified_domain,
    dmarc_subdomain_label,
    dkim_subdomain_label,
    known_dkim_selector_list
  )

# """
# subgraph parent-zone [#{DomainName.name(verified_domain)} zone];

#   direction LR;

# end;
# """

zones =
  ZoneTree.define_dns_sub_zones(verified_domain, enumerated_subdomains)

Feeding this definition string into the scaffolded diagram definition provides us with the complete diagram definition.

dns_zone_diagram =
  """
  #{dns_zone_diagram_configuration}
  #{diagram_type};

  #{parent_node}

  #{parent_zone}

  #{zones};

  #{diagram_styles}
  """
Kino.Markdown.new(dns_zone_diagram)

The diagram definition, stored in the dns_zone_diagram variable, can now be used to draw the DNS zone diagram.

Kino.Mermaid.new(dns_zone_diagram)

Audit summary and advice

Kino.Markdown.new("""
### BIMI

#{Enum.join(bimi_advice, "\n\n")}


### DKIM

#{Enum.join(dkim_advice, "\n\n")}

**DKIM record** _`#{String.slice(dkim_scan_result, 0..60)}...`_


### DMARC

#{Enum.join(dmarc_advice, "\n\n")}

**DMARC record** _`#{dmarc_scan_result}`_

### Domain

#{Enum.join(domain_advice, "\n\n")}


### MX

#{Enum.join(mx_advice, "\n\n")}


### SPF

#{Enum.join(spf_advice, "\n\n")}

**SPF record** _`#{spf_scan_result}`_
""")