defmodule MdnsLite.Options do
@moduledoc """
MdnsLite options
MdnsLite is usually configured in a project's application environment
(`config.exs`) as follows:
```elixir
config :mdns_lite,
hosts: [:hostname, "nerves"],
ttl: 120,
instance_name: "mDNS Lite Device",
services: [
%{
id: :web_server,
protocol: "http",
transport: "tcp",
port: 80,
txt_payload: ["key=value"]
},
%{
id: :ssh_daemon,
instance_name: "More particular mDNS Lite Device"
protocol: "ssh",
transport: "tcp",
port: 22
}
]
```
The configurable keys are:
* `:hosts` - A list of hostnames to respond to. Normally this would be set to
`:hostname` and `mdns_lite` will advertise the actual hostname with `.local`
appended.
* `:ttl` - The default mDNS record time-to-live. The default of 120
seconds is probably fine for most use. See [RFC 6762 - Multicast
DNS](https://tools.ietf.org/html/rfc6762) for considerations.
* `instance_name` - A user friendly name that will be used as the name for this
device's advertised service(s). Per RFC6763 Appendix C, this should describe
the user-facing purpose or description of the device, and should not be
considered a unique identifier. For example, 'Nerves Device' and 'MatCo
Laser Printer Model CRM-114' are good choices here. If instance_name is not
defined it defaults to the first entry in the `hosts` list
* `:excluded_ifnames` - A list of network interfaces names to ignore. By
default, `mdns_lite` will ignore loopback and cellular network interfaces.
* `:ipv4_only` - Set to `true` to only respond on IPv4 interfaces. Since IPv6
isn't fully supported yet, this is the default. Note that it's still
possible to get AAAA records when using IPv4.
* `:if_monitor` - Set to `MdnsLite.VintageNetMonitor` when using Nerves or
`MdnsLite.InetMonitor` elsewhere. The default is
`MdnsLite.VintageNetMonitor`.
* `:dns_bridge_enabled` - Set to `true` to start a DNS server running that
will bridge DNS to mDNS.
* `:dns_bridge_ip` - The IP address for the DNS server. Defaults to
127.0.0.53.
* `:dns_bridge_port` - The UDP port for the DNS server. Defaults to 53.
* `:dns_bridge_recursive` - If a regular DNS request comes on the DNS bridge,
forward it to a DNS server rather than returning an error. This is the
default since there's an issue on Linux and Nerves that prevents Erlang's
DNS resolver from checking the next one.
* `:services` - A list of services to advertise. See `MdnsLite.service` for
details.
Some options are modifiable at runtime. Functions for modifying these are in
the `MdnsLite` module.
"""
require Logger
@default_host_name_list [:hostname]
@default_ttl 120
@default_dns_ip {127, 0, 0, 53}
@default_dns_port 53
@default_excluded_ifnames ["lo0", "lo", "ppp0", "wwan0"]
@default_ipv4_only true
defstruct services: MapSet.new(),
dot_local_names: [],
hosts: [],
ttl: @default_ttl,
instance_name: :unspecified,
dns_bridge_enabled: false,
dns_bridge_ip: @default_dns_ip,
dns_bridge_port: @default_dns_port,
dns_bridge_recursive: true,
if_monitor: nil,
excluded_ifnames: @default_excluded_ifnames,
ipv4_only: @default_ipv4_only
@typedoc false
@type t :: %__MODULE__{
services: MapSet.t(map()),
dot_local_names: [String.t()],
hosts: [String.t()],
ttl: pos_integer(),
instance_name: MdnsLite.instance_name(),
dns_bridge_enabled: boolean(),
dns_bridge_ip: :inet.ip_address(),
dns_bridge_port: 1..65535,
dns_bridge_recursive: boolean(),
if_monitor: module(),
excluded_ifnames: [String.t()],
ipv4_only: boolean()
}
@doc false
@spec new(Enumerable.t()) :: t()
def new(enumerable \\ %{}) do
opts = Map.new(enumerable)
hosts = get_host_option(opts)
ttl = Map.get(opts, :ttl, @default_ttl)
instance_name = Map.get(opts, :instance_name, :unspecified)
config_services = Map.get(opts, :services, []) |> filter_invalid_services()
dns_bridge_enabled = Map.get(opts, :dns_bridge_enabled, false)
dns_bridge_ip = Map.get(opts, :dns_bridge_ip, @default_dns_ip)
dns_bridge_port = Map.get(opts, :dns_bridge_port, @default_dns_port)
dns_bridge_recursive = Map.get(opts, :dns_bridge_recursive, true)
if_monitor = Map.get(opts, :if_monitor, default_if_monitor())
ipv4_only = Map.get(opts, :ipv4_only, @default_ipv4_only)
excluded_ifnames = Map.get(opts, :excluded_ifnames, @default_excluded_ifnames)
%__MODULE__{
ttl: ttl,
instance_name: instance_name,
dns_bridge_enabled: dns_bridge_enabled,
dns_bridge_ip: dns_bridge_ip,
dns_bridge_port: dns_bridge_port,
dns_bridge_recursive: dns_bridge_recursive,
if_monitor: if_monitor,
excluded_ifnames: excluded_ifnames,
ipv4_only: ipv4_only
}
|> add_hosts(hosts)
|> add_services(config_services)
end
defp default_if_monitor() do
if has_vintage_net?() do
MdnsLite.VintageNetMonitor
else
MdnsLite.InetMonitor
end
end
defp has_vintage_net?() do
Application.loaded_applications()
|> Enum.find_value(fn {app, _, _} -> app == :vintage_net end)
end
# This used to be called :host, but now it's :hosts. It's a list, but be
# nice and wrap rather than crash.
defp get_host_option(%{host: host}) do
Logger.warning("mdns_lite: the :host app environment option is deprecated. Change to :hosts")
List.wrap(host)
end
defp get_host_option(%{hosts: hosts}), do: List.wrap(hosts)
defp get_host_option(_), do: @default_host_name_list
@doc false
@spec set_instance_name(t(), MdnsLite.instance_name()) :: t()
def set_instance_name(options, instance_name) do
%{options | instance_name: instance_name}
end
@doc false
@spec add_service(t(), MdnsLite.service()) :: t()
def add_service(options, service) do
{:ok, normalized_service} = normalize_service(service)
%{options | services: MapSet.put(options.services, normalized_service)}
end
@doc false
@spec add_services(t(), [MdnsLite.service()]) :: t()
def add_services(%__MODULE__{} = options, services) do
Enum.reduce(services, options, fn service, options -> add_service(options, service) end)
end
@doc false
@spec filter_invalid_services([MdnsLite.service()]) :: [MdnsLite.service()]
def filter_invalid_services(services) do
Enum.flat_map(services, fn service ->
case normalize_service(service) do
{:ok, normalized_service} ->
[normalized_service]
{:error, reason} ->
Logger.warning("mdns_lite: ignoring service (#{inspect(service)}): #{reason}")
[]
end
end)
end
@doc """
Normalize a service description
All service descriptions are normalized before use. Call this function if
you're unsure how the service description will be transformed for use.
"""
@spec normalize_service(MdnsLite.service()) :: {:ok, MdnsLite.service()} | {:error, String.t()}
def normalize_service(service) do
with {:ok, id} <- normalize_id(service),
{:ok, instance_name} <- normalize_instance_name(service),
{:ok, port} <- normalize_port(service),
{:ok, type} <- normalize_type(service) do
{:ok,
%{
id: id,
instance_name: instance_name,
port: port,
type: type,
txt_payload: Map.get(service, :txt_payload, []),
priority: Map.get(service, :priority, 0),
weight: Map.get(service, :weight, 0)
}}
end
end
defp normalize_id(%{id: id}), do: {:ok, id}
defp normalize_id(%{name: name}) do
Logger.warning("mdns_lite: names are deprecated now. Specify an :id that's an atom")
{:ok, name}
end
defp normalize_id(_), do: {:ok, :unspecified}
defp normalize_instance_name(%{instance_name: instance_name}), do: {:ok, instance_name}
defp normalize_instance_name(_), do: {:ok, :unspecified}
defp normalize_type(%{type: type}) when is_binary(type) and byte_size(type) > 0 do
{:ok, type}
end
defp normalize_type(%{protocol: protocol, transport: transport} = service)
when is_binary(protocol) and is_binary(transport) do
{:ok, "_#{service.protocol}._#{service.transport}"}
end
defp normalize_type(_other) do
{:error, "Specify either 1. :protocol and :transport or 2. :type"}
end
defp normalize_port(%{port: port}) when port >= 0 and port <= 65535, do: {:ok, port}
defp normalize_port(_), do: {:error, "Specify a port"}
@doc false
@spec get_services(t()) :: [MdnsLite.service()]
def get_services(%__MODULE__{} = options) do
MapSet.to_list(options.services)
end
@doc false
@spec remove_service_by_id(t(), MdnsLite.service_id()) :: t()
def remove_service_by_id(%__MODULE__{} = options, service_id) do
services_set =
options.services
|> Enum.reject(&(&1.id == service_id))
|> MapSet.new()
%{options | services: services_set}
end
@doc false
@spec set_hosts(t(), [String.t() | :hostname]) :: t()
def set_hosts(%__MODULE__{} = options, hosts) do
%{options | dot_local_names: [], hosts: []}
|> add_hosts(hosts)
end
@doc false
@spec add_host(t(), String.t() | :hostname) :: t()
def add_host(%__MODULE__{} = options, host) do
resolved_host = resolve_mdns_name(host)
dot_local_name = "#{resolved_host}.local"
%{
options
| dot_local_names: options.dot_local_names ++ [dot_local_name],
hosts: options.hosts ++ [resolved_host]
}
end
@doc false
@spec add_hosts(t(), [String.t() | :hostname]) :: t()
def add_hosts(%__MODULE__{} = options, hosts) do
Enum.reduce(hosts, options, &add_host(&2, &1))
end
defp resolve_mdns_name(:hostname) do
{:ok, hostname} = :inet.gethostname()
to_string(hostname)
end
defp resolve_mdns_name(mdns_name) when is_binary(mdns_name), do: mdns_name
defp resolve_mdns_name(_other) do
raise RuntimeError, "Host must be :hostname or a string"
end
end