Compare commits

...
12 Commits
Author SHA1 Message Date
Tobias Gesellchen 6aef2b807d Enhance ResolvConf migration to support multiple DHCP script variants
This update allows the service to correctly patch both /etc/udhcpc.d/50default and /opt/Bose/udhcpc.script (used in SoundTouch 10 firmware) for DNS redirection. It also improves robustness by adding file existence checks in rc.local and ensures clean state by reverting to .original backups during migration.
2026-02-16 18:52:25 +01:00
Tobias Gesellchen 95f5e9c831 fix(setup): prevent and clean up corrupted rc.local with cat error message 2026-02-16 18:20:16 +01:00
Tobias Gesellchen 7337296ae9 refactor(setup): reduce cyclomatic complexity of RevertMigration 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 92a5d3592c feat(setup): replace obsolete resolv method with persistent DHCP-aware DNS hook 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 2f04af872b feat(setup): implement Aftertouch Hook (DHCP-aware DNS redirection); update UI and tests; docs now use aftertouch.resolv.conf 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 9479d6d11d Fix missing request body in recorded proxy interactions 2026-02-16 16:30:41 +01:00
Tobias Gesellchen f687ba0d82 go mod tidy 2026-02-16 12:45:04 +01:00
Tobias Gesellchen ab2bf0731a Add DNS-based discovery and migration via /etc/resolv.conf 2026-02-16 12:18:06 +01:00
Tobias Gesellchen cafaba1be0 Update SOUNDTOUCH-SERVICE.md with recent features (Soundcork proxy, session archiving, enhanced redaction) 2026-02-15 23:54:14 +01:00
Tobias Gesellchen 93082d2cdc Update root endpoint JSON response with AfterTouch and docs link 2026-02-15 23:47:10 +01:00
Tobias Gesellchen 087006c483 Add regression test for settings persistence 2026-02-15 23:28:45 +01:00
Tobias Gesellchen b7013a5ec8 Apply 'Redact Sensitive Headers' to recordings 2026-02-15 23:12:19 +01:00
22 changed files with 2677 additions and 38 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
- 📊 **Traffic Analysis**: Proxy and log device communications
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
+71 -4
View File
@@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
@@ -137,6 +138,23 @@ func main() {
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server for non-Bose queries",
Value: "8.8.8.8",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
@@ -160,10 +178,32 @@ func main() {
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
sm.GetDNSRunning = server.GetDNSRunning
server.SetSoundcorkURL(config.soundcorkURL)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
if err == nil && len(dnsDiscoveries) > 0 {
initial := make(map[string]*discovery.DiscoveredHost)
for _, entry := range dnsDiscoveries {
initial[entry.Hostname] = &discovery.DiscoveredHost{
Hostname: entry.Hostname,
FirstSeen: entry.FirstSeen,
LastSeen: entry.LastSeen,
QueryCount: entry.QueryCount,
IsBoseService: entry.IsBoseService,
IsIntercepted: entry.IsIntercepted,
RemoteAddr: entry.RemoteAddr,
}
}
server.SetDNSDiscoveries(initial)
}
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
@@ -253,6 +293,9 @@ type serviceConfig struct {
logBody bool
record bool
enableSoundcorkProxy bool
dnsEnabled bool
dnsUpstream string
dnsBind string
discoveryInterval time.Duration
domains []string
}
@@ -300,6 +343,10 @@ func loadConfig(c *cli.Context) serviceConfig {
record := c.Bool("record-interactions")
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
dnsEnabled := c.Bool("dns-discovery")
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
@@ -322,6 +369,9 @@ func loadConfig(c *cli.Context) serviceConfig {
logBody: logBody,
record: record,
enableSoundcorkProxy: enableSoundcorkProxy,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
discoveryInterval: discoveryInterval,
domains: domains,
}
@@ -380,10 +430,19 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy || config.enableSoundcorkProxy
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
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)
})
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestApplyPersistedSettings(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "main-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(tmpDir)
t.Run("overrides true with false", func(t *testing.T) {
config := &serviceConfig{
redact: true,
logBody: true,
record: true,
enableSoundcorkProxy: true,
}
// Simulate the bug by using the old bitwise OR logic in the test,
// which should fail if we expect false.
// config.redact = config.redact || false -> stays true
settings := datastore.Settings{
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
EnableSoundcorkProxy: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
if config.logBody != false {
t.Errorf("Expected logBody to be false, got true")
}
if config.record != false {
t.Errorf("Expected record to be false, got true")
}
if config.enableSoundcorkProxy != false {
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
}
})
t.Run("retains false when settings are false", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
})
t.Run("overrides false with true", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: true,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != true {
t.Errorf("Expected redact to be true, got false")
}
})
}
+1
View File
@@ -1,6 +1,7 @@
accounts/
certs/
default/
dns/
interactions/
patterns.json
settings.json
+4 -1
View File
@@ -27,7 +27,10 @@ Before you proceed with the actual migration, follow these steps:
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially.
5. **Migration Methods**:
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
+107 -15
View File
@@ -7,13 +7,16 @@ 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
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
## Architecture
@@ -148,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
@@ -235,6 +241,75 @@ curl "http://192.168.1.100:8090/presets"
curl "http://localhost:8000/events/192.168.1.100"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
> **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**: Falls back to the standard network DNS (provided by your router) if the Aftertouch service is unavailable.
- **DHCP Compatible**: Preserves your router's assigned search domain and secondary DNS servers.
- **Wildcard Support**: Seamlessly handles `*.bose.com` redirection via your local DNS server.
- **Persistent**: Survives reboots and DHCP renewals.
**How it works:**
1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition.
2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched.
3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts.
4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings.
**Setup:**
1. Enable SSH via the `remote_services` USB trick.
2. Create `/mnt/nv/aftertouch.resolv.conf` with your server details:
```text
# Created by Aftertouch/SoundTouch-Service
# Priority nameserver for Bose service redirection
nameserver 192.168.1.XXX
```
3. Update `/mnt/nv/rc.local` with the idempotent patch:
```sh
#!/bin/sh
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf"
if [ -f "$HOOK_MARKER" ]; then
# Patch 50default if it exists
TARGET_FILE="/etc/udhcpc.d/50default"
if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then
sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE"
fi
# Patch udhcpc.script if it exists (e.g. SoundTouch 10)
TARGET_SCRIPT="/opt/Bose/udhcpc.script"
if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT"
fi
fi
```
4. Make the script executable: `chmod +x /mnt/nv/rc.local`.
5. Reboot the speaker.
### 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
@@ -381,6 +456,8 @@ The web management interface provides a comprehensive dashboard for managing you
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
- **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
@@ -410,6 +487,8 @@ By default, the service redacts sensitive information from the recorded `.http`
- `Authorization` headers
- `Cookie` headers
- `X-Bose-Token` headers
- `X-Bose-Key` headers
- `Proxy-Authorization` headers
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
@@ -459,6 +538,8 @@ data/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ └── http-client.env.json
├── dns/
│ └── discoveries.json
├── stats/
│ ├── usage/
│ │ └── *.json
@@ -480,6 +561,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
@@ -559,6 +643,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.
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.48.0
@@ -13,7 +14,6 @@ require (
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
+342
View File
@@ -0,0 +1,342 @@
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
package discovery
import (
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSDiscovery handles DNS queries and records discovered hosts.
type DNSDiscovery struct {
// Configuration
upstreamDNS string
serviceIP string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
// Callbacks
onNewDiscovery func(hostname string)
// Servers for Shutdown
udpServer *dns.Server
tcpServer *dns.Server
}
// DiscoveredHost represents a host discovered via DNS queries.
type DiscoveredHost struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
discovered: make(map[string]*DiscoveredHost),
}
}
// ServeDNS implements the dns.Handler interface.
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
remoteAddr := ""
if w.RemoteAddr() != nil {
remoteAddr = w.RemoteAddr().String()
}
// Decide how to respond
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
// Record discovery
d.recordQuery(hostname, isIntercepted, remoteAddr)
if isIntercepted {
// Return your service IP
d.respondWithIP(w, r, d.serviceIP)
log.Printf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP)
} else {
// Forward to real DNS
log.Printf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS)
d.forward(w, r)
}
}
// recordQuery logs a DNS query and updates the internal state.
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
d.mu.Lock()
defer d.mu.Unlock()
host, exists := d.discovered[hostname]
if !exists {
// New discovery!
host = &DiscoveredHost{
Hostname: hostname,
FirstSeen: time.Now(),
LastSeen: time.Now(),
QueryCount: 1,
IsBoseService: d.isBoseRelated(hostname),
IsIntercepted: isIntercepted,
RemoteAddr: remoteAddr,
}
d.discovered[hostname] = host
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
hostname, host.IsBoseService, host.IsIntercepted)
if d.onNewDiscovery != nil {
go d.onNewDiscovery(hostname)
}
} else {
host.LastSeen = time.Now()
host.QueryCount++
host.IsIntercepted = isIntercepted
if remoteAddr != "" {
host.RemoteAddr = remoteAddr
}
}
}
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
// Intercept known Bose cloud services
interceptList := []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
}
for _, service := range interceptList {
if strings.Contains(hostname, service) {
return true
}
}
return false
}
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
return strings.Contains(hostname, "bose") ||
strings.Contains(hostname, "soundtouch")
}
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false // Embedded clients sometimes don't like compression
m.Authoritative = true
m.RecursionAvailable = true
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
switch q.Qtype {
case dns.TypeA, dns.TypeANY:
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
} else {
log.Printf("[DNS] Error creating A record: %v", err)
}
case dns.TypeAAAA:
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
}
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write response: %v", err)
}
}
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
if r.Question[0].Qtype == dns.TypePTR {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
}
return
}
c := new(dns.Client)
// Add port 53 if not present
upstream := d.upstreamDNS
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
in, _, err := c.Exchange(r, upstream)
if err != nil {
log.Printf("[DNS ERROR] Forward failed for %s (type %d): %v", r.Question[0].Name, r.Question[0].Qtype, err)
// Return a failure response instead of just dropping
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
}
return
}
if err := w.WriteMsg(in); err != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
}
}
// GetDiscovered returns a map of all discovered hosts.
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
// Return copy
result := make(map[string]*DiscoveredHost)
for k, v := range d.discovered {
result[k] = v
}
return result
}
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
var result []*DiscoveredHost
for _, host := range d.discovered {
if host.IsBoseService {
result = append(result, host)
}
}
return result
}
// SetDiscovered sets the map of discovered hosts.
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
d.mu.Lock()
defer d.mu.Unlock()
d.discovered = discovered
}
// Start DNS server starts both UDP and TCP listeners
func (d *DNSDiscovery) Start(addr string) error {
mux := dns.NewServeMux()
mux.HandleFunc(".", d.ServeDNS)
d.mu.Lock()
d.udpServer = &dns.Server{
Addr: addr,
Net: "udp",
Handler: mux,
}
d.tcpServer = &dns.Server{
Addr: addr,
Net: "tcp",
Handler: mux,
}
d.mu.Unlock()
errChan := make(chan error, 2)
go func() {
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
if err := d.udpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("UDP server failed: %w", err)
}
}()
go func() {
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
if err := d.tcpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("TCP server failed: %w", err)
}
}()
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
// Wait for first error
return <-errChan
}
// IsRunning returns true if the DNS server is active and bound to the specified address.
func (d *DNSDiscovery) IsRunning(addr string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
if d.udpServer == nil || d.tcpServer == nil {
return false
}
// We check if the address matches what we expect
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
}
// Shutdown stops the DNS server listeners
func (d *DNSDiscovery) Shutdown() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.udpServer != nil {
if err := d.udpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down UDP server: %v", err)
}
d.udpServer = nil
}
if d.tcpServer != nil {
if err := d.tcpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down TCP server: %v", err)
}
d.tcpServer = nil
}
return nil
}
+194
View File
@@ -0,0 +1,194 @@
package discovery
import (
"net"
"testing"
"time"
"github.com/miekg/dns"
)
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Test intercepting Bose service
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
}
// Test aftertouch.test
m2 := new(dns.Msg)
m2.SetQuestion("aftertouch.test.", dns.TypeA)
rw2 := &mockResponseWriter{}
d.ServeDNS(rw2, m2)
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
t.Fatal("Expected response for aftertouch.test")
}
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
}
}
func TestDNSDiscovery_Forwarding(t *testing.T) {
// This test is harder because it needs a real upstream or a mock.
// For now, let's just test that it calls forward and record.
serviceIP := "192.168.1.100"
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
// Start a mock upstream DNS server
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// We expect forward to succeed
d.ServeDNS(rw, m)
d.mu.RLock()
host, exists := d.discovered["google.com"]
d.mu.RUnlock()
if !exists {
t.Error("Expected google.com to be recorded in discovery")
}
if host.IsBoseService {
t.Error("google.com should not be identified as a Bose service")
}
}
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5354"
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
// Test TCP resolution
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
c := new(dns.Client)
c.Net = "tcp"
in, _, err := c.Exchange(m, addr)
if err != nil {
t.Fatalf("Failed to exchange via TCP: %v", err)
}
if len(in.Answer) == 0 {
t.Fatal("Expected answer in TCP response")
}
if a, ok := in.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
}
// Test Shutdown
err = d.Shutdown()
if err != nil {
t.Errorf("Shutdown failed: %v", err)
}
// Verify it's really shut down by trying to connect
_, _, err = c.Exchange(m, addr)
if err == nil {
t.Error("Expected error after shutdown, but could still exchange")
}
}
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5355"
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false before Start")
}
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
if !d.IsRunning(addr) {
t.Error("Expected IsRunning to be true after Start")
}
if d.IsRunning("127.0.0.1:9999") {
t.Error("Expected IsRunning to be false for wrong address")
}
_ = d.Shutdown()
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false after Shutdown")
}
}
type mockResponseWriter struct {
msg *dns.Msg
}
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (m *mockResponseWriter) Close() error { return nil }
func (m *mockResponseWriter) TsigStatus() error { return nil }
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
+79
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
@@ -707,6 +708,9 @@ type Settings struct {
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
@@ -822,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
return copiedEvents
}
// DNSDiscoveryEntry represents a persisted DNS discovery.
type DNSDiscoveryEntry struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
if ds == nil || ds.DataDir == "" {
return nil
}
dir := filepath.Join(ds.DataDir, "dns")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create dns directory: %w", err)
}
path := filepath.Join(dir, "discoveries.json")
// Sort by last seen descending
sort.Slice(discoveries, func(i, j int) bool {
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
})
data, err := json.MarshalIndent(discoveries, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
if ds == nil || ds.DataDir == "" {
return []DNSDiscoveryEntry{}, nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var discoveries []DNSDiscoveryEntry
if err := json.Unmarshal(data, &discoveries); err != nil {
return nil, err
}
return discoveries, nil
}
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
func (ds *DataStore) ClearDNSDiscoveries() error {
if ds == nil || ds.DataDir == "" {
return nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return nil
}
return os.Remove(path)
}
@@ -0,0 +1,75 @@
package datastore
import (
"os"
"testing"
"time"
)
func TestDNSDiscoveryPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
now := time.Now().Round(time.Second)
discoveries := []DNSDiscoveryEntry{
{
Hostname: "api.bose.com",
FirstSeen: now.Add(-1 * time.Hour),
LastSeen: now,
QueryCount: 10,
IsBoseService: true,
IsIntercepted: true,
RemoteAddr: "192.168.1.100",
},
{
Hostname: "google.com",
FirstSeen: now.Add(-2 * time.Hour),
LastSeen: now.Add(-1 * time.Hour),
QueryCount: 5,
IsBoseService: false,
IsIntercepted: false,
RemoteAddr: "192.168.1.101",
},
}
// Test Save
err = ds.SaveDNSDiscoveries(discoveries)
if err != nil {
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
}
// Test Load
loaded, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
}
if len(loaded) != 2 {
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
}
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
if loaded[0].Hostname != "api.bose.com" {
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
}
// Test Clear
err = ds.ClearDNSDiscoveries()
if err != nil {
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
}
loadedAfterClear, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
}
if len(loadedAfterClear) != 0 {
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`)
_, _ = fmt.Fprintf(w, `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`)
return
}
+1 -2
View File
@@ -67,8 +67,7 @@ func TestRootEndpointJSON(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`
expected := `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`
if strings.TrimSpace(string(body)) != expected {
t.Errorf("Expected body %s, got %s", expected, string(body))
}
+12
View File
@@ -48,6 +48,13 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
lp.RecordEnabled = s.recordEnabled
lp.SetRecorder(s.recorder)
// Capture request body for recording, as it will be consumed by the proxy
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
rp := httputil.NewSingleHostReverseProxy(target)
rp.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
@@ -75,6 +82,11 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
res.Header["ETag"] = etags
}
// Restore captured request body for the recorder
if reqBody != nil {
res.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
lp.LogResponse(res)
return nil
@@ -0,0 +1,96 @@
package handlers
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "proxy-request-body-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Start a backend server to receive the proxied request
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read the body to ensure it's consumed
_, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte("<response>ok</response>"))
}))
defer backend.Close()
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
server.recordEnabled = true
server.proxyLogBody = true
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
// Create a proxy request to the backend
requestBody := "<request>data</request>"
targetURL := backend.URL
proxyPath := "/proxy/" + targetURL
req := httptest.NewRequest("POST", proxyPath, bytes.NewBufferString(requestBody))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
server.HandleProxyRequest(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify that the interaction was recorded and contains the request body
sessionID := recorder.SessionID
// The recorder uses sanitized segments for the directory.
// Since the target URL is http://127.0.0.1:PORT, the path is empty,
// so it should be in the "root" directory under the category.
// We'll search recursively to be sure
foundBody := false
err = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
content, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(content), requestBody) {
foundBody = true
}
}
return nil
})
if err != nil {
t.Fatalf("failed to walk interactions dir: %v", err)
}
if !foundBody {
t.Errorf("request body %q not found in any recorded interaction file", requestBody)
// List all files found for debugging
_ = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if !info.IsDir() {
content, _ := os.ReadFile(path)
t.Logf("Found file %s with content:\n%s", path, string(content))
}
return nil
})
}
}
+144
View File
@@ -6,11 +6,13 @@ import (
"log"
"net/http"
"os"
"sort"
"strconv"
"time"
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -148,17 +150,27 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsBindAddr := s.dnsBindAddr
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
shortcuts := s.shortcuts
s.mu.RUnlock()
dnsRunning, actualBind := s.GetDNSRunning()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"soundcork_url": soundcorkURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": dnsUpstream,
"dns_bind_addr": dnsBindAddr,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
"log_bodies": logBody,
@@ -177,6 +189,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
SoundcorkURL string `json:"soundcork_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts"`
}
@@ -200,6 +215,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
s.discoveryEnabled = settings.DiscoveryEnabled
s.dnsEnabled = settings.DNSEnabled
s.dnsUpstream = settings.DNSUpstream
s.dnsBindAddr = settings.DNSBindAddr
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if settings.Shortcuts != nil {
@@ -227,6 +245,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
})
@@ -384,6 +405,85 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
}
}
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
// 1. Get current in-memory discoveries
inMemory := s.GetDNSDiscovery()
// 2. Load persisted discoveries
persisted, err := s.ds.LoadDNSDiscoveries()
if err != nil {
log.Printf("Warning: Failed to load DNS discoveries: %v", err)
}
// 3. Merge them
merged := make(map[string]datastore.DNSDiscoveryEntry)
for _, p := range persisted {
merged[p.Hostname] = p
}
for hostname, h := range inMemory {
m, exists := merged[hostname]
if !exists || h.LastSeen.After(m.LastSeen) {
merged[hostname] = datastore.DNSDiscoveryEntry{
Hostname: h.Hostname,
FirstSeen: h.FirstSeen,
LastSeen: h.LastSeen,
QueryCount: h.QueryCount,
IsBoseService: h.IsBoseService,
IsIntercepted: h.IsIntercepted,
RemoteAddr: h.RemoteAddr,
}
} else if h.QueryCount > m.QueryCount {
// If exists and persisted is newer (rare but possible), update query count if higher
m.QueryCount = h.QueryCount
merged[hostname] = m
}
}
// Convert to slice
result := make([]datastore.DNSDiscoveryEntry, 0, len(merged))
for _, entry := range merged {
result = append(result, entry)
}
// Sort by last seen descending
sort.Slice(result, func(i, j int) bool {
return result[i].LastSeen.After(result[j].LastSeen)
})
// 4. Update persistence with merged results
if err := s.ds.SaveDNSDiscoveries(result); err != nil {
log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
// 1. Clear in-memory
s.SetDNSDiscoveries(make(map[string]*discovery.DiscoveredHost))
// 2. Clear persistence
if err := s.ds.ClearDNSDiscoveries(); err != nil {
http.Error(w, "Failed to clear DNS discoveries: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleTrustCACert injects the local Root CA into the device's shared trust store.
func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
@@ -579,6 +679,10 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
s.recordEnabled = settings.Record
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if s.recorder != nil {
s.recorder.Redact = settings.Redact
}
// Persist to datastore
// Access fields directly since we already hold the lock
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
@@ -653,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")
+92 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
"net/url"
"sync"
"time"
@@ -28,9 +29,13 @@ type Server struct {
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
dnsEnabled bool
dnsUpstream string
dnsBindAddr string
enableSoundcorkProxy bool
shortcuts map[string]int
recorder *proxy.Recorder
dnsDiscovery *discovery.DNSDiscovery
UpstreamProxy http.Handler
Version string
Commit string
@@ -39,7 +44,7 @@ type Server struct {
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
return &Server{
s := &Server{
ds: ds,
sm: sm,
serverURL: serverURL,
@@ -50,6 +55,8 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
enableSoundcorkProxy: enableSoundcorkProxy,
discoveryInterval: 5 * time.Minute,
}
return s
}
// SetVersionInfo sets the version information for the server.
@@ -71,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()
@@ -113,7 +198,13 @@ func (s *Server) SetSoundcorkURL(url string) {
// SetRecorder sets the recorder for the server.
func (s *Server) SetRecorder(r *proxy.Recorder) {
s.mu.Lock()
defer s.mu.Unlock()
s.recorder = r
if r != nil {
r.Redact = s.proxyRedact
}
}
// GetRecordEnabled returns whether recording is enabled.
+72 -1
View File
@@ -100,6 +100,24 @@
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
@@ -194,12 +212,31 @@
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
<strong>Preliminary DNS Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div style="margin-top: 10px;">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px;">
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
</div>
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</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 Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. 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>
+170 -1
View File
@@ -14,6 +14,15 @@ async function fetchSettings() {
if (settings.discovery_enabled !== undefined) {
document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
}
if (settings.dns_enabled !== undefined) {
document.getElementById('dns-enabled').checked = settings.dns_enabled;
}
if (settings.dns_upstream) {
document.getElementById('dns-upstream').value = settings.dns_upstream;
}
if (settings.dns_bind_addr) {
document.getElementById('dns-bind').value = settings.dns_bind_addr;
}
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
@@ -62,6 +71,9 @@ async function updateSettings() {
proxy_url: document.getElementById('soundcork-url').value,
discovery_interval: document.getElementById('discovery-interval').value,
discovery_enabled: document.getElementById('discovery-enabled').checked,
dns_enabled: document.getElementById('dns-enabled').checked,
dns_upstream: document.getElementById('dns-upstream').value,
dns_bind_addr: document.getElementById('dns-bind').value,
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
};
const status = document.getElementById('settings-status');
@@ -191,6 +203,7 @@ function openTab(evt, tabId) {
if (tabId === 'tab-interactions') {
fetchInteractionStats();
fetchInteractions();
fetchDNSDiscoveries();
}
if (evt) {
@@ -504,6 +517,74 @@ async function viewInteraction(file) {
}
}
async function fetchDNSDiscoveries() {
console.log('Fetching DNS discoveries...');
try {
const response = await fetch('/setup/dns-discoveries');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const discoveries = await response.json();
console.log('Fetched DNS discoveries:', discoveries);
const list = document.getElementById('dns-discoveries-list');
if (!list) {
console.error('Could not find dns-discoveries-list element');
return;
}
list.innerHTML = '';
if (!discoveries || discoveries.length === 0) {
list.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS queries discovered yet.</td></tr>';
return;
}
discoveries.forEach(d => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #eee';
const hostname = d.hostname || "";
const lastSeen = d.last_seen || "";
const count = d.query_count || 0;
const isBose = d.is_bose_service ? '✅' : '❌';
const category = d.is_intercepted ? 'self' : 'upstream';
const remoteAddr = d.remote_addr || 'unknown';
tr.innerHTML = `
<td style="padding: 8px; font-weight: bold;">${hostname}</td>
<td style="padding: 8px; font-size: 0.85em;">${lastSeen}</td>
<td style="padding: 8px; text-align: center;">${count}</td>
<td style="padding: 8px; text-align: center;">${isBose}</td>
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
<td style="padding: 8px; font-size: 0.8em; color: #666;">${remoteAddr}</td>
`;
list.appendChild(tr);
});
} catch (error) {
console.error('Failed to fetch DNS discoveries', error);
}
}
async function clearDNSDiscoveries() {
if (!confirm('Are you sure you want to clear all DNS discovery logs?')) {
return;
}
try {
const response = await fetch('/setup/dns-discoveries', {
method: 'DELETE'
});
if (response.ok) {
fetchDNSDiscoveries();
} else {
const err = await response.text();
alert('Failed to clear DNS discoveries: ' + err);
}
} catch (error) {
alert('Error clearing DNS discoveries: ' + error.message);
}
}
async function showDeviceEvents() {
const overlay = document.getElementById('device-events-overlay');
overlay.style.display = 'block';
@@ -796,6 +877,12 @@ async function showSummary(ip) {
document.getElementById('planned-config').innerText = summary.planned_config;
document.getElementById('planned-hosts').innerText = summary.planned_hosts || '';
document.getElementById('planned-resolv').innerText = summary.planned_resolv || '';
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,111 @@ 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 = 'none';
serviceOptions.style.display = 'none';
hostsTestPane.style.display = 'none';
dnsTestPane.style.display = 'block';
const resolvNote = document.getElementById('resolv-note');
if (resolvNote) {
resolvNote.innerHTML = '<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router\'s search domain and secondary DNS servers. It also injects the Local Root CA.';
}
// 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...') {
+133
View File
@@ -0,0 +1,133 @@
package proxy
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRecorder_Redaction(t *testing.T) {
// Disable async for testing
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-redact-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = true // Enable redaction
req := httptest.NewRequest("GET", "http://example.com/api/test", nil)
req.Header.Set("Authorization", "Bearer sensitive-token")
req.Header.Set("X-Custom", "safe-value")
w := httptest.NewRecorder()
w.Header().Set("X-Bose-Token", "sensitive-bose-token")
w.Header().Set("Content-Type", "text/plain")
_, _ = w.WriteString("hello")
res := w.Result()
res.Request = req
err = r.Record("test", req, res)
if err != nil {
t.Fatalf("Failed to record: %v", err)
}
// Find the recorded file
var recordedFile string
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
recordedFile = path
}
return nil
})
if err != nil {
t.Fatalf("Error walking temp dir: %v", err)
}
if recordedFile == "" {
t.Fatal("No recorded .http file found")
}
content, err := os.ReadFile(recordedFile)
if err != nil {
t.Fatalf("Failed to read recorded file: %v", err)
}
contentStr := string(content)
// Check for redaction in request headers
if strings.Contains(contentStr, "sensitive-token") {
t.Errorf("Recorded file contains sensitive Authorization header value:\n%s", contentStr)
}
if !strings.Contains(contentStr, "Authorization: [REDACTED]") {
t.Errorf("Recorded file does not contain redacted Authorization header:\n%s", contentStr)
}
// Check for redaction in response headers
if strings.Contains(contentStr, "sensitive-bose-token") {
t.Errorf("Recorded file contains sensitive X-Bose-Token header value:\n%s", contentStr)
}
if !strings.Contains(contentStr, "X-Bose-Token: [REDACTED]") {
t.Errorf("Recorded file does not contain redacted X-Bose-Token header:\n%s", contentStr)
}
// Check that non-sensitive headers are NOT redacted
if !strings.Contains(contentStr, "X-Custom: safe-value") {
t.Errorf("Recorded file missing non-sensitive header or it was incorrectly redacted:\n%s", contentStr)
}
}
func TestRecorder_NoRedaction(t *testing.T) {
// Disable async for testing
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-no-redact-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = false // Disable redaction
req := httptest.NewRequest("GET", "http://example.com/api/test", nil)
req.Header.Set("Authorization", "Bearer sensitive-token")
w := httptest.NewRecorder()
w.Header().Set("X-Bose-Token", "sensitive-bose-token")
_, _ = w.WriteString("hello")
res := w.Result()
res.Request = req
err = r.Record("test", req, res)
if err != nil {
t.Fatalf("Failed to record: %v", err)
}
// Find the recorded file
var recordedFile string
filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && strings.HasSuffix(path, ".http") {
recordedFile = path
}
return nil
})
content, _ := os.ReadFile(recordedFile)
contentStr := string(content)
if !strings.Contains(contentStr, "Bearer sensitive-token") {
t.Errorf("Recorded file should contain sensitive Authorization header when Redact=false:\n%s", contentStr)
}
if !strings.Contains(contentStr, "sensitive-bose-token") {
t.Errorf("Recorded file should contain sensitive X-Bose-Token header when Redact=false:\n%s", contentStr)
}
}
+462 -5
View File
@@ -27,6 +27,8 @@ const (
MigrationMethodXML MigrationMethod = "xml"
// MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store.
MigrationMethodHosts MigrationMethod = "hosts"
// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic 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,8 @@ 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"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
}
@@ -79,6 +83,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.
@@ -209,6 +216,10 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
if hostName != "" && hostName != "localhost" {
client := m.NewSSH(deviceIP)
hostIP := m.resolveIP(hostName, client)
// Predicted aftertouch.resolv.conf
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
@@ -236,6 +247,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 +321,33 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
}
}
}
// Case 3: /etc/resolv.conf Migration (including Aftertouch hook)
// Check if /etc/resolv.conf contains our target nameserver OR if hook marker exists
if summary.SSHSuccess {
// Check for aftertouch.resolv.conf
if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
if summary.CACertTrusted {
summary.IsMigrated = true
return
}
}
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 +569,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 +1058,153 @@ 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 /mnt/nv/aftertouch.resolv.conf content
resolvContent := fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
// 3. Upload /mnt/nv/aftertouch.resolv.conf
// Ensure /mnt/nv exists
_, _ = client.Run("mkdir -p /mnt/nv")
if err := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); err != nil {
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", err)
}
logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n"
// 4. Update /mnt/nv/rc.local with idempotent patch
rcLocalPath := "/mnt/nv/rc.local"
targetDHCPFile := "/etc/udhcpc.d/50default"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
// Check if rc.local exists and read it
currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
if rcErr != nil {
currentRcLocal = ""
}
patchLogic := fmt.Sprintf(`
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
if [ -f "%s" ]; then
if [ -f "%s" ] && ! grep -q "%s" "%s"; then
logger -t "aftertouch" "Patching %s with Aftertouch DNS hook"
sed -i '/echo "search \$domain"/a \ [ -f '"%s"' ] && cat '"%s"' && dns=""' "%s"
fi
targetScript="/opt/Bose/udhcpc.script"
if [ -f "$targetScript" ] && ! grep -q "%s" "$targetScript"; then
logger -t "aftertouch" "Patching $targetScript with Aftertouch DNS hook"
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"%s"' ] && cat '"%s"' >> '"$RESOLV_CONF"' && dns=""' "$targetScript"
fi
fi
`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker)
if !strings.Contains(currentRcLocal, hookMarker) {
newRcLocal := currentRcLocal
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(newRcLocal, "cat: can't open") {
newRcLocal = ""
}
if !strings.HasPrefix(newRcLocal, "#!/bin/sh") {
newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh")
}
if !strings.HasSuffix(newRcLocal, "\n") {
newRcLocal += "\n"
}
newRcLocal += patchLogic
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err)
}
logs += fmt.Sprintf("Updated %s with DNS hook logic\n", rcLocalPath)
// Make it executable
_, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath))
} else {
logs += fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath)
}
// 5. Apply patch immediately to /etc/udhcpc.d/50default
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetDHCPFile, targetDHCPFile))
logs += fmt.Sprintf("cp %s %s.original: %s\n", targetDHCPFile, targetDHCPFile, out)
} else {
// If backup exists, revert to it first to ensure we start from a clean state
_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetDHCPFile, targetDHCPFile))
}
// Run the patch logic via SSH to apply it now
patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' && dns=\"\"' %s", hookMarker, hookMarker, targetDHCPFile)
if _, err := client.Run(patchCmd); err != nil {
logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetDHCPFile, err)
} else {
logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile)
}
// Apply patch immediately to /opt/Bose/udhcpc.script if it exists
targetScript := "/opt/Bose/udhcpc.script"
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil {
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript))
logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out)
} else {
// If backup exists, revert to it first to ensure we start from a clean state
_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript))
}
patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript)
if _, err := client.Run(patchCmdScript); err != nil {
logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetScript, err)
} else {
logs += fmt.Sprintf("Applied patch to %s\n", targetScript)
}
}
// 6. 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)
@@ -968,6 +1213,31 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
var logs string
// 1. Revert SoundTouchSdkPrivateCfg.xml
out, err := m.revertXMLConfig(client, rwCmd)
logs += out
if err != nil {
return logs, err
}
// 2. Revert /etc/hosts
logs += m.revertHosts(client, rwCmd)
// 2b. Revert /etc/resolv.conf
logs += m.revertResolvConf(client, rwCmd)
// 2c. Revert Aftertouch DNS Hook
logs += m.revertAftertouchHook(client, rwCmd)
// 3. Remove CA certificate from trust store if it exists
logs += m.revertCACert(client, rwCmd)
return logs, nil
}
func (m *Manager) revertXMLConfig(client SSHClient, rwCmd string) (string, error) {
var logs string
remotePath := SoundTouchSdkPrivateCfgPath
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", remotePath)
@@ -982,7 +1252,12 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath)
}
// 2. Revert /etc/hosts
return logs, nil
}
func (m *Manager) revertHosts(client SSHClient, rwCmd string) string {
var logs string
hostsPath := "/etc/hosts"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", hostsPath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", hostsPath)
@@ -991,12 +1266,120 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
logs += fmt.Sprintf("cp %s.original %s: %s\n", hostsPath, hostsPath, out)
if err != nil {
// Don't return error here, try to continue with other reverts
fmt.Printf("Warning: failed to revert %s: %v\n", hostsPath, err)
}
}
// 3. Remove CA certificate from trust store if it exists
return logs
}
func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string {
var logs string
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)
}
}
return logs
}
func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
var logs string
aftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
rcLocalPath := "/mnt/nv/rc.local"
targetDHCPFile := "/etc/udhcpc.d/50default"
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", aftertouchConfPath)); err == nil {
logs += fmt.Sprintf("Removing %s\n", aftertouchConfPath)
fmt.Printf("Removing %s\n", aftertouchConfPath)
_, _ = client.Run(fmt.Sprintf("rm %s", aftertouchConfPath))
}
if currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)); err == nil {
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(currentRcLocal, "cat: can't open") {
logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath)
_, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath))
return logs
}
if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
// Simple removal: filter out lines between the marker and the 'fi'
lines := strings.Split(currentRcLocal, "\n")
var newLines []string
skip := false
for _, line := range lines {
if strings.Contains(line, "# Aftertouch DNS hook") {
skip = true
continue
}
if skip && strings.TrimSpace(line) == "fi" {
skip = false
continue
}
if !skip {
newLines = append(newLines, line)
}
}
newRcLocal := strings.Join(newLines, "\n")
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
}
}
}
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", targetDHCPFile)
fmt.Printf("Reverting %s from backup\n", targetDHCPFile)
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetDHCPFile, targetDHCPFile))
logs += fmt.Sprintf("cp %s.original %s: %s\n", targetDHCPFile, targetDHCPFile, out)
if err != nil {
fmt.Printf("Warning: failed to revert %s: %v\n", targetDHCPFile, err)
}
}
targetScript := "/opt/Bose/udhcpc.script"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", targetScript)
fmt.Printf("Reverting %s from backup\n", targetScript)
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetScript, targetScript))
logs += fmt.Sprintf("cp %s.original %s: %s\n", targetScript, targetScript, out)
if err != nil {
fmt.Printf("Warning: failed to revert %s: %v\n", targetScript, err)
}
}
return logs
}
func (m *Manager) revertCACert(client SSHClient, rwCmd string) string {
var logs string
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) {
logs += fmt.Sprintf("Removing local CA certificate from %s\n", bundlePath)
@@ -1027,6 +1410,7 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent([]byte(bundleContent), bundlePath); err != nil {
logs += "Warning: failed to remove CA from " + bundlePath + ": " + err.Error() + "\n"
fmt.Printf("Warning: failed to remove CA from %s: %v\n", bundlePath, err)
@@ -1035,7 +1419,7 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
}
}
return logs, nil
return logs
}
// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
@@ -1132,6 +1516,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 +1740,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
+520 -5
View File
@@ -265,9 +265,9 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig)
}
// Test PlannedHosts
if !contains(summary.PlannedHosts, "target\tstreaming.bose.com") {
t.Errorf("Expected PlannedHosts to contain redirect for target, got: %s", summary.PlannedHosts)
// Test PlannedResolv
if !contains(summary.PlannedResolv, "nameserver target") {
t.Errorf("Expected PlannedResolv to contain nameserver target, got: %s", summary.PlannedResolv)
}
}
@@ -701,9 +701,14 @@ func TestRevertMigration(t *testing.T) {
if command == "cat /etc/pki/tls/certs/ca-bundle.crt" {
return "existing content\n" + CALabel + "\nCERT DATA\n" + CALabel + "\nmore content", nil
}
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n# Aftertouch DNS hook\nlogic\nfi\n", nil
}
// Mock file existence checks for .original files
if strings.HasPrefix(command, "[ -f") && strings.Contains(command, ".original") {
return "", nil // file exists
if strings.HasPrefix(command, "[ -f") {
if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
return "", nil // file exists
}
}
return "", nil
},
@@ -722,7 +727,12 @@ func TestRevertMigration(t *testing.T) {
// Verify revert commands
foundXMLRevert := false
foundHostsRevert := false
foundResolvRevert := false
foundChattrRemove := false
foundReboot := false
foundAftertouchConfRemove := false
foundDHCPRevert := false
for _, call := range runCalls {
if strings.Contains(call, "cp "+SoundTouchSdkPrivateCfgPath+".original "+SoundTouchSdkPrivateCfgPath) {
foundXMLRevert = true
@@ -730,9 +740,21 @@ 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
}
if strings.Contains(call, "rm /mnt/nv/aftertouch.resolv.conf") {
foundAftertouchConfRemove = true
}
if strings.Contains(call, "cp /etc/udhcpc.d/50default.original /etc/udhcpc.d/50default") {
foundDHCPRevert = true
}
}
if !foundXMLRevert {
@@ -741,10 +763,31 @@ 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 !foundAftertouchConfRemove {
t.Errorf("Expected /mnt/nv/aftertouch.resolv.conf removal")
}
if !foundDHCPRevert {
t.Errorf("Expected /etc/udhcpc.d/50default revert")
}
if foundReboot {
t.Errorf("Expected reboot NOT to be called automatically during revert")
}
// Verify rc.local cleanup
if content, ok := uploadCalls["/mnt/nv/rc.local"]; ok {
if strings.Contains(content, "# Aftertouch DNS hook") {
t.Errorf("Expected Aftertouch hook to be removed from rc.local, got: %s", content)
}
} else {
t.Errorf("Expected rc.local to be updated")
}
// Verify RemoveRemoteServices was NOT called
for _, call := range runCalls {
if strings.Contains(call, "rm -f /etc/remote_services") {
@@ -765,6 +808,47 @@ func TestRevertMigration(t *testing.T) {
}
}
func TestRevertMigration_CorruptedRcLocal(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil
}
if strings.HasPrefix(command, "[ -f") {
if strings.Contains(command, ".original") {
if strings.Contains(command, "SoundTouchSdkPrivateCfg.xml") {
return "", nil // Pretend XML backup exists to satisfy RevertMigration
}
return "", fmt.Errorf("not found")
}
}
return "", nil
},
}
}
_, err := m.RevertMigration("192.168.1.10")
if err != nil {
t.Fatalf("RevertMigration failed: %v", err)
}
foundRmRcLocal := false
for _, call := range runCalls {
if call == "rm /mnt/nv/rc.local" {
foundRmRcLocal = true
break
}
}
if !foundRmRcLocal {
t.Errorf("Expected corrupted rc.local to be removed")
}
}
func TestRevertMigration_NoBackup(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
@@ -817,6 +901,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 +1091,257 @@ 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{}
uploads := make(map[string]string)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = 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 uploads
if !strings.Contains(uploads["/mnt/nv/aftertouch.resolv.conf"], "nameserver 192.168.1.100") {
t.Errorf("aftertouch.resolv.conf missing nameserver")
}
if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/aftertouch.resolv.conf") {
t.Errorf("rc.local missing hook logic")
}
// Verify immediate patch
foundPatch := false
for _, call := range runCalls {
if strings.Contains(call, "sed -i") && strings.Contains(call, "/etc/udhcpc.d/50default") {
foundPatch = true
break
}
}
if !foundPatch {
t.Errorf("Expected immediate patch to /etc/udhcpc.d/50default")
}
}
func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-resolv-corrupted")
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)
uploads := make(map[string]string)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if command == "cat /mnt/nv/rc.local" {
// Simulate corrupted file containing error message
return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = 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 uploads - rc.local should have been sanitized and only contain shebang and hook
rcLocal := uploads["/mnt/nv/rc.local"]
if strings.Contains(rcLocal, "cat: can't open") {
t.Errorf("rc.local still contains corrupted content: %s", rcLocal)
}
if !strings.HasPrefix(rcLocal, "#!/bin/sh") {
t.Errorf("rc.local missing shebang: %s", rcLocal)
}
if !strings.Contains(rcLocal, "/mnt/nv/aftertouch.resolv.conf") {
t.Errorf("rc.local missing hook logic: %s", rcLocal)
}
}
func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-resolv-script")
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{}
uploads := make(map[string]string)
targetScript := "/opt/Bose/udhcpc.script"
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n", nil
}
if command == "[ -f "+targetScript+" ]" {
return "", nil // file exists
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = 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 immediate patch to udhcpc.script
foundPatch := false
for _, call := range runCalls {
if strings.Contains(call, "sed -i") && strings.Contains(call, targetScript) {
foundPatch = true
break
}
}
if !foundPatch {
t.Errorf("Expected immediate patch to %s", targetScript)
}
// Verify rc.local contains patch for udhcpc.script
rcLocal := uploads["/mnt/nv/rc.local"]
if !strings.Contains(rcLocal, "targetScript=\"/opt/Bose/udhcpc.script\"") {
t.Errorf("rc.local missing targetScript definition: %s", rcLocal)
}
if !strings.Contains(rcLocal, "sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a") {
t.Errorf("rc.local missing sed patch for udhcpc.script: %s", rcLocal)
}
}
func TestRevertMigration_ResolvConf(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-revert-resolv")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
m := NewManager("http://192.168.1.100:8000", nil, nil)
runCalls := []string{}
uploads := make(map[string]string)
targetDHCPFile := "/etc/udhcpc.d/50default"
targetScript := "/opt/Bose/udhcpc.script"
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil
}
if strings.Contains(command, ".original ]") {
return "", nil // backup exists
}
if strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") {
return "", nil
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = string(content)
return nil
},
}
}
_, err = m.RevertMigration("192.168.1.10")
if err != nil {
t.Fatalf("RevertMigration failed: %v", err)
}
// Verify backups were restored
foundDHCPRestore := false
foundScriptRestore := false
for _, call := range runCalls {
if strings.Contains(call, "cp "+targetDHCPFile+".original "+targetDHCPFile) {
foundDHCPRestore = true
}
if strings.Contains(call, "cp "+targetScript+".original "+targetScript) {
foundScriptRestore = true
}
}
if !foundDHCPRestore {
t.Errorf("Expected %s to be restored from backup", targetDHCPFile)
}
if !foundScriptRestore {
t.Errorf("Expected %s to be restored from backup", targetScript)
}
// Verify rc.local was cleaned up
rcLocal := uploads["/mnt/nv/rc.local"]
if strings.Contains(rcLocal, "# Aftertouch DNS hook") {
t.Errorf("rc.local still contains hook logic after revert: %s", rcLocal)
}
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
@@ -974,3 +1407,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)
}
}