Fix mDNS discovery IPv6 issues and improve timeout handling

- Force IPv4-only mDNS queries with DisableIPv6=true to avoid routing issues
- Add automatic IPv4 interface selection for better compatibility
- Filter mDNS results to only include SoundTouch devices
- Clean up device names by unescaping mDNS characters
- Fix timeout flag handling to respect DISCOVERY_TIMEOUT from .env file
- Only override discovery timeout when --timeout flag is explicitly provided
- Add file operations safety guidelines to docs/CLAUDE.md
- Remove duplicate timeout flags from discover command, use global flags

Fixes IPv6 'no route to host' errors that prevented mDNS discovery.
Now discovers same devices as native dns-sd and dig tools.
This commit is contained in:
Tobias Gesellchen
2026-01-10 21:56:19 +01:00
parent fbc09fdc59
commit ad43cdaf88
4 changed files with 115 additions and 20 deletions
+24 -11
View File
@@ -12,29 +12,42 @@ import (
// discoverDevices handles device discovery command
func discoverDevices(c *cli.Context) error {
timeout := c.Duration("timeout")
httpTimeout := c.Duration("timeout")
showAll := c.Bool("all")
fmt.Printf("Discovering SoundTouch devices...\n")
if showAll {
fmt.Printf("Timeout: %v\n", timeout)
fmt.Printf("Mode: Detailed information\n")
}
fmt.Println()
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
cfg = config.DefaultConfig()
}
// Override discovery timeout if provided
if timeout > 0 {
cfg.DiscoveryTimeout = timeout
// Only override discovery timeout if user explicitly provided --timeout flag
// This respects DISCOVERY_TIMEOUT from .env file when no flag is provided
if c.IsSet("timeout") {
cfg.HTTPTimeout = httpTimeout
// Set discovery timeout to be 2x HTTP timeout (min 5s, max 30s)
discoveryTimeout := httpTimeout * 2
if discoveryTimeout < 5*time.Second {
discoveryTimeout = 5 * time.Second
}
if discoveryTimeout > 30*time.Second {
discoveryTimeout = 30 * time.Second
}
cfg.DiscoveryTimeout = discoveryTimeout
}
if showAll {
fmt.Printf("HTTP Timeout: %v\n", cfg.HTTPTimeout)
fmt.Printf("Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
fmt.Printf("Mode: Detailed information\n")
}
fmt.Println()
// Create discovery service
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
-1
View File
@@ -68,7 +68,6 @@ func main() {
{
Name: "name",
Usage: "Get or set device name",
Flags: CommonFlags,
Before: RequireHost,
Subcommands: []*cli.Command{
{
+8
View File
@@ -80,6 +80,14 @@ When creating test data for API endpoints, prefer real device responses over hyp
- **Coverage**: Use multiple real devices to cover different response variations
- **Non-responsive endpoints**: Some endpoints like `/trackInfo` may not respond or exist on all devices
### 9. File Operations Safety
- **Never delete files** - use move/rename instead when possible
- **Ask before destructive operations** - especially for config files (.env, *.config, etc.)
- **Prefer non-destructive operations** - copy, move, rename over delete
- **Respect user data** - treat all user files as potentially containing sensitive data
- **Configuration files are sacred** - .env, config files may contain secrets and personal settings
## Additional Notes
- **Language: English** for code, commits, labels, and text in code
+83 -8
View File
@@ -47,18 +47,38 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v",
soundTouchServiceType, soundTouchDomain, m.timeout)
// Query for SoundTouch devices
// Note: hashicorp/mdns expects service and domain separately
// IPv4-only query to fix "no route to host" errors on IPv6
// This addresses the issue where hashicorp/mdns fails with:
// "write udp6 [::]:port->[ff02::fb]:5353: sendto: no route to host"
// The trailing dot in service names is handled correctly by separating
// service and domain parameters as expected by the library.
err := mdns.Query(&mdns.QueryParam{
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
DisableIPv6: true, // Force IPv4 only to avoid routing issues
Interface: m.getIPv4Interface(), // Use specific interface if available
})
if err != nil {
log.Printf("mDNS query completed with error: %v", err)
log.Printf("mDNS IPv4 query failed: %v", err)
// Fallback to standard query (both IPv4 and IPv6)
log.Printf("mDNS: Falling back to standard query...")
err = mdns.Query(&mdns.QueryParam{
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
})
if err != nil {
log.Printf("mDNS query completed with error: %v", err)
} else {
log.Printf("mDNS query completed successfully")
}
} else {
log.Printf("mDNS query completed successfully")
log.Printf("mDNS IPv4 query completed successfully")
}
}()
@@ -78,6 +98,12 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
log.Printf("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 devices
if !strings.Contains(entry.Name, "_soundtouch._tcp") {
log.Printf("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
continue
}
device := m.serviceEntryToDevice(entry)
if device != nil {
log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
@@ -170,6 +196,11 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
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,
@@ -182,3 +213,47 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
return device
}
// getIPv4Interface returns the first suitable IPv4 network interface
func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
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
}
// Check if this interface has IPv4 addresses
addrs, err := iface.Addrs()
if err != nil {
continue
}
hasIPv4 := false
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
}
}
log.Printf("mDNS: No suitable IPv4 interface found")
return nil
}