diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index adac01f..c7e448e 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -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 diff --git a/go.mod b/go.mod index 2d7372b..897bc54 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 21b5a59..761151b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/config/config.go b/pkg/config/config.go index 08b4680..96da3c5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/discovery/mdns.go b/pkg/discovery/mdns.go index 9c78c72..6104a5c 100644 --- a/pkg/discovery/mdns.go +++ b/pkg/discovery/mdns.go @@ -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 +} diff --git a/pkg/discovery/mdns_test.go b/pkg/discovery/mdns_test.go index 960a9a3..f04a385 100644 --- a/pkg/discovery/mdns_test.go +++ b/pkg/discovery/mdns_test.go @@ -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) diff --git a/pkg/discovery/unified.go b/pkg/discovery/unified.go index 516be7f..0b87e17 100644 --- a/pkg/discovery/unified.go +++ b/pkg/discovery/unified.go @@ -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, diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go index b77d0c9..578b8b7 100644 --- a/pkg/discovery/upnp.go +++ b/pkg/discovery/upnp.go @@ -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))