mdns

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

mdns

A multicast DNS (RFC 6762) and DNS-SD (RFC 6763) responder and browser in pure Go.

Go Reference test Go

It announces services with subtypes, probes and renames on conflicts, and browses so that it finds responders that answer only by unicast as well as those that answer only by multicast, once or continuously. It is built on golang.org/x/net and the standard library, without cgo. It was written for SigmaM2M, a oneM2M platform, where it serves OpenADR 3, Matter and several device integrations.

Features

Feature Status
Responder: PTR, SRV, TXT, A, AAAA, ANY; subtypes; _services._dns-sd._udp yes
Probing, conflict detection while probing, renaming (Options.Rename, at most 15) yes
Simultaneous-probe tie-breaking (RFC 6762 8.2, 8.2.1) yes
Legacy unicast replies, known-answer suppression, QU questions yes
TXT update without probing (UpdateTXT) yes
Re-join and re-announce on interface changes (checked every 10 s) yes; a new interface is announced on without a new probe
Conflicts after probing (RFC 6762 9, ongoing) no
Browser: one-shot and multicast queries at once, several types in one query train yes
Per-interface cache flush, merged multihomed answers yes
Continuous browsing (Watch): passive cache, refresh at 80 to 95 % of TTL, events yes
NSEC, known answers split over truncated packets, response aggregation and rate limits no
IPv4 and IPv6 yes
Why both ways at once

Responders on real networks answer a query in one of two ways, and a browser that queries only one way misses the others:

Responder Answers One-shot only (ephemeral port) From 5353 only Both (default)
hashicorp/mdns, a cast receiver unicast to the query's source found missed where the OS responder holds 5353 found
Go zeroconf forks, betamos, matter.js, a Spotify desktop responder multicast only missed found found
this module, mDNSResponder, Avahi both found found found

The 5353 sockets set SO_REUSEADDR and SO_REUSEPORT so they share the port with mDNSResponder, Avahi or systemd-resolved. When the port cannot be bound (another responder holds it without SO_REUSEPORT, or Windows), the browser carries on with one-shot queries. In a container, multicast needs the host network.

Install

go get github.com/cB-Sigma/mdns@latest

Requires Go 1.27.1 or later.

Quick start

import "github.com/cB-Sigma/mdns"

// Once.
found, err := mdns.Browse(ctx, "_hue._tcp", mdns.Options{Wait: 3 * time.Second})

// Continuously.
w, err := mdns.Watch(ctx, []string{"_ewelink._tcp", "_shelly._tcp"}, mdns.Options{}, func(e mdns.Event) {
	fmt.Println(e.Kind, e.Type, e.Service.Instance, e.Service.TXT["seq"])
})
defer w.Close()

// Announce, with a subtype, renaming on a conflict.
a, err := mdns.Announce(ctx, mdns.Service{Instance: "Site gateway", Host: "gateway", Port: 8443,
	TXT: map[string]string{"version": "1"}, Subtypes: []string{"_vtn"}}, "_openadr3._tcp",
	mdns.Options{Rename: mdns.RenameInstance})
defer a.Close()
_ = a.UpdateTXT(map[string]string{"version": "2"})

These mirror ExampleBrowse, ExampleWatch and ExampleAnnounce in example_test.go, which compile with the tests but do not run (they need a network). ExampleRenameInstance runs and checks its output.

Documentation

API reference: https://pkg.go.dev/github.com/cB-Sigma/mdns

Package What it does
mdns (module root) Announce (probe with tie-breaking, Options.Rename with RenameInstance, announce, answer, UpdateTXT, re-join on interface changes, goodbye on Close), Browse and BrowseTypes (one-shot and multicast queries at once by default; Options.Mode for either alone), Watch (continuous: cached announcements, refresh before expiry, Added/Updated/Removed events), TestInterface
internal/reuse SO_REUSEADDR and SO_REUSEPORT on the mDNS sockets
interop (nested module, tests only) this module against hashicorp/mdns and grandcat/zeroconf on the real port 5353

Testing

go test -race ./...

Tests that need multicast pick a random port and an interface on which multicast loops back to the host; they skip when there is none, and with -short. Tests carry // Covers: SIG-... and // Covers: SIG-MTR-... comments: these are requirement IDs of the SigmaM2M project's compliance report and have no meaning outside it.

The browse and watch tests against the simulated devices are in mdns-sim, so this module does not depend on its simulator.

interop/ is a nested module with its own go.mod, so its dependencies stay out of this one. It runs this module against hashicorp/mdns and grandcat/zeroconf on the real port 5353, and is skipped unless SIGMA_MDNS_INTEROP=1 is set:

cd interop && SIGMA_MDNS_INTEROP=1 go test -v ./...
Interop tested against
  • In process, on a test port: the simulated devices of each answer style in mdns-sim (written with dnsmessage rather than this module's encoder, but by the same author, so not independent).
  • On the real port 5353 on one macOS LAN (interop, 2026-10-06): hashicorp/mdns v1.0.7 and grandcat/zeroconf v1.0.0 as responders (found by the default browse; hashicorp missed from 5353 alone, zeroconf missed one-shot alone) and as browsers (both found our announcer).
  • Real devices on that LAN: a Sony BRAVIA cast receiver (_googlecast._tcp), a Sony audio device and a Spotify desktop responder (_spotify-connect._tcp) were all found by the default browse, the same instances dns-sd -B listed; one-shot alone missed the Spotify desktop responder.
  • matter.js 0.17.9, through the interop tests of SigmaM2M's Matter stack (runs made before this module was split out).
  • Not yet: Linux with Avahi or systemd-resolved, Windows, several active interfaces, IPv6-only links.
  • mdns-sim: simulated mDNS/DNS-SD devices in each answer style, as a Go package and a command.

Status

v0.x: the API may change before v1.

Contributing

See CONTRIBUTING.md.

Security

See SECURITY.md to report a vulnerability privately.

Licence

Apache-2.0 (see LICENSE and NOTICE). Copyright 2026 Abhinav Gautam.

Documentation

Overview

Package mdns is a multicast DNS (RFC 6762) and DNS-SD (RFC 6763) responder and browser with subtype support, written on golang.org/x/net (dnsmessage, ipv4, ipv6) and the standard library. It began as SigmaM2M's OpenADR 3 local-VTN discovery and was extended for Matter; this module is the one copy both use (see PROVENANCE.md).

The responder (Announce) answers PTR, SRV, TXT, A, AAAA and ANY questions for the names it owns, probes before announcing with simultaneous-probe tie-breaking, renames after a conflict through Options.Rename, announces unsolicited, answers legacy unicast (one-shot) queries, applies known-answer suppression, re-announces a changed TXT record without probing again, re-joins the groups when interfaces change and sends goodbyes on Close.

The browser (Browse, BrowseTypes) queries both ways at once by default: one-shot queries from an ephemeral port, which responders that only answer by unicast reply to, and multicast queries from the mDNS port with the groups joined, shared with the operating system's responder, which catches responders that only answer by multicast. Watch keeps browsing: it caches unsolicited announcements, re-queries before records expire and reports instances that appear, change and leave.

Not built: NSEC negative answers, known answers split over truncated packets, and the response aggregation and rate limits a responder for a large network would need.

Index

Examples

Constants

View Source
const DefaultPort = 5353

DefaultPort is the multicast DNS port (RFC 6762 clause 3).

View Source
const DefaultWait = 3 * time.Second

DefaultWait is how long Browse collects answers when neither opts.Wait nor a context deadline bounds it.

View Source
const MaxRenames = 15

MaxRenames bounds automatic renaming after conflicts (RFC 6762 clause 9 limits a responder to 15 conflicts in 10 seconds).

Variables

View Source
var ErrConflict = errors.New("mdns: name already in use on the link")

ErrConflict is returned by Announce when another responder on the link already answers for the service instance or host name with other data (RFC 6762 clause 9).

View Source
var MaxQueryInterval = time.Hour

MaxQueryInterval caps the gap between a Watcher's periodic queries (RFC 6762 clause 5.2 says at most one hour).

Functions

func BrowseTypes

func BrowseTypes(ctx context.Context, services []string, opts Options) (map[string][]Service, error)

BrowseTypes is Browse for several service types over one set of sockets and one query train; the result is keyed by the types as given.

func TestInterface

func TestInterface(port int) (*net.Interface, error)

TestInterface picks an interface on which multicast sent from this host on port comes back to this host, for tests that announce and browse in one process. It returns an error (tests skip) when none works.

Types

type Announcer

type Announcer struct {
	// contains filtered or unexported fields
}

Announcer advertises one service until Close or until its context ends.

func Announce

func Announce(ctx context.Context, s Service, service string, opts Options) (*Announcer, error)

Announce advertises s as an instance of service (for example "_matter._tcp"). It joins 224.0.0.251 and ff02::fb on opts.Port (5353 by default) on the given interfaces (all multicast-capable ones when none), probes that the names are free, sends unsolicited announcements and answers queries. Close, or the end of ctx, sends goodbyes (TTL 0) and leaves.

The sockets set SO_REUSEADDR and SO_REUSEPORT where the platform has them, so the announcer can share 5353 with the operating system's own responder (mDNSResponder on macOS, Avahi or systemd-resolved on Linux).

When probing finds a conflict and opts.Rename is set, Announce asks it for a new service (new instance and/or host name) and probes again, up to MaxRenames times (RFC 6762 clause 9); otherwise it fails with ErrConflict.

Example

Announce advertises a service with a subtype and renames it if the name is taken; UpdateTXT changes its metadata without probing again.

package main

import (
	"context"

	"github.com/cB-Sigma/mdns"
)

func main() {
	ctx := context.Background()
	a, err := mdns.Announce(ctx, mdns.Service{Instance: "Site gateway", Host: "gateway", Port: 8443,
		TXT: map[string]string{"version": "1"}, Subtypes: []string{"_vtn"}}, "_openadr3._tcp",
		mdns.Options{Rename: mdns.RenameInstance})
	if err != nil {
		return
	}
	defer a.Close()
	_ = a.UpdateTXT(map[string]string{"version": "2"})
}

func (*Announcer) Close

func (a *Announcer) Close() error

Close sends goodbye records (TTL 0), leaves the groups and closes the sockets. It is safe to call more than once.

func (*Announcer) UpdateTXT

func (a *Announcer) UpdateTXT(txt map[string]string) error

UpdateTXT replaces the TXT metadata and announces the new record twice, one second apart, without probing again: the instance name stays the same, and the record's cache-flush bit makes caches replace the old one (RFC 6762 clauses 8.4 and 10.2).

type BrowseMode

type BrowseMode int

BrowseMode is how a browser sends its queries and hears the answers.

const (
	// BrowseBoth sends one-shot queries from an ephemeral port and
	// multicast queries from the mDNS port with the groups joined, and
	// listens on both. Responders that answer only by unicast to the
	// query's source (RFC 6762 clause 6.7) answer the first; responders
	// that answer only by multicast (clause 6) are heard on the second,
	// which also receives announcements. When the mDNS port cannot be
	// bound, the browser carries on with one-shot queries alone.
	BrowseBoth BrowseMode = iota
	// BrowseOneShot only sends one-shot queries from an ephemeral port
	// (RFC 6762 clause 5.1) and never binds the mDNS port.
	BrowseOneShot
	// BrowseMulticast only queries from the mDNS port with the groups
	// joined. A unicast reply to the mDNS port reaches only one of the
	// sockets bound there, so responders that answer by unicast may be
	// missed while the operating system's responder holds the port.
	BrowseMulticast
)

type Event

type Event struct {
	Kind    EventKind
	Type    string
	Service Service
}

Event is one change seen by a Watcher. Type is the service type as given to Watch; for Removed, Service is the instance as last seen.

type EventKind

type EventKind int

EventKind says what happened to a watched instance.

const (
	// Added: an instance was found (its SRV record arrived).
	Added EventKind = iota + 1
	// Updated: its host, port, addresses or TXT record changed.
	Updated
	// Removed: it said goodbye (TTL 0) or its records expired without
	// being refreshed.
	Removed
)

The event kinds.

func (EventKind) String

func (k EventKind) String() string

type Options

type Options struct {
	// Interfaces to use; all up, multicast-capable, non point-to-point
	// interfaces when empty (loopback only when Loopback is set).
	Interfaces []net.Interface
	// Wait bounds Browse; DefaultWait when zero and ctx has no deadline.
	Wait time.Duration
	// Port is the multicast DNS port; 0 means 5353. Tests use another port
	// so they do not collide with the operating system's responder.
	Port int
	// Mode selects how Browse, BrowseTypes and Watch query; BrowseBoth
	// when zero.
	Mode BrowseMode
	// Rename, when set, picks a new name after a probing conflict; attempt
	// counts from 1. Announce retries with the returned service.
	Rename func(s Service, attempt int) Service
	// Loopback turns on multicast loopback, so an announcer and a browser
	// on the same host see each other, and lets the default interface set
	// include the loopback interface.
	Loopback bool
}

Options configure Announce and Browse.

type Service

type Service struct {
	Instance, Host string
	Port           int
	TXT            map[string]string
	IPs            []net.IP
	// Subtypes are DNS-SD subtype labels (RFC 6763 clause 7.1), for
	// example "_L840"; the responder answers PTR queries for
	// "<subtype>._sub.<service>.local." with this instance.
	Subtypes []string
}

Service is one DNS-SD service instance.

Instance is the instance label (for example "Site gateway"); it may hold spaces but no dots. Host is the host label, with or without ".local" ("myvtn" and "myvtn.local" are the same host). Port is the TCP port of the service. TXT holds the key/value metadata. IPs, when set, are the addresses the responder answers for Host; when empty it answers with the addresses of the interface a query arrived on.

Browse returns Host as "<label>.local" (no trailing dot).

func Browse

func Browse(ctx context.Context, service string, opts Options) ([]Service, error)

Browse looks for instances of service (for example "_matter._tcp" or a subtype such as "_L840._sub._matterc._udp") and returns what answered before ctx ended or opts.Wait passed.

By default (BrowseBoth) it sends one-shot queries from an ephemeral port to the mDNS group on each interface (RFC 6762 clause 5.1), to which responders reply by unicast, and the same query from the mDNS port with the groups joined (shared with the operating system's responder through SO_REUSEPORT), so responders that answer only by multicast are heard as well. Later queries list the complete instances already known (known-answer suppression) and ask directly for the SRV, TXT and address records still missing. Instances whose goodbye (TTL 0) arrives are dropped. Results are sorted by instance name; use Service.Addr to connect, which favours the .local host name.

Example

Browse finds the instances of a type: by default it queries one-shot from an ephemeral port and from the mDNS port at once.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/cB-Sigma/mdns"
)

func main() {
	ctx := context.Background()
	found, err := mdns.Browse(ctx, "_hue._tcp", mdns.Options{Wait: 3 * time.Second})
	if err != nil {
		return
	}
	for _, s := range found {
		fmt.Println(s.Instance, s.Addr(), s.TXT["bridgeid"])
	}
}

func RenameInstance

func RenameInstance(s Service, attempt int) Service

RenameInstance is an Options.Rename that keeps the host name and gives the instance a number: "Site gateway" becomes "Site gateway (2)", then "Site gateway (3)" (RFC 6762 clause 9 leaves the choice of the new name to the responder; this is the form DNS-SD browsers show).

Example

RenameInstance numbers the instance name after a conflict and keeps the host name.

package main

import (
	"fmt"

	"github.com/cB-Sigma/mdns"
)

func main() {
	s := mdns.Service{Instance: "Site gateway", Host: "gateway", Port: 8443}
	s = mdns.RenameInstance(s, 1)
	fmt.Println(s.Instance)
	fmt.Println(mdns.RenameInstance(s, 2).Instance)
	fmt.Println(s.Addr())
}
Output:
Site gateway (2)
Site gateway (3)
gateway:8443

func (Service) Addr

func (s Service) Addr() string

Addr returns host:port for connecting to the service. It favours the .local host name over an address, because DHCP-assigned addresses change; it falls back to the first IP only when no host name is known.

type Watcher

type Watcher struct {
	// contains filtered or unexported fields
}

Watcher browses continuously for one or more service types over one set of sockets. It queries at start like Browse, then at intervals that double up to MaxQueryInterval; it keeps every announcement heard on the mDNS port (with the default BrowseBoth or BrowseMulticast mode); it asks again for each record at 80, 85, 90 and 95 % of its TTL, so instances that only answer queries stay known; and it reports instances that appear, change and leave.

func Watch

func Watch(ctx context.Context, services []string, opts Options, fn func(Event)) (*Watcher, error)

Watch starts watching the service types until Close or the end of ctx. fn, when not nil, is called for every change, one call at a time, from the Watcher's own goroutine; it must not call Close. opts.Wait (or DefaultWait) sets when Settled is closed: by then the first queries have had the time a Browse gives them.

Example

Watch follows instances as they come, change and go.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/cB-Sigma/mdns"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	w, err := mdns.Watch(ctx, []string{"_ewelink._tcp", "_shelly._tcp"}, mdns.Options{}, func(e mdns.Event) {
		fmt.Println(e.Kind, e.Type, e.Service.Instance, e.Service.TXT["seq"])
	})
	if err != nil {
		return
	}
	defer w.Close()
	<-w.Settled()
	fmt.Println(len(w.Services("_ewelink._tcp")), "DIY devices so far")
	<-ctx.Done()
}

func (*Watcher) Close

func (w *Watcher) Close() error

Close stops watching and closes the sockets. It is safe to call more than once.

func (*Watcher) Done

func (w *Watcher) Done() <-chan struct{}

Done is closed when the Watcher has stopped.

func (*Watcher) Services

func (w *Watcher) Services(service string) []Service

Services returns the instances of service (one of the types given to Watch) known now, sorted by instance name.

func (*Watcher) Settled

func (w *Watcher) Settled() <-chan struct{}

Settled is closed once the first round of queries has had opts.Wait (or DefaultWait) to be answered, or when the Watcher stops.

Directories

Path Synopsis
internal
reuse
Package reuse sets the socket options that let several sockets (ours, the simulator's and the operating system's responder) bind the mDNS port together.
Package reuse sets the socket options that let several sockets (ours, the simulator's and the operating system's responder) bind the mDNS port together.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL