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:
- Service is the typed record bundle.
- BuildOperationalService / BuildCommissionableService map the bridge state to a Service.
- Advertiser is the runtime surface; NewZeroconf returns the implementation that announces and responds via UDP multicast on 5353. NewNoop is a stub for tests.
- Subtype PTRs (`_L<discriminator>._sub._matterc._udp` and the rest of Matter §4.3.1.4) are not part of that registration. They require a SubtypeResponder attached to the advertiser before the first Publish — see Zeroconf for the call order and for what a caller that skips it loses.
The implementation does *not* aim to be a general-purpose mDNS stack. It services the Matter discovery requirements only.
Index ¶
- Constants
- Variables
- func DeriveUniqueIDFromIdentity(vendorID, productID uint16, serialNumber, nodeLabel string) []byte
- func GenerateRotatingID(uniqueID []byte, lifetimeCounter uint16) string
- func IsEnabled(a Advertiser) bool
- func MustGenerateRotatingID(uniqueID []byte, lifetimeCounter uint16) string
- func OperationalInstanceQName(compressedFabricID [8]byte, nodeID uint64) string
- func PackDNSQuery(questions []DNSQuestion, knownAnswers []DNSRecord) ([]byte, error)
- func SelectionPreference(a netip.Addr) int
- type Advertiser
- type CommissionableServiceConfig
- type DNSMessage
- type DNSQuestion
- type DNSRecord
- type DNSSRV
- type DNSType
- type Enabled
- type Finding
- type Noop
- type OperationalResolver
- type OperationalServiceConfig
- type QueryConn
- type QueryResponse
- type Reannouncer
- type Service
- type Severity
- type SubtypeResponder
- func (r *SubtypeResponder) AddSubtype(subType, primaryFQDN string)
- func (r *SubtypeResponder) Announce()
- func (r *SubtypeResponder) Close() error
- func (r *SubtypeResponder) RemoveSubtype(subType string)
- func (r *SubtypeResponder) SetClock(c clock.Clock)
- func (r *SubtypeResponder) SetHost(name string, addrsFor func(ifIndex int) []net.IP)
- func (r *SubtypeResponder) Start(ctx context.Context)
- type TXTRecord
- type Zeroconf
- func (z *Zeroconf) Active() []Service
- func (z *Zeroconf) AttachSubtypeResponder(r *SubtypeResponder)
- func (z *Zeroconf) Close() error
- func (z *Zeroconf) Enabled() bool
- func (z *Zeroconf) Publish(_ context.Context, svc Service) error
- func (z *Zeroconf) SetClock(c clock.Clock)
- func (z *Zeroconf) SetLogger(l *slog.Logger)
- func (z *Zeroconf) StartReannounceLoop(ctx context.Context, interval time.Duration) (cancel func(), trigger chan<- struct{})
- func (z *Zeroconf) TriggerReannounce(ctx context.Context)
- func (z *Zeroconf) Withdraw(_ context.Context, instanceName, serviceType string) error
Constants ¶
const ( ServiceTypeOperational = "_matter._tcp" ServiceTypeCommissionable = "_matterc._udp" )
Operational + Commissionable service-type labels per Matter §4.3.1.
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.
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 ¶
var ( // ErrInvalidService is returned when [Service.Validate] rejects // a service-record bundle. ErrInvalidService = errors.New("mdns: invalid service") )
Errors.
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.
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 ¶
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 ¶
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 ¶
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
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
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 DNSType ¶ added in v0.4.0
type DNSType uint16
DNSType is a DNS resource-record type.
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 ¶
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 (*Noop) Close ¶
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
Enabled implements Enabled: a Noop advertiser publishes nothing.
func (*Noop) Publish ¶
Publish 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
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 ¶
FQDN returns the fully-qualified service-instance name in DNS form: "<instance>.<service-type>.<domain>.".
func (Service) MarshalTXT ¶
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.
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 ¶
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) 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 ¶
Close implements Advertiser. Idempotent — repeated calls return nil.
func (*Zeroconf) Enabled ¶ added in v0.5.0
Enabled implements Enabled: a Zeroconf advertiser publishes on the network.
func (*Zeroconf) Publish ¶
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
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
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 ¶
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 ¶
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.