lib/vintage_net/name_resolver.ex

defmodule VintageNet.NameResolver do
  use GenServer
  require Logger
  alias VintageNet.IP
  alias VintageNet.Resolver.ResolvConf

  @moduledoc """
  This module manages the contents of "/etc/resolv.conf".

  This file is used by the C standard library and by Erlang for resolving
  domain names.  Since both C programs and Erlang can do resolution, debugging
  problems in this area can be confusing due to varying behavior based on who's
  resolving at the time. See the `/etc/erl_inetrc` file on the target to review
  Erlang's configuration.

  This module assumes exclusive ownership on "/etc/resolv.conf", so if any
  other code in the system tries to modify the file, their changes will be lost
  on the next update.

  It is expected that each network interface provides a configuration. This
  module will track configurations to network interfaces so that it can reflect
  which resolvers are around. Resolver order isn't handled.
  """

  defmodule State do
    @moduledoc false
    defstruct [:path, :entries, :additional_name_servers]
  end

  @doc """
  Start the resolv.conf manager.

  Accepted args:

  * `resolvconf` - path to the resolvconf file
  * `additional_name_servers` - list of additional servers
  """
  @spec start_link(keyword) :: GenServer.on_start()
  def start_link(args) do
    relevant_args = Keyword.take(args, [:resolvconf, :additional_name_servers])
    GenServer.start_link(__MODULE__, relevant_args, name: __MODULE__)
  end

  @doc """
  Stop the resolv.conf manager.
  """
  @spec stop() :: :ok
  def stop() do
    GenServer.stop(__MODULE__)
  end

  @doc """
  Set the search domain and name server list for the specified interface.

  This replaces any entries in the `/etc/resolv.conf` for this interface.
  """
  @spec setup(String.t(), String.t() | nil, [VintageNet.any_ip_address()]) :: :ok
  def setup(ifname, domain, name_servers) do
    GenServer.call(__MODULE__, {:setup, ifname, domain, name_servers})
  end

  @doc """
  Clear all entries in "/etc/resolv.conf" that are associated with
  the specified interface.
  """
  @spec clear(String.t()) :: :ok
  def clear(ifname) do
    GenServer.call(__MODULE__, {:clear, ifname})
  end

  @doc """
  Completely clear out "/etc/resolv.conf".
  """
  @spec clear_all() :: :ok
  def clear_all() do
    GenServer.call(__MODULE__, :clear_all)
  end

  ## GenServer

  @impl GenServer
  def init(args) do
    resolvconf_path = Keyword.get(args, :resolvconf)

    additional_name_servers =
      Keyword.get(args, :additional_name_servers, [])
      |> Enum.reduce([], &ip_to_tuple_safe/2)
      |> Enum.reverse()

    state = %State{
      path: resolvconf_path,
      entries: %{},
      additional_name_servers: additional_name_servers
    }

    write_resolvconf(state)
    {:ok, state}
  end

  @impl GenServer
  def handle_call({:setup, ifname, domain, name_servers}, _from, state) do
    servers = Enum.map(name_servers, &IP.ip_to_tuple!/1)
    ifentry = %{domain: domain, name_servers: servers}

    state = %{state | entries: Map.put(state.entries, ifname, ifentry)}
    write_resolvconf(state)
    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_call({:clear, ifname}, _from, state) do
    state = %{state | entries: Map.delete(state.entries, ifname)}
    write_resolvconf(state)
    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_call(:clear_all, _from, state) do
    state = %{state | entries: %{}}
    write_resolvconf(state)
    {:reply, :ok, state}
  end

  defp write_resolvconf(%State{
         path: path,
         entries: entries,
         additional_name_servers: additional_name_servers
       }) do
    File.write!(path, ResolvConf.to_config(entries, additional_name_servers))
  end

  @spec ip_to_tuple_safe(VintageNet.any_ip_address(), [:inet.ip_address()]) :: [
          :inet.ip_address()
        ]
  defp ip_to_tuple_safe(ip, acc) do
    case IP.ip_to_tuple(ip) do
      {:error, reason} ->
        Logger.error("Failed to parse IP address: #{inspect(ip)} (#{reason})")
        acc

      {:ok, ip} ->
        [ip | acc]
    end
  end
end