# 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