Help us shape the future of Go! Take the 2026 Go Developer Survey

mdns

package
v0.12.0 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: Apache-2.0 Imports: 27 Imported by: 1

Documentation

Overview

Package mdns advertises the Matter bridge over mDNS / DNS-SD per Matter Core Spec §4.3.

Two service types are emitted:

  • `_matter._tcp` — operational service. The bridge advertises this once any commissioner has paired and at least one fabric is active. Service instance name is `<compressed-fabric-id>-<node-id>` in 16+16 lowercase hex.
  • `_matterc._udp` — commissionable service. Active only when the bridge has an open commissioning window (see ADR 0012 on the pairing UI). Service instance name is a 16-byte random identifier in lowercase hex.

TXT records carry the discovery metadata the commissioner uses to pre-screen candidates (discriminator, vendor / product, pairing hint, etc.) without speaking to the device.

go-fabric ships a minimal pure-Go implementation:

The implementation does *not* aim to be a general-purpose mDNS stack. It services the Matter discovery requirements only.

Index

Constants

View Source
const (
	ServiceTypeOperational    = "_matter._tcp"
	ServiceTypeCommissionable = "_matterc._udp"
)

Operational + Commissionable service-type labels per Matter §4.3.1.

View Source
const PairingHintDefault uint16 = 0x21

PairingHintDefault is the bitmask go-fabric emits for PH when the caller leaves PairingHint zero. Mirrors matter.js packages/protocol/src/mdns/MdnsConsts.ts:15-18 DEFAULT_PAIRING_HINT: { powerCycle: true, deviceManual: true }. Per packages/protocol/src/advertisement/PairingHintBitmap.ts, powerCycle is bit 0 (0x01) and deviceManual is bit 5 (0x20) of the Matter §4.3.1.6 PairingHintBitmap — combined value 0x21. Bit 4 (customInstruction) is NOT part of the default: it requires a PI (custom instruction) value the bridge never supplies, so setting it without one is non-conformant.

View Source
const RotatingIDUniqueIDLength = 16

RotatingIDUniqueIDLength is the byte length of the platform-provided Unique-ID input used by the rotating-device-id derivation. Per Matter Core Spec §5.4.2.4 / §11.1.5.5 the Unique-ID is "at least 16 bytes of random data" generated once at first boot and persisted across the lifetime of the device.

Variables

View Source
var (
	// ErrInvalidService is returned when [Service.Validate] rejects
	// a service-record bundle.
	ErrInvalidService = errors.New("mdns: invalid service")
)

Errors.

View Source
var ErrOperationalNotResolved = errors.New("mdns: operational instance not resolved")

ErrOperationalNotResolved is returned when a peer's operational instance did not resolve to an address before the context ended.

View Source
var (
	// ErrServiceNotFound is returned by Update / Withdraw when the
	// instance name was never published.
	ErrServiceNotFound = errors.New("mdns: service not found")
)

Errors.

Functions

func DeriveUniqueIDFromIdentity

func DeriveUniqueIDFromIdentity(vendorID, productID uint16, serialNumber, nodeLabel string) []byte

DeriveUniqueIDFromIdentity computes a deterministic 16-byte UniqueID for the rotating-device-id derivation from the bridge's stable identity tuple (VendorID, ProductID, SerialNumber, optional NodeLabel). The result is the first 16 bytes of SHA-256(vendor || product || serial || label). Stable across daemon restarts, distinct per bridge identity, and entirely stateless — no extra persistence required.

This is a pragmatic v1.0 alternative to the "16 random bytes persisted at first boot" approach mandated by Matter §11.1.5.5 / chip's `kRotatingDeviceIDUniqueIDLength`. It satisfies the commissionable surface for every controller observed (Apple Home, chip-tool) because the spec only requires *uniqueness per commissionable device* and *stability across reboots* — both of which a SHA-256 over the bridge identity provides.

Callers that want a truly random UniqueID (e.g. multi-bridge deployments with identical config) should persist their own 16-byte blob and pass it to GenerateRotatingID directly.

func GenerateRotatingID

func GenerateRotatingID(uniqueID []byte, lifetimeCounter uint16) string

GenerateRotatingID builds the rotating-device-id hex string per the chip + Matter Core Spec formula:

RDI = uint16_le(LifetimeCounter) || SHA-256(UniqueID || uint16_le(LifetimeCounter))[-16:]
RotatingID(hex) = uppercase(hex(RDI))

The 2-byte counter is little-endian (chip `setup_payload/AdditionalDataPayloadGenerator.cpp:43 using namespace chip::Encoding::LittleEndian` → all `Put16` calls in the generator use LE).

Total binary length is 18 bytes (2-byte counter prefix + 16-byte hash suffix); hex representation is 36 uppercase characters. Apple Home, chip-tool, and matter.js commissioners accept this format — it is the protocol-defined Rotating Device Id surface that ships in the `RI` TXT key of `_matterc._udp` commissionable mDNS records (Matter §4.3.1.6).

uniqueID must be at least RotatingIDUniqueIDLength bytes; the function returns the empty string when uniqueID is too short so callers can suppress the `RI` TXT key on pre-init bridges.

References:

  • chip `src/setup_payload/AdditionalDataPayloadGenerator.cpp:81` (generateRotatingDeviceIdAsBinary)
  • chip `src/setup_payload/AdditionalDataPayloadGenerator.cpp:113` (generateRotatingDeviceIdAsHexString)
  • Matter Core Spec §5.4.2.4 "Rotating Device Identifier"

func IsEnabled added in v0.5.0

func IsEnabled(a Advertiser) bool

IsEnabled reports whether a publishes on the network: its Enabled() when it implements Enabled, true otherwise (an advertiser that does not say is taken at its word that it advertises), false for nil.

func MustGenerateRotatingID

func MustGenerateRotatingID(uniqueID []byte, lifetimeCounter uint16) string

MustGenerateRotatingID is a helper for tests / first-boot wiring that constructs a RotatingID from a fresh uniqueID. It panics when uniqueID is shorter than RotatingIDUniqueIDLength so the caller gets a clear failure mode at construction rather than a silent empty RI key.

func OperationalInstanceQName added in v0.2.0

func OperationalInstanceQName(compressedFabricID [8]byte, nodeID uint64) string

OperationalInstanceQName returns the DNS-SD name of a node's operational instance, `<CompressedFabricID>-<NodeID>._matter._tcp.local.`, both ids as 16 upper-case hex digits. Mirrors matter.js packages/protocol/src/mdns/MdnsConsts.ts:getOperationalDeviceQname, and is the instance name BuildOperationalService advertises.

func PackDNSQuery added in v0.4.0

func PackDNSQuery(questions []DNSQuestion, knownAnswers []DNSRecord) ([]byte, error)

PackDNSQuery encodes an mDNS query (id 0, RFC 6762 §18.1) with the given questions and known answers. Known answers are written without the cache-flush bit, which means nothing outside a response (RFC 6762 §18.12), whatever their CacheFlush says.

func SelectionPreference added in v0.2.0

func SelectionPreference(a netip.Addr) int

SelectionPreference ranks an address for connection attempts, lower first: IPv6 link-local 0, IPv6 ULA 1, other IPv6 2, IPv4 3. Mirrors matter.js packages/general/src/net/ServerAddress.ts:selectionPreferenceOfIp.

Types

type Advertiser

type Advertiser interface {
	// Publish announces (or re-announces) svc. Replaces an existing
	// record with the same InstanceName + ServiceType.
	Publish(ctx context.Context, svc Service) error
	// Withdraw retracts the record. Implementations send a
	// "goodbye" packet (TTL=0) before deleting their local copy
	// when the underlying transport supports it.
	Withdraw(ctx context.Context, instanceName, serviceType string) error
	// Active lists the currently published service records.
	Active() []Service
	// Close tears down the advertiser. Idempotent.
	Close() error
}

Advertiser is the runtime surface a bridge component holds onto. Implementations vary in how they push records onto the wire — the production Zeroconf registers each service with a responder that probes, announces and answers on UDP/5353; Noop only records the services in memory and puts nothing on the network, which is what tests and the boot phase before the network stack is up want.

type CommissionableServiceConfig

type CommissionableServiceConfig struct {
	// InstanceID is the 16-byte randomly-generated identifier the
	// bridge advertises while the commissioning window is open.
	// Caller is responsible for cycling it on every window-open.
	InstanceID [8]byte
	// Discriminator is the 12-bit short discriminator (Matter
	// §4.3.1.5.1). Embedded in the `_S<short>` subtype.
	Discriminator uint16
	// VendorID is the bridge's IANA-assigned vendor identifier.
	VendorID uint16
	// ProductID is the vendor-assigned product identifier.
	ProductID uint16
	// CommissioningMode per Matter §4.3.1.6 (`CM` TXT key):
	//   0 = not in commissioning mode (should never appear on this
	//       service — the service is only emitted while open),
	//   1 = standard commissioning,
	//   2 = enhanced commissioning.
	CommissioningMode uint8
	// DeviceTypeID is the bridge's primary device type for UI hint
	// (`DT` TXT key). Aggregator = 0x000E.
	DeviceTypeID uint32
	// DeviceName is the operator-visible name (`DN` TXT key, max 32
	// utf-8 bytes).
	DeviceName string
	// PairingHint / PairingInstruction follow Matter §4.3.1.6 (`PH`
	// / `PI`). PairingHint defaults to
	// [PairingHintDefault] (powerCycle | deviceManual = 0x21) when
	// zero, matching matter.js
	// CommissionableMdnsAdvertisement.ts:90 DEFAULT_PAIRING_HINT.
	PairingHint        uint16
	PairingInstruction string
	// RotatingID is the optional Rotating Device Identifier (Matter
	// §4.3.1.6 `RI` TXT key). When non-empty, emitted verbatim so
	// controllers that use the rotating ID for "already added?"
	// checks can correlate devices.
	// matter.js CommissionableMdnsAdvertisement.ts (Scanner.ts:47
	// `RI?: string`) and chip
	// Advertiser_ImplMinimalMdns.cpp:878-881 both emit RI only when
	// the platform supplies it; go-fabric does the same.
	RotatingID string
	// ICD, when non-nil, emits the `ICD` TXT key with the given
	// operating-mode value per Matter §4.3.1.6.
	// matter.js MdnsAdvertisement.ts:191-193; chip TxtFields.cpp:302.
	ICD *uint8
	// Port is the commissioning listener UDP port (typically 5540).
	Port uint16
	// HostName / Addresses mirror OperationalServiceConfig.
	HostName  string
	Addresses []net.IP
	// SessionIdleInterval / SessionActiveInterval are the MRP tuning
	// hints added to the commissioning TXT record in Matter 1.4
	// (`SII` / `SAI` keys per §4.3.1.6 and matter.js
	// packages/protocol/src/session/SessionIntervals.ts). The spec
	// recommends SII=5000 ms, SAI=300 ms as default values. Zero
	// triggers the defaults.
	SessionIdleInterval   uint32
	SessionActiveInterval uint32
	// SessionActiveThreshold is the SAT TXT key (Matter 1.4) — see
	// the operational config; default 4000 ms when zero.
	SessionActiveThreshold uint32
	// Logger receives the builder's diagnostics; nil is slog.Default().
	Logger *slog.Logger
}

CommissionableServiceConfig drives BuildCommissionableService.

type DNSMessage added in v0.4.0

type DNSMessage struct {
	// Response is the QR bit.
	Response    bool
	Questions   []DNSQuestion
	Answers     []DNSRecord
	Authorities []DNSRecord
	Additional  []DNSRecord
}

DNSMessage is a decoded DNS message.

func ParseDNSMessage added in v0.4.0

func ParseDNSMessage(msg []byte) (DNSMessage, error)

ParseDNSMessage decodes msg. It fails on a message that does not parse.

type DNSQuestion added in v0.4.0

type DNSQuestion struct {
	// Name is the queried name, with or without the trailing dot.
	Name string
	Type DNSType
}

DNSQuestion is one question of a query.

type DNSRecord added in v0.4.0

type DNSRecord struct {
	// Name is the owner name, without the trailing dot.
	Name string
	Type DNSType
	// TTL is the time to live in seconds; 0 is a goodbye.
	TTL uint32
	// CacheFlush is the top bit of the class (RFC 6762 §10.2).
	CacheFlush bool
	// IP is the address of an A or AAAA record.
	IP netip.Addr
	// PTR is the target of a PTR record, without the trailing dot.
	PTR string
	// SRV is the value of an SRV record; its target has no trailing dot,
	// and the root target "." decodes as "".
	SRV DNSSRV
	// TXT is the entries of a TXT record, as raw bytes.
	TXT [][]byte
}

DNSRecord is one resource record in the shape matter.js's DnsCodec decodes it (packages/general/src/codec/DnsCodec.ts): names without the trailing root dot, the cache-flush bit split off the class, the value typed by the record type. A record of another type keeps its name, type and TTL and carries no value.

type DNSSRV added in v0.4.0

type DNSSRV struct {
	Target   string
	Port     uint16
	Priority uint16
	Weight   uint16
}

DNSSRV is the value of an SRV record.

type DNSType added in v0.4.0

type DNSType uint16

DNSType is a DNS resource-record type.

const (
	DNSTypeA    DNSType = DNSType(dns.TypeA)
	DNSTypePTR  DNSType = DNSType(dns.TypePTR)
	DNSTypeTXT  DNSType = DNSType(dns.TypeTXT)
	DNSTypeAAAA DNSType = DNSType(dns.TypeAAAA)
	DNSTypeSRV  DNSType = DNSType(dns.TypeSRV)
)

The record types DNS-SD discovery uses.

func (DNSType) String added in v0.4.0

func (t DNSType) String() string

String returns the record type's mnemonic ("A", "PTR", …).

type Enabled added in v0.5.0

type Enabled interface {
	// Enabled is true when the advertiser publishes on the network.
	Enabled() bool
}

Enabled is an Advertiser that states whether it puts records on the network, so a diagnostic need not assert the concrete type. It is optional; IsEnabled reads it.

type Finding

type Finding struct {
	Severity Severity
	// Code is a stable machine-readable identifier; the message is
	// prose for an operator and may be reworded.
	Code    string
	Message string
	// Service names the advertised record the finding applies to, empty
	// when it concerns the advertisement as a whole.
	Service string
}

Finding is one diagnosed condition of the advertised records.

func Diagnose

func Diagnose(services []Service) []Finding

Diagnose inspects the records the bridge currently advertises and reports what would keep a controller from finding or commissioning it.

It exists because every one of these conditions is silent: the daemon advertises successfully, the log says so, and the controller simply never shows the bridge. Each check below corresponds to a failure that has actually happened rather than a rule read off the spec.

type Noop

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

Noop is an advertiser that just records published services in memory. Used by tests and by the daemon during the boot phase before the network stack is up.

func NewNoop

func NewNoop() *Noop

NewNoop returns an empty no-op advertiser.

func (*Noop) Active

func (n *Noop) Active() []Service

Active implements Advertiser.

func (*Noop) Close

func (n *Noop) Close() error

Close implements Advertiser. No-op for the no-op advertiser; the in-memory map drops out of scope with the receiver.

func (*Noop) Enabled added in v0.5.0

func (n *Noop) Enabled() bool

Enabled implements Enabled: a Noop advertiser publishes nothing.

func (*Noop) Publish

func (n *Noop) Publish(_ context.Context, svc Service) error

Publish implements Advertiser.

func (*Noop) Withdraw

func (n *Noop) Withdraw(_ context.Context, instanceName, serviceType string) error

Withdraw implements Advertiser.

type OperationalResolver added in v0.2.0

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

OperationalResolver finds the address of one peer's operational instance by multicast DNS query. It is the minimum matter.js's own operational discovery does for a peer it wants to reach (packages/general/src/net/dns-sd/IpServiceResolution.ts): query SRV and TXT for the instance, query A/AAAA for an SRV target that came without addresses, and stop at the first address. It is not a browser — it resolves exactly the one instance it is asked for and keeps nothing between calls.

go-fabric uses it only to re-establish former subscriptions after a restart (docs/adr/0008).

func NewOperationalResolver added in v0.2.0

func NewOperationalResolver(logger *slog.Logger) *OperationalResolver

NewOperationalResolver returns a resolver on the host's multicast interfaces.

func (*OperationalResolver) ResolveOperational added in v0.2.0

func (r *OperationalResolver) ResolveOperational(ctx context.Context, compressedFabricID [8]byte, nodeID uint64) ([]*net.UDPAddr, error)

ResolveOperational resolves the operational instance of nodeID on the fabric with compressedFabricID and returns its addresses, most desirable first (IPv6 link-local, ULA, other IPv6, IPv4 — matter.js ServerAddress.selectionPreferenceOf). It returns as soon as the SRV record and at least one address of its target are known, or ErrOperationalNotResolved when ctx ends first.

func (*OperationalResolver) SetClock added in v0.7.2

func (r *OperationalResolver) SetClock(c clock.Clock)

SetClock sets the clock the query retries are paced on; nil is clock.System.

type OperationalServiceConfig

type OperationalServiceConfig struct {
	// CompressedFabricID is the 8-byte derived fabric identifier
	// (HKDF over the root public key + fabric ID, Matter §4.13.2.4).
	CompressedFabricID [8]byte
	// NodeID is the 64-bit operational node identifier.
	NodeID uint64
	// Port is the TCP listener port (typically 5540 per Matter §4.4).
	Port uint16
	// HostName is the bridge hostname.
	HostName string
	// Addresses are the local IPv6 / IPv4 addresses.
	Addresses []net.IP
	// SessionActiveThreshold is the Matter 1.4 SAT TXT key — the
	// minimum time the node SHOULD stay active after network activity
	// (matter.js packages/protocol/src/session/SessionIntervals.ts:29
	// default 4000 ms). Apple Home + chip-tool's MRP timer reads SAT
	// to size its retry budget; absence collapses retries to a stale
	// 4000 ms hardcode. Zero triggers the matter.js default.
	SessionActiveThreshold uint32
	// SessionIdleInterval / SessionActiveInterval are the MRP idle /
	// active retransmission tuning hints (Matter §4.3.1.6 SII / SAI).
	SessionIdleInterval   uint32
	SessionActiveInterval uint32
	// TCPClient / TCPServer flag bits (Matter 1.5 §4.3.1.6 TCPCommon).
	// 0 = unsupported, 1 = supported (TCPSupport feature).
	TCPClient bool
	TCPServer bool
	// ICD, when non-nil, emits the `ICD` TXT key in the operational
	// record: the node's ICD operating mode, 0 SIT or 1 LIT (matter.js
	// MdnsAdvertisement.ts:209-211). nil for a node that is no ICD.
	ICD *uint8
	// OmitSessionIdleInterval leaves the SII key out: a LIT ICD SHOULD
	// NOT advertise it (Core §9.15.1.6.2; matter.js
	// IcdManagementServer.ts:490-494 sets no idleInterval for LIT).
	OmitSessionIdleInterval bool
	// Logger receives the builder's diagnostics; nil is slog.Default().
	Logger *slog.Logger
}

OperationalServiceConfig drives BuildOperationalService.

type QueryConn added in v0.4.0

type QueryConn interface {
	// Send multicasts one DNS message.
	Send(msg []byte) error
	// Responses delivers received DNS messages with the index of the
	// interface they arrived on (0 when unknown). A full queue drops a
	// message rather than blocking the socket; the querier's retry asks
	// again. The channel is not closed by Close.
	Responses() <-chan QueryResponse
	// Close leaves the groups and releases the sockets. Idempotent.
	Close() error
}

QueryConn is the multicast transport a querier runs over: it sends DNS messages to the mDNS groups (224.0.0.251 and ff02::fb, UDP/5353) and delivers every DNS message received there, with the index of the interface it arrived on. OperationalResolver resolves one instance over it; the controller's browser (client/discovery) keeps a record cache on top of it. Production joins the groups on every multicast interface (OpenQueryConn); tests substitute a fake responder.

It is the socket half of matter.js's MdnsSocket (packages/general/src/net/dns-sd/MdnsSocket.ts): receive with the source interface, send to the groups. The relevance pre-filter and the query/answer splitting of MdnsSocket are not part of it.

func OpenQueryConn added in v0.4.0

func OpenQueryConn() (QueryConn, error)

OpenQueryConn joins the mDNS groups on every multicast interface, IPv4 and IPv6, and returns the connection. It fails only when neither family could join.

type QueryResponse added in v0.4.0

type QueryResponse struct {
	// Msg is the raw DNS message.
	Msg []byte
	// IfIndex is the index of the receiving interface, 0 when unknown.
	IfIndex int
}

QueryResponse is one DNS message received on the mDNS groups. Despite the name it may be a query — the groups carry both; ParseDNSMessage reports which.

type Reannouncer added in v0.5.0

type Reannouncer interface {
	// StartReannounceLoop re-publishes every active record every interval
	// until cancel is called or ctx ends; a value sent on trigger
	// re-publishes at once ([Zeroconf.StartReannounceLoop]).
	StartReannounceLoop(ctx context.Context, interval time.Duration) (cancel func(), trigger chan<- struct{})
	// TriggerReannounce re-publishes every active record now, outside the
	// periodic cadence ([Zeroconf.TriggerReannounce]).
	TriggerReannounce(ctx context.Context)
}

Reannouncer is an Advertiser that re-publishes its records on its own: periodically, and on demand after a link change. It is optional — Advertiser does not carry it — so a holder of the interface (the node facade) starts the loop on an advertiser that has one and skips it on one that does not. Zeroconf implements it.

The loop is this module's own device, not a matter.js port: matter.js sends no unsolicited periodic announcement, its MdnsServer re-sends an answer as multicast on a query once a quarter of the record's TTL has passed since it was last multicast (matter.js packages/protocol/src/mdns/MdnsServer.ts:158-164 at 0ceb202d). The upstream library Zeroconf runs on probes and announces once per Register and never again, so the periodic re-publish stands in for it.

type Service

type Service struct {
	// InstanceName is the leftmost label of the SRV / TXT record
	// (e.g. "9C71D38FBE48F2E5-0000000012345678"). 1..63 octets.
	InstanceName string
	// ServiceType is the service-type label
	// ("_matter._tcp" / "_matterc._udp").
	ServiceType string
	// Domain is typically "local". Empty defaults to "local".
	Domain string
	// Port is the operational TCP / commissioning UDP port.
	Port uint16
	// HostName is the bridge's hostname (sans domain) — same value
	// for both service records.
	HostName string
	// Addresses are the local IP addresses the bridge is reachable at.
	// IPv6 first per Matter §4.3.1.5.
	Addresses []net.IP
	// TXT records carry the discovery metadata. Order is not
	// significant — [Service.MarshalTXT] sorts before emit.
	TXT []TXTRecord
	// Subtypes are the additional service-type labels the responder
	// announces (e.g. `_L<long-discriminator>` for commissionable
	// service). Each subtype is the bare label without the
	// `._sub.<service-type>` suffix.
	Subtypes []string
}

Service is a typed mDNS service record. The Advertiser converts it to wire-format DNS messages.

func BuildCommissionableService

func BuildCommissionableService(cfg CommissionableServiceConfig) Service

BuildCommissionableService constructs the `_matterc._udp` record. Subtypes:

  • `_L<long>` long discriminator (always)
  • `_S<short>` short discriminator (always)
  • `_V<vendor-id>` vendor filter (always)
  • `_CM` commissioning-mode (per Matter §4.3.1.5.3)
  • `_T<deviceTypeID>` device-type filter (per Matter §4.3.1.5.4)

TXT keys include `SII` and `SAI` per §4.3.1.6 and matter.js packages/protocol/src/session/SessionIntervals.ts defaults.

func BuildOperationalService

func BuildOperationalService(cfg OperationalServiceConfig) Service

BuildOperationalService constructs the operational `_matter._tcp` record from the bridge's runtime state.

func (Service) FQDN

func (s Service) FQDN() string

FQDN returns the fully-qualified service-instance name in DNS form: "<instance>.<service-type>.<domain>.".

func (Service) HostFQDN

func (s Service) HostFQDN() string

HostFQDN returns the host's fully-qualified name: "<host>.<domain>.".

func (Service) MarshalTXT

func (s Service) MarshalTXT() []string

MarshalTXT returns the TXT records as `key=value` strings sorted by key (case-insensitive). Sorting keeps the wire form deterministic across re-emit cycles — convenient for golden-vector tests.

func (Service) Validate

func (s Service) Validate() error

Validate checks the service record for the constraints common to every Advertiser implementation.

type Severity

type Severity string

Severity ranks a finding by what it costs an operator.

const (
	// SeverityError marks a condition under which commissioning or
	// operational discovery cannot work at all.
	SeverityError Severity = "error"
	// SeverityWarning marks a condition that works for some ecosystems
	// and not others, or that depends on the network the daemon runs on.
	SeverityWarning Severity = "warning"
)

Severity values.

type SubtypeResponder

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

SubtypeResponder is a side-car mDNS responder that serves PTR queries for service subtypes (`_<sub>._sub.<service>.local.`) with a PTR record pointing at the primary instance — the form Apple Home and Google Home rely on when filtering commissionable Matter bridges by long discriminator (`_L<long>`), short discriminator (`_S<short>`), commissioning mode (`_CM`) or vendor (`_V<vid>`).

`grandcat/zeroconf` lacks a first-class subtype API; emitting each subtype as its own service registers PTRs of the form `_<sub>._sub.<service> PTR <instance>._<sub>._sub.<service>` instead of `_<sub>._sub.<service> PTR <instance>.<service>`. Apple's commissioner browse hits the wrong target, sees no SRV with the expected name, and gives up with "device not found".

This responder fixes that without forking the upstream library: it joins the mDNS multicast groups (224.0.0.251, ff02::fb) on UDP/5353 alongside zeroconf via `SO_REUSEADDR`-style multicast wildcard bind and answers only the PTR queries it has explicit mappings for. It also answers the address questions for the advertiser's own host name (A, AAAA, ANY), which grandcat/zeroconf never answers on its own — see SubtypeResponder.SetHost. Anything else is ignored — the upstream library still owns the service records (PTR/SRV/TXT) and the A/AAAA additionals it sends with them.

func NewSubtypeResponder

func NewSubtypeResponder(logger *slog.Logger) (*SubtypeResponder, error)

NewSubtypeResponder binds the responder to the mDNS multicast groups on every multicast-capable interface. Returns an error if neither IPv4 nor IPv6 could be joined; either alone is sufficient for normal operation (Apple Home pairs over both).

The caller owns the responder lifecycle: call SubtypeResponder.Start to spin up the receive loops and SubtypeResponder.Close during shutdown.

func (*SubtypeResponder) AddSubtype

func (r *SubtypeResponder) AddSubtype(subType, primaryFQDN string)

AddSubtype registers a `_<sub>._sub.<service>.local.` → `<instance>.<service>.local.` mapping. Idempotent. Lowercases the qname for case-insensitive lookup.

func (*SubtypeResponder) Announce

func (r *SubtypeResponder) Announce()

Announce proactively multicasts every registered subtype PTR as an unsolicited response — the RFC 6762 §8.3 announcement. Commissioners do not usually query for a subtype they have never seen: Apple's browse-by-long-discriminator (`_L<disc>._sub._matterc._udp`) is satisfied from the peer's mDNS cache, which the primary record fills via grandcat/zeroconf's own register-time announcements. Without announcing the subtype PTRs alongside, the cache holds the primary instance but not the `_L*` filter record and the commissioner reports "device not found" although the bridge is resolvable. Mirrors matter.js packages/protocol/src/mdns/MdnsServer.ts announce(), which broadcasts the full record set (subtypes included) per announcement.

The spec asks for at least two transmissions one second apart; the caller schedules the repeats (see [Zeroconf.announceSubtypes]) so this method stays synchronous and single-shot.

func (*SubtypeResponder) Close

func (r *SubtypeResponder) Close() error

Close cancels the receive loops and releases the multicast sockets. Idempotent and safe from several goroutines: the responder has two owners in practice — whoever constructed it and the advertiser it was attached to — and neither can know whether the other already shut it down. Without the lock the second Close would race the first on the cancel func and the two packet conns.

func (*SubtypeResponder) RemoveSubtype

func (r *SubtypeResponder) RemoveSubtype(subType string)

RemoveSubtype drops a previously registered mapping and multicasts a goodbye (TTL=0) for the record so caches evict it immediately — the counterpart to the SubtypeResponder.Announce fill. Without the goodbye a withdrawn commissioning window stays browsable for the remaining record TTL.

func (*SubtypeResponder) SetClock added in v0.7.2

func (r *SubtypeResponder) SetClock(c clock.Clock)

SetClock sets the clock the responder's backoff and its host-name answerer's suppression windows run on; nil is clock.System.

func (*SubtypeResponder) SetHost added in v0.2.0

func (r *SubtypeResponder) SetHost(name string, addrsFor func(ifIndex int) []net.IP)

SetHost makes the responder answer A, AAAA and ANY questions for name (the SRV target the advertiser publishes) with the addresses addrsFor returns for the interface a query arrived on — nil or empty for an interface the host does not advertise on, whose queries then go unanswered. An empty name stops answering. Zeroconf calls it on every Publish with its own host name and address policy; a name the OS responder owns (the OS host name) must not be set, or two responders answer for it.

func (*SubtypeResponder) Start

func (r *SubtypeResponder) Start(ctx context.Context)

Start begins the receive loops. Idempotent — repeated calls are no-ops.

type TXTRecord

type TXTRecord struct {
	Key   string
	Value string
}

TXTRecord is one (key, value) entry. Matter TXT records use ASCII keys with case-insensitive lookup; values are ASCII text.

type Zeroconf

type Zeroconf struct {

	// InterfaceFilter decides which host interfaces are kept out of the
	// advertised A/AAAA records: it reports true for an interface name
	// whose addresses must NOT be published. [NewZeroconf] installs
	// [isVirtualInterfaceName]; a nil value falls back to the same
	// predicate. Replace it before the first Publish to change the
	// policy — it is read on every Publish, under the advertiser lock.
	InterfaceFilter func(name string) bool
	// HostName, when set, is the SRV target of every record this
	// advertiser publishes, in place of the one the Service carries (the
	// MAC-derived default the Build* helpers fill in). A test host whose
	// LAN interface has no IPv6 sets the OS host name here, whose address
	// records the OS responder publishes for every interface — the IPv6
	// link-local ones an IPv6-only controller needs included. Set it
	// before the first Publish.
	HostName string
	// contains filtered or unexported fields
}

Zeroconf is an Advertiser backed by github.com/grandcat/zeroconf. It binds the bridge's `_matter._tcp` (operational) and `_matterc._udp` (commissionable) records to the host's multicast interfaces so chip-tool's `pairing onnetwork-*` discovery can find the bridge by service-type instead of needing an explicit `<ip> <port>` argument.

Subtype records are NOT published on their own. A Service may carry Subtypes (`_L<discriminator>`, `_S<short>`, `_CM`, `_V<vid>` — Matter §4.3.1.4), but Publish forwards them only when a SubtypeResponder has been attached: the loop that turns them into `_<sub>._sub.<service>.local.` PTR mappings sits behind a `z.responder != nil` guard, and with no responder the field is read, dropped, and an empty subtype list recorded for the key. Nothing fails and nothing logs — the primary record set goes out complete and only the browse-by-filter form is missing, which is precisely the form Apple Home and Google Home use to find a commissionable bridge. A caller that wants subtypes on the wire must, in order:

r, err := mdns.NewSubtypeResponder(logger) // binds 224.0.0.251 / ff02::fb
r.Start(ctx)                               // receive loops answer PTR queries
zc.AttachSubtypeResponder(r)               // BEFORE the first Publish
zc.Publish(ctx, svc)

Attaching after a Publish leaves that publish's subtypes lost until it is re-published; the mappings are handed over inside Publish, not replayed on attach. Closing the advertiser closes the responder.

One zeroconf.Server is held per published (instance, service-type) pair — subtypes add none, since the side-car answers for them — so Withdraw and Close dismantle each primary independently.

The upstream library probes and announces once per Register and does not re-announce afterwards, so a commissioner that missed the announce window (late join, transient link loss, cache eviction past TTL) never learns about the bridge. Re-announcement is this type's job, not the library's: run Zeroconf.StartReannounceLoop for the periodic cadence and call Zeroconf.TriggerReannounce on link changes. Which host interfaces contribute A/AAAA records is likewise decided here, by Zeroconf.InterfaceFilter.

func NewZeroconf

func NewZeroconf() *Zeroconf

NewZeroconf returns a multicast advertiser backed by zeroconf. The caller is responsible for calling Zeroconf.Close (typically during the daemon's shutdown sequence).

func (*Zeroconf) Active

func (z *Zeroconf) Active() []Service

Active implements Advertiser.

func (*Zeroconf) AttachSubtypeResponder

func (z *Zeroconf) AttachSubtypeResponder(r *SubtypeResponder)

AttachSubtypeResponder wires a side-car responder that handles PTR queries for service subtypes — the form Apple Home and Google Home use to filter commissionable Matter bridges by long discriminator, short discriminator, vendor, and commissioning mode. Must be called before Zeroconf.Publish for the responder to receive the subtype-mappings of subsequent publishes.

Attaching transfers the responder's shutdown to this advertiser: Zeroconf.Close closes it, releasing both receive goroutines and both multicast sockets.

func (*Zeroconf) Close

func (z *Zeroconf) Close() error

Close implements Advertiser. Idempotent — repeated calls return nil.

func (*Zeroconf) Enabled added in v0.5.0

func (z *Zeroconf) Enabled() bool

Enabled implements Enabled: a Zeroconf advertiser publishes on the network.

func (*Zeroconf) Publish

func (z *Zeroconf) Publish(_ context.Context, svc Service) error

Publish implements Advertiser. Each publish call registers a fresh zeroconf.Server for the primary type plus one per Service.Subtypes entry; if the same instance/service-type pair is already published the prior server bundle is shut down first so re-publish produces a clean record (for example after a TXT-record bump). On any subtype-registration failure the primary is also torn down and the error is returned, so a partial publish never leaks half-registered records.

func (*Zeroconf) SetClock added in v0.7.2

func (z *Zeroconf) SetClock(c clock.Clock)

SetClock sets the clock the subtype re-announcement and the re-announce loop run on, and the attached SubtypeResponder's; nil is clock.System.

func (*Zeroconf) SetLogger added in v0.10.0

func (z *Zeroconf) SetLogger(l *slog.Logger)

SetLogger sets the logger the advertiser's diagnostics go to; nil is slog.Default(). It is safe to call at any time.

func (*Zeroconf) StartReannounceLoop

func (z *Zeroconf) StartReannounceLoop(ctx context.Context, interval time.Duration) (cancel func(), trigger chan<- struct{})

StartReannounceLoop spawns a background goroutine that re-publishes every active service every `interval` and also responds to TriggerReannounce calls on the returned trigger channel. mDNS responder libraries like grandcat/zeroconf only Probe + Announce on initial Register; commissioners that miss the announce window (transient network loss, late join, mDNS cache eviction past TTL) never see the bridge until it re-announces.

The returned trigger channel accepts a single value; sending to it causes an immediate re-publish before the next periodic tick. The channel is buffered (capacity 1) so a burst of events collapses into one re-announce. Callers that do not use event-driven triggering may ignore the channel — the periodic cadence still applies.

Returns a (cancel func, trigger chan); cancel is safe to call more than once. One loop runs at a time: while one is running, a second call starts none, logs at Info and returns a no-op cancel and the running loop's trigger channel — so a host that starts the loop beside the node facade, which starts it itself, gets one loop, not two. Once the running loop is cancelled (or its ctx ends) a new call starts one.

func (*Zeroconf) TriggerReannounce

func (z *Zeroconf) TriggerReannounce(ctx context.Context)

TriggerReannounce performs an immediate re-publish of every active service outside the periodic tick interval. Intended for use on network-change events (interface up/down, address assigned) where waiting for the next periodic tick would leave the bridge invisible to commissioners for up to the full interval. Safe to call from any goroutine; the underlying republishAll is concurrency-safe.

func (*Zeroconf) Withdraw

func (z *Zeroconf) Withdraw(_ context.Context, instanceName, serviceType string) error

Withdraw implements Advertiser. The library emits goodbye packets (TTL=0) on Shutdown so peers learn the service vanished without waiting for the TTL to elapse. Subtypes registered alongside the primary are also withdrawn.

Jump to

Keyboard shortcuts

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