mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Add DNS-based discovery and migration via /etc/resolv.conf
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user