mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aef2b807d | ||
|
|
95f5e9c831 | ||
|
|
7337296ae9 | ||
|
|
92a5d3592c | ||
|
|
2f04af872b | ||
|
|
9479d6d11d | ||
|
|
f687ba0d82 | ||
|
|
ab2bf0731a | ||
|
|
cafaba1be0 | ||
|
|
93082d2cdc | ||
|
|
087006c483 | ||
|
|
b7013a5ec8 |
@@ -20,7 +20,9 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
|
||||
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
|
||||
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
|
||||
- 📊 **Traffic Analysis**: Proxy and log device communications
|
||||
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
|
||||
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
@@ -137,6 +138,23 @@ func main() {
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server for non-Bose queries",
|
||||
Value: "8.8.8.8",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
@@ -160,10 +178,32 @@ func main() {
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetSoundcorkURL(config.soundcorkURL)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
if err == nil && len(dnsDiscoveries) > 0 {
|
||||
initial := make(map[string]*discovery.DiscoveredHost)
|
||||
for _, entry := range dnsDiscoveries {
|
||||
initial[entry.Hostname] = &discovery.DiscoveredHost{
|
||||
Hostname: entry.Hostname,
|
||||
FirstSeen: entry.FirstSeen,
|
||||
LastSeen: entry.LastSeen,
|
||||
QueryCount: entry.QueryCount,
|
||||
IsBoseService: entry.IsBoseService,
|
||||
IsIntercepted: entry.IsIntercepted,
|
||||
RemoteAddr: entry.RemoteAddr,
|
||||
}
|
||||
}
|
||||
|
||||
server.SetDNSDiscoveries(initial)
|
||||
}
|
||||
|
||||
server.SetShortcuts(persisted.Shortcuts)
|
||||
|
||||
for path, status := range persisted.Shortcuts {
|
||||
@@ -253,6 +293,9 @@ type serviceConfig struct {
|
||||
logBody bool
|
||||
record bool
|
||||
enableSoundcorkProxy bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
}
|
||||
@@ -300,6 +343,10 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
record := c.Bool("record-interactions")
|
||||
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
|
||||
|
||||
dnsEnabled := c.Bool("dns-discovery")
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
dnsBind := c.String("dns-bind")
|
||||
|
||||
discoveryIntervalStr := c.String("discovery-interval")
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
@@ -322,6 +369,9 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
enableSoundcorkProxy: enableSoundcorkProxy,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -27,7 +27,10 @@ Before you proceed with the actual migration, follow these steps:
|
||||
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
|
||||
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
|
||||
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
|
||||
5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially.
|
||||
5. **Migration Methods**:
|
||||
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
|
||||
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
|
||||
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
|
||||
@@ -7,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DNSDiscovery handles DNS queries and records discovered hosts.
|
||||
type DNSDiscovery struct {
|
||||
// Configuration
|
||||
upstreamDNS string
|
||||
serviceIP string
|
||||
|
||||
// State
|
||||
discovered map[string]*DiscoveredHost
|
||||
mu sync.RWMutex
|
||||
|
||||
// Callbacks
|
||||
onNewDiscovery func(hostname string)
|
||||
|
||||
// Servers for Shutdown
|
||||
udpServer *dns.Server
|
||||
tcpServer *dns.Server
|
||||
}
|
||||
|
||||
// DiscoveredHost represents a host discovered via DNS queries.
|
||||
type DiscoveredHost struct {
|
||||
Hostname string `json:"hostname"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
QueryCount int `json:"query_count"`
|
||||
IsBoseService bool `json:"is_bose_service"`
|
||||
IsIntercepted bool `json:"is_intercepted"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
}
|
||||
|
||||
// NewDNSDiscovery creates a new DNSDiscovery instance.
|
||||
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
}
|
||||
}
|
||||
|
||||
// ServeDNS implements the dns.Handler interface.
|
||||
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
if len(r.Question) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
q := r.Question[0]
|
||||
hostname := strings.TrimSuffix(q.Name, ".")
|
||||
|
||||
remoteAddr := ""
|
||||
if w.RemoteAddr() != nil {
|
||||
remoteAddr = w.RemoteAddr().String()
|
||||
}
|
||||
|
||||
// Decide how to respond
|
||||
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
|
||||
|
||||
// Record discovery
|
||||
d.recordQuery(hostname, isIntercepted, remoteAddr)
|
||||
|
||||
if isIntercepted {
|
||||
// Return your service IP
|
||||
d.respondWithIP(w, r, d.serviceIP)
|
||||
log.Printf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP)
|
||||
} else {
|
||||
// Forward to real DNS
|
||||
log.Printf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS)
|
||||
d.forward(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// recordQuery logs a DNS query and updates the internal state.
|
||||
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
host, exists := d.discovered[hostname]
|
||||
if !exists {
|
||||
// New discovery!
|
||||
host = &DiscoveredHost{
|
||||
Hostname: hostname,
|
||||
FirstSeen: time.Now(),
|
||||
LastSeen: time.Now(),
|
||||
QueryCount: 1,
|
||||
IsBoseService: d.isBoseRelated(hostname),
|
||||
IsIntercepted: isIntercepted,
|
||||
RemoteAddr: remoteAddr,
|
||||
}
|
||||
d.discovered[hostname] = host
|
||||
|
||||
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
|
||||
hostname, host.IsBoseService, host.IsIntercepted)
|
||||
|
||||
if d.onNewDiscovery != nil {
|
||||
go d.onNewDiscovery(hostname)
|
||||
}
|
||||
} else {
|
||||
host.LastSeen = time.Now()
|
||||
host.QueryCount++
|
||||
|
||||
host.IsIntercepted = isIntercepted
|
||||
if remoteAddr != "" {
|
||||
host.RemoteAddr = remoteAddr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
// Intercept known Bose cloud services
|
||||
interceptList := []string{
|
||||
"api.bose.com",
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
}
|
||||
|
||||
for _, service := range interceptList {
|
||||
if strings.Contains(hostname, service) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
|
||||
return strings.Contains(hostname, "bose") ||
|
||||
strings.Contains(hostname, "soundtouch")
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Compress = false // Embedded clients sometimes don't like compression
|
||||
m.Authoritative = true
|
||||
m.RecursionAvailable = true
|
||||
|
||||
q := r.Question[0]
|
||||
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA, dns.TypeANY:
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record: %v", err)
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
default:
|
||||
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
|
||||
if len(r.Question) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
|
||||
if r.Question[0].Qtype == dns.TypePTR {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
|
||||
m.Rcode = dns.RcodeNameError
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
// Add port 53 if not present
|
||||
upstream := d.upstreamDNS
|
||||
if !strings.Contains(upstream, ":") {
|
||||
upstream += ":53"
|
||||
}
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err != nil {
|
||||
log.Printf("[DNS ERROR] Forward failed for %s (type %d): %v", r.Question[0].Name, r.Question[0].Qtype, err)
|
||||
// Return a failure response instead of just dropping
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(in); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDiscovered returns a map of all discovered hosts.
|
||||
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
// Return copy
|
||||
result := make(map[string]*DiscoveredHost)
|
||||
for k, v := range d.discovered {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
|
||||
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
var result []*DiscoveredHost
|
||||
|
||||
for _, host := range d.discovered {
|
||||
if host.IsBoseService {
|
||||
result = append(result, host)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SetDiscovered sets the map of discovered hosts.
|
||||
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.discovered = discovered
|
||||
}
|
||||
|
||||
// Start DNS server starts both UDP and TCP listeners
|
||||
func (d *DNSDiscovery) Start(addr string) error {
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc(".", d.ServeDNS)
|
||||
|
||||
d.mu.Lock()
|
||||
d.udpServer = &dns.Server{
|
||||
Addr: addr,
|
||||
Net: "udp",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
d.tcpServer = &dns.Server{
|
||||
Addr: addr,
|
||||
Net: "tcp",
|
||||
Handler: mux,
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
|
||||
|
||||
if err := d.udpServer.ListenAndServe(); err != nil {
|
||||
errChan <- fmt.Errorf("UDP server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
|
||||
|
||||
if err := d.tcpServer.ListenAndServe(); err != nil {
|
||||
errChan <- fmt.Errorf("TCP server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
|
||||
|
||||
// Wait for first error
|
||||
return <-errChan
|
||||
}
|
||||
|
||||
// IsRunning returns true if the DNS server is active and bound to the specified address.
|
||||
func (d *DNSDiscovery) IsRunning(addr string) bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if d.udpServer == nil || d.tcpServer == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// We check if the address matches what we expect
|
||||
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
|
||||
}
|
||||
|
||||
// Shutdown stops the DNS server listeners
|
||||
func (d *DNSDiscovery) Shutdown() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.udpServer != nil {
|
||||
if err := d.udpServer.Shutdown(); err != nil {
|
||||
log.Printf("[DNS] Error shutting down UDP server: %v", err)
|
||||
}
|
||||
|
||||
d.udpServer = nil
|
||||
}
|
||||
|
||||
if d.tcpServer != nil {
|
||||
if err := d.tcpServer.Shutdown(); err != nil {
|
||||
log.Printf("[DNS] Error shutting down TCP server: %v", err)
|
||||
}
|
||||
|
||||
d.tcpServer = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Test intercepting Bose service
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test aftertouch.test
|
||||
m2 := new(dns.Msg)
|
||||
m2.SetQuestion("aftertouch.test.", dns.TypeA)
|
||||
rw2 := &mockResponseWriter{}
|
||||
d.ServeDNS(rw2, m2)
|
||||
|
||||
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
|
||||
t.Fatal("Expected response for aftertouch.test")
|
||||
}
|
||||
|
||||
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
// This test is harder because it needs a real upstream or a mock.
|
||||
// For now, let's just test that it calls forward and record.
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
// Start a mock upstream DNS server
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// We expect forward to succeed
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
d.mu.RLock()
|
||||
host, exists := d.discovered["google.com"]
|
||||
d.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected google.com to be recorded in discovery")
|
||||
}
|
||||
if host.IsBoseService {
|
||||
t.Error("google.com should not be identified as a Bose service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5354"
|
||||
go func() {
|
||||
_ = d.Start(addr)
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Test TCP resolution
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Net = "tcp"
|
||||
in, _, err := c.Exchange(m, addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to exchange via TCP: %v", err)
|
||||
}
|
||||
|
||||
if len(in.Answer) == 0 {
|
||||
t.Fatal("Expected answer in TCP response")
|
||||
}
|
||||
|
||||
if a, ok := in.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
|
||||
}
|
||||
|
||||
// Test Shutdown
|
||||
err = d.Shutdown()
|
||||
if err != nil {
|
||||
t.Errorf("Shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's really shut down by trying to connect
|
||||
_, _, err = c.Exchange(m, addr)
|
||||
if err == nil {
|
||||
t.Error("Expected error after shutdown, but could still exchange")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_IsRunning(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5355"
|
||||
|
||||
if d.IsRunning(addr) {
|
||||
t.Error("Expected IsRunning to be false before Start")
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = d.Start(addr)
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if !d.IsRunning(addr) {
|
||||
t.Error("Expected IsRunning to be true after Start")
|
||||
}
|
||||
|
||||
if d.IsRunning("127.0.0.1:9999") {
|
||||
t.Error("Expected IsRunning to be false for wrong address")
|
||||
}
|
||||
|
||||
_ = d.Shutdown()
|
||||
|
||||
if d.IsRunning(addr) {
|
||||
t.Error("Expected IsRunning to be false after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
type mockResponseWriter struct {
|
||||
msg *dns.Msg
|
||||
}
|
||||
|
||||
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
|
||||
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
|
||||
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
|
||||
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (m *mockResponseWriter) Close() error { return nil }
|
||||
func (m *mockResponseWriter) TsigStatus() error { return nil }
|
||||
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (m *mockResponseWriter) Hijack() {}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -707,6 +708,9 @@ type Settings struct {
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
}
|
||||
|
||||
@@ -822,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
|
||||
|
||||
return copiedEvents
|
||||
}
|
||||
|
||||
// DNSDiscoveryEntry represents a persisted DNS discovery.
|
||||
type DNSDiscoveryEntry struct {
|
||||
Hostname string `json:"hostname"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
QueryCount int `json:"query_count"`
|
||||
IsBoseService bool `json:"is_bose_service"`
|
||||
IsIntercepted bool `json:"is_intercepted"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
}
|
||||
|
||||
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
|
||||
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(ds.DataDir, "dns")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dns directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "discoveries.json")
|
||||
|
||||
// Sort by last seen descending
|
||||
sort.Slice(discoveries, func(i, j int) bool {
|
||||
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
|
||||
})
|
||||
|
||||
data, err := json.MarshalIndent(discoveries, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
|
||||
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return []DNSDiscoveryEntry{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
||||
if !exists(path) {
|
||||
return []DNSDiscoveryEntry{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var discoveries []DNSDiscoveryEntry
|
||||
if err := json.Unmarshal(data, &discoveries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return discoveries, nil
|
||||
}
|
||||
|
||||
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
|
||||
func (ds *DataStore) ClearDNSDiscoveries() error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
||||
if !exists(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDNSDiscoveryPersistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
discoveries := []DNSDiscoveryEntry{
|
||||
{
|
||||
Hostname: "api.bose.com",
|
||||
FirstSeen: now.Add(-1 * time.Hour),
|
||||
LastSeen: now,
|
||||
QueryCount: 10,
|
||||
IsBoseService: true,
|
||||
IsIntercepted: true,
|
||||
RemoteAddr: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
Hostname: "google.com",
|
||||
FirstSeen: now.Add(-2 * time.Hour),
|
||||
LastSeen: now.Add(-1 * time.Hour),
|
||||
QueryCount: 5,
|
||||
IsBoseService: false,
|
||||
IsIntercepted: false,
|
||||
RemoteAddr: "192.168.1.101",
|
||||
},
|
||||
}
|
||||
|
||||
// Test Save
|
||||
err = ds.SaveDNSDiscoveries(discoveries)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
|
||||
}
|
||||
|
||||
// Test Load
|
||||
loaded, err := ds.LoadDNSDiscoveries()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded) != 2 {
|
||||
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
|
||||
}
|
||||
|
||||
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
|
||||
if loaded[0].Hostname != "api.bose.com" {
|
||||
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
|
||||
}
|
||||
|
||||
// Test Clear
|
||||
err = ds.ClearDNSDiscoveries()
|
||||
if err != nil {
|
||||
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
|
||||
}
|
||||
|
||||
loadedAfterClear, err := ds.LoadDNSDiscoveries()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loadedAfterClear) != 0 {
|
||||
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...') {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user