# 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, ip: nil, priority: nil, weight: nil, port: nil, target: nil, additional_info: nil, records: [] @type t :: %__MODULE__{ domain: String.t(), instance_name: String.t(), ip: :inet.ip_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. """ def dump() do GenServer.call(__MODULE__, :dump) 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 -> %Service{instance | ip: data} |> 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