lib/vintage_net/resolver/resolv_conf.ex

defmodule VintageNet.Resolver.ResolvConf do
  @moduledoc """
  Utilities for creating resolv.conf file contents
  """
  alias VintageNet.IP

  # Convert name resolver configurations into
  # /etc/resolv.conf contents

  @typedoc "Name resolver settings for an interface"
  @type entry :: %{
          priority: integer(),
          domain: String.t(),
          name_servers: [:inet.ip_address()]
        }

  @typedoc "All entries"
  @type entry_map :: %{VintageNet.ifname() => entry()}
  @type additional_name_servers :: [:inet.ip_address()]
  @type name_server_info :: %{address: :inet.ip_address(), from: [:global | VintageNet.ifname()]}

  @doc """
  Convert the name server information to resolv.conf contents
  """
  @spec to_config(entry_map(), additional_name_servers()) :: iolist()
  def to_config(entries, additional_name_servers) do
    domains = Enum.reduce(entries, %{}, &add_domain/2)
    name_servers = to_name_server_list(entries, additional_name_servers)

    [
      "# This file is managed by VintageNet. Do not edit.\n\n",
      Enum.map(domains, &domain_text/1),
      Enum.map(name_servers, &name_server_text/1)
    ]
  end

  @spec to_name_server_list(entry_map(), additional_name_servers) :: [name_server_info()]
  def to_name_server_list(entries, additional_name_servers) do
    # This is trickier than it looks since we want the ordering of name
    # servers to be deterministic. Here are the rules:
    #
    # 1. No duplicates entries (interfaces can supply similar entries to this is a common case)
    # 2. Entries listed in the order supplied if possible. It's possible that
    #    that two interfaces specify the same entries in a different order, so don't try to fix that.
    # 3. Global entries are always first

    Enum.reduce(entries, %{}, &add_name_servers(&2, &1))
    |> add_name_servers({:global, %{name_servers: additional_name_servers}})
    |> Enum.map(&sort_ifname_lists/1)
    |> Enum.sort(&name_server_lte/2)
    |> Enum.map(fn {address, ifname_tuples} ->
      %{address: address, from: Enum.map(ifname_tuples, fn {ifname, _ix} -> ifname end)}
    end)
  end

  defp add_domain({ifname, %{domain: domain}}, acc) when is_binary(domain) and domain != "" do
    Map.update(acc, domain, [ifname], fn ifnames -> [ifname | ifnames] end)
  end

  defp add_domain(_other, acc), do: acc

  defp add_name_servers(name_servers, {ifname, %{name_servers: servers}}) do
    indexed_servers = Enum.with_index(servers)
    Enum.reduce(indexed_servers, name_servers, &add_name_server(ifname, &1, &2))
  end

  defp add_name_server(ifname, {server, index}, acc) do
    ifname_index = {ifname, index}
    Map.update(acc, server, [ifname_index], fn ifnames -> [ifname_index | ifnames] end)
  end

  defp sort_ifname_lists({ns, ifname_index_list}) do
    sorted_list = Enum.sort(ifname_index_list, &ifname_ix_compare/2)
    {ns, sorted_list}
  end

  defp ifname_ix_compare({ifname, ix1}, {ifname, ix2}), do: ix1 <= ix2
  defp ifname_ix_compare({:global, _ix1}, _not_global), do: true
  defp ifname_ix_compare(_not_global, {:global, _ix2}), do: false
  defp ifname_ix_compare({ifname1, _ix1}, {ifname2, _ix2}), do: ifname1 <= ifname2

  defp name_server_lte(
         {_ns1, ifname_index_list1} = a,
         {_ns2, ifname_index_list2} = b,
         ifname \\ :global
       ) do
    index1 = find_ifname_index(ifname_index_list1, ifname)
    index2 = find_ifname_index(ifname_index_list2, ifname)

    case {index1, index2} do
      {nil, nil} ->
        # The lists are sorted by this point so arbitrarily pick
        # the first one's list to choose the ifname to compare.
        # Note that the recursive call is guaranteed not to hit
        # this case again since index1 will be found.
        [{first_ifname, _index} | _] = ifname_index_list1
        name_server_lte(a, b, first_ifname)

      {nil, _not_nil} ->
        false

      {_not_nil, nil} ->
        true

      {_not_nil, _not_nil2} ->
        index1 <= index2
    end
  end

  defp find_ifname_index([], _ifname), do: nil
  defp find_ifname_index([{ifname, index} | _rest], ifname), do: index
  defp find_ifname_index([_no | rest], ifname), do: find_ifname_index(rest, ifname)

  defp domain_text({domain, ifnames}),
    do: ["search ", domain, " # From ", Enum.join(ifnames, ","), "\n"]

  defp name_server_text(%{address: address, from: ifnames}) do
    ["nameserver ", IP.ip_to_string(address), " # From ", Enum.join(ifnames, ","), "\n"]
  end
end