mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-09 20:26:13 +00:00
Fixes CodeQL go/log-injection alerts in the final batch of packages. New logutil.go helpers: pkg/client, pkg/testutils/amazon, pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web, cmd/dummy-speaker, cmd/mdns-scanner. pkg/discovery/logger.go: added sanitizeLog and a nil-safe remoteAddrString helper to the existing file (alongside logVerbose). Call sites wrapped across 11 files — device IDs, source types, hostnames, IPs, interface names, URLs, service names, HTTP method/form values, WebSocket URLs and payloads, TLS SNI names, remote addresses. No behaviour change. golangci-lint and make check pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
338 lines
9.1 KiB
Go
338 lines
9.1 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/hashicorp/mdns"
|
|
)
|
|
|
|
// MDNSDiscoveryService handles mDNS/Bonjour discovery of SoundTouch devices
|
|
type MDNSDiscoveryService struct {
|
|
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,
|
|
ifaceName: ifaceName,
|
|
}
|
|
}
|
|
|
|
// DiscoverDevices discovers SoundTouch devices using mDNS
|
|
func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error) {
|
|
// Initialize devices slice to ensure it's never nil
|
|
devices := make([]*models.DiscoveredDevice, 0)
|
|
|
|
// Create a channel to collect service entries
|
|
entries := make(chan *mdns.ServiceEntry, 100)
|
|
|
|
// Create a timeout context
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, m.timeout)
|
|
defer cancel()
|
|
|
|
// Fan out one query per SoundTouch service-type variant; mDNS has no
|
|
// wildcard service-type query, so we issue them in parallel and merge
|
|
// into a single entries channel. close(entries) only once all queries
|
|
// are done (or the timeout fires).
|
|
go func() {
|
|
defer close(entries)
|
|
|
|
log.Printf("mDNS: Starting discovery for %d service-type variant(s) with timeout %v",
|
|
len(soundTouchServiceTypes), m.timeout)
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for _, service := range soundTouchServiceTypes {
|
|
wg.Add(1)
|
|
|
|
go func(service string) {
|
|
defer wg.Done()
|
|
|
|
m.queryService(service, entries)
|
|
}(service)
|
|
}
|
|
|
|
wg.Wait()
|
|
logVerbose("mDNS: All %d service-type queries finished", len(soundTouchServiceTypes))
|
|
}()
|
|
|
|
// Collect discovered devices, deduplicating by host:port since a single
|
|
// speaker may answer multiple service types (older firmware advertises
|
|
// both `_soundtouch._tcp` and `_bose-soundtouch._tcp` simultaneously).
|
|
seen := make(map[string]bool)
|
|
|
|
for {
|
|
select {
|
|
case <-timeoutCtx.Done():
|
|
// Timeout reached, return what we have
|
|
return devices, nil
|
|
case entry, ok := <-entries:
|
|
if !ok {
|
|
// Channel closed, return collected devices
|
|
log.Printf("mDNS discovery finished. Found %d devices total.", len(devices))
|
|
return devices, nil
|
|
}
|
|
|
|
logVerbose("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
|
|
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
|
|
|
|
// Only process SoundTouch-family services.
|
|
if !isSoundTouchServiceName(entry.Name) {
|
|
logVerbose("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
|
|
continue
|
|
}
|
|
|
|
device := m.serviceEntryToDevice(entry)
|
|
if device == nil {
|
|
log.Printf("mDNS: Failed to convert service entry to device (no valid IP address)")
|
|
continue
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:%d", device.Host, device.Port)
|
|
if seen[key] {
|
|
logVerbose("mDNS: Skipping duplicate device %s (already seen via another service-type query)", key)
|
|
continue
|
|
}
|
|
|
|
seen[key] = true
|
|
|
|
logVerbose("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
|
|
devices = append(devices, device)
|
|
}
|
|
}
|
|
}
|
|
|
|
// queryService issues a single mDNS Query for the given service type
|
|
// against the IPv4 interface first, with a graceful fallback to the
|
|
// library's default (IPv4+IPv6) behaviour if the IPv4-only path fails.
|
|
// All results stream into the shared entries channel; the caller is
|
|
// responsible for fan-in deduplication.
|
|
func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns.ServiceEntry) {
|
|
logVerbose("mDNS: Query '%s.%s' starting", service, soundTouchDomain)
|
|
|
|
err := mdns.Query(&mdns.QueryParam{
|
|
Service: service,
|
|
Domain: "local.",
|
|
Timeout: m.timeout,
|
|
Entries: entries,
|
|
DisableIPv6: true,
|
|
Interface: m.getIPv4Interface(),
|
|
})
|
|
if err == nil {
|
|
logVerbose("mDNS: Query '%s' (IPv4) completed successfully", service)
|
|
return
|
|
}
|
|
|
|
log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", sanitizeLog(service), err)
|
|
|
|
err = mdns.Query(&mdns.QueryParam{
|
|
Service: service,
|
|
Domain: "local.",
|
|
Timeout: m.timeout,
|
|
Entries: entries,
|
|
})
|
|
if err != nil {
|
|
log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", sanitizeLog(service), err)
|
|
} else {
|
|
logVerbose("mDNS: Query '%s' (dual-stack) completed successfully", service)
|
|
}
|
|
}
|
|
|
|
// serviceEntryToDevice converts an mdns ServiceEntry to a DiscoveredDevice
|
|
func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *models.DiscoveredDevice {
|
|
if entry == nil {
|
|
logVerbose("mDNS: Received nil service entry")
|
|
return nil
|
|
}
|
|
|
|
// Get the IP address - prefer IPv4
|
|
var (
|
|
host string
|
|
ipSource string
|
|
)
|
|
|
|
switch {
|
|
case entry.AddrV4 != nil:
|
|
host = entry.AddrV4.String()
|
|
ipSource = "IPv4"
|
|
|
|
logVerbose("mDNS: Using IPv4 address: %s", host)
|
|
case entry.AddrV6 != nil:
|
|
host = entry.AddrV6.String()
|
|
ipSource = "IPv6"
|
|
|
|
logVerbose("mDNS: Using IPv6 address: %s", host)
|
|
default:
|
|
// Try to resolve from hostname
|
|
logVerbose("mDNS: No direct IP address, trying to resolve hostname: %s", entry.Host)
|
|
|
|
ips, err := net.LookupIP(entry.Host)
|
|
if err != nil || len(ips) == 0 {
|
|
log.Printf("mDNS: Failed to resolve hostname '%s': %v", sanitizeLog(entry.Host), err)
|
|
return nil
|
|
}
|
|
|
|
// Prefer IPv4
|
|
for _, ip := range ips {
|
|
if ip.To4() != nil {
|
|
host = ip.String()
|
|
ipSource = "resolved IPv4"
|
|
|
|
logVerbose("mDNS: Resolved to IPv4 address: %s", host)
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// If no IPv4 found, use first available
|
|
if host == "" {
|
|
host = ips[0].String()
|
|
if ips[0].To4() != nil {
|
|
ipSource = "resolved IPv4 (fallback)"
|
|
} else {
|
|
ipSource = "resolved IPv6 (fallback)"
|
|
}
|
|
|
|
logVerbose("mDNS: Using fallback address (%s): %s", ipSource, host)
|
|
}
|
|
}
|
|
|
|
if host == "" {
|
|
log.Printf("mDNS: No usable IP address found for entry")
|
|
return nil
|
|
}
|
|
|
|
port := entry.Port
|
|
// Default to port 8090 if port is 0 or invalid
|
|
if port == 0 {
|
|
port = 8090
|
|
}
|
|
|
|
// Extract device name from instance name or use a default
|
|
name := entry.Name
|
|
if name == "" {
|
|
name = fmt.Sprintf("SoundTouch-%s", host)
|
|
}
|
|
|
|
// Clean up the name by removing the service type suffix
|
|
if strings.HasSuffix(name, "."+soundTouchServiceType+"."+soundTouchDomain) {
|
|
name = strings.TrimSuffix(name, "."+soundTouchServiceType+"."+soundTouchDomain)
|
|
}
|
|
|
|
// Unescape any escaped characters in the name (common in mDNS)
|
|
name = strings.ReplaceAll(name, `\ `, " ")
|
|
name = strings.ReplaceAll(name, `\.`, ".")
|
|
name = strings.ReplaceAll(name, `\\`, `\`)
|
|
|
|
device := &models.DiscoveredDevice{
|
|
Host: host,
|
|
Port: port,
|
|
Name: name,
|
|
LastSeen: time.Now(),
|
|
DiscoveryMethod: "mDNS/Bonjour",
|
|
APIBaseURL: fmt.Sprintf("http://%s:%d/", host, port),
|
|
InfoURL: fmt.Sprintf("http://%s:%d/info", host, port),
|
|
MDNSHostname: entry.Host,
|
|
MDNSService: entry.Name,
|
|
}
|
|
|
|
logVerbose("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
|
|
|
|
return device
|
|
}
|
|
|
|
// 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", sanitizeLog(m.ifaceName), err)
|
|
return nil
|
|
}
|
|
|
|
if !interfaceHasIPv4(iface) {
|
|
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", sanitizeLog(m.ifaceName))
|
|
return nil
|
|
}
|
|
|
|
logVerbose("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)
|
|
return nil
|
|
}
|
|
|
|
for _, iface := range interfaces {
|
|
// Skip loopback, down interfaces, and point-to-point interfaces
|
|
if iface.Flags&net.FlagLoopback != 0 ||
|
|
iface.Flags&net.FlagUp == 0 ||
|
|
iface.Flags&net.FlagPointToPoint != 0 {
|
|
continue
|
|
}
|
|
|
|
if !interfaceHasIPv4(&iface) {
|
|
continue
|
|
}
|
|
|
|
logVerbose("mDNS: Using IPv4 interface: %s", iface.Name)
|
|
|
|
return &iface
|
|
}
|
|
|
|
logVerbose("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
|
|
}
|