Add DNS-based discovery and migration via /etc/resolv.conf

This commit is contained in:
Tobias Gesellchen
2026-02-16 12:18:06 +01:00
parent cafaba1be0
commit ab2bf0731a
15 changed files with 1830 additions and 20 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
- 📊 **Traffic Analysis**: Proxy and log device communications
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
+67
View File
@@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
@@ -137,6 +138,23 @@ func main() {
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server for non-Bose queries",
Value: "8.8.8.8",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
@@ -160,10 +178,32 @@ func main() {
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
sm.GetDNSRunning = server.GetDNSRunning
server.SetSoundcorkURL(config.soundcorkURL)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
if err == nil && len(dnsDiscoveries) > 0 {
initial := make(map[string]*discovery.DiscoveredHost)
for _, entry := range dnsDiscoveries {
initial[entry.Hostname] = &discovery.DiscoveredHost{
Hostname: entry.Hostname,
FirstSeen: entry.FirstSeen,
LastSeen: entry.LastSeen,
QueryCount: entry.QueryCount,
IsBoseService: entry.IsBoseService,
IsIntercepted: entry.IsIntercepted,
RemoteAddr: entry.RemoteAddr,
}
}
server.SetDNSDiscoveries(initial)
}
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
@@ -253,6 +293,9 @@ type serviceConfig struct {
logBody bool
record bool
enableSoundcorkProxy bool
dnsEnabled bool
dnsUpstream string
dnsBind string
discoveryInterval time.Duration
domains []string
}
@@ -300,6 +343,10 @@ func loadConfig(c *cli.Context) serviceConfig {
record := c.Bool("record-interactions")
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
dnsEnabled := c.Bool("dns-discovery")
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
@@ -322,6 +369,9 @@ func loadConfig(c *cli.Context) serviceConfig {
logBody: logBody,
record: record,
enableSoundcorkProxy: enableSoundcorkProxy,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
discoveryInterval: discoveryInterval,
domains: domains,
}
@@ -385,6 +435,15 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.record = persisted.RecordInteractions
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
config.dnsEnabled = persisted.DNSEnabled
if persisted.DNSUpstream != "" {
config.dnsUpstream = persisted.DNSUpstream
}
if persisted.DNSBindAddr != "" {
config.dnsBind = persisted.DNSBindAddr
}
return persisted
}
@@ -399,6 +458,9 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
EnableSoundcorkProxy: config.enableSoundcorkProxy,
DNSEnabled: config.dnsEnabled,
DNSUpstream: config.dnsUpstream,
DNSBindAddr: config.dnsBind,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -546,6 +608,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceIP}", server.HandleTestDNSRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
@@ -556,6 +619,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
+1
View File
@@ -1,6 +1,7 @@
accounts/
certs/
default/
dns/
interactions/
patterns.json
settings.json
+4 -1
View File
@@ -27,7 +27,10 @@ Before you proceed with the actual migration, follow these steps:
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially.
5. **Migration Methods**:
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
+69 -15
View File
@@ -7,7 +7,8 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl
The service provides:
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
@@ -150,20 +151,23 @@ The service supports multiple ways to configure its behavior. When multiple sour
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|--------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
@@ -237,6 +241,42 @@ curl "http://192.168.1.100:8090/presets"
curl "http://localhost:8000/events/192.168.1.100"
```
#### ResolvConf Migration (DNS Redirection)
The most robust migration method. Instead of modifying specific host files, it configures the device to use the AfterTouch service as its primary DNS server.
> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration.
**Advantages:**
- **Discovery**: Automatically discover all Bose endpoints queried by the device.
- **Dynamic Interception**: Intercept new or unknown services without further device modifications.
- **Fail-Safe**: Can fall back to upstream DNS for non-intercepted queries.
**Setup:**
1. Enable DNS discovery in the service settings.
2. Select the `resolv` method when migrating a device.
3. The service will automatically make `/etc/resolv.conf` immutable (`chattr +i`) to prevent DHCP overrides.
### DNS Discovery Server
The SoundTouch service includes a built-in DNS server specifically designed for Bose devices.
#### How it Works
When enabled, the DNS server:
1. Receives DNS queries from migrated SoundTouch devices.
2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP.
3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints.
4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`).
#### Configuration
You can enable and configure the DNS server via the Web UI or environment variables:
- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server.
- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53).
- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider.
#### Manual Discovery via DNS
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
## API Reference
### Discovery & Setup
@@ -384,6 +424,7 @@ The web management interface provides a comprehensive dashboard for managing you
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream).
### Usage Tips
@@ -464,6 +505,8 @@ data/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ └── http-client.env.json
├── dns/
│ └── discoveries.json
├── stats/
│ ├── usage/
│ │ └── *.json
@@ -485,6 +528,9 @@ data/
- **Presets.xml**: Cross-device preset synchronization
- **Recents.xml**: Recent playback history
#### DNS Data (`dns/`)
- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication
#### Statistics (`stats/`)
- **usage/**: Device usage analytics and patterns
- **error/**: Error logs and diagnostic information
@@ -564,6 +610,14 @@ Deletes all recordings associated with a specific session.
#### `DELETE /setup/interactions/sessions?keep={N}`
Bulk cleanup: deletes all but the most recent `N` sessions.
### DNS Discovery API
#### `GET /setup/dns-discoveries`
Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp.
#### `DELETE /setup/dns-discoveries`
Clears all recorded DNS discovery data from memory and disk.
### Emulated Services
- `/bmx/registry/v1/services`: BMX service registry.
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
+342
View File
@@ -0,0 +1,342 @@
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
package discovery
import (
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSDiscovery handles DNS queries and records discovered hosts.
type DNSDiscovery struct {
// Configuration
upstreamDNS string
serviceIP string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
// Callbacks
onNewDiscovery func(hostname string)
// Servers for Shutdown
udpServer *dns.Server
tcpServer *dns.Server
}
// DiscoveredHost represents a host discovered via DNS queries.
type DiscoveredHost struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
discovered: make(map[string]*DiscoveredHost),
}
}
// ServeDNS implements the dns.Handler interface.
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
remoteAddr := ""
if w.RemoteAddr() != nil {
remoteAddr = w.RemoteAddr().String()
}
// Decide how to respond
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
// Record discovery
d.recordQuery(hostname, isIntercepted, remoteAddr)
if isIntercepted {
// Return your service IP
d.respondWithIP(w, r, d.serviceIP)
log.Printf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP)
} else {
// Forward to real DNS
log.Printf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS)
d.forward(w, r)
}
}
// recordQuery logs a DNS query and updates the internal state.
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
d.mu.Lock()
defer d.mu.Unlock()
host, exists := d.discovered[hostname]
if !exists {
// New discovery!
host = &DiscoveredHost{
Hostname: hostname,
FirstSeen: time.Now(),
LastSeen: time.Now(),
QueryCount: 1,
IsBoseService: d.isBoseRelated(hostname),
IsIntercepted: isIntercepted,
RemoteAddr: remoteAddr,
}
d.discovered[hostname] = host
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
hostname, host.IsBoseService, host.IsIntercepted)
if d.onNewDiscovery != nil {
go d.onNewDiscovery(hostname)
}
} else {
host.LastSeen = time.Now()
host.QueryCount++
host.IsIntercepted = isIntercepted
if remoteAddr != "" {
host.RemoteAddr = remoteAddr
}
}
}
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
// Intercept known Bose cloud services
interceptList := []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
}
for _, service := range interceptList {
if strings.Contains(hostname, service) {
return true
}
}
return false
}
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
return strings.Contains(hostname, "bose") ||
strings.Contains(hostname, "soundtouch")
}
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false // Embedded clients sometimes don't like compression
m.Authoritative = true
m.RecursionAvailable = true
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
switch q.Qtype {
case dns.TypeA, dns.TypeANY:
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
} else {
log.Printf("[DNS] Error creating A record: %v", err)
}
case dns.TypeAAAA:
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
}
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write response: %v", err)
}
}
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
if r.Question[0].Qtype == dns.TypePTR {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
}
return
}
c := new(dns.Client)
// Add port 53 if not present
upstream := d.upstreamDNS
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
in, _, err := c.Exchange(r, upstream)
if err != nil {
log.Printf("[DNS ERROR] Forward failed for %s (type %d): %v", r.Question[0].Name, r.Question[0].Qtype, err)
// Return a failure response instead of just dropping
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
}
return
}
if err := w.WriteMsg(in); err != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
}
}
// GetDiscovered returns a map of all discovered hosts.
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
// Return copy
result := make(map[string]*DiscoveredHost)
for k, v := range d.discovered {
result[k] = v
}
return result
}
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
var result []*DiscoveredHost
for _, host := range d.discovered {
if host.IsBoseService {
result = append(result, host)
}
}
return result
}
// SetDiscovered sets the map of discovered hosts.
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
d.mu.Lock()
defer d.mu.Unlock()
d.discovered = discovered
}
// Start DNS server starts both UDP and TCP listeners
func (d *DNSDiscovery) Start(addr string) error {
mux := dns.NewServeMux()
mux.HandleFunc(".", d.ServeDNS)
d.mu.Lock()
d.udpServer = &dns.Server{
Addr: addr,
Net: "udp",
Handler: mux,
}
d.tcpServer = &dns.Server{
Addr: addr,
Net: "tcp",
Handler: mux,
}
d.mu.Unlock()
errChan := make(chan error, 2)
go func() {
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
if err := d.udpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("UDP server failed: %w", err)
}
}()
go func() {
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
if err := d.tcpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("TCP server failed: %w", err)
}
}()
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
// Wait for first error
return <-errChan
}
// IsRunning returns true if the DNS server is active and bound to the specified address.
func (d *DNSDiscovery) IsRunning(addr string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
if d.udpServer == nil || d.tcpServer == nil {
return false
}
// We check if the address matches what we expect
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
}
// Shutdown stops the DNS server listeners
func (d *DNSDiscovery) Shutdown() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.udpServer != nil {
if err := d.udpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down UDP server: %v", err)
}
d.udpServer = nil
}
if d.tcpServer != nil {
if err := d.tcpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down TCP server: %v", err)
}
d.tcpServer = nil
}
return nil
}
+194
View File
@@ -0,0 +1,194 @@
package discovery
import (
"net"
"testing"
"time"
"github.com/miekg/dns"
)
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Test intercepting Bose service
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
}
// Test aftertouch.test
m2 := new(dns.Msg)
m2.SetQuestion("aftertouch.test.", dns.TypeA)
rw2 := &mockResponseWriter{}
d.ServeDNS(rw2, m2)
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
t.Fatal("Expected response for aftertouch.test")
}
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
}
}
func TestDNSDiscovery_Forwarding(t *testing.T) {
// This test is harder because it needs a real upstream or a mock.
// For now, let's just test that it calls forward and record.
serviceIP := "192.168.1.100"
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
// Start a mock upstream DNS server
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// We expect forward to succeed
d.ServeDNS(rw, m)
d.mu.RLock()
host, exists := d.discovered["google.com"]
d.mu.RUnlock()
if !exists {
t.Error("Expected google.com to be recorded in discovery")
}
if host.IsBoseService {
t.Error("google.com should not be identified as a Bose service")
}
}
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5354"
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
// Test TCP resolution
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
c := new(dns.Client)
c.Net = "tcp"
in, _, err := c.Exchange(m, addr)
if err != nil {
t.Fatalf("Failed to exchange via TCP: %v", err)
}
if len(in.Answer) == 0 {
t.Fatal("Expected answer in TCP response")
}
if a, ok := in.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
}
// Test Shutdown
err = d.Shutdown()
if err != nil {
t.Errorf("Shutdown failed: %v", err)
}
// Verify it's really shut down by trying to connect
_, _, err = c.Exchange(m, addr)
if err == nil {
t.Error("Expected error after shutdown, but could still exchange")
}
}
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5355"
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false before Start")
}
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
if !d.IsRunning(addr) {
t.Error("Expected IsRunning to be true after Start")
}
if d.IsRunning("127.0.0.1:9999") {
t.Error("Expected IsRunning to be false for wrong address")
}
_ = d.Shutdown()
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false after Shutdown")
}
}
type mockResponseWriter struct {
msg *dns.Msg
}
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (m *mockResponseWriter) Close() error { return nil }
func (m *mockResponseWriter) TsigStatus() error { return nil }
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
+79
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
@@ -707,6 +708,9 @@ type Settings struct {
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
@@ -822,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
return copiedEvents
}
// DNSDiscoveryEntry represents a persisted DNS discovery.
type DNSDiscoveryEntry struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
if ds == nil || ds.DataDir == "" {
return nil
}
dir := filepath.Join(ds.DataDir, "dns")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create dns directory: %w", err)
}
path := filepath.Join(dir, "discoveries.json")
// Sort by last seen descending
sort.Slice(discoveries, func(i, j int) bool {
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
})
data, err := json.MarshalIndent(discoveries, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
if ds == nil || ds.DataDir == "" {
return []DNSDiscoveryEntry{}, nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var discoveries []DNSDiscoveryEntry
if err := json.Unmarshal(data, &discoveries); err != nil {
return nil, err
}
return discoveries, nil
}
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
func (ds *DataStore) ClearDNSDiscoveries() error {
if ds == nil || ds.DataDir == "" {
return nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return nil
}
return os.Remove(path)
}
@@ -0,0 +1,75 @@
package datastore
import (
"os"
"testing"
"time"
)
func TestDNSDiscoveryPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
now := time.Now().Round(time.Second)
discoveries := []DNSDiscoveryEntry{
{
Hostname: "api.bose.com",
FirstSeen: now.Add(-1 * time.Hour),
LastSeen: now,
QueryCount: 10,
IsBoseService: true,
IsIntercepted: true,
RemoteAddr: "192.168.1.100",
},
{
Hostname: "google.com",
FirstSeen: now.Add(-2 * time.Hour),
LastSeen: now.Add(-1 * time.Hour),
QueryCount: 5,
IsBoseService: false,
IsIntercepted: false,
RemoteAddr: "192.168.1.101",
},
}
// Test Save
err = ds.SaveDNSDiscoveries(discoveries)
if err != nil {
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
}
// Test Load
loaded, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
}
if len(loaded) != 2 {
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
}
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
if loaded[0].Hostname != "api.bose.com" {
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
}
// Test Clear
err = ds.ClearDNSDiscoveries()
if err != nil {
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
}
loadedAfterClear, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
}
if len(loadedAfterClear) != 0 {
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
}
}
+140
View File
@@ -6,11 +6,13 @@ import (
"log"
"net/http"
"os"
"sort"
"strconv"
"time"
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -148,17 +150,27 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsBindAddr := s.dnsBindAddr
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
shortcuts := s.shortcuts
s.mu.RUnlock()
dnsRunning, actualBind := s.GetDNSRunning()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"soundcork_url": soundcorkURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": dnsUpstream,
"dns_bind_addr": dnsBindAddr,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
"log_bodies": logBody,
@@ -177,6 +189,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
SoundcorkURL string `json:"soundcork_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts"`
}
@@ -200,6 +215,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
s.discoveryEnabled = settings.DiscoveryEnabled
s.dnsEnabled = settings.DNSEnabled
s.dnsUpstream = settings.DNSUpstream
s.dnsBindAddr = settings.DNSBindAddr
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if settings.Shortcuts != nil {
@@ -227,6 +245,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
})
@@ -384,6 +405,85 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
}
}
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
// 1. Get current in-memory discoveries
inMemory := s.GetDNSDiscovery()
// 2. Load persisted discoveries
persisted, err := s.ds.LoadDNSDiscoveries()
if err != nil {
log.Printf("Warning: Failed to load DNS discoveries: %v", err)
}
// 3. Merge them
merged := make(map[string]datastore.DNSDiscoveryEntry)
for _, p := range persisted {
merged[p.Hostname] = p
}
for hostname, h := range inMemory {
m, exists := merged[hostname]
if !exists || h.LastSeen.After(m.LastSeen) {
merged[hostname] = datastore.DNSDiscoveryEntry{
Hostname: h.Hostname,
FirstSeen: h.FirstSeen,
LastSeen: h.LastSeen,
QueryCount: h.QueryCount,
IsBoseService: h.IsBoseService,
IsIntercepted: h.IsIntercepted,
RemoteAddr: h.RemoteAddr,
}
} else if h.QueryCount > m.QueryCount {
// If exists and persisted is newer (rare but possible), update query count if higher
m.QueryCount = h.QueryCount
merged[hostname] = m
}
}
// Convert to slice
result := make([]datastore.DNSDiscoveryEntry, 0, len(merged))
for _, entry := range merged {
result = append(result, entry)
}
// Sort by last seen descending
sort.Slice(result, func(i, j int) bool {
return result[i].LastSeen.After(result[j].LastSeen)
})
// 4. Update persistence with merged results
if err := s.ds.SaveDNSDiscoveries(result); err != nil {
log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
// 1. Clear in-memory
s.SetDNSDiscoveries(make(map[string]*discovery.DiscoveredHost))
// 2. Clear persistence
if err := s.ds.ClearDNSDiscoveries(); err != nil {
http.Error(w, "Failed to clear DNS discoveries: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleTrustCACert injects the local Root CA into the device's shared trust store.
func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
@@ -657,6 +757,46 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque
}
}
// HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service.
func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
return
}
targetURL := r.URL.Query().Get("target_url")
if targetURL == "" {
targetURL = s.serverURL
}
output, err := s.sm.TestDNSRedirection(deviceIP, targetURL)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": err.Error(),
"output": output,
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"message": "DNS redirection test successful",
"output": output,
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
+83
View File
@@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
"net/url"
"sync"
"time"
@@ -28,9 +29,13 @@ type Server struct {
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
dnsEnabled bool
dnsUpstream string
dnsBindAddr string
enableSoundcorkProxy bool
shortcuts map[string]int
recorder *proxy.Recorder
dnsDiscovery *discovery.DNSDiscovery
UpstreamProxy http.Handler
Version string
Commit string
@@ -73,6 +78,84 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.discoveryEnabled = enabled
}
// SetDNSSettings sets the DNS discovery settings for the server.
func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
s.mu.Lock()
defer s.mu.Unlock()
oldBind := s.dnsBindAddr
oldUpstream := s.dnsUpstream
s.dnsEnabled = enabled
s.dnsUpstream = upstream
s.dnsBindAddr = bind
if s.dnsDiscovery != nil {
if !enabled || bind != oldBind || upstream != oldUpstream {
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
_ = s.dnsDiscovery.Shutdown()
s.dnsDiscovery = nil
}
}
if enabled && s.dnsDiscovery == nil {
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
u, _ := url.Parse(s.serverURL)
serviceIP := u.Hostname()
if serviceIP == "localhost" || serviceIP == "" {
serviceIP = "127.0.0.1"
}
if s.sm != nil {
serviceIP = s.sm.GetResolvedIP(serviceIP)
}
s.dnsDiscovery = discovery.NewDNSDiscovery(upstream, serviceIP)
go func(d *discovery.DNSDiscovery, addr string) {
if err := d.Start(addr); err != nil {
log.Printf("Warning: DNS discovery server error: %v", err)
}
}(s.dnsDiscovery, bind)
}
}
// GetDNSRunning returns whether DNS discovery is active and its bind address.
func (s *Server) GetDNSRunning() (bool, string) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.dnsDiscovery == nil {
return false, ""
}
return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr
}
// SetDNSDiscoveries sets the initial DNS discoveries for the server.
func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dnsDiscovery != nil {
s.dnsDiscovery.SetDiscovered(discoveries)
}
}
// GetDNSDiscovery returns the current DNS discoveries.
func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost {
s.mu.RLock()
defer s.mu.RUnlock()
if s.dnsDiscovery == nil {
return nil
}
return s.dnsDiscovery.GetDiscovered()
}
// SetShortcuts sets the request shortcuts for the server.
func (s *Server) SetShortcuts(shortcuts map[string]int) {
s.mu.Lock()
+72 -1
View File
@@ -100,6 +100,24 @@
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
@@ -194,12 +212,31 @@
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
<strong>Preliminary DNS Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div style="margin-top: 10px;">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px;">
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
</div>
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DNS Discovery Mode - robust)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Current /etc/resolv.conf</span>
<pre id="current-resolv-content"></pre>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
@@ -270,6 +307,13 @@
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/resolv.conf</span>
<pre id="planned-resolv"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method prepends AfterTouch as the nameserver and makes the file immutable (<code>chattr +i</code>). It also injects the Local Root CA.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
@@ -314,7 +358,9 @@
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<h3>Browse Recordings</h3>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">Browse Recordings</h3>
</div>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
@@ -357,6 +403,31 @@
</div>
</div>
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">DNS Discoveries</h3>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Hostname</th>
<th style="padding: 8px;">Last Seen</th>
<th style="padding: 8px; text-align: center;">Queries</th>
<th style="padding: 8px; text-align: center;">Bose?</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
+165 -1
View File
@@ -14,6 +14,15 @@ async function fetchSettings() {
if (settings.discovery_enabled !== undefined) {
document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
}
if (settings.dns_enabled !== undefined) {
document.getElementById('dns-enabled').checked = settings.dns_enabled;
}
if (settings.dns_upstream) {
document.getElementById('dns-upstream').value = settings.dns_upstream;
}
if (settings.dns_bind_addr) {
document.getElementById('dns-bind').value = settings.dns_bind_addr;
}
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
@@ -62,6 +71,9 @@ async function updateSettings() {
proxy_url: document.getElementById('soundcork-url').value,
discovery_interval: document.getElementById('discovery-interval').value,
discovery_enabled: document.getElementById('discovery-enabled').checked,
dns_enabled: document.getElementById('dns-enabled').checked,
dns_upstream: document.getElementById('dns-upstream').value,
dns_bind_addr: document.getElementById('dns-bind').value,
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
};
const status = document.getElementById('settings-status');
@@ -191,6 +203,7 @@ function openTab(evt, tabId) {
if (tabId === 'tab-interactions') {
fetchInteractionStats();
fetchInteractions();
fetchDNSDiscoveries();
}
if (evt) {
@@ -504,6 +517,74 @@ async function viewInteraction(file) {
}
}
async function fetchDNSDiscoveries() {
console.log('Fetching DNS discoveries...');
try {
const response = await fetch('/setup/dns-discoveries');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const discoveries = await response.json();
console.log('Fetched DNS discoveries:', discoveries);
const list = document.getElementById('dns-discoveries-list');
if (!list) {
console.error('Could not find dns-discoveries-list element');
return;
}
list.innerHTML = '';
if (!discoveries || discoveries.length === 0) {
list.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS queries discovered yet.</td></tr>';
return;
}
discoveries.forEach(d => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #eee';
const hostname = d.hostname || "";
const lastSeen = d.last_seen || "";
const count = d.query_count || 0;
const isBose = d.is_bose_service ? '✅' : '❌';
const category = d.is_intercepted ? 'self' : 'upstream';
const remoteAddr = d.remote_addr || 'unknown';
tr.innerHTML = `
<td style="padding: 8px; font-weight: bold;">${hostname}</td>
<td style="padding: 8px; font-size: 0.85em;">${lastSeen}</td>
<td style="padding: 8px; text-align: center;">${count}</td>
<td style="padding: 8px; text-align: center;">${isBose}</td>
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
<td style="padding: 8px; font-size: 0.8em; color: #666;">${remoteAddr}</td>
`;
list.appendChild(tr);
});
} catch (error) {
console.error('Failed to fetch DNS discoveries', error);
}
}
async function clearDNSDiscoveries() {
if (!confirm('Are you sure you want to clear all DNS discovery logs?')) {
return;
}
try {
const response = await fetch('/setup/dns-discoveries', {
method: 'DELETE'
});
if (response.ok) {
fetchDNSDiscoveries();
} else {
const err = await response.text();
alert('Failed to clear DNS discoveries: ' + err);
}
} catch (error) {
alert('Error clearing DNS discoveries: ' + error.message);
}
}
async function showDeviceEvents() {
const overlay = document.getElementById('device-events-overlay');
overlay.style.display = 'block';
@@ -796,6 +877,12 @@ async function showSummary(ip) {
document.getElementById('planned-config').innerText = summary.planned_config;
document.getElementById('planned-hosts').innerText = summary.planned_hosts || '';
document.getElementById('planned-resolv').innerText = `nameserver ${new URL(targetUrl).hostname}\n${summary.current_resolv_conf || ''}`;
const currentResolvElem = document.getElementById('current-resolv-content');
if (currentResolvElem) {
currentResolvElem.innerText = summary.current_resolv_conf || 'Not available';
}
const testUrlElem = document.getElementById('test-url');
testUrlElem.innerText = summary.server_https_url || 'N/A';
@@ -806,6 +893,7 @@ async function showSummary(ip) {
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(ip, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(ip, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(ip);
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(ip);
toggleMigrationMethod();
@@ -1154,30 +1242,106 @@ async function testHostsRedirection(ip) {
}
}
async function testDNSRedirection(ip) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('dns-test-result');
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running DNS redirection test from ' + ip + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-dns/${ip}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
testResultDiv.style.backgroundColor = '#ccffcc';
testResultDiv.innerText = '✅ ' + result.message + '\n\nOutput:\n' + result.output;
} else {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Test failed: ' + result.message + '\n\nOutput:\n' + result.output;
}
} catch (error) {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Error triggering test: ' + error;
}
}
function toggleOriginalConfig() {
const pane = document.getElementById('original-config-pane');
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
}
function toggleMigrationMethod() {
async function toggleMigrationMethod() {
const method = document.getElementById('migration-method').value;
const xmlDiffPane = document.getElementById('xml-diff-pane');
const plannedXmlPane = document.getElementById('planned-xml-pane');
const plannedHostsPane = document.getElementById('planned-hosts-pane');
const plannedResolvPane = document.getElementById('planned-resolv-pane');
const currentResolvPane = document.getElementById('current-resolv-pane');
const serviceOptions = document.getElementById('service-options');
const hostsTestPane = document.getElementById('hosts-redirection-test');
const dnsTestPane = document.getElementById('dns-redirection-test');
const dnsWarning = document.getElementById('dns-port-warning');
if (method === 'hosts') {
xmlDiffPane.style.display = 'none';
plannedXmlPane.style.display = 'none';
plannedHostsPane.style.display = 'block';
plannedResolvPane.style.display = 'none';
currentResolvPane.style.display = 'none';
serviceOptions.style.display = 'none';
hostsTestPane.style.display = 'block';
dnsTestPane.style.display = 'none';
if (dnsWarning) dnsWarning.style.display = 'none';
} else if (method === 'resolv') {
xmlDiffPane.style.display = 'none';
plannedXmlPane.style.display = 'none';
plannedHostsPane.style.display = 'none';
plannedResolvPane.style.display = 'block';
currentResolvPane.style.display = 'block';
serviceOptions.style.display = 'none';
hostsTestPane.style.display = 'none';
dnsTestPane.style.display = 'block';
// Check DNS settings
try {
const response = await fetch('/setup/settings');
const settings = await response.json();
const dnsBind = settings.dns_bind_addr || '';
const isPort53 = dnsBind.endsWith(':53') || dnsBind === '53';
const isEnabled = settings.dns_enabled;
const isRunning = settings.dns_running;
const actualBind = settings.dns_actual_bind;
if (dnsWarning) {
if (!isEnabled) {
dnsWarning.innerText = '⚠️ DNS Discovery is DISABLED in Settings. Migration will fail.';
dnsWarning.style.display = 'block';
} else if (!isPort53) {
dnsWarning.innerText = `⚠️ DNS Discovery is bound to ${dnsBind}, but port 53 is required for migration.`;
dnsWarning.style.display = 'block';
} else if (!isRunning) {
dnsWarning.innerText = `⚠️ DNS Discovery server is NOT RUNNING on ${dnsBind} (check for port conflicts/permissions). Migration will fail.`;
dnsWarning.style.display = 'block';
} else {
dnsWarning.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to check DNS settings', e);
}
} else {
xmlDiffPane.style.display = 'block';
plannedXmlPane.style.display = 'block';
plannedHostsPane.style.display = 'none';
plannedResolvPane.style.display = 'none';
currentResolvPane.style.display = 'none';
hostsTestPane.style.display = 'none';
dnsTestPane.style.display = 'none';
// Only show service options if we have a parsed config
const currentConfig = document.getElementById('current-config').innerText;
if (currentConfig && !currentConfig.startsWith('Error') && currentConfig !== 'loading...') {
+269 -1
View File
@@ -27,6 +27,8 @@ const (
MigrationMethodXML MigrationMethod = "xml"
// MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store.
MigrationMethodHosts MigrationMethod = "hosts"
// MigrationMethodResolvConf redirects services by modifying /etc/resolv.conf and updating the CA trust store.
MigrationMethodResolvConf MigrationMethod = "resolv"
)
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
@@ -64,6 +66,7 @@ type MigrationSummary struct {
FirmwareVersion string `json:"firmware_version,omitempty"`
CACertTrusted bool `json:"ca_cert_trusted"`
ServerHTTPSURL string `json:"server_https_url,omitempty"`
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
IsMigrated bool `json:"is_migrated"`
}
@@ -79,6 +82,9 @@ type Manager struct {
DataStore *datastore.DataStore
Crypto *certmanager.CertificateManager
NewSSH func(host string) SSHClient
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
GetDNSRunning func() (bool, string)
}
// NewManager creates a new Manager with the given base server URL.
@@ -236,6 +242,14 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
// 4. Check if CA certificate is trusted
m.checkCACertTrusted(summary, deviceIP)
// 4b. Check current /etc/resolv.conf
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
if resolvConf, err := client.Run("cat /etc/resolv.conf"); err == nil {
summary.CurrentResolvConf = resolvConf
}
}
// 5. Provide HTTPS URL for testing
if parsedURL, err := url.Parse(targetURL); err == nil {
hostIP := parsedURL.Hostname()
@@ -302,6 +316,23 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
}
}
}
// Case 3: /etc/resolv.conf Migration
// Check if /etc/resolv.conf contains our target nameserver
if summary.CurrentResolvConf != "" {
targetURL := m.ServerURL
parsedTarget, err := url.Parse(targetURL)
if err == nil {
targetHost := parsedTarget.Hostname()
if strings.Contains(summary.CurrentResolvConf, targetHost) {
if summary.CACertTrusted {
summary.IsMigrated = true
return
}
}
}
}
}
// populateDeviceInfo fills in device information from datastore and live info
@@ -523,10 +554,62 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
logs += "Pre-flight: Write access verified.\n"
if method == MigrationMethodHosts {
switch method {
case MigrationMethodHosts:
out, err := m.migrateViaHosts(deviceIP, targetURL)
return logs + out, err
case MigrationMethodResolvConf:
if err := m.checkDNSPreFlight(); err != nil {
return logs, err
}
out, err := m.migrateViaResolvConf(deviceIP, targetURL)
return logs + out, err
case MigrationMethodXML:
out, err := m.migrateViaXML(deviceIP, targetURL, proxyURL, options, client, rwCmd)
return logs + out, err
default:
return logs, fmt.Errorf("unsupported migration method: %s", method)
}
}
func (m *Manager) checkDNSPreFlight() error {
// Pre-flight check: DNS server must be enabled and bound to port 53
settings, err := m.DataStore.GetSettings()
if err != nil {
return fmt.Errorf("failed to retrieve settings: %w", err)
}
if !settings.DNSEnabled {
return fmt.Errorf("DNS discovery server is not enabled. Please enable it in Settings before using /etc/resolv.conf migration")
}
if !strings.HasSuffix(settings.DNSBindAddr, ":53") && settings.DNSBindAddr != "53" {
return fmt.Errorf("DNS discovery server is bound to %s, but port 53 is required for /etc/resolv.conf migration", settings.DNSBindAddr)
}
// Also check the actual running state if callback is available
if m.GetDNSRunning != nil {
isRunning, bindAddr := m.GetDNSRunning()
if !isRunning {
return fmt.Errorf("DNS discovery server is configured but not actually running on %s. Please check logs for binding errors", bindAddr)
}
if !strings.HasSuffix(bindAddr, ":53") && bindAddr != "53" {
// This shouldn't happen based on previous check, but for completeness
return fmt.Errorf("DNS discovery server is running on %s, but port 53 is required", bindAddr)
}
}
return nil
}
func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options map[string]string, client SSHClient, rwCmd string) (string, error) {
var logs string
out, err := m.EnsureRemoteServices(deviceIP)
logs += "Ensuring remote services:\n" + out + "\n"
@@ -960,6 +1043,101 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
return logs, nil
}
func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
// 1. Resolve target hostname to IP
parsedURL, err := url.Parse(targetURL)
if err != nil {
return "", fmt.Errorf("failed to parse target URL: %w", err)
}
hostName := parsedURL.Hostname()
if hostName == "" || hostName == "localhost" {
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /etc/resolv.conf content
// We prepend our nameserver to the existing ones
resolvConf, err := client.Run("cat /etc/resolv.conf")
logs += "cat /etc/resolv.conf: " + resolvConf + "\n"
if err != nil {
return logs, fmt.Errorf("failed to read /etc/resolv.conf: %w", err)
}
lines := strings.Split(resolvConf, "\n")
var newLines []string
newLines = append(newLines, "# Added by AfterTouch migration")
newLines = append(newLines, fmt.Sprintf("nameserver %s", hostIP))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "nameserver") {
fields := strings.Fields(trimmed)
if len(fields) >= 2 && fields[1] == hostIP {
// Avoid duplicate nameserver entry
continue
}
}
newLines = append(newLines, line)
}
resolvConf = strings.Join(newLines, "\n")
if !strings.HasSuffix(resolvConf, "\n") {
resolvConf += "\n"
}
// 3. Upload new /etc/resolv.conf
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// Backup /etc/resolv.conf if it doesn't exist
if _, err := client.Run("[ -f /etc/resolv.conf.original ]"); err != nil {
out, _ := client.Run("cp /etc/resolv.conf /etc/resolv.conf.original")
logs += "cp /etc/resolv.conf /etc/resolv.conf.original: " + out + "\n"
}
if err := client.UploadContent([]byte(resolvConf), "/etc/resolv.conf"); err != nil {
return logs, fmt.Errorf("failed to update /etc/resolv.conf: %w", err)
}
logs += "Uploaded updated /etc/resolv.conf\n"
// 3b. Make it immutable if chattr is available
if _, err := client.Run("chattr +i /etc/resolv.conf"); err == nil {
logs += "Made /etc/resolv.conf immutable with chattr +i\n"
}
fmt.Printf("Updated /etc/resolv.conf on %s:\n%s\n", deviceIP, resolvConf)
// 4. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
}
return logs, nil
}
// RevertMigration reverts the speaker to its original Bose cloud configuration.
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
@@ -996,6 +1174,23 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
}
}
// 2b. Revert /etc/resolv.conf
resolvPath := "/etc/resolv.conf"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", resolvPath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", resolvPath)
fmt.Printf("Reverting %s from backup\n", resolvPath)
// Try to remove immutable flag if it was set
_, _ = client.Run(fmt.Sprintf("chattr -i %s", resolvPath))
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, resolvPath, resolvPath))
logs += fmt.Sprintf("cp %s.original %s: %s\n", resolvPath, resolvPath, out)
if err != nil {
fmt.Printf("Warning: failed to revert %s: %v\n", resolvPath, err)
}
}
// 3. Remove CA certificate from trust store if it exists
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) {
@@ -1132,6 +1327,74 @@ func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, erro
return combinedOutput, nil
}
// TestDNSRedirection performs a check from the device to see if DNS queries are intercepted by the AfterTouch service.
func (m *Manager) TestDNSRedirection(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
hostIP, _, err := m.parseTargetURLAndResolveIP(targetURL, client)
if err != nil {
return "", err
}
// Use a raw DNS query via nc (netcat) to test DNS resolution from the device,
// because BusyBox nslookup might not support custom ports.
testDomain := "aftertouch.test"
// Fetch configured DNS port if available
dnsPort := "53"
if m.DataStore != nil {
if dsSettings, getSettingsErr := m.DataStore.GetSettings(); getSettingsErr == nil && dsSettings.DNSBindAddr != "" {
if lastColon := strings.LastIndex(dsSettings.DNSBindAddr, ":"); lastColon != -1 {
port := dsSettings.DNSBindAddr[lastColon+1:]
if _, atoiErr := strconv.Atoi(port); atoiErr == nil {
dnsPort = port
}
}
}
}
// Raw DNS query for aftertouch.test (Type A, Class IN)
// Transaction ID: 0xAAAA, Flags: 0x0100 (Standard query), Questions: 1, Answer RRs: 0, Authority RRs: 0, Additional RRs: 0
// Query: aftertouch.test, Type: A, Class: IN
// For TCP, we need a 2-byte length prefix: 0x0021 (33 bytes)
dnsQueryHex := "\\x00\\x21\\xaa\\xaa\\x01\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x0aaftertouch\\x04test\\x00\\x00\\x01\\x00\\x01"
// We use TCP (default for nc) because BusyBox nc might not support -u,
// and our DNS server listens on both TCP and UDP.
// DNS over TCP response also has a 2-byte length prefix, but tail -c 4 will still get the IP from the end.
ncCmd := fmt.Sprintf("echo -ne '%s' | nc -w 5 %s %s | tail -c 4 | od -An -tu1", dnsQueryHex, hostIP, dnsPort)
output, err := client.Run(ncCmd)
if err == nil {
// Parse the IP from od output: " 192 168 178 122"
fields := strings.Fields(output)
if len(fields) == 4 {
resolvedIP := fmt.Sprintf("%s.%s.%s.%s", fields[0], fields[1], fields[2], fields[3])
if resolvedIP == hostIP {
return fmt.Sprintf("Success: Raw DNS query for %s returned %s via nc to %s:%s", testDomain, resolvedIP, hostIP, dnsPort), nil
}
return output, fmt.Errorf("DNS redirection test failed: nc returned %s, expected %s", resolvedIP, hostIP)
}
}
// Fallback to nslookup if nc fails (maybe nc is missing or it's standard port 53)
serverAddr := hostIP
if dnsPort != "53" {
serverAddr = fmt.Sprintf("%s:%s", hostIP, dnsPort)
}
nslookupCmd := fmt.Sprintf("nslookup %s %s", testDomain, serverAddr)
nslookupOutput, nslookupErr := client.Run(nslookupCmd)
if nslookupErr == nil && strings.Contains(nslookupOutput, hostIP) {
return nslookupOutput, nil
}
return fmt.Sprintf("nc Output: %s (err: %v)\nnslookup Output: %s (err: %v)", output, err, nslookupOutput, nslookupErr),
fmt.Errorf("DNS redirection test failed: both nc and nslookup failed to resolve %s", testDomain)
}
func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient) (string, *url.URL, error) {
parsedURL, err := url.Parse(targetURL)
if err != nil {
@@ -1288,6 +1551,11 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
return output, nil
}
// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
func (m *Manager) GetResolvedIP(host string) string {
return m.resolveIP(host, nil)
}
func (m *Manager) resolveIP(host string, client SSHClient) string {
if net.ParseIP(host) != nil {
return host
+267
View File
@@ -722,6 +722,8 @@ func TestRevertMigration(t *testing.T) {
// Verify revert commands
foundXMLRevert := false
foundHostsRevert := false
foundResolvRevert := false
foundChattrRemove := false
foundReboot := false
for _, call := range runCalls {
if strings.Contains(call, "cp "+SoundTouchSdkPrivateCfgPath+".original "+SoundTouchSdkPrivateCfgPath) {
@@ -730,6 +732,12 @@ func TestRevertMigration(t *testing.T) {
if strings.Contains(call, "cp /etc/hosts.original /etc/hosts") {
foundHostsRevert = true
}
if strings.Contains(call, "cp /etc/resolv.conf.original /etc/resolv.conf") {
foundResolvRevert = true
}
if strings.Contains(call, "chattr -i /etc/resolv.conf") {
foundChattrRemove = true
}
if strings.Contains(call, "reboot") {
foundReboot = true
}
@@ -741,6 +749,12 @@ func TestRevertMigration(t *testing.T) {
if !foundHostsRevert {
t.Errorf("Expected /etc/hosts revert")
}
if !foundResolvRevert {
t.Errorf("Expected /etc/resolv.conf revert")
}
if !foundChattrRemove {
t.Errorf("Expected chattr -i /etc/resolv.conf")
}
if foundReboot {
t.Errorf("Expected reboot NOT to be called automatically during revert")
}
@@ -817,6 +831,104 @@ func TestReboot(t *testing.T) {
}
}
func TestTestDNSRedirection(t *testing.T) {
m := NewManager("http://192.168.1.100:8000", nil, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if !strings.Contains(command, "-u") && strings.Contains(command, "nc") {
// Verify TCP length prefix is present: \x00\x21
if !strings.Contains(command, "\\x00\\x21") {
return "", fmt.Errorf("missing TCP length prefix in nc command")
}
// Mock od output: " 192 168 1 100"
return " 192 168 1 100", nil
}
if strings.HasPrefix(command, "nslookup aftertouch.test 192.168.1.100") {
return "Server: 192.168.1.100\nAddress 1: 192.168.1.100\n\nName: aftertouch.test\nAddress 1: 192.168.1.100", nil
}
return "", nil
},
}
}
output, err := m.TestDNSRedirection("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("TestDNSRedirection failed: %v", err)
}
if !strings.Contains(output, "192.168.1.100") {
t.Errorf("Expected output to contain service IP, got %s", output)
}
foundNc := false
for _, call := range runCalls {
if strings.Contains(call, "nc") && !strings.Contains(call, "-u") && strings.Contains(call, "192.168.1.100 53") {
foundNc = true
break
}
}
if !foundNc {
t.Errorf("Expected nc command with port 53, got calls: %v", runCalls)
}
}
func TestTestDNSRedirection_CustomPort(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-dns-port")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
_ = ds.SaveSettings(datastore.Settings{
DNSBindAddr: ":1053",
})
m := NewManager("http://192.168.1.100:8000", ds, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if !strings.Contains(command, "-u") && strings.Contains(command, "nc") {
// Verify TCP length prefix is present: \x00\x21
if !strings.Contains(command, "\\x00\\x21") {
return "", fmt.Errorf("missing TCP length prefix in nc command")
}
return " 192 168 1 100", nil
}
return "", nil
},
}
}
output, err := m.TestDNSRedirection("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("TestDNSRedirection failed: %v", err)
}
if !strings.Contains(output, "192.168.1.100") {
t.Errorf("Expected output to contain service IP, got %s", output)
}
foundNc := false
for _, call := range runCalls {
if strings.Contains(call, "nc") && !strings.Contains(call, "-u") && strings.Contains(call, "192.168.1.100 1053") {
foundNc = true
break
}
}
if !foundNc {
t.Errorf("Expected nc command with custom port 1053, got calls: %v", runCalls)
}
}
func TestBackupConfigOffDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "backup-test")
if err != nil {
@@ -909,6 +1021,79 @@ func TestMigrateSpeaker_PreFlightFailure(t *testing.T) {
}
}
func TestMigrateViaResolvConf(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-resolv")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
if err := cm.EnsureCA(); err != nil {
t.Fatalf("Failed to ensure CA: %v", err)
}
m := NewManager("http://192.168.1.100:8000", nil, cm)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /etc/resolv.conf" {
return "nameserver 8.8.8.8", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
if strings.HasPrefix(command, "grep -F") {
return "", fmt.Errorf("not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
if remotePath == "/etc/resolv.conf" {
if !strings.Contains(string(content), "nameserver 192.168.1.100") {
t.Errorf("Expected resolv.conf content to contain nameserver, got %s", string(content))
}
if !strings.Contains(string(content), "nameserver 8.8.8.8") {
t.Errorf("Expected resolv.conf content to retain old nameserver, got %s", string(content))
}
}
return nil
},
}
}
_, err = m.migrateViaResolvConf("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaResolvConf failed: %v", err)
}
// Verify backups were attempted
foundResolvBackup := false
for _, call := range runCalls {
if strings.Contains(call, "cp /etc/resolv.conf /etc/resolv.conf.original") {
foundResolvBackup = true
}
}
if !foundResolvBackup {
t.Errorf("Expected /etc/resolv.conf backup to be attempted")
}
// Verify chattr +i was attempted
foundChattr := false
for _, call := range runCalls {
if strings.Contains(call, "chattr +i /etc/resolv.conf") {
foundChattr = true
break
}
}
if !foundChattr {
t.Errorf("Expected chattr +i /etc/resolv.conf to be attempted")
}
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
@@ -974,3 +1159,85 @@ func TestCheckIsMigrated(t *testing.T) {
}
})
}
func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
m := NewManager("http://192.168.1.100:8000", ds, cm)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
return "", nil
},
}
}
// 1. DNS Disabled
ds.SaveSettings(datastore.Settings{
DNSEnabled: false,
DNSBindAddr: ":53",
})
// Mock HTTP server for device info
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<info deviceID="12345"><name>Test Speaker</name><type>ST10</type><maccAddress>00:11:22:33:44:55</maccAddress><margeAccountUUID>acc-123</margeAccountUUID></info>`))
}
}))
defer ts.Close()
// Use the test server address as device IP
tsIP := strings.TrimPrefix(ts.URL, "http://")
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "DNS discovery server is not enabled") {
t.Errorf("Expected error about DNS not being enabled, got %v", err)
}
// 2. DNS Enabled but wrong port
ds.SaveSettings(datastore.Settings{
DNSEnabled: true,
DNSBindAddr: ":5353",
})
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "port 53 is required") {
t.Errorf("Expected error about port 53 required, got %v", err)
}
// 3. DNS Enabled and port 53, but not running
ds.SaveSettings(datastore.Settings{
DNSEnabled: true,
DNSBindAddr: ":53",
})
m.GetDNSRunning = func() (bool, string) {
return false, ":53"
}
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "not actually running") {
t.Errorf("Expected error about DNS not actually running, got %v", err)
}
// 4. DNS Enabled and port 53, and running
m.GetDNSRunning = func() (bool, string) {
return true, ":53"
}
// This should now proceed to migrateViaResolvConf
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err != nil && (strings.Contains(err.Error(), "DNS discovery server is not enabled") ||
strings.Contains(err.Error(), "port 53 is required") ||
strings.Contains(err.Error(), "not actually running")) {
t.Errorf("Did not expect pre-flight DNS errors, got %v", err)
}
}