lib/mdns_lite/dns_bridge.ex

defmodule MdnsLite.DNSBridge do
  @moduledoc """
  DNS server that responds to mDNS queries

  This is a simple DNS server that can be used to resolve mDNS queries
  so that the rest of Erlang and Elixir can seamlessly use mDNS. To use
  this, you must enable the `:dns_bridge_enabled` option and then make the
  first DNS server be this server's IP address and port.

  This DNS server can either return an error or recursively look up a non-mDNS record
  depending on how it's configured. Erlang's DNS resolver currently has an issue
  with the error strategy so it can't be used.

  Configure this using the following application environment options:

  * `:dns_bridge_enabled` - set to true to enable the bridge
  * `:dns_bridge_ip` - IP address in tuple form for server (defaults to `{127, 0, 0, 53}`)
  * `:dns_bridge_port` - UDP port for server (defaults to 53)
  * `:dns_bridge_recursive` - set to true to recursively look up non-mDNS queries
  """

  use GenServer

  import MdnsLite.DNS
  alias MdnsLite.{DNS, Options}
  require Logger

  @doc false
  @spec start_link(MdnsLite.Options.t()) :: GenServer.on_start()
  def start_link(%Options{} = init_args) do
    GenServer.start_link(__MODULE__, init_args, name: __MODULE__)
  end

  ##############################################################################
  #   GenServer callbacks
  ##############################################################################
  @impl GenServer
  def init(opts) do
    if opts.dns_bridge_enabled do
      {:ok, udp} = :gen_udp.open(opts.dns_bridge_port, udp_options(opts))

      {:ok,
       %{
         udp: udp,
         recursive: opts.dns_bridge_recursive,
         our_ip_port: {opts.dns_bridge_ip, opts.dns_bridge_port}
       }}
    else
      :ignore
    end
  end

  @impl GenServer
  def handle_info({:udp, _socket, src_ip, src_port, packet}, state) do
    # Decode the UDP packet
    with {:ok, dns_record} <- DNS.decode(packet),
         dns_rec(header: header, qdlist: qdlist) = dns_record,
         # qr is the query/response flag; false (0) = query, true (1) = response
         dns_header(qr: false) <- header do
      # only respond to the first query

      result = MdnsLite.query(hd(qdlist))

      send_response(qdlist, result, dns_record, {src_ip, src_port}, state)
    end

    {:noreply, state}
  end

  @impl GenServer
  def handle_info(_msg, state) do
    {:noreply, state}
  end

  ##############################################################################
  #   Private functions
  ##############################################################################
  defp send_response(
         qdlist,
         %{answer: []},
         dns_rec(header: dns_header(id: id)),
         {dest_address, dest_port},
         state
       ) do
    result =
      if state.recursive do
        try_recursive_lookup(id, qdlist, state.our_ip_port)
      else
        lookup_failure(id, qdlist)
      end

    packet = DNS.encode(result)
    _ = :gen_udp.send(state.udp, dest_address, dest_port, packet)

    :ok
  end

  defp send_response(
         qdlist,
         result,
         dns_rec(header: dns_header(id: id, opcode: opcode, rd: rd)),
         {dest_address, dest_port},
         state
       ) do
    packet =
      dns_rec(
        header: dns_header(id: id, qr: true, opcode: opcode, aa: true, rd: rd, rcode: 0),
        # Query list. Must be empty according to RFC 6762 Section 6.
        qdlist: qdlist,
        # A list of answer entries. Can be empty.
        anlist: result.answer,
        # nslist Can be empty.
        nslist: [],
        # arlist A list of resource entries. Can be empty.
        arlist: result.additional
      )

    dns_record = DNS.encode(packet)
    :gen_udp.send(state.udp, dest_address, dest_port, dns_record)
  end

  defp udp_options(opts) do
    [
      :binary,
      active: true,
      ip: opts.dns_bridge_ip,
      reuseaddr: true
    ]
  end

  defp try_recursive_lookup(id, qdlist, our_ip_port) do
    dns_query(domain: domain, class: class, type: type) = hd(qdlist)

    case :inet_res.resolve(domain, class, type, nameservers: nameservers(our_ip_port)) do
      {:ok, result} ->
        header = dns_rec(result, :header)

        dns_rec(result, header: dns_header(header, id: id))

      {:error, _reason} ->
        lookup_failure(id, qdlist)
    end
  end

  defp nameservers(our_ip_port) do
    :inet_db.res_option(:nameservers)
    |> List.delete(our_ip_port)
  end

  defp lookup_failure(id, qdlist) do
    dns_rec(
      header: dns_header(id: id, qr: 1, aa: 0, tc: 0, rd: true, ra: 0, pr: 0, rcode: 5),
      qdlist: qdlist
    )
  end
end