feat(discovery): allow pinning mDNS and UPnP to a specific interface

On a multi-homed host the discovery layer used to walk net.Interfaces()
and pick the first non-loopback IPv4 NIC, while UPnP/SSDP bound a
wildcard UDP socket and let the kernel route the multicast send. That
meant --bind on soundtouch-web only moved the HTTP listener; the
discovery still went out whatever interface the kernel preferred (often
the wrong one on hosts where the speakers sit behind a secondary NIC).

Introduce a separate DiscoveryInterface knob:

  * pkg/config: DiscoveryInterface field + DISCOVERY_INTERFACE env var.
  * pkg/discovery/mdns: NewMDNSDiscoveryServiceWithInterface; the
    interface resolver now honours an explicit name and validates it
    has a usable IPv4 address before handing it to hashicorp/mdns.
  * pkg/discovery/upnp: when an interface is configured, bind the UDP
    socket's source IP to the NIC's IPv4 and call
    ipv4.PacketConn.SetMulticastInterface so M-SEARCH leaves the right
    NIC. Without an interface, behaviour is unchanged.
  * cmd/soundtouch-web: new --interface flag (DISCOVERY_INTERFACE env)
    plumbed into the config before the discovery service is built.

go.mod/go.sum reflect promoting golang.org/x/net from indirect to a
direct dependency (now imported for ipv4.PacketConn).

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-13 19:36:06 +02:00
co-authored by Claude Opus 4.7
parent 67111b6f5e
commit cb071c9b1b
8 changed files with 173 additions and 32 deletions
+10
View File
@@ -39,10 +39,16 @@ func main() {
Usage: "Address (host or IP) for the HTTP listener; leave empty to listen on all interfaces",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "interface",
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP discovery; leave empty to auto-pick",
EnvVars: []string{"DISCOVERY_INTERFACE"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
bindAddr := c.String("bind")
ifaceName := c.String("interface")
addr := ":" + port
if bindAddr != "" {
@@ -63,6 +69,10 @@ func main() {
cfg.DiscoveryTimeout = 10 * time.Second
cfg.CacheEnabled = true
if ifaceName != "" {
cfg.DiscoveryInterface = ifaceName
}
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
// Discover devices on startup
+3 -3
View File
@@ -15,21 +15,21 @@ require (
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/term v0.43.0
)
require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
+4 -4
View File
@@ -1,5 +1,5 @@
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -11,8 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+8
View File
@@ -19,6 +19,10 @@ type Config struct {
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
UPnPEnabled bool `env:"UPNP_ENABLED" default:"true"`
MDNSEnabled bool `env:"MDNS_ENABLED" default:"true"`
// DiscoveryInterface restricts mDNS and UPnP/SSDP discovery to a single
// network interface (e.g. "eth0"). Empty means "auto-pick the first
// suitable interface", which is the historical behaviour.
DiscoveryInterface string `env:"DISCOVERY_INTERFACE" default:""`
// Preferred devices from .env file
PreferredDevices []DeviceConfig `env:"PREFERRED_DEVICES"`
@@ -81,6 +85,10 @@ func LoadFromEnv() (*Config, error) {
config.MDNSEnabled = mdns == "true" || mdns == "1"
}
if iface := os.Getenv("DISCOVERY_INTERFACE"); iface != "" {
config.DiscoveryInterface = iface
}
if timeout := os.Getenv("HTTP_TIMEOUT"); timeout != "" {
if d, err := time.ParseDuration(timeout); err == nil {
config.HTTPTimeout = d
+60 -20
View File
@@ -14,17 +14,26 @@ import (
// MDNSDiscoveryService handles mDNS/Bonjour discovery of SoundTouch devices
type MDNSDiscoveryService struct {
timeout time.Duration
timeout time.Duration
ifaceName string
}
// NewMDNSDiscoveryService creates a new mDNS discovery service
func NewMDNSDiscoveryService(timeout time.Duration) *MDNSDiscoveryService {
return NewMDNSDiscoveryServiceWithInterface(timeout, "")
}
// NewMDNSDiscoveryServiceWithInterface creates a new mDNS discovery service
// pinned to the given network interface (e.g. "eth0"). An empty ifaceName
// falls back to the historical auto-pick behaviour.
func NewMDNSDiscoveryServiceWithInterface(timeout time.Duration, ifaceName string) *MDNSDiscoveryService {
if timeout == 0 {
timeout = defaultTimeout
}
return &MDNSDiscoveryService{
timeout: timeout,
timeout: timeout,
ifaceName: ifaceName,
}
}
@@ -218,8 +227,27 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
return device
}
// getIPv4Interface returns the first suitable IPv4 network interface
// getIPv4Interface returns the network interface to use for mDNS queries.
// If an explicit name was configured, it is resolved and validated; otherwise
// the first suitable, up, non-loopback IPv4 interface is returned.
func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
if m.ifaceName != "" {
iface, err := net.InterfaceByName(m.ifaceName)
if err != nil {
log.Printf("mDNS: Configured interface %q not found: %v", m.ifaceName, err)
return nil
}
if !interfaceHasIPv4(iface) {
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", m.ifaceName)
return nil
}
log.Printf("mDNS: Using configured IPv4 interface: %s", iface.Name)
return iface
}
interfaces, err := net.Interfaces()
if err != nil {
log.Printf("mDNS: Failed to get network interfaces: %v", err)
@@ -234,30 +262,42 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
continue
}
// Check if this interface has IPv4 addresses
addrs, err := iface.Addrs()
if err != nil {
if !interfaceHasIPv4(&iface) {
continue
}
hasIPv4 := false
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
hasIPv4 = true
break
}
}
}
if hasIPv4 {
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
return &iface
}
return &iface
}
log.Printf("mDNS: No suitable IPv4 interface found")
return nil
}
// interfaceHasIPv4 reports whether iface has at least one non-loopback IPv4
// address assigned and is administratively up.
func interfaceHasIPv4(iface *net.Interface) bool {
if iface.Flags&net.FlagUp == 0 {
return false
}
addrs, err := iface.Addrs()
if err != nil {
return false
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
return true
}
}
return false
}
+28
View File
@@ -92,6 +92,34 @@ func TestMDNSDiscoveryTimeout(t *testing.T) {
_ = err
}
func TestMDNSGetIPv4InterfaceUnknownName(t *testing.T) {
service := NewMDNSDiscoveryServiceWithInterface(5*time.Second, "definitely-not-a-real-iface-xyz")
if iface := service.getIPv4Interface(); iface != nil {
t.Errorf("Expected nil for unknown interface name, got %q", iface.Name)
}
}
func TestMDNSGetIPv4InterfaceExplicitMatchesAutoPick(t *testing.T) {
auto := NewMDNSDiscoveryService(5 * time.Second).getIPv4Interface()
if auto == nil {
t.Skip("No suitable IPv4 interface available on this host")
}
explicit := NewMDNSDiscoveryServiceWithInterface(5*time.Second, auto.Name).getIPv4Interface()
if explicit == nil {
t.Fatalf("Expected explicit lookup of %q to succeed", auto.Name)
}
if explicit.Name != auto.Name {
t.Errorf("Expected explicit interface %q, got %q", auto.Name, explicit.Name)
}
// Sanity: the resolved interface really has an IPv4 we could bind to.
if !interfaceHasIPv4(explicit) {
t.Errorf("Resolved interface %q has no IPv4 address", explicit.Name)
}
}
func TestMDNSDiscoveryWithCancelledContext(t *testing.T) {
service := NewMDNSDiscoveryService(5 * time.Second)
+1 -1
View File
@@ -142,7 +142,7 @@ func NewUnifiedDiscoveryService(cfg *config.Config) *UnifiedDiscoveryService {
return &UnifiedDiscoveryService{
ssdpService: NewServiceWithConfig(cfg),
mdnsService: NewMDNSDiscoveryService(timeout),
mdnsService: NewMDNSDiscoveryServiceWithInterface(timeout, cfg.DiscoveryInterface),
config: cfg,
cache: make(map[string]*models.DiscoveredDevice),
cacheTTL: cacheTTL,
+59 -4
View File
@@ -16,6 +16,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/models"
"golang.org/x/net/ipv4"
)
// Service handles UPnP SSDP discovery of SoundTouch devices
@@ -26,6 +27,7 @@ type Service struct {
mutex sync.RWMutex
config *config.Config
httpClient *http.Client
ifaceName string
}
// NewService creates a new UPnP discovery service
@@ -63,6 +65,7 @@ func NewServiceWithConfig(cfg *config.Config) *Service {
mutex: sync.RWMutex{},
config: cfg,
httpClient: &http.Client{Timeout: 5 * time.Second},
ifaceName: cfg.DiscoveryInterface,
}
}
@@ -189,15 +192,16 @@ func (d *Service) PerformDiscovery(ctx context.Context) ([]*models.DiscoveredDev
}
func (d *Service) setupUDPListener() (*net.UDPConn, error) {
listenAddr, err := net.ResolveUDPAddr("udp4", ":0")
listenIP, iface, err := d.resolveListenInterface()
if err != nil {
log.Printf("UPnP: Failed to resolve listen address: %v", err)
return nil, fmt.Errorf("failed to resolve listen address: %w", err)
return nil, err
}
listenAddr := &net.UDPAddr{IP: listenIP, Port: 0}
listener, err := net.ListenUDP("udp4", listenAddr)
if err != nil {
log.Printf("UPnP: Failed to create UDP listener: %v", err)
log.Printf("UPnP: Failed to create UDP listener on %s: %v", listenAddr, err)
return nil, fmt.Errorf("failed to create UDP listener: %w", err)
}
@@ -212,11 +216,62 @@ func (d *Service) setupUDPListener() (*net.UDPConn, error) {
return nil, fmt.Errorf("failed to cast local address to UDPAddr: %v", addr)
}
// Pin the outgoing multicast packets to the configured interface so the
// M-SEARCH leaves through the right NIC on multi-homed hosts.
if iface != nil {
if err := ipv4.NewPacketConn(listener).SetMulticastInterface(iface); err != nil {
log.Printf("UPnP: Failed to set multicast interface to %q: %v", iface.Name, err)
// Continue regardless — the kernel will fall back to its own routing decision.
}
}
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
return listener, nil
}
// resolveListenInterface returns the source IP to bind the UDP listener to and
// the interface to use for outgoing multicast. When no interface is configured,
// the IP is nil (wildcard) and the iface is nil, preserving the historical
// behaviour where the kernel picks a route.
func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
if d.ifaceName == "" {
return nil, nil, nil
}
iface, err := net.InterfaceByName(d.ifaceName)
if err != nil {
log.Printf("UPnP: Configured interface %q not found: %v", d.ifaceName, err)
return nil, nil, fmt.Errorf("configured interface %q not found: %w", d.ifaceName, err)
}
addrs, err := iface.Addrs()
if err != nil {
log.Printf("UPnP: Failed to read addresses for interface %q: %v", d.ifaceName, err)
return nil, nil, fmt.Errorf("read addresses for interface %q: %w", d.ifaceName, err)
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ipv4Addr := ipNet.IP.To4()
if ipv4Addr == nil || ipNet.IP.IsLoopback() {
continue
}
log.Printf("UPnP: Binding UDP listener to interface %q (%s)", iface.Name, ipv4Addr)
return ipv4Addr, iface, nil
}
log.Printf("UPnP: Configured interface %q has no usable IPv4 address", d.ifaceName)
return nil, nil, fmt.Errorf("interface %q has no usable IPv4 address", d.ifaceName)
}
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))