lib/exagon/zeroconf/mdns/dnssd.ex

# Copyright 2022 Exagon team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0

defmodule Exagon.Zeroconf.Mdns.Dnssd do
  @moduledoc """
  DNS-SD service discovery.

  provides [DNS service discrovery](https://www.rfc-editor.org/rfc/rfc6763) over multicast DNS.
  It uses `Exagon.Zeroconf.Mdns.Dnssd.Server` to listen for DNS-SD relevand records and build a list of discovred services.
  """
  use GenServer
  require Logger

  alias Phoenix.PubSub
  alias Exagon.Zeroconf.Mdns

  @topic_name "Exagon.Zeroconf.Mdns.Dnssd"

  defmodule State do
    @moduledoc false
    defstruct services: %{}, mdns_domain: nil
  end

  defmodule Service do
    @moduledoc """
    Description of DNS-SD service
    """
    defstruct domain: nil,
              instance_name: nil,
              ip4: nil,
              ip6: nil,
              priority: nil,
              weight: nil,
              port: nil,
              target: nil,
              additional_info: nil,
              records: []

    @type t :: %__MODULE__{
            domain: String.t(),
            instance_name: String.t(),
            ip4: :inet.ip4_address(),
            ip6: :inet.ip6_address(),
            priority: integer(),
            weight: integer,
            port: integer,
            target: String.t(),
            additional_info: map(),
            records: list()
          }
    @doc """
    Indicates if a service instance informations are complete.

    Service instance informations comes from PTR, SRV and TXT records. A service instance
    is not complete until these three records are received from the MDNS server.
    """
    @spec is_complete?(Service.t()) :: boolean()
    def is_complete?(struct) do
      [:txt, :srv, :ptr] -- struct.records == []
    end
  end

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  @spec init(any) :: {:ok, %Exagon.Zeroconf.Mdns.Dnssd.State{mdns_domain: any, services: map}}
  def init(_args) do
    mdns_conf = Application.get_env(:exagon_zeroconf, :mdns)
    Mdns.Server.subscribe()

    # Backfill from running server cache
    for resource <- Mdns.Server.dump() do
      # GenServer.cast(__MODULE__, {:record_added, resource})
      Process.send(__MODULE__, {:record_added, resource}, [:noconnect])
    end

    {:ok, %State{services: %{}, mdns_domain: mdns_conf[:domain_name]}}
  end

  @spec query(String.t()) :: list
  @doc """
  Returns the list of currently discovered services.

  List can be filtered by providing a domain list. Example:

  ```
  Exagon.Zeroconf.Mdns.Dnssd.query("_http._tcp.local")
  ```
  """
  def query(namespace \\ ".local") do
    GenServer.call(__MODULE__, {:query, namespace})
  end

  @doc """
  Register a process for receiving notifications on added, removed or changed services

  Notifications use [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html).

  Listeners will receive:
   - `{:service_changed, old_service, service}`, when a service DNS data are updated
   - `{:service_added, service}`, when a new service is discovered
  """
  def subscribe(), do: PubSub.subscribe(:zeroconf_pubsub, @topic_name)

  @doc """
  Dump list of `Service` discovered by the server.

  List of service if organized inside a map under each service domain. The list may contain partially discovered services.
  Use `query/1` to get a list of services with completed discovery.
  """
  def dump() do
    GenServer.call(__MODULE__, :dump)
  end

  @doc """
  Register a new DNS-SD service.

  Registering a DNS-SD service adds new authoritative resources to the `Mdns.Server` which
  are then broadcasted to the network
  """
  @spec add_service(Service.t()) :: :ok
  def add_service(%Service{} = service) do
    GenServer.cast(__MODULE__, {:add_service, service})
  end

  def handle_cast({:add_service, %Service{} = service}, state) do
    resources =
      ([
         %DNS.Resource{
           type: :ptr,
           data: to_charlist(service.instance_name),
           domain: to_charlist(service.domain)
         },
         %DNS.Resource{
           type: :srv,
           domain: to_charlist(service.instance_name),
           data: {service.priority, service.weight, service.port, to_charlist(service.target)}
         },
         %DNS.Resource{
           type: :txt,
           domain: to_charlist(service.instance_name),
           data: Enum.map(service.additional_info, fn {k, v} -> to_charlist("#{k}=#{v}") end)
         }
       ] ++
         if not is_nil(service.ip4) do
           [
             %DNS.Resource{
               type: :a,
               domain: to_charlist(service.target),
               data: service.ip4
             }
           ]
         else
           []
         end ++
         if not is_nil(service.ip6) do
           [
             %DNS.Resource{
               type: :aaaa,
               domain: to_charlist(service.target),
               data: service.ip6
             }
           ]
         else
           []
         end)
      |> Mdns.Server.add_resources()

    {:noreply, state}
  end

  def handle_call(:dump, _from, state) do
    {:reply, state.services, state}
  end

  def handle_call({:query, namespace}, _from, state) do
    services =
      for service <-
            Map.keys(state.services) |> Enum.filter(fn s -> String.ends_with?(s, namespace) end) do
        Map.get(state.services, service)
      end
      |> Enum.concat()
      |> Enum.filter(fn instance -> Service.is_complete?(instance) end)

    {:reply, services, state}
  end

  defp notify_diffs(new_state, old_state) do
    old_instances_names =
      all_completed_instances(old_state) |> Enum.map(fn i -> i.instance_name end)

    new_instances_names =
      all_completed_instances(new_state) |> Enum.map(fn i -> i.instance_name end)

    for instance_name <- new_instances_names -- old_instances_names do
      old_instance = find_completed_instance(instance_name, old_state)
      new_instance = find_completed_instance(instance_name, new_state)

      if is_nil(old_instance) do
        PubSub.broadcast!(
          :zeroconf_pubsub,
          @topic_name,
          {:service_added, new_instance}
        )

        Logger.debug("Service instance added : #{instance_name}")
      else
        if old_instance != new_instance do
          PubSub.broadcast!(
            :zeroconf_pubsub,
            @topic_name,
            {:service_changed, new_instance}
          )

          Logger.debug("Service instance changed : #{instance_name}")
        end
      end
    end

    new_state
  end

  def handle_info({:record_added, resource}, state) do
    {:noreply, handle_resource(resource, state) |> notify_diffs(state)}
  end

  def handle_info({:record_changed, _old_resource, resource}, state) do
    {:noreply, handle_resource(resource, state) |> notify_diffs(state)}
  end

  def handle_info({:record_removed, _resource}, state) do
    {:noreply, state}
  end

  defp handle_resource(%DNS.Resource{type: :ptr, domain: domain, data: data}, state) do
    domain =
      if domain == "_services._dns-sd._udp" <> state.mdns_domain do
        to_string(data)
      else
        to_string(domain)
      end

    instance_name = to_string(domain)

    if String.ends_with?(domain, state.mdns_domain) do
      case find_instance(instance_name, state) do
        nil ->
          %Service{
            domain: domain,
            instance_name: to_string(data),
            records: [:ptr]
          }

        instance ->
          %Service{
            instance
            | domain: domain,
              instance_name: to_string(data),
              records: Enum.uniq([:ptr] ++ instance.records)
          }
      end
      |> add_update_service_instance(state)
    else
      state
    end
  end

  defp handle_resource(
         %DNS.Resource{type: :srv, domain: domain, data: {priority, weight, port, target}},
         state
       ) do
    instance_name = to_string(domain)
    service = find_service(instance_name, state)

    if String.ends_with?(instance_name, state.mdns_domain) do
      if Map.has_key?(state.services, service) do
        case find_instance(instance_name, state) do
          nil ->
            %Service{
              domain: service,
              instance_name: to_string(instance_name),
              priority: priority,
              weight: weight,
              port: port,
              target: to_string(target),
              records: [:srv]
            }

          instance ->
            %Service{
              instance
              | domain: service,
                instance_name: to_string(instance_name),
                priority: priority,
                weight: weight,
                port: port,
                target: to_string(target),
                records: Enum.uniq([:srv] ++ instance.records)
            }
        end
        |> add_update_service_instance(state)
      else
        state
      end
    else
      state
    end
  end

  defp handle_resource(%DNS.Resource{type: :txt, domain: domain, data: data}, state) do
    instance_name = to_string(domain)

    case find_instance(instance_name, state) do
      nil ->
        state

      instance ->
        info =
          Enum.reduce(data, %{}, fn kv, acc ->
            case String.split(to_string(kv), "=", parts: 2) do
              [k, v] -> Map.put(acc, String.downcase(k), String.trim(v))
              _ -> nil
            end
          end)

        %Service{
          instance
          | additional_info: info,
            records: Enum.uniq([:txt] ++ instance.records)
        }
        |> add_update_service_instance(state)
    end
  end

  defp handle_resource(%DNS.Resource{type: type, domain: domain, data: data}, state)
       when type == :a or type == :aaaa do
    instance_name = to_string(domain)

    case find_instance_by_target(instance_name, state) do
      nil ->
        state

      instance ->
        case type do
          :a -> %Service{instance | ip4: data, records: Enum.uniq([type] ++ instance.records)}
          :aaaa -> %Service{instance | ip6: data, records: Enum.uniq([type] ++ instance.records)}
        end
        |> add_update_service_instance(state)
    end
  end

  defp handle_resource(%DNS.Resource{}, state) do
    state
  end

  defp add_update_service_instance(instance, state) do
    instances =
      Map.get(state.services, instance.domain, [])
      |> Enum.filter(fn i -> i.instance_name != instance.instance_name end)

    %State{state | services: Map.put(state.services, instance.domain, [instance] ++ instances)}
  end

  @spec find_service(String.t(), State.t()) :: String.t() | nil
  defp find_service(instance_name, state) do
    Map.keys(state.services)
    |> Enum.find(nil, fn service -> String.ends_with?(instance_name, service) end)
  end

  defp all_instances(state) do
    Map.values(state.services)
    |> Enum.concat()
  end

  defp all_completed_instances(state) do
    all_instances(state)
    |> Enum.filter(fn instance -> Service.is_complete?(instance) end)
  end

  @spec find_instance(String.t(), State.t()) :: Service.t() | nil
  defp find_instance(instance_name, state) do
    all_instances(state)
    |> Enum.find(fn instance -> instance.instance_name == instance_name end)
  end

  @spec find_completed_instance(String.t(), State.t()) :: Service.t() | nil
  defp find_completed_instance(instance_name, state) do
    all_completed_instances(state)
    |> Enum.find(fn instance -> instance.instance_name == instance_name end)
  end

  @spec find_instance_by_target(String.t(), State.t()) :: Service.t() | nil
  defp find_instance_by_target(target, state) do
    Map.values(state.services)
    |> Enum.concat()
    |> Enum.find(fn instance -> instance.target == target end)
  end
end