Madness (Madness v0.5.0)
View SourceQueries mDNS and publishes DNS-SD services on the local network.
Queries use multicast UDP over IPv4, IPv6, or both and return
Madness.Record values as a stream or as process messages:
Madness.request({"_http._tcp.local", :ptr})
|> Enum.to_list()
{:ok, ref} = Madness.request({"mydevice.local", :a}, into: :self)
receive do
{^ref, %Madness.Record{} = record} -> IO.inspect(record)
{^ref, :done} -> :ok
endResponses include Answer and Additional records. Check
record.metadata.section when the distinction matters.
Services are published through Avahi or Apple's mDNSResponder daemon:
{:ok, publication} =
Madness.publish(name: "Web", type: "_http._tcp", port: 4000)
:ok = Madness.unpublish(publication)The publication follows its owner process and is withdrawn when that process
exits. The default backend setting, config :madness, backend: :auto, probes
Avahi and then mDNSResponder. The native responder is not implemented.
See request/2, publish/2, and Madness.Service for the complete options.
Summary
Types
Options for publish/2.
A question tuple of {domain, type}
One or more questions to send in a query
Options for request/2.
DNS resource record types supported by Madness.
Functions
Publishes a service on the local network.
Sends an mDNS query and returns responses.
Withdraws a published service.
Types
@type publish_option() :: {:interface, :any | String.t() | pos_integer()} | {:family, :any | :inet | :inet6} | {:owner, pid() | atom()} | {:backend, module()} | {:timeout, timeout()} | {:notify, boolean() | pid() | atom()}
Options for publish/2.
See the Options section in publish/2 for detailed descriptions.
A question tuple of {domain, type}
One or more questions to send in a query
@type request_option() :: {:into, :stream | :self} | {:timeout, non_neg_integer()} | {:family, :any | :inet | :inet6} | {:interface, :any | String.t() | non_neg_integer()} | {:unicast_response, boolean()}
Options for request/2.
See the Options section in request/2 for detailed descriptions.
@type rrtype() :: :a | :aaaa | :ptr | :srv | :txt | :cname | :ns | :mx | :soa | :nsec
DNS resource record types supported by Madness.
| Type | Code | Description |
|---|---|---|
:a | 1 | IPv4 address |
:aaaa | 28 | IPv6 address |
:ptr | 12 | Pointer (for service discovery) |
:srv | 33 | Service location (port, priority, target) |
:txt | 16 | Text records (service metadata) |
:cname | 5 | Canonical name (alias) |
:ns | 2 | Name server |
:mx | 15 | Mail exchange |
:soa | 6 | Start of authority |
:nsec | 47 | NSEC (negative response, indicates no record) |
Functions
@spec publish(Madness.Service.t() | keyword() | map(), [publish_option()]) :: {:ok, Madness.Publication.t()} | {:error, term()}
Publishes a service on the local network.
Returns a Madness.Publication handle. The publication is bound to a
process — the caller by default, or whichever process you name with :owner.
When that process exits the service is withdrawn and goodbye packets are
sent, so a crash cannot leave a stale advertisement behind. Call
unpublish/1 to withdraw it earlier.
Arguments
A Madness.Service, or the fields to build one:
Madness.publish(name: "My Printer", type: "_ipp._tcp", port: 631)
{:ok, service} = Madness.Service.new(name: "My Printer", type: "_ipp._tcp", port: 631)
Madness.publish(service)Subtypes use their DNS form in the same :type field:
Madness.publish(name: "Printer", type: "_printer._sub._http._tcp", port: 80)The selected backend translates that form when its registration API uses a different spelling.
Options
:interface- which interface to publish on::any(default) - all multicast-capable interfaces, as one registration. Interface churn is then the responder's problem, which is what you want unless you have a reason to care.String.t()- one interface by name (e.g."en0").pos_integer()- one interface by OS index. Index0is invalid here; use:anyto publish on all multicast-capable interfaces.
A scoped publication is bound to the concrete interface that is usable when
publish/2runs. If that interface loses its last usable address in the requested family scope, is replaced, or can no longer be watched, the publication terminates. It never attaches itself to an interface that appears later, even when the name or OS index is reused. Monitor the returned publication pid and callpublish/2again when that is the policy you want. Publishing fails when no matching interface is usable.Requires
:inertialBounding a publication to an interface needs real-time interface events, which come from the optional
:inertialdependency. Add{:inertial, "~> 2.2"}to your own deps to use this option; without it, a scoped publish returns{:error, :interface_events_unavailable}. Inertial is Linux and macOS only and carries a NIF, which is why madness does not require it.interface: :anyneeds none of this and works everywhere.:family-:any(default),:inetor:inet6. Only some backends can enforce this; see "Portability" below.:owner- the process whose lifetime the publication is bound to, as a pid or a registered name. Defaults to the caller.Set this whenever the process being advertised is not the one calling
publish/2— publishing from aTask, a setup function, or a supervisor'sinit/1on behalf of a long-lived listener. Left at the default, the advertisement is withdrawn the moment that short-lived caller finishes, which is rarely what anyone wants:# The listener owns the port, so it should own the advertisement. Madness.publish([name: "Web", type: "_http._tcp", port: 80], owner: MyApp.Listener )An owner that is already dead is an error rather than a publication that withdraws itself immediately, and an owner that dies during registration is reported the same way rather than as a success that withdraws itself a moment later.
:backend- override the node's settled backend for this publication only, and validate it on the spot. Errors rather than falling back if it is unusable. Mostly for tests and for advanced callers; to choose the backend for the whole node, useconfig :madness, backend: …instead.:timeout- how long to wait for the responder to confirm the registration, in milliseconds. The budget begins before the publication process is started. If it expires, that process is stopped immediately; any connection it opened closes with it and revokes the registration. Default:5000.The deadline it implies is also handed to the responder backend, which may stop waiting at it rather than be torn down mid-call. A backend's own ceiling still applies: a longer
:timeoutdoes not raise it.:notify- where to send lifecycle events, as{:madness, ref, event}withrefbeing the publication's:ref.false(the default) sends none;truesends them to the owner, which is the caller unless:ownersays otherwise; a pid or registered name sends them there.truefollowing the owner rather than the caller is deliberate: with a transient caller — the case:ownerexists for — events addressed to the caller would go to a dead process for the rest of the publication's life. Passnotify: self()if you want them anyway. A recipient that is named but not running is an error; one that dies later simply stops receiving.{:madness, ref, {:renamed, name}} {:madness, ref, {:registered, interface}} {:madness, ref, {:error, reason}}Default:
false.
Conflicts and renaming
DNS-SD instance names are display names, not identifiers. If another host on the network already advertises the name you asked for, the responder appends a disambiguating suffix and registers that. The name it settled on is in the returned publication:
{:ok, pub} = Madness.publish(name: "My Printer", type: "_ipp._tcp", port: 631)
pub.name
# => "My Printer (2)"This can also happen long after publishing, when a conflicting host appears.
The struct you are holding is not updated — use notify: true to hear about
it.
Portability
Backends differ in what they can enforce, and Madness refuses a scope rather
than ignoring it, so that a publication is never quietly wider than asked for.
In particular Apple's daemon has no per-address-family control, so :family
is rejected there with {:error, {:unsupported, :family_scope, backend}}.
family: :any is the portable choice.
Returns
{:ok, Madness.Publication.t()}{:error, :responder_unavailable}- the node's responder could not be reached, or its backend does not exist yet. Reachability is re-established on its own, so this can stop being true while the node runs.{:error, :interface_events_unavailable}- an:interfacescope was given but the optional:inertialdependency is not available.{:error, {:interface_unavailable, interface}}- no matching interface has a usable address in the requested family scope when publishing starts.{:error, {:unsupported, capability, backend}}- the scope cannot be enforced by the selected backend. See "Portability".{:error, {:invalid_option, key, value}}and{:error, {:unknown_option, key, value}}- options are validated before anything is started, and an unrecognised key is an error rather than being ignored, since a typo in a scoping option would publish more widely than intended.{:error, reason}- the backend refused the registration.
After a scoped publish succeeds, its pid exits with
{:shutdown, {:interface_lost, interface}} when the bound interface is lost
or replaced, and with {:shutdown, :interface_events_unavailable} if the
watcher loses continuity. With notify: true, these also send
{:madness, ref, {:error, {:interface_lost, interface}}} or
{:madness, ref, {:error, :interface_events_unavailable}}, respectively.
These are terminal states; starting a new publication is an explicit caller
decision.
Delegated backends
Publishing works on Linux through Madness.Responder.Avahi and on hosts
running Apple's daemon through Madness.Responder.MDNSResponder. The native
responder is not written yet.
Examples
# Publish on every interface
{:ok, pub} = Madness.publish(name: "Web", type: "_http._tcp", port: 80)
# With metadata, scoped to one interface, watching for renames
{:ok, pub} =
Madness.publish(
[name: "Web", type: "_http._tcp", port: 80, txt: %{"path" => "/"}],
interface: "en0",
notify: true
)
receive do
{:madness, ref, {:renamed, name}} when ref == pub.ref ->
IO.puts("now advertising as #{name}")
end
Madness.unpublish(pub)
@spec request(questions(), [request_option()]) :: Enumerable.t() | {:ok, reference()}
Sends an mDNS query and returns responses.
Arguments
questions- A single question tuple or list of question tuples. Each question is{domain, type}where type is an atom like:a,:aaaa,:ptr,:srv,:txt, etc.# Single question Madness.request({"mydevice.local", :a}) # Multiple questions in one query Madness.request([ {"mydevice.local", :a}, {"mydevice.local", :aaaa} ])
Options
:into- Determines how responses are delivered::stream(default) - Returns a lazyStream.t(Madness.Record.t())that yields records as they arrive. The stream terminates when the timeout expires or all questions are answered.:self- Returns{:ok, reference()}immediately and sends messages to the calling process. Messages have the form{ref, record}for each record and{ref, :done}when the query completes.
:timeout- Maximum time to wait for responses in milliseconds. Default:5000(5 seconds).The query may complete before the timeout in two cases:
Positive responses: When all non-PTR questions receive answers.
NSEC negative responses: When a responder sends an NSEC record indicating that a queried record type does not exist. For example, if you query for both A and AAAA records but the device only has IPv4, it may respond with an A record plus an NSEC record signaling "no AAAA". This allows early termination without waiting for the full timeout.
Note: PTR queries (service discovery) always wait for the full timeout since multiple responders may reply and there's no way to know when all services have been discovered.
:family- The IP address family for multicast::any(default) - Both IPv4 and IPv6 multicast:inet- IPv4 multicast only (224.0.0.251):inet6- IPv6 multicast only (ff02::fb)
:interface- Which network interface(s) to use::any(default) - Query on all multicast-capable interfacesString.t()- Query on a specific interface by name (e.g.,"en0")non_neg_integer()- Query on a specific interface by index (e.g.,1)
:unicast_response- Whether to request unicast responses. Default:true.When
true, sets the QU (unicast) bit in the query, requesting that responders send replies directly to this host rather than via multicast. This reduces network traffic and is recommended for most use cases.Set to
falseif you need to see all multicast responses on the network (e.g., for debugging or passive monitoring).
Returns
- With
into: :stream- ReturnsEnumerable.t()(aStream) - With
into: :self- Returns{:ok, reference()}
Examples
# Discover HTTP services (stream mode)
Madness.request({"_http._tcp.local", :ptr})
|> Enum.to_list()
# Get device addresses with short timeout
Madness.request({"mydevice.local", :a}, timeout: 1000)
|> Enum.to_list()
# Query for both IPv4 and IPv6 (will terminate early if NSEC received)
Madness.request([{"mydevice.local", :a}, {"mydevice.local", :aaaa}])
|> Enum.to_list()
# Use IPv6 multicast only
Madness.request({"mydevice.local", :aaaa}, family: :inet6)
|> Enum.to_list()
# Message mode for async handling
{:ok, ref} = Madness.request({"_http._tcp.local", :ptr}, into: :self)
defp handle_info({ref, %Madness.Record{} = record}, state) do
# Process record...
{:noreply, state}
end
defp handle_info({ref, :done}, state) do
# Query complete
{:noreply, state}
end
# Query on a specific interface
Madness.request({"mydevice.local", :a}, interface: "en0")
|> Enum.to_list()
@spec unpublish(Madness.Publication.t()) :: :ok
Withdraws a published service.
Sends goodbye packets so that peers drop the records promptly rather than
waiting for them to expire. Safe to call on a publication that has already
gone away — withdrawing twice, or withdrawing after the owning process died,
returns :ok.
The handle is checked before anything is stopped: the publication must still be live and still be the one this struct names. A stale handle whose pid has since been recycled by an unrelated process is therefore inert, rather than taking that process down with it.
Examples
{:ok, pub} = Madness.publish(name: "Web", type: "_http._tcp", port: 80)
:ok = Madness.unpublish(pub)