Skip to content

Commit bda6d87

Browse files
authored
fix(mdns): send responses from UDP 5353 (GitHub Issue #1) (NVIDIA#102)
fix(mdns): send responses from UDP 5353 (GitHub Issue #1) Summary Bind mDNS responses, announcements, goodbyes, and supplemental discovery queries to UDP source port 5353. Preserve per-interface sending and platform-specific socket sharing. Add source-port regression tests, architecture documentation, and native Linux, Windows, and macOS CI coverage.
1 parent b4a0cc5 commit bda6d87

10 files changed

Lines changed: 564 additions & 71 deletions

File tree

‎docs/architecture.mdx‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -587,13 +587,17 @@ scanner mints one and persists it.
587587
### Sharing UDP 5353
588588

589589
PAIR runs its own mDNS responder rather than depending on a system one, because
590-
Windows ships none. That responder must coexist with whatever else is on the
591-
port, including Bonjour, Avahi, and PAIR's own sibling processes. It therefore
592-
sets `SO_REUSEADDR` on the socket to share UDP 5353.
593-
594-
It deliberately does **not** set `SO_REUSEPORT`. On Linux that load-balances
595-
incoming unicast datagrams across every socket sharing the port, which would let
596-
one process swallow mDNS replies meant for another.
590+
Windows ships none. Its receive socket and short-lived per-interface send
591+
sockets bind UDP 5353, as RFC 6762 requires for mDNS queries and responses.
592+
Binding each sender to the selected interface address also preserves reliable
593+
egress on multi-homed Windows hosts.
594+
595+
Those sockets set `SO_REUSEADDR` so they coexist with Bonjour, Avahi, and other
596+
PAIR processes. A Darwin sender also sets `SO_REUSEPORT`, matching the BSD
597+
multicast sharing behavior needed to coexist with the system mDNS responder.
598+
Linux deliberately omits `SO_REUSEPORT`: there it can load-balance incoming
599+
unicast datagrams into a short-lived send socket and steal replies from the
600+
long-lived receiver.
597601

598602
### Node Enrichment
599603

‎services/readme.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ This tree builds twelve Go binaries. `nvpair-ui-broker` is the parent service an
5454

5555
Shared code lives in the local `shared/` Go module (imported as `nvpair-shared/…`, replaced via `replace nvpair-shared => ../shared`). It provides logging, wire types, JSON-RPC and IPC, discovery records, mDNS, network monitoring, stable node identity, application data paths, and cluster trust helpers.
5656

57-
The mDNS responder is our own rather than the host's, because Windows ships none. It sets `SO_REUSEADDR` so it shares UDP 5353 with sibling PAIR processes and with a system responder — `avahi-daemon` on Linux, Bonjour where present — needing no configuration on either platform.
57+
The mDNS responder is our own rather than the host's, because Windows ships
58+
none. Its receive and per-interface send sockets bind UDP 5353 as RFC 6762
59+
requires. Socket reuse lets them coexist with sibling PAIR processes and with a
60+
system responder — `avahi-daemon` on Linux or Bonjour where present — without
61+
configuration.
5862

5963
The broker feeds every accepted local or peer workload transition plus compact
6064
GPU telemetry to the scheduler. Queued and running work is counted by destination

‎services/shared/discovery/discovery.go‎

Lines changed: 33 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
//
99
// The core is a scan-and-diff state machine: each scan browses a service type
1010
// over grandcat/zeroconf, re-sends the PTR query from a per-interface unicast
11-
// socket (the Windows send workaround — zeroconf sends from a multicast-bound
12-
// socket Windows refuses to transmit on), and reconciles the result against the
13-
// known-node map. Address/TXT comparison is order-insensitive so a multi-homed
14-
// node whose records come back reordered doesn't churn a spurious "updated".
11+
// UDP 5353 socket (the Windows send workaround — zeroconf sends from a
12+
// multicast-bound socket Windows refuses to transmit on), and reconciles the
13+
// result against the known-node map. Address/TXT comparison is order-insensitive
14+
// so a multi-homed node whose records come back reordered doesn't churn a
15+
// spurious "updated".
1516
//
1617
// The per-service variations are expressed as functional options rather than
1718
// forks:
@@ -44,9 +45,10 @@ import (
4445
"sync"
4546
"time"
4647

48+
"nvpair-shared/mdns"
49+
4750
"github.com/grandcat/zeroconf"
4851
"github.com/miekg/dns"
49-
"golang.org/x/net/ipv4"
5052
)
5153

5254
// Event types emitted by Run.
@@ -607,8 +609,6 @@ func sendMulticastQuery(service, domain string) map[string]bool {
607609
return outcomes
608610
}
609611

610-
target := &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251), Port: 5353}
611-
612612
ifaces, err := net.Interfaces()
613613
if err != nil {
614614
slog.Warn("mdns send: enumerate interfaces failed", "err", err)
@@ -630,44 +630,20 @@ func sendMulticastQuery(service, domain string) map[string]bool {
630630
if err != nil {
631631
continue
632632
}
633-
var src net.IP
634-
for _, a := range addrs {
635-
ipnet, ok := a.(*net.IPNet)
636-
if !ok {
637-
continue
638-
}
639-
if ip4 := ipnet.IP.To4(); ip4 != nil {
640-
src = ip4
641-
break
642-
}
643-
}
633+
ifi := ifi
634+
src, err := sendMulticastQueryOnInterface(buf, &ifi, addrs, mdns.SendFromInterface)
644635
if src == nil {
645636
continue
646637
}
647-
648-
ifi := ifi
649-
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: src, Port: 0})
650638
if err != nil {
651-
slog.Debug("mdns send: bind failed", "iface", ifi.Name, "ip", src.String(), "err", err)
639+
slog.Debug("mdns send: send failed", "iface", ifi.Name, "ip", src.String(), "err", err)
652640
outcomes[ifi.Name] = false
653-
failures = append(failures, fmt.Sprintf("%s bind: %v", ifi.Name, err))
641+
failures = append(failures, fmt.Sprintf("%s send: %v", ifi.Name, err))
654642
continue
655643
}
656-
pc := ipv4.NewPacketConn(conn)
657-
if err := pc.SetMulticastInterface(&ifi); err != nil {
658-
slog.Debug("mdns send: SetMulticastInterface failed", "iface", ifi.Name, "err", err)
659-
}
660-
_ = pc.SetMulticastTTL(255)
661-
if _, err := conn.WriteToUDP(buf, target); err != nil {
662-
slog.Debug("mdns send: write failed", "iface", ifi.Name, "ip", src.String(), "err", err)
663-
outcomes[ifi.Name] = false
664-
failures = append(failures, fmt.Sprintf("%s write: %v", ifi.Name, err))
665-
} else {
666-
slog.Debug("mdns send: query sent", "service", service, "iface", ifi.Name, "ip", src.String())
667-
outcomes[ifi.Name] = true
668-
sent++
669-
}
670-
_ = conn.Close()
644+
slog.Debug("mdns send: query sent", "service", service, "iface", ifi.Name, "ip", src.String())
645+
outcomes[ifi.Name] = true
646+
sent++
671647
}
672648

673649
if sent == 0 {
@@ -681,6 +657,25 @@ func sendMulticastQuery(service, domain string) map[string]bool {
681657
return outcomes
682658
}
683659

660+
func sendMulticastQueryOnInterface(
661+
buf []byte,
662+
ifi *net.Interface,
663+
addrs []net.Addr,
664+
send func([]byte, *net.Interface, net.IP, *net.UDPAddr) error,
665+
) (net.IP, error) {
666+
for _, addr := range addrs {
667+
ipnet, ok := addr.(*net.IPNet)
668+
if !ok {
669+
continue
670+
}
671+
if ip4 := ipnet.IP.To4(); ip4 != nil {
672+
target := &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251), Port: 5353}
673+
return ip4, send(buf, ifi, ip4, target)
674+
}
675+
}
676+
return nil, nil
677+
}
678+
684679
// UUIDFromTXT returns the value of the "uuid=" TXT record, or "" if absent. It's
685680
// the stable per-host identity carried on the node-scanner daemon's single
686681
// _nvpair-node record, and was triplicated across the two proxies and the scanner

‎services/shared/discovery/discovery_test.go‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package discovery
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
10+
"net"
911
"sync/atomic"
1012
"testing"
1113
"time"
@@ -446,6 +448,90 @@ func TestRunEmitsAndCloses(t *testing.T) {
446448
}
447449
}
448450

451+
func TestSendMulticastQueryOnInterfaceUsesFirstIPv4AndMDNSTarget(t *testing.T) {
452+
ifi := &net.Interface{Index: 7, Name: "eth0"}
453+
wantSource := net.IPv4(192, 0, 2, 10)
454+
addrs := []net.Addr{
455+
&net.IPNet{IP: net.ParseIP("2001:db8::10")},
456+
&net.IPNet{IP: wantSource},
457+
&net.IPNet{IP: net.IPv4(198, 51, 100, 20)},
458+
}
459+
payload := []byte("PTR query")
460+
461+
var gotPayload []byte
462+
var gotInterface *net.Interface
463+
var gotSource net.IP
464+
var gotTarget *net.UDPAddr
465+
source, err := sendMulticastQueryOnInterface(
466+
payload,
467+
ifi,
468+
addrs,
469+
func(buf []byte, sentIfi *net.Interface, src net.IP, target *net.UDPAddr) error {
470+
gotPayload = append([]byte(nil), buf...)
471+
gotInterface = sentIfi
472+
gotSource = append(net.IP(nil), src...)
473+
gotTarget = target
474+
return nil
475+
},
476+
)
477+
if err != nil {
478+
t.Fatalf("sendMulticastQueryOnInterface: %v", err)
479+
}
480+
if !source.Equal(wantSource) || !gotSource.Equal(wantSource) {
481+
t.Fatalf("source = %s / sent %s, want %s", source, gotSource, wantSource)
482+
}
483+
if gotInterface != ifi {
484+
t.Errorf("interface = %v, want %v", gotInterface, ifi)
485+
}
486+
if string(gotPayload) != string(payload) {
487+
t.Errorf("payload = %q, want %q", gotPayload, payload)
488+
}
489+
if gotTarget == nil || !gotTarget.IP.Equal(net.IPv4(224, 0, 0, 251)) || gotTarget.Port != 5353 {
490+
t.Errorf("target = %v, want 224.0.0.251:5353", gotTarget)
491+
}
492+
}
493+
494+
func TestSendMulticastQueryOnInterfaceReturnsSenderFailure(t *testing.T) {
495+
wantErr := errors.New("send refused")
496+
wantSource := net.IPv4(192, 0, 2, 10)
497+
source, err := sendMulticastQueryOnInterface(
498+
[]byte("PTR query"),
499+
&net.Interface{Index: 7, Name: "eth0"},
500+
[]net.Addr{&net.IPNet{IP: wantSource}},
501+
func([]byte, *net.Interface, net.IP, *net.UDPAddr) error {
502+
return wantErr
503+
},
504+
)
505+
if !source.Equal(wantSource) {
506+
t.Fatalf("source = %s, want %s", source, wantSource)
507+
}
508+
if !errors.Is(err, wantErr) {
509+
t.Fatalf("error = %v, want %v", err, wantErr)
510+
}
511+
}
512+
513+
func TestSendMulticastQueryOnInterfaceSkipsInterfacesWithoutIPv4(t *testing.T) {
514+
called := false
515+
source, err := sendMulticastQueryOnInterface(
516+
[]byte("PTR query"),
517+
&net.Interface{Index: 7, Name: "eth0"},
518+
[]net.Addr{&net.IPNet{IP: net.ParseIP("2001:db8::10")}},
519+
func([]byte, *net.Interface, net.IP, *net.UDPAddr) error {
520+
called = true
521+
return nil
522+
},
523+
)
524+
if err != nil {
525+
t.Fatalf("sendMulticastQueryOnInterface: %v", err)
526+
}
527+
if source != nil {
528+
t.Fatalf("source = %s, want nil", source)
529+
}
530+
if called {
531+
t.Fatal("sender called without an IPv4 address")
532+
}
533+
}
534+
449535
// TestSendFailuresNeedARunAndClearOnRecovery: this feeds address selection, so a
450536
// single blip must not move a host's canonical address, and one success must undo
451537
// the suppression immediately.

‎services/shared/mdns/responder.go‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
// invisible to LAN peers.
1717
//
1818
// We keep zeroconf's receive trick (join the group on each multicast interface,
19-
// which works fine on Windows) but send every reply/announcement from a
20-
// per-interface unicast-bound socket with SetMulticastInterface set explicitly.
21-
// That path is well-supported on Windows.
19+
// which works fine on Windows) but send every reply/announcement from UDP 5353
20+
// on a per-interface unicast-bound socket with SetMulticastInterface set
21+
// explicitly. That path is both RFC-compliant and well-supported on Windows.
2222
//
2323
// This is the single implementation consolidated (the mDNS dedup) from the five
2424
// near-identical copies that lived in nvpair-advertiser,
@@ -48,7 +48,6 @@ import (
4848
)
4949

5050
const (
51-
mdnsPort = 5353
5251
// recordTTL matches what zeroconf advertises for non-A records (3200s)
5352
// for service-level records, but RFC 6762 §10 says A records SHOULD use
5453
// a TTL of 120s to account for IP address changes. We use the shorter
@@ -571,9 +570,8 @@ func (r *Responder) sendUnicast(buf []byte, ifIndex int, to net.Addr) {
571570
}
572571

573572
// sendOnInterface is the core of the Windows send workaround: it transmits buf
574-
// from a fresh unicast-bound socket on the given interface (setting the
575-
// multicast interface + TTL for group targets), never from the multicast-bound
576-
// receive socket that Windows refuses to send from.
573+
// from a fresh UDP 5353 socket bound to the given interface address, never from
574+
// the multicast-bound receive socket that Windows refuses to send from.
577575
func (r *Responder) sendOnInterface(buf []byte, ifIndex int, target *net.UDPAddr) error {
578576
addrs, ok := r.ifaces()[ifIndex]
579577
if !ok || len(addrs) == 0 {
@@ -584,19 +582,8 @@ func (r *Responder) sendOnInterface(buf []byte, ifIndex int, target *net.UDPAddr
584582
if err != nil {
585583
return err
586584
}
587-
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: src, Port: 0})
588-
if err != nil {
589-
slog.Debug("mdns: bind failed", "iface", ifi.Name, "ip", src.String(), "err", err)
590-
return err
591-
}
592-
defer conn.Close()
593-
if target.IP.IsMulticast() {
594-
pc := ipv4.NewPacketConn(conn)
595-
_ = pc.SetMulticastInterface(ifi)
596-
_ = pc.SetMulticastTTL(255)
597-
}
598-
if _, err := conn.WriteToUDP(buf, target); err != nil {
599-
slog.Debug("mdns: write failed", "iface", ifi.Name, "ip", src.String(), "target", target.String(), "err", err)
585+
if err := SendFromInterface(buf, ifi, src, target); err != nil {
586+
slog.Debug("mdns: send failed", "iface", ifi.Name, "ip", src.String(), "target", target.String(), "err", err)
600587
return err
601588
}
602589
return nil

0 commit comments

Comments
 (0)