Compare commits

...
12 Commits
30 changed files with 2590 additions and 396 deletions
+23 -23
View File
@@ -146,8 +146,8 @@ func main() {
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server for non-Bose queries",
Value: "8.8.8.8",
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
Value: "",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
@@ -211,16 +211,17 @@ func main() {
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
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)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
server.SetBaseURL(config.baseURL)
if config.spotifyClientID != "" {
spotifyService := spotify.NewSpotifyService(
@@ -357,7 +358,6 @@ type serviceConfig struct {
spotifyRedirectURI string
mgmtUsername string
mgmtPassword string
baseURL string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -421,7 +421,6 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyRedirectURI := c.String("spotify-redirect-uri")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
baseURL := c.String("base-url")
return serviceConfig{
port: port,
@@ -446,7 +445,6 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyRedirectURI: spotifyRedirectURI,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
baseURL: baseURL,
}
}
@@ -509,8 +507,8 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
config.dnsEnabled = persisted.DNSEnabled
if persisted.DNSUpstream != "" {
config.dnsUpstream = persisted.DNSUpstream
if len(persisted.DNSUpstream) > 0 {
config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",")
}
if persisted.DNSBindAddr != "" {
@@ -532,7 +530,7 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
DiscoveryEnabled: true,
EnableSoundcorkProxy: config.enableSoundcorkProxy,
DNSEnabled: config.dnsEnabled,
DNSUpstream: config.dnsUpstream,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
@@ -675,6 +673,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
})
})
@@ -688,19 +687,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
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("/info/{deviceId}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceId}", server.HandleBackupConfig)
r.Post("/sync/{deviceId}", server.HandleInitialSync)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
@@ -713,6 +712,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
+4
View File
@@ -38,6 +38,10 @@
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Concepts
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
* [Supported URLs](analysis/SUPPORTED-URLS.md)
+137
View File
@@ -0,0 +1,137 @@
# Spotify OAuth Integration
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
## OAuth Flows
The service supports two primary OAuth flows: a browser-based flow and a mobile app-based flow (specifically for the [ueberboese](https://github.com/julius-d/ueberboese-app) app).
### 1. Browser-based Flow
The user initiates the flow, completes authorization in their browser, and is redirected back to the service.
```mermaid
sequenceDiagram
participant Client as Client (curl/app)
participant Service as Service
participant Spotify as Spotify Auth Server
participant Browser as User's Browser
Client->>Service: POST /mgmt/spotify/init [Basic Auth]
Service-->>Client: {"redirectUrl": "https://accounts.spotify.com/authorize?..."}
Client->>Browser: User opens URL
Browser->>Spotify: User logs in & grants access
Spotify-->>Browser: Redirect to /mgmt/spotify/callback?code=abc
Browser->>Service: GET /mgmt/spotify/callback?code=abc
Note over Service: No auth needed for callback
Service->>Spotify: POST /api/token (exchange code)
Spotify-->>Service: {access_token, refresh_token}
Service->>Spotify: GET /v1/me (fetch profile)
Spotify-->>Service: {id, display_name, email}
Note over Service: Store account to disk
Service-->>Browser: HTML: "Spotify Connected. You can close this window."
```
### 2. Mobile App Flow (ueberboese)
The mobile app handles the redirect via a deep link and then confirms the authorization with the service.
```mermaid
sequenceDiagram
participant App as ueberboese Flutter App
participant Service as Service
participant Spotify as Spotify Auth Server
App->>Service: POST /mgmt/spotify/init [Basic Auth]
Service-->>App: {"redirectUrl": "https://..."}
App->>Spotify: Open in-app browser (User authorizes)
Spotify-->>App: Deep link redirect: ueberboese-login://spotify?code=abc
App->>Service: POST /mgmt/spotify/confirm?code=abc [Basic Auth]
Service->>Spotify: POST /api/token (exchange code)
Spotify-->>Service: {access_token, refresh_token}
Service->>Spotify: GET /v1/me (fetch profile)
Spotify-->>Service: {profile}
Service-->>App: {"ok": true}
```
### 3. Token Retrieval (Boot Primer / Speaker Setup)
Once an account is linked, access tokens can be retrieved for use with speakers (e.g., via the `addUser` ZeroConf command).
```mermaid
sequenceDiagram
participant Primer as Boot Primer Script
participant Service as Service
participant Spotify as Spotify Token API
participant Speaker as Speaker (Bose ST 20)
Primer->>Service: GET /mgmt/spotify/token [Basic Auth]
alt Token expired
Service->>Spotify: POST /api/token (refresh)
Spotify-->>Service: new tokens
end
Service-->>Primer: {"access_token": "...", "username": "..."}
Note over Primer: Spotify Connect ZeroConf
Primer->>Speaker: POST /SpotifyConnect (addUser with token)
Speaker-->>Primer: OK
Note over Speaker: Speaker now has Spotify access
```
## Boot Primer Script
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
### Automated Installation via Service
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
### Automated Installation Steps
When you run the Spotify primer installation, the service performs the following:
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
- `# --- Aftertouch Spotify hook START ---`
- `# --- Aftertouch Spotify hook END ---`
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
## Endpoints
| Method | Path | Auth | Purpose |
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
## Security
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
- The `GetAccounts` endpoint strips sensitive tokens from the response.
+84
View File
@@ -0,0 +1,84 @@
# Spotify Priming Strategy
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
## Overview
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token.
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
## Core Principles
### 1. User Intent (Opt-in)
AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify.
### 2. Device Cleanliness (Minimalist Footprint)
We avoid invasive modifications to the speaker's filesystem.
- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts.
- **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server.
### 3. Triggers for Priming
Priming is triggered when the speaker signals it is active and ready, specifically:
- **Power On:** When the speaker calls the `/marge/streaming/support/power_on` endpoint, AfterTouch ensures the device's ZeroConf state is correctly primed. This is the primary trigger.
- **Manual Override:** Users can manually trigger a "Prime Spotify" from the device list in the UI if needed.
During any of these events, the server:
1. Checks if a Spotify account is linked in AfterTouch.
2. Checks the device's current priming status (via ZeroConf).
3. If unprimed and an account is linked, it pushes the priming command.
### 4. Automated Recovery
AfterTouch ensures that if a speaker loses its session (due to a crash or power loss), it is re-primed when it next powers on and reaches out to the service.
### 5. Decoupling
The logic for account management and device interaction remains decoupled:
- **Spotify Service:** Manages OAuth tokens and account state.
- **Discovery Service:** Finds devices and tracks their network presence.
- **Orchestrator:** Connects the two, deciding when to push tokens to discovered devices based on the current link status.
## Workflow
### Initial Setup (The "Add Source" UX)
1. User opens the AfterTouch Dashboard.
2. User selects "Link Spotify Account."
3. OAuth flow completes; AfterTouch stores the token.
4. AfterTouch immediately triggers a discovery run to find and prime all compatible speakers.
### Maintenance (The "Watchdog" UX)
1. A speaker reboots or loses its token.
2. A discovery event occurs (periodic or triggered by UI).
3. AfterTouch detects the "Empty" user state on the speaker.
4. AfterTouch pushes a fresh token from the Spotify Service.
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
### Manual Override
Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device.
## Network Topology & Deployment Scenarios
The strategy adapts based on where the AfterTouch server is deployed:
### Local Deployment (Home Server / Docker)
- **Mechanism:** Both "Pull" (Marge) and "Push" (ZeroConf side-channel) are used.
- **Advantage:** The server can proactively fix the speaker's state via port 8200 as soon as it sees a "Liveness Signal."
### External Deployment (Cloud VPS)
- **Mechanism:** Primarily relies on "Pull" (Marge).
- **Constraint:** The server cannot reach port 8200 on the speaker due to NAT/Firewall.
- **Strategy:** In this scenario, AfterTouch acts as a passive token provider. The speaker must initiate the connection to our intercepted Bose endpoints to receive its Spotify configuration. If the speaker completely loses its user state and stops "pulling," a manual re-prime from a local machine or a temporary local discovery run might be required.
## Transition & Cleanup
As AfterTouch moves to the Server-Centric model, we will:
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts.
3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text.
## Implementation Roadmap (Conceptual)
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks.
2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API.
3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state.
4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
+1 -1
View File
@@ -43,7 +43,7 @@ To migrate your speakers, the service needs SSH access. You can enable it by:
3. Rebooting the speaker (unplug/replug).
**Verify SSH Access:**
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@<IP>`
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
Once enabled, you can log in as `root` (no password).
+103 -42
View File
@@ -4,6 +4,7 @@ package discovery
import (
"fmt"
"log"
"net"
"strings"
"sync"
"time"
@@ -14,7 +15,7 @@ import (
// DNSDiscovery handles DNS queries and records discovered hosts.
type DNSDiscovery struct {
// Configuration
upstreamDNS string
upstreamDNS []string
serviceIP string
// State
@@ -48,7 +49,7 @@ type DiscoveredHost struct {
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
@@ -83,7 +84,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
} else {
// Forward to real DNS
if d.upstreamDNS == "" {
if len(d.upstreamDNS) == 0 {
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
m := new(dns.Msg)
@@ -94,7 +95,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS))
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %v", hostname, q.Qtype, d.upstreamDNS))
d.forward(w, r)
}
}
@@ -155,6 +156,7 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"streamingoauth.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
@@ -191,19 +193,78 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
resolvedIP := ip
if net.ParseIP(ip) == nil {
// Attempt resolution if it's not a numeric IP
ips, err := net.LookupIP(ip)
if err == nil && len(ips) > 0 {
for _, rIP := range ips {
if rIP.To4() != nil {
resolvedIP = rIP.String()
break
}
}
if resolvedIP == ip && len(ips) > 0 {
resolvedIP = ips[0].String()
}
}
}
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)
if net.ParseIP(resolvedIP) == nil || strings.Contains(resolvedIP, ":") {
// If it's still not a valid IPv4 address, we can't create an A record.
// Try CNAME as a fallback if it looks like a hostname.
if !strings.Contains(resolvedIP, ":") {
// Normalize hostname for CNAME
target := resolvedIP
if !strings.HasSuffix(target, ".") {
target += "."
}
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN CNAME %s", q.Name, target))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
} else {
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
m.Rcode = dns.RcodeServerFailure
}
} else {
m.Rcode = dns.RcodeServerFailure
}
} else {
log.Printf("[DNS] Error creating A record: %v", err)
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, resolvedIP))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
} else {
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
m.Rcode = dns.RcodeServerFailure
}
}
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)
// Check if we have an IPv6 address
if net.ParseIP(resolvedIP) != nil && strings.Contains(resolvedIP, ":") {
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN AAAA %s", q.Name, resolvedIP))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
} else {
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
m.Rcode = dns.RcodeServerFailure
}
} else {
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
}
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
}
@@ -233,44 +294,44 @@ func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
return
}
// Add port 53 if not present
upstream := d.upstreamDNS
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
// Loop prevention: don't forward to ourselves
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
c := new(dns.Client)
c.Timeout = 2 * time.Second
in, _, err := c.Exchange(r, upstream)
if err != nil {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.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)
for _, upstream := range d.upstreamDNS {
// Add port 53 if not present
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
return
// Loop prevention: don't forward to ourselves
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
continue
}
in, _, err := c.Exchange(r, upstream)
if err == nil {
if in.Rcode == dns.RcodeSuccess {
if writeErr := w.WriteMsg(in); writeErr != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr)
}
return
}
d.throttledLog(fmt.Sprintf("[DNS] Upstream %s returned %s for %s, trying next", upstream, dns.RcodeToString[in.Rcode], q.Name))
} else {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d) via %s: %v", q.Name, q.Qtype, upstream, err))
}
}
if err := w.WriteMsg(in); err != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
// If we reach here, all upstreams failed
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)
}
}
+147 -8
View File
@@ -12,7 +12,7 @@ import (
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Test intercepting Bose service
@@ -38,6 +38,24 @@ func TestDNSDiscovery_Interception(t *testing.T) {
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
}
// Test intercepting streamingoauth.bose.com
m3 := new(dns.Msg)
m3.SetQuestion("streamingoauth.bose.com.", dns.TypeA)
rw3 := &mockResponseWriter{}
d.ServeDNS(rw3, m3)
if rw3.msg == nil || len(rw3.msg.Answer) == 0 {
t.Fatal("Expected response for streamingoauth.bose.com")
}
if a, ok := rw3.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for streamingoauth.bose.com, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for streamingoauth.bose.com, got %T", rw3.msg.Answer[0])
}
// Test aftertouch.test
m2 := new(dns.Msg)
m2.SetQuestion("aftertouch.test.", dns.TypeA)
@@ -61,7 +79,7 @@ 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
upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
@@ -102,7 +120,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5354"
@@ -151,7 +169,7 @@ func TestDNSDiscovery_StartTCP(t *testing.T) {
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5355"
@@ -196,7 +214,7 @@ func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
func TestDNSDiscovery_LogThrottling(t *testing.T) {
d := NewDNSDiscovery("8.8.8.8", "192.168.1.100")
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.1.100")
// Capture log output
var logBuf strings.Builder
@@ -229,7 +247,7 @@ func TestDNSDiscovery_LogThrottling(t *testing.T) {
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
serviceIP := "192.168.1.100"
bindAddr := "127.0.0.1:53"
upstreamDNS := "127.0.0.1:53"
upstreamDNS := []string{"127.0.0.1:53"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = bindAddr
@@ -256,7 +274,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "" // Empty upstream
var upstreamDNS []string // Empty upstream
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = ":53"
@@ -280,7 +298,7 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
serviceIP := "192.168.1.100"
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable
upstreamDNS := []string{"192.0.2.1:53"} // TEST-NET-1, usually non-routable
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
@@ -300,3 +318,124 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
t.Errorf("Expected RcodeServerFailure after timeout")
}
}
func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
serviceIP := "192.168.1.100"
// Mock server 1: returns NXDOMAIN
mux1 := dns.NewServeMux()
mux1.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
_ = w.WriteMsg(m)
})
ts1 := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux1}
go func() { _ = ts1.ListenAndServe() }()
defer func() { _ = ts1.Shutdown() }()
// Mock server 2: succeeds
mux2 := dns.NewServeMux()
mux2.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("1.2.3.4"),
})
_ = w.WriteMsg(m)
})
ts2 := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux2}
go func() { _ = ts2.ListenAndServe() }()
defer func() { _ = ts2.Shutdown() }()
time.Sleep(100 * time.Millisecond)
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("test.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.forward(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message")
}
// It should succeed because it falls back to the second upstream
if rw.msg.Rcode != dns.RcodeSuccess {
t.Errorf("Expected RcodeSuccess (0), got %d. Fallback failed.", rw.msg.Rcode)
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer from the second upstream")
}
}
func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
// Use localhost which should resolve to 127.0.0.1
serviceIP := "localhost"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
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 {
// It should be resolved to 127.0.0.1 (or whatever localhost resolves to)
if a.A.String() == "" {
t.Error("Expected a non-empty IP address")
}
log.Printf("Resolved localhost to %s", a.A.String())
} else if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
// Fallback to CNAME is also acceptable if resolution failed but it shouldn't for localhost
if cname.Target != "localhost." {
t.Errorf("Expected CNAME to localhost., got %s", cname.Target)
}
} else {
t.Errorf("Expected A or CNAME record, got %T", rw.msg.Answer[0])
}
}
func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
// Use a likely unresolvable hostname
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
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 (CNAME fallback)")
}
if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
expected := serviceIP + "."
if cname.Target != expected {
t.Errorf("Expected CNAME to %s, got %s", expected, cname.Target)
}
} else {
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
}
}
+1 -1
View File
@@ -709,7 +709,7 @@ type Settings struct {
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream,omitempty"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
+11 -7
View File
@@ -23,7 +23,7 @@ func TestDNSSettingsValidation(t *testing.T) {
r, server := setupRouter("http://localhost:8001", ds)
// Test Case 1: Enable DNS with empty upstream
// Test Case 1: Enable DNS with empty upstream (should fallback to system DNS)
update := map[string]interface{}{
"dns_enabled": true,
"dns_upstream": "",
@@ -38,14 +38,18 @@ func TestDNSSettingsValidation(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 when enabling DNS without upstream, got %d", w.Code)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 when enabling DNS without upstream (fallback to system), got %d. Body: %s", w.Code, w.Body.String())
}
// Verify DNS server is NOT running
running, _ := server.GetDNSRunning()
if running {
t.Error("DNS server should not be running after invalid config attempt")
// Verify DNS state in server
if !server.dnsEnabled {
t.Error("DNS should be enabled in server state")
}
// Verify it TRIED to start (either it is running, or it failed due to port conflict but state is enabled)
if !server.dnsEnabled {
t.Error("DNS state should be enabled")
}
// Test Case 2: Enable DNS with valid upstream
+38 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/xml"
"io"
"log"
"net"
"net/http"
"strconv"
"time"
@@ -56,7 +57,43 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
}
// HandleMargePowerOn handles the Marge power on request.
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[Marge] Failed to read power_on body: %v", err)
w.WriteHeader(http.StatusOK) // Silent failure is usually better for device requests
return
}
var req models.CustomerSupportRequest
if err := xml.Unmarshal(body, &req); err != nil {
log.Printf("[Marge] Failed to parse power_on body: %v", err)
// Fallback to remote address if body parsing fails
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
go s.PrimeDeviceWithSpotify(host)
}
w.WriteHeader(http.StatusOK)
return
}
deviceID := req.Device.ID
deviceIP := req.DiagnosticData.DeviceLandscape.IPAddress
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
if deviceIP != "" {
go s.PrimeDeviceWithSpotify(deviceIP)
} else {
// Fallback to remote address if IP is missing from XML
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
go s.PrimeDeviceWithSpotify(host)
}
}
w.WriteHeader(http.StatusOK)
}
+21 -9
View File
@@ -414,16 +414,28 @@ func TestMargePowerOn(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("<powerOn/>")))
if err != nil {
t.Fatal(err)
}
t.Run("EmptyBody", func(t *testing.T) {
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("<powerOn/>")))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
t.Run("FullBody", func(t *testing.T) {
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="A81B6A536A98"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>A81B6A536A98</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
}
func TestMargeAdvancedFeatures(t *testing.T) {
+26 -1
View File
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -215,7 +216,7 @@ func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Reques
}
}
// HandleMgmtSpotifyToken returns a fresh Spotify access token and username.
// HandleMgmtSpotifyToken returns a fresh Spotify access token for the linked account.
func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
@@ -286,3 +287,27 @@ func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request)
log.Printf("[Mgmt] Failed to encode entity: %v", err)
}
}
// HandleMgmtPrimeDevice triggers a Spotify priming for a specific device.
func (s *Server) HandleMgmtPrimeDevice(w http.ResponseWriter, r *http.Request) {
deviceID := r.URL.Query().Get("deviceId")
if deviceID == "" {
http.Error(w, `{"error":"missing deviceId"}`, http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
log.Printf("[Mgmt] Prime failed: %v", err)
http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusNotFound)
return
}
// Trigger priming
go s.PrimeDeviceWithSpotify(deviceIP)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"Priming triggered"}`))
}
+266
View File
@@ -0,0 +1,266 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
func TestHandleMgmtSpotifyInit(t *testing.T) {
s := &Server{}
// No spotify service configured
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyInit(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
// With spotify service
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
w = httptest.NewRecorder()
s.HandleMgmtSpotifyInit(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
}
}
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
s := &Server{}
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
req := httptest.NewRequest("GET", "/mgmt/spotify/accounts", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyAccounts(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string][]spotify.Account
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if len(resp["accounts"]) != 0 {
t.Errorf("expected 0 accounts, got %d", len(resp["accounts"]))
}
}
func TestHandleMgmtListSpeakers(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
_, s := setupRouter("http://localhost:8000", ds)
req := httptest.NewRequest("GET", "/mgmt/accounts/default/speakers", nil)
w := httptest.NewRecorder()
s.HandleMgmtListSpeakers(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if _, ok := resp["speakers"]; !ok {
t.Error("expected 'speakers' in response")
}
}
func TestHandleMgmtSpotifyCallback(t *testing.T) {
s := &Server{}
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
// Mock Spotify token and profile endpoints
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "at",
"refresh_token": "rt",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "user123",
"display_name": "Test User",
})
}))
defer profileServer.Close()
// Use internal members to override URLs (available because we are in the same package)
// Actually we need to reach through s.spotifyService which is private.
// But s.spotifyService is *spotify.Service, which we have a handle to (svc).
// We can't access private fields of spotify.Service from handlers package.
// Wait, I can't override tokenURL from here if it's unexported in spotify package.
// Let's check service.go again. Yes, tokenURL and apiBase are unexported.
// Since I can't easily mock the external Spotify API here without exported fields,
// I will test the error paths.
t.Run("Missing code", func(t *testing.T) {
req := httptest.NewRequest("GET", "/mgmt/spotify/callback", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyCallback(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Missing authorization code") {
t.Errorf("expected missing code error message, got %s", w.Body.String())
}
})
t.Run("Spotify error", func(t *testing.T) {
req := httptest.NewRequest("GET", "/mgmt/spotify/callback?error=access_denied", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyCallback(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "access_denied") {
t.Errorf("expected access_denied error message, got %s", w.Body.String())
}
})
}
func TestHandleMgmtSpotifyConfirm(t *testing.T) {
s := &Server{}
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
t.Run("Missing code", func(t *testing.T) {
req := httptest.NewRequest("POST", "/mgmt/spotify/confirm", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyConfirm(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
})
}
func TestHandleMgmtDeviceEvents(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
_, s := setupRouter("http://localhost:8000", ds)
r := chi.NewRouter()
r.Get("/mgmt/devices/{deviceId}/events", s.HandleMgmtDeviceEvents)
req := httptest.NewRequest("GET", "/mgmt/devices/device123/events", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if _, ok := resp["events"]; !ok {
t.Error("expected 'events' in response")
}
}
func TestBasicAuthMgmt(t *testing.T) {
s := &Server{}
s.SetMgmtConfig("admin", "secret123")
handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}))
t.Run("Valid credentials", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
req.SetBasicAuth("admin", "secret123")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
}
if rr.Body.String() != "OK" {
t.Errorf("expected body 'OK', got %q", rr.Body.String())
}
})
t.Run("Wrong username", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
req.SetBasicAuth("wrong", "secret123")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
}
if rr.Header().Get("WWW-Authenticate") == "" {
t.Error("expected WWW-Authenticate header to be set")
}
})
t.Run("Wrong password", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
req.SetBasicAuth("admin", "wrongpass")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
}
})
t.Run("Missing auth header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
}
})
t.Run("Empty credentials", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
req.SetBasicAuth("", "")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
}
})
}
+216 -51
View File
@@ -8,6 +8,7 @@ import (
"os"
"sort"
"strconv"
"strings"
"time"
"fmt"
@@ -156,6 +157,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
shortcuts := s.shortcuts
spotifyConfigured := s.spotifyService != nil
s.mu.RUnlock()
dnsRunning, actualBind := s.GetDNSRunning()
@@ -169,13 +171,14 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": dnsUpstream,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -201,8 +204,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
if settings.DNSEnabled && settings.DNSUpstream == "" {
http.Error(w, "DNS Upstream is required when DNS Discovery is enabled", http.StatusBadRequest)
return
// No strict requirement for DNSUpstream here as SetDNSSettings will
// try to fall back to system DNS. We only log it if both are empty later.
log.Printf("[DNS] DNS Discovery enabled without explicit upstreams, will try system DNS.")
}
interval, err := time.ParseDuration(settings.DiscoveryInterval)
@@ -221,7 +225,20 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.discoveryEnabled = settings.DiscoveryEnabled
s.dnsEnabled = settings.DNSEnabled
s.dnsUpstream = settings.DNSUpstream
// Handle comma-separated upstream DNS servers
var upstreamList []string
if settings.DNSUpstream != "" {
for _, u := range strings.Split(settings.DNSUpstream, ",") {
u = strings.TrimSpace(u)
if u != "" {
upstreamList = append(upstreamList, u)
}
}
}
s.dnsUpstream = upstreamList
s.dnsBindAddr = settings.DNSBindAddr
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
@@ -258,12 +275,12 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
})
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsUpstreamStr := strings.Join(s.dnsUpstream, ",")
dnsBindAddr := s.dnsBindAddr
s.mu.Unlock()
s.SetDNSSettings(dnsEnabled, dnsUpstream, dnsBindAddr)
s.SetDNSSettings(dnsEnabled, dnsUpstreamStr, dnsBindAddr)
if err != nil {
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
@@ -280,9 +297,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
// HandleGetDeviceInfo returns live information for a device.
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -302,9 +325,15 @@ func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
// HandleGetMigrationSummary returns a summary of the migration plan for a device.
func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -335,12 +364,25 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
// HandleMigrateDevice starts the migration process for a device.
func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -383,12 +425,25 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
// HandleRevertMigration reverts the migration for a device.
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -419,6 +474,32 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
result := s.getMergedDNSDiscoveries()
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
}
}
// HandleDownloadDNSDiscoveries returns recorded DNS discoveries as a downloadable JSON file.
func (s *Server) HandleDownloadDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
result := s.getMergedDNSDiscoveries()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=\"dns-discoveries.json\"")
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(result); err != nil {
log.Printf("Error encoding DNS discoveries for download: %v", err)
}
}
func (s *Server) getMergedDNSDiscoveries() []datastore.DNSDiscoveryEntry {
// 1. Get current in-memory discoveries
inMemory := s.GetDNSDiscovery()
@@ -469,12 +550,7 @@ func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request)
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
}
return result
}
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
@@ -498,12 +574,25 @@ func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Reques
// 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")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -534,12 +623,25 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
// HandleEnsureRemoteServices ensures that remote services are configured on a device.
func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -570,12 +672,25 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
// HandleRemoveRemoteServices removes remote services configuration from a device.
func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -606,12 +721,25 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
// HandleBackupConfig creates a backup of the device configuration.
func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -731,9 +859,15 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
// HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection.
func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -771,9 +905,15 @@ 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)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -811,9 +951,15 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
// 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")
if deviceIP == "" {
http.Error(w, "Missing deviceIP", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Missing deviceId", http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -828,12 +974,25 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
// HandleRebootDevice reboots a device.
func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -864,9 +1023,15 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
// HandleTestConnection performs a connection check from the device to the server.
func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -150,6 +151,13 @@ func TestMigrationAndCA(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// Add device to datastore for resolution
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
DeviceID: "192.168.1.10",
IPAddress: "192.168.1.10",
AccountID: "default",
})
// 1. Test GET /setup/ca.crt
res, err := http.Get(ts.URL + "/setup/ca.crt")
if err != nil {
+8 -8
View File
@@ -87,14 +87,14 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
})
+180 -33
View File
@@ -2,9 +2,13 @@ package handlers
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -14,6 +18,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/miekg/dns"
)
// Server handles HTTP requests for the SoundTouch service.
@@ -31,7 +36,7 @@ type Server struct {
discoveryInterval time.Duration
discoveryEnabled bool
dnsEnabled bool
dnsUpstream string
dnsUpstream []string
dnsBindAddr string
enableSoundcorkProxy bool
shortcuts map[string]int
@@ -46,7 +51,6 @@ type Server struct {
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
baseURL string
spotifyService *spotify.Service
}
@@ -86,6 +90,47 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.discoveryEnabled = enabled
}
// parseUpstreamDNS splits a comma-separated string of DNS servers.
func parseUpstreamDNS(upstream string) []string {
var upstreamList []string
if upstream != "" {
for _, u := range strings.Split(upstream, ",") {
u = strings.TrimSpace(u)
if u != "" {
upstreamList = append(upstreamList, u)
}
}
}
return upstreamList
}
// getSystemDNS returns the DNS servers from /etc/resolv.conf.
func getSystemDNS() []string {
config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")
if config != nil && len(config.Servers) > 0 {
return config.Servers
}
return nil
}
// areUpstreamsEqual compares two slices of DNS server addresses.
func areUpstreamsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// SetDNSSettings sets the DNS discovery settings for the server.
func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
s.mu.Lock()
@@ -95,11 +140,23 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
oldUpstream := s.dnsUpstream
s.dnsEnabled = enabled
s.dnsUpstream = upstream
s.dnsBindAddr = bind
upstreamList := parseUpstreamDNS(upstream)
// Try to get system DNS if none provided
if enabled && len(upstreamList) == 0 {
upstreamList = getSystemDNS()
if len(upstreamList) > 0 {
log.Printf("[DNS] Using system DNS servers from /etc/resolv.conf: %v", upstreamList)
}
}
s.dnsUpstream = upstreamList
upstreamChanged := !areUpstreamsEqual(upstreamList, oldUpstream)
if s.dnsDiscovery != nil {
if !enabled || bind != oldBind || upstream != oldUpstream {
if !enabled || bind != oldBind || upstreamChanged {
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
_ = s.dnsDiscovery.Shutdown()
@@ -107,8 +164,8 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
}
}
if enabled && upstream == "" {
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty")
if enabled && len(upstreamList) == 0 {
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty and no system DNS found")
s.dnsEnabled = false
@@ -116,28 +173,32 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
}
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)
s.startDNSDiscovery(bind, upstreamList)
}
}
func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
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(upstreamList, 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()
@@ -242,14 +303,6 @@ func (s *Server) SetMgmtConfig(username, password string) {
s.mgmtPassword = password
}
// SetBaseURL sets the external base URL for OAuth callbacks.
func (s *Server) SetBaseURL(baseURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.baseURL = baseURL
}
// SetSpotifyService sets the Spotify OAuth service.
func (s *Server) SetSpotifyService(ss *spotify.Service) {
s.mu.Lock()
@@ -274,6 +327,14 @@ func (s *Server) GetSettings() (string, string, string) {
return s.serverURL, s.soundcorkURL, s.httpsServerURL
}
// IsSpotifyConfigured returns whether Spotify integration is configured.
func (s *Server) IsSpotifyConfigured() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.spotifyService != nil
}
// GetProxySettings returns the current proxy settings.
func (s *Server) GetProxySettings() (bool, bool, bool, bool) {
s.mu.RLock()
@@ -317,6 +378,75 @@ func (s *Server) DiscoverDevices(ctx context.Context) {
s.mergeOverlappingDevices()
}
// PrimeDeviceWithSpotify triggers a Spotify priming of the speaker if a Spotify account is linked.
func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
return
}
// We'll use the first linked account. In the future, we might want to let the user
// pick or map accounts to speakers, but for now, we follow the "One linked account" model.
accessToken, username, err := svc.GetFreshToken()
if err != nil {
log.Printf("[Spotify Watchdog] Failed to get fresh token for %s: %v", deviceIP, err)
return
}
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
} else {
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
}
}
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
// ZeroConf API endpoint on the speaker
var zcURL string
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
// If port is specified (e.g. in tests), keep it but usually it's just IP
zcURL = fmt.Sprintf("http://%s/zc", deviceIP)
} else {
// If no port specified, default to 8200
zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP)
}
data := url.Values{}
data.Set("action", "addUser")
data.Set("userName", username)
data.Set("blob", accessToken)
data.Set("clientKey", "")
data.Set("tokenType", "accesstoken")
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.PostForm(zcURL, data)
if err != nil {
return fmt.Errorf("POST to %s failed: %w", zcURL, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST to %s returned status %d: %s", zcURL, resp.StatusCode, string(body))
}
return nil
}
func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo)
@@ -479,3 +609,20 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi
return nil
}
func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// 1. Try to find in Datastore
devices, err := s.ds.ListAllDevices()
if err == nil {
for i := range devices {
if devices[i].DeviceID == deviceID {
return devices[i].IPAddress, nil
}
}
}
return "", fmt.Errorf("device not found: %s", deviceID)
}
+37
View File
@@ -103,6 +103,43 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
.status-success { background-color: #e8f5e9; color: #2e7d32; }
.status-error { background-color: #ffebee; color: #c62828; }
.info-toggle {
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
background-color: #607D8B;
color: white;
border-radius: 50%;
font-size: 12px;
cursor: pointer;
margin-left: 5px;
font-style: normal;
user-select: none;
}
.info-toggle:hover {
background-color: #455A64;
}
.info-details {
display: none;
background-color: #f0f7ff;
border: 1px solid #d0e0f0;
padding: 10px;
margin-top: 5px;
border-radius: 4px;
font-size: 0.85em;
color: #333;
line-height: 1.4;
max-width: 400px;
}
.info-details code {
background-color: #e3f2fd;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.badge {
padding: 2px 8px;
border-radius: 10px;
+27 -6
View File
@@ -108,8 +108,13 @@
</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>
<input type="text" id="dns-upstream" placeholder="Default: system nameservers" style="width: 200px;">
<span class="info-toggle" onclick="toggleInfo('dns-upstream-info')"></span>
<div id="dns-upstream-info" class="info-details">
Optional: comma-separated list of DNS servers (e.g., <code>1.1.1.1, 8.8.8.8</code>).<br>
If empty, AfterTouch defaults to the system nameservers (e.g. from <code>/etc/resolv.conf</code>).<br>
<div id="dns-current-upstream" style="margin-top: 5px; font-weight: bold;"></div>
</div>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
@@ -118,6 +123,12 @@
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Spotify Integration:</strong>
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em;">
Checking configuration...
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
@@ -134,7 +145,13 @@
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="spotify-status-header" style="background: #f0f0f0; padding: 5px 15px; border-radius: 20px; font-size: 0.9em; display: flex; align-items: center; gap: 10px;">
Spotify: <span id="spotify-account-name" style="font-weight: bold;">Not Linked</span>
<button id="link-spotify-btn" onclick="linkSpotify()" style="font-size: 0.8em; padding: 2px 8px; background: #1DB954; color: white; border: none; border-radius: 10px; cursor: pointer;">Link Account</button>
</div>
</div>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
@@ -179,8 +196,9 @@
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<h3>Migration Summary for <span id="summary-device-display"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<input type="hidden" id="summary-device-id">
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
@@ -229,7 +247,7 @@
<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>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Redirect via DNS Hook)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
@@ -406,7 +424,10 @@
<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 style="display: flex; gap: 10px;">
<button onclick="downloadDNSDiscoveries()" class="btn-info">Download JSON</button>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
</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;">
+318 -116
View File
@@ -1,3 +1,124 @@
async function fetchSpotifyStatus() {
try {
const settingsResponse = await fetch('/setup/settings');
const settings = await settingsResponse.json();
const header = document.getElementById('spotify-status-header');
if (!settings.spotify_configured) {
if (header) header.style.display = 'none';
return;
}
if (header) header.style.display = 'flex';
const response = await fetch('/mgmt/spotify/accounts');
if (!response.ok) return;
const data = await response.json();
const nameEl = document.getElementById('spotify-account-name');
const linkBtn = document.getElementById('link-spotify-btn');
if (data.accounts && data.accounts.length > 0) {
header.style.background = '#e6ffed';
header.style.border = '1px solid #28a745';
nameEl.innerText = data.accounts[0].display_name || data.accounts[0].user_id || 'Linked';
if (linkBtn) linkBtn.style.display = 'none';
// Show Prime Spotify buttons on all devices
document.querySelectorAll('.btn-spotify').forEach(btn => {
btn.style.display = 'inline-block';
});
} else {
header.style.background = '#f0f0f0';
header.style.border = '1px solid #ccc';
nameEl.innerText = 'Not Linked';
if (linkBtn) linkBtn.style.display = 'inline-block';
document.querySelectorAll('.btn-spotify').forEach(btn => {
btn.style.display = 'none';
});
}
} catch (error) {
console.error('Failed to fetch Spotify status', error);
}
}
function toggleInfo(id) {
const el = document.getElementById(id);
if (el) {
el.style.display = el.style.display === 'block' ? 'none' : 'block';
}
}
async function linkSpotify() {
try {
const response = await fetch('/mgmt/spotify/init', { method: 'POST' });
if (!response.ok) {
const err = await response.text();
alert('Failed to initialize Spotify link: ' + err);
return;
}
const data = await response.json();
if (data.redirectUrl) {
// Open in a new tab
const win = window.open(data.redirectUrl, '_blank');
if (win) {
win.focus();
// Start polling for status change
const pollInterval = setInterval(async () => {
const statusResponse = await fetch('/mgmt/spotify/accounts');
if (statusResponse.ok) {
const statusData = await statusResponse.json();
if (statusData.accounts && statusData.accounts.length > 0) {
clearInterval(pollInterval);
fetchSpotifyStatus();
}
}
}, 2000);
// Stop polling after 2 minutes
setTimeout(() => clearInterval(pollInterval), 120000);
} else {
alert('Please allow popups to link your Spotify account.');
}
}
} catch (error) {
alert('Error linking Spotify: ' + error.message);
}
}
async function primeSpotify(deviceId) {
const btn = document.getElementById('prime-spotify-' + deviceId);
const originalText = btn.innerText;
btn.innerText = 'Priming...';
btn.disabled = true;
try {
const response = await fetch(`/mgmt/spotify/prime?deviceId=${encodeURIComponent(deviceId)}`, {
method: 'POST'
});
if (response.ok) {
btn.innerText = '✅ Primed';
btn.style.background = '#28a745';
setTimeout(() => {
btn.innerText = originalText;
btn.style.background = '';
btn.disabled = false;
}, 3000);
} else {
const err = await response.text();
alert('Failed to prime Spotify: ' + err);
btn.innerText = '❌ Failed';
setTimeout(() => {
btn.innerText = originalText;
btn.disabled = false;
}, 3000);
}
} catch (error) {
alert('Error priming Spotify: ' + error.message);
btn.innerText = originalText;
btn.disabled = false;
}
}
async function fetchSettings() {
try {
const response = await fetch('/setup/settings');
@@ -23,10 +144,30 @@ async function fetchSettings() {
if (settings.dns_bind_addr) {
document.getElementById('dns-bind').value = settings.dns_bind_addr;
}
const dnsCurrentUpstream = document.getElementById('dns-current-upstream');
if (dnsCurrentUpstream && settings.dns_upstream) {
dnsCurrentUpstream.innerText = 'Current upstreams: ' + settings.dns_upstream;
} else if (dnsCurrentUpstream) {
dnsCurrentUpstream.innerText = '';
}
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
const spotifyStatus = document.getElementById('spotify-config-status');
if (spotifyStatus) {
if (settings.spotify_configured) {
spotifyStatus.innerHTML = '<span style="color: green;">✅ Configured</span> (Client ID present)';
} else {
spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not Configured</span><br>' +
'<span style="font-size: 0.85em; color: #888;">To enable Spotify, provide <code>SPOTIFY_CLIENT_ID</code> and <code>SPOTIFY_CLIENT_SECRET</code> to the server.</span>';
}
}
fetchProxySettings();
fetchSpotifyStatus();
} catch (error) {
console.error('Failed to fetch settings', error);
}
@@ -127,33 +268,34 @@ async function fetchDevices() {
devices.forEach(d => {
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<tr id="device-row-${d.device_id}">
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || 'default'}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
`;
const optSync = document.createElement('option');
optSync.value = d.ip_address;
optSync.value = d.device_id;
optSync.textContent = `${d.name} (${d.ip_address})`;
syncSelector.appendChild(optSync);
const optMigrate = document.createElement('option');
optMigrate.value = d.ip_address;
optMigrate.value = d.device_id;
optMigrate.textContent = `${d.name} (${d.ip_address})`;
migrationSelector.appendChild(optMigrate);
if (eventSelector) {
const optEvent = document.createElement('option');
optEvent.value = d.device_id || d.ip_address;
optEvent.value = d.device_id;
optEvent.textContent = `${d.name} (${d.ip_address})`;
eventSelector.appendChild(optEvent);
}
@@ -166,22 +308,23 @@ async function fetchDevices() {
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
devices.forEach(d => updateDeviceInfo(d.device_id, d.ip_address));
fetchSpotifyStatus();
}
} catch (error) {
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
}
}
function prepareSync(ip) {
document.getElementById('sync-device-list').value = ip;
function prepareSync(deviceId) {
document.getElementById('sync-device-list').value = deviceId;
openTab(null, 'tab-sync');
}
function prepareMigration(ip) {
document.getElementById('migration-device-list').value = ip;
function prepareMigration(deviceId) {
document.getElementById('migration-device-list').value = deviceId;
openTab(null, 'tab-migration');
showSummary(ip);
showSummary(deviceId);
}
function openTab(evt, tabId) {
@@ -220,9 +363,46 @@ function openTab(evt, tabId) {
}
}
function getDeviceDisplayName(deviceId) {
if (!deviceId) return "Unknown Device";
// 1. Try migration selector
const migrationSelector = document.getElementById('migration-device-list');
if (migrationSelector) {
for (let opt of migrationSelector.options) {
if (opt.value === deviceId && opt.textContent !== '-- Select a device --') {
return opt.textContent;
}
}
}
// 2. Try sync selector
const syncSelector = document.getElementById('sync-device-list');
if (syncSelector) {
for (let opt of syncSelector.options) {
if (opt.value === deviceId && opt.textContent !== '-- Select a device --') {
return opt.textContent;
}
}
}
// 3. Try table lookup
const rows = document.querySelectorAll('#device-list tr');
for (const row of rows) {
const idCell = row.querySelector('.col-deviceid');
if (idCell && idCell.innerText === deviceId) {
const name = row.querySelector('.col-name').innerText;
const ip = row.querySelector('.col-ip').innerText;
return `${name} (${ip})`;
}
}
return deviceId;
}
async function startSync() {
const ip = document.getElementById('sync-device-list').value;
if (!ip) {
const deviceId = document.getElementById('sync-device-list').value;
if (!deviceId) {
alert('Please select a device first');
return;
}
@@ -233,24 +413,25 @@ async function startSync() {
status.style.display = 'block';
status.style.backgroundColor = '#eef';
status.textContent = 'Syncing data from ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
status.textContent = 'Syncing data from ' + display + '...';
results.style.display = 'none';
log.innerHTML = '';
try {
const response = await fetch('/setup/sync/' + ip, { method: 'POST' });
const response = await fetch('/setup/sync/' + encodeURIComponent(deviceId), { method: 'POST' });
if (response.ok) {
status.style.backgroundColor = '#dfd';
status.textContent = '✅ Sync completed successfully!';
status.textContent = '✅ Sync completed successfully for ' + display + '!';
results.style.display = 'block';
log.innerHTML = 'Data fetched and saved to local datastore.\nPresets: OK\nRecents: OK\nSources: OK';
log.innerHTML = 'Data fetched and saved to local datastore for ' + display + '.\nPresets: OK\nRecents: OK\nSources: OK';
} else {
const err = await response.text();
throw new Error(err);
}
} catch (error) {
status.style.backgroundColor = '#fdd';
status.textContent = '❌ Sync failed: ' + error.message;
status.textContent = '❌ Sync failed for ' + display + ': ' + error.message;
}
}
@@ -585,6 +766,10 @@ async function clearDNSDiscoveries() {
}
}
function downloadDNSDiscoveries() {
window.location.href = '/setup/dns-discoveries/download';
}
async function showDeviceEvents() {
const overlay = document.getElementById('device-events-overlay');
overlay.style.display = 'block';
@@ -725,13 +910,13 @@ async function pollDiscoveryStatus() {
}
}
async function updateDeviceInfo(ip) {
async function updateDeviceInfo(deviceId, ip) {
try {
const response = await fetch('/setup/info/' + ip);
const response = await fetch('/setup/info/' + encodeURIComponent(deviceId));
if (!response.ok) return;
const info = await response.json();
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const rowId = 'device-row-' + deviceId;
const row = document.getElementById(rowId);
if (row) {
const nameEl = row.querySelector('.col-name');
@@ -757,8 +942,8 @@ async function updateDeviceInfo(ip) {
}
}
async function showSummary(ip) {
if (!ip) {
async function showSummary(deviceId) {
if (!deviceId) {
document.getElementById('migration-summary').style.display = 'none';
return;
}
@@ -775,18 +960,20 @@ async function showSummary(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Fetching summary for ' + display + '...';
const outputBox = document.getElementById('command-output-box');
if (outputBox) outputBox.style.display = 'none';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
const outputBox = document.getElementById('command-output-box');
if (outputBox) outputBox.style.display = 'none';
try {
const response = await fetch('/setup/summary/' + ip + query);
const response = await fetch('/setup/summary/' + encodeURIComponent(deviceId) + query);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
@@ -794,10 +981,15 @@ async function showSummary(ip) {
const summary = await response.json();
statusDiv.style.display = 'none';
document.getElementById('summary-ip').innerText = ip;
const ip = summary.ip_address || deviceId;
const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip;
document.getElementById('summary-device-display').innerText = finalDisplay;
// Keep deviceId hidden for subsequent calls
document.getElementById('summary-device-id').value = deviceId;
// Update table row if it exists
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const rowId = 'device-row-' + deviceId;
const row = document.getElementById(rowId);
if (row) {
const nameEl = row.querySelector('.col-name');
@@ -860,7 +1052,7 @@ async function showSummary(ip) {
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
document.getElementById('trust-ca-btn').onclick = () => trustCA(ip);
document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId, ip);
} else {
remoteStatus.innerText = '❓ Unknown';
remoteStatus.style.color = 'gray';
@@ -890,51 +1082,51 @@ async function showSummary(ip) {
testResultDiv.style.display = 'none';
testResultDiv.innerText = '';
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);
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(deviceId, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(deviceId, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(deviceId);
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(deviceId);
toggleMigrationMethod();
const migrateBtn = document.getElementById('confirm-migrate-btn');
migrateBtn.onclick = () => migrate(ip);
migrateBtn.onclick = () => migrate(deviceId, ip);
migrateBtn.disabled = !summary.ssh_success;
const revertBtn = document.getElementById('revert-migrate-btn');
revertBtn.onclick = () => revert(ip);
revertBtn.onclick = () => revert(deviceId, ip);
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.onclick = () => reboot(ip);
rebootBtn.onclick = () => reboot(deviceId, ip);
rebootBtn.disabled = !summary.ssh_success;
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.onclick = () => ensureRemoteServices(deviceId, ip);
remoteBtn.disabled = !summary.ssh_success;
const removeRemoteBtn = document.getElementById('remove-remote-btn');
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId, ip);
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
const backupBtn = document.getElementById('backup-config-btn');
backupBtn.onclick = () => backupConfig(ip);
backupBtn.onclick = () => backupConfig(deviceId, ip);
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
document.getElementById('migration-summary').style.display = 'block';
document.getElementById('migration-summary').scrollIntoView();
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error fetching summary for ' + display + ': ' + error;
}
}
function refreshSummary() {
const ip = document.getElementById('summary-ip').innerText;
if (ip) {
showSummary(ip);
const deviceId = document.getElementById('summary-device-id').value;
if (deviceId) {
showSummary(deviceId);
}
}
@@ -949,12 +1141,13 @@ function showCommandOutput(result) {
}
}
async function revert(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function revert(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
const display = getDeviceDisplayName(deviceId);
if (!confirm('Are you sure you want to revert ' + display + ' to Bose cloud defaults?')) {
return;
}
@@ -964,59 +1157,60 @@ async function revert(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Reverting ' + ip + ' to defaults...';
statusDiv.innerHTML = 'Reverting ' + display + ' to defaults...';
try {
const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
const response = await fetch('/setup/revert/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started revert for ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Revert failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error reverting ' + display + ': ' + error;
}
}
async function reboot(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function reboot(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
const display = getDeviceDisplayName(deviceId);
if (!confirm('Are you sure you want to reboot the speaker at ' + display + '?')) {
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Rebooting ' + ip + '...';
statusDiv.innerHTML = 'Rebooting ' + display + '...';
try {
const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
const response = await fetch('/setup/reboot/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started reboot for ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Reboot failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error rebooting ' + display + ': ' + error;
}
}
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function migrate(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const targetUrl = document.getElementById('target-domain').value;
@@ -1036,7 +1230,8 @@ async function migrate(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Migrating ' + display + ' using ' + method + '...';
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
@@ -1044,12 +1239,12 @@ async function migrate(ip) {
}
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const response = await fetch('/setup/migrate/' + encodeURIComponent(deviceId) + query, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. <strong>Please reboot the device to activate the changes.</strong>';
statusDiv.innerHTML = 'Successfully started migration for ' + display + '. <strong>Please reboot the device to activate the changes.</strong>';
// Make reboot button available and prominent
const rebootBtn = document.getElementById('reboot-speaker-btn');
@@ -1061,45 +1256,46 @@ async function migrate(ip) {
summaryDiv.style.display = 'block';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Migration failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error migrating ' + display + ': ' + error;
}
}
async function trustCA(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function trustCA(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + display + '...';
try {
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
const response = await fetch('/setup/trust-ca/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
showSummary(ip); // Refresh to update status
statusDiv.innerHTML = 'Successfully injected Root CA on ' + display + '.';
showSummary(deviceId); // Refresh to update status
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to trust CA on ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error trusting CA on ' + display + ': ' + error;
}
}
async function ensureRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function ensureRemoteServices(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const summaryDiv = document.getElementById('migration-summary');
@@ -1108,31 +1304,33 @@ async function ensureRemoteServices(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Ensuring remote services for ' + display + '...';
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const response = await fetch('/setup/ensure-remote-services/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to ensure remote services for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error ensuring remote services for ' + display + ': ' + error;
}
}
async function removeRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function removeRemoteServices(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
const display = getDeviceDisplayName(deviceId);
if (!confirm('Are you sure you want to remove remote services from ' + display + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
@@ -1141,65 +1339,67 @@ async function removeRemoteServices(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
statusDiv.innerHTML = 'Removing remote services for ' + display + '...';
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const response = await fetch('/setup/remove-remote-services/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
statusDiv.innerHTML = 'Successfully removed remote services from ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to remove remote services for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error removing remote services for ' + display + ': ' + error;
}
}
async function backupConfig(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function backupConfig(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Creating backup for ' + display + '...';
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const response = await fetch('/setup/backup/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
showSummary(ip); // Refresh
statusDiv.innerHTML = 'Successfully created backup for ' + display + '.';
showSummary(deviceId); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Backup failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error creating backup for ' + display + ': ' + error;
}
}
async function testConnection(ip, useExplicitCA) {
async function testConnection(deviceId, useExplicitCA) {
const testUrl = document.getElementById('test-url').innerText;
const testResultDiv = document.getElementById('test-result');
const display = getDeviceDisplayName(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running connection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running connection test from ' + display + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1215,18 +1415,19 @@ async function testConnection(ip, useExplicitCA) {
}
}
async function testHostsRedirection(ip) {
async function testHostsRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('hosts-test-result');
const display = getDeviceDisplayName(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running hosts redirection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running hosts redirection test from ' + display + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1242,18 +1443,19 @@ async function testHostsRedirection(ip) {
}
}
async function testDNSRedirection(ip) {
async function testDNSRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('dns-test-result');
const display = getDeviceDisplayName(deviceId);
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)';
testResultDiv.innerText = 'Running DNS redirection test from ' + display + '...\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 response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
+174 -59
View File
@@ -86,6 +86,10 @@ type Manager struct {
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
GetDNSRunning func() (bool, string)
// Spotify management credentials for the boot primer
MgmtUsername string
MgmtPassword string
}
// NewManager creates a new Manager with the given base server URL.
@@ -97,6 +101,8 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
NewSSH: func(host string) SSHClient {
return ssh.NewClient(host)
},
MgmtUsername: "admin",
MgmtPassword: "change_me!",
}
}
@@ -724,6 +730,20 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err)
}
// 3. Inject CA Certificate (optional but recommended)
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
fmt.Printf("Warning: failed to trust CA: %v\n", err)
}
}
return logs, nil
}
@@ -1117,18 +1137,18 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
hostIP := m.resolveIP(hostName, client)
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /mnt/nv/aftertouch.resolv.conf content
// 2. Prepare /mnt/nv/soundtouch-service/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")
// 3. Upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf
// Ensure /mnt/nv/soundtouch-service exists
_, _ = client.Run("mkdir -p /mnt/nv/soundtouch-service")
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); uploadErr != nil {
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", uploadErr)
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"); uploadErr != nil {
return logs, fmt.Errorf("failed to upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf: %w", uploadErr)
}
logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n"
logs += "Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf\n"
// 4. Update /mnt/nv/rc.local with idempotent patch
patchOut, err := m.updateRcLocalWithDNSHook(client)
@@ -1138,11 +1158,14 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
return logs, err
}
// 5. Apply patch immediately to /etc/udhcpc.d/50default
// 5. Cleanup legacy file
_, _ = client.Run("rm -f /mnt/nv/aftertouch.resolv.conf")
// 6. Apply patch immediately to /etc/udhcpc.d/50default
rwOut, _ := client.Run(rwCmd)
logs += rwCmd + ": " + rwOut + "\n"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
targetDHCPFile := "/etc/udhcpc.d/50default"
dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker)
logs += dhcpPatchOut
@@ -1185,7 +1208,7 @@ func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
rcLocalPath := "/mnt/nv/rc.local"
targetDHCPFile := "/etc/udhcpc.d/50default"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
// Check if rc.local exists and read it
currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
@@ -1197,8 +1220,12 @@ func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil
}
patchStartMarker := "# --- Aftertouch DNS hook START ---"
patchEndMarker := "# --- Aftertouch DNS hook END ---"
patchLogic := fmt.Sprintf(`
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
%s
# 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"
@@ -1210,9 +1237,47 @@ if [ -f "%s" ]; then
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)
%s
`, patchStartMarker, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker, patchEndMarker)
newRcLocal := currentRcLocal
// Remove old-style DNS hook if it exists
if strings.Contains(newRcLocal, "# Aftertouch DNS hook") && !strings.Contains(newRcLocal, patchStartMarker) {
// Old removal: filter out lines between the marker and the first 'fi'
lines := strings.Split(newRcLocal, "\n")
var filteredLines []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 {
filteredLines = append(filteredLines, line)
}
}
newRcLocal = strings.Join(filteredLines, "\n")
}
// Remove existing marker-based hook if it exists (for update)
if strings.Contains(newRcLocal, patchStartMarker) {
startIdx := strings.Index(newRcLocal, patchStartMarker)
endIdx := strings.Index(newRcLocal, patchEndMarker)
if startIdx != -1 && endIdx != -1 {
newRcLocal = newRcLocal[:startIdx] + newRcLocal[endIdx+len(patchEndMarker):]
}
}
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(newRcLocal, "cat: can't open") {
newRcLocal = ""
@@ -1392,59 +1457,21 @@ func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string {
func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
var logs string
aftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
legacyConfPath := "/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)
}
for _, p := range []string{aftertouchConfPath, legacyConfPath} {
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", p)); err == nil {
logs += fmt.Sprintf("Removing %s\n", p)
fmt.Printf("Removing %s\n", p)
_, _ = client.Run(fmt.Sprintf("rm %s", p))
}
}
logs += m.removeRcLocalHooks(client, rcLocalPath)
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)
@@ -1471,6 +1498,94 @@ func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
return logs
}
func (m *Manager) removeRcLocalHooks(client SSHClient, rcLocalPath string) string {
var logs string
patchStartMarker := "# --- Aftertouch DNS hook START ---"
patchEndMarker := "# --- Aftertouch DNS hook END ---"
spotifyPatchStartMarker := "# --- Aftertouch Spotify hook START ---"
spotifyPatchEndMarker := "# --- Aftertouch Spotify hook END ---"
aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
legacyAftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
if err != nil {
return ""
}
// 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
}
modified := false
if strings.Contains(currentRcLocal, patchStartMarker) {
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
startIdx := strings.Index(currentRcLocal, patchStartMarker)
endIdx := strings.Index(currentRcLocal, patchEndMarker)
if startIdx != -1 && endIdx != -1 {
currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(patchEndMarker):]
modified = true
}
} else if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, legacyAftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
logs += fmt.Sprintf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
fmt.Printf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
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)
}
}
currentRcLocal = strings.Join(newLines, "\n")
modified = true
}
if strings.Contains(currentRcLocal, spotifyPatchStartMarker) {
logs += fmt.Sprintf("Removing Spotify hook logic from %s\n", rcLocalPath)
fmt.Printf("Removing Spotify hook logic from %s\n", rcLocalPath)
startIdx := strings.Index(currentRcLocal, spotifyPatchStartMarker)
endIdx := strings.Index(currentRcLocal, spotifyPatchEndMarker)
if startIdx != -1 && endIdx != -1 {
currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(spotifyPatchEndMarker):]
modified = true
}
}
if modified {
if err := client.UploadContent([]byte(currentRcLocal), rcLocalPath); err != nil {
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
}
}
return logs
}
func (m *Manager) revertCACert(client SSHClient, rwCmd string) string {
var logs string
+9 -9
View File
@@ -719,7 +719,7 @@ func TestRevertMigration(t *testing.T) {
}
// Mock file existence checks for .original files
if strings.HasPrefix(command, "[ -f") {
if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
return "", nil // file exists
}
}
@@ -1128,7 +1128,7 @@ func TestMigrateViaResolvConf(t *testing.T) {
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n", nil
}
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") {
return "OK", nil
}
if strings.HasPrefix(command, "[ -f") {
@@ -1149,11 +1149,11 @@ func TestMigrateViaResolvConf(t *testing.T) {
}
// Verify uploads
if !strings.Contains(uploads["/mnt/nv/aftertouch.resolv.conf"], "nameserver 192.168.1.100") {
if !strings.Contains(uploads["/mnt/nv/soundtouch-service/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") {
if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") {
t.Errorf("rc.local missing hook logic")
}
@@ -1193,7 +1193,7 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) {
// 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, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") {
return "OK", nil
}
if strings.HasPrefix(command, "[ -f") {
@@ -1221,7 +1221,7 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) {
if !strings.HasPrefix(rcLocal, "#!/bin/sh") {
t.Errorf("rc.local missing shebang: %s", rcLocal)
}
if !strings.Contains(rcLocal, "/mnt/nv/aftertouch.resolv.conf") {
if !strings.Contains(rcLocal, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") {
t.Errorf("rc.local missing hook logic: %s", rcLocal)
}
}
@@ -1252,7 +1252,7 @@ func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) {
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n", nil
}
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") {
return "OK", nil
}
if command == "[ -f "+targetScript+" ]" {
@@ -1320,12 +1320,12 @@ func TestRevertMigration_ResolvConf(t *testing.T) {
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
return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/soundtouch-service/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 ]") {
if strings.Contains(command, "[ -f /mnt/nv/soundtouch-service/aftertouch.resolv.conf ]") || strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") {
return "", nil
}
return "", nil
+35 -21
View File
@@ -207,39 +207,53 @@ self_update() {
return
fi
log "Newer installer found for ${VERSION}. Re-executing..."
chmod +x "${tmp_script}"
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
rm -f "${tmp_script}"
# Export current env vars to the new script
export IS_SELF_UPDATE="true"
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
export SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REDIRECT_URI MGMT_USERNAME MGMT_PASSWORD
exec "${tmp_script}" "$@"
exec "${SCRIPT_PATH}" "$@"
}
write_env_file() {
log "Writing env file: ${ENV_FILE}"
cat > "${ENV_FILE}" <<EOF
PORT=${HTTP_PORT}
HTTPS_PORT=${HTTPS_PORT}
DATA_DIR=${DATA_DIR}
log "Updating env file: ${ENV_FILE}"
LOG_PROXY_BODY=${LOG_PROXY_BODY}
REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}
RECORD_INTERACTIONS=${RECORD_INTERACTIONS}
DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
# 1. Start with a list of all variables we want to manage
local vars=(
"PORT=${HTTP_PORT}"
"HTTPS_PORT=${HTTPS_PORT}"
"DATA_DIR=${DATA_DIR}"
"LOG_PROXY_BODY=${LOG_PROXY_BODY}"
"REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}"
"RECORD_INTERACTIONS=${RECORD_INTERACTIONS}"
"DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}"
"SERVER_URL=${SERVER_URL}"
"HTTPS_SERVER_URL=${HTTPS_SERVER_URL}"
"SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}"
"SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}"
"SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}"
"MGMT_USERNAME=${MGMT_USERNAME}"
"MGMT_PASSWORD=${MGMT_PASSWORD}"
)
SERVER_URL=${SERVER_URL}
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
if [[ ! -f "${ENV_FILE}" ]]; then
for entry in "${vars[@]}"; do
echo "${entry}" >> "${ENV_FILE}"
done
else
for entry in "${vars[@]}"; do
local key="${entry%%=*}"
local val="${entry#*=}"
if ! grep -q "^${key}=" "${ENV_FILE}"; then
echo "${key}=${val}" >> "${ENV_FILE}"
fi
done
fi
SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
MGMT_USERNAME=${MGMT_USERNAME}
MGMT_PASSWORD=${MGMT_PASSWORD}
EOF
chmod 0640 "${ENV_FILE}"
# group-readable so you can add yourself to the group if desired
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
+97
View File
@@ -0,0 +1,97 @@
# On-Speaker Spotify Boot Primer for Bose SoundTouch
Self-contained boot-time Spotify primer that runs directly on the speaker.
No Spotify credentials on the device — it fetches a fresh token from a
[Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server at boot.
No jq, no rootfs modification — just files on persistent storage.
## How It Works
Bose SoundTouch speakers run embedded Linux with a persistent writable volume
at `/mnt/nv`. The init script `shelby_local` (S97) has a built-in hook:
```
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
```
This runs before SoundTouch itself (S99), so we background a primer script
that waits for the Spotify Connect ZeroConf endpoint (port 8200) to come up,
fetches a fresh Spotify token from the service, and primes the speaker — all
within ~30 seconds of boot.
## File Layout
```
/mnt/nv/
rc.local boot hook (S97 checks this)
.profile PATH setup for interactive SSH
bin/
spotify-boot-primer main script
BoseApp-Persistence/1/
spotify-primer.conf service credentials (mode 600)
Sources.xml, Presets.xml, ... existing speaker data
```
Scripts live in `/mnt/nv/bin/` (added to PATH via `.profile`), config lives
alongside the speaker's own persistence files in `/mnt/nv/BoseApp-Persistence/1/`.
## Speaker Environment
Tested on SoundTouch 20. Other SoundTouch models likely similar.
| Item | Detail |
|------|--------|
| OS | Linux 3.14.43+ ARM (hostname `spotty`) |
| Root FS | Read-only ubifs (can be remounted rw) |
| Persistent storage | `/mnt/nv` — writable ubifs, ~24M free |
| curl | 7.50.3 with OpenSSL (HTTPS works) |
| bash/grep/sed/awk | Available via busybox |
| jq | **Not available** (not needed) |
| Init | SysV, runlevel 5 |
| Production mode | Yes — cron is disabled |
## Prerequisites
1. **SSH access to the speaker**:
```
ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@SPEAKER_IP
```
2. **A running [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server** with:
- A linked Spotify account (via the management API OAuth flow)
- The `GET /mgmt/spotify/token` endpoint (returns `{accessToken, username}`)
- Management API credentials (HTTP Basic Auth)
## Installation
SSH into the speaker and run:
```bash
# 1. Create bin directory
mkdir -p /mnt/nv/bin
# 2. Create the config file with your service connection info
cat > /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf << 'EOF'
SOUNDTOUCH_URL=https://soundtouch.example.com
SOUNDTOUCH_USER=admin
SOUNDTOUCH_PASS=secret
EOF
chmod 600 /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf
# 3. Copy spotify-boot-primer to the speaker
# From your local machine:
# cat scripts/spotify/spotify-boot-primer | ssh root@SPEAKER_IP "cat > /mnt/nv/bin/spotify-boot-primer"
chmod +x /mnt/nv/bin/spotify-boot-primer
# 4. Create the boot hook
cat > /mnt/nv/rc.local << 'EOF'
#!/bin/bash
/mnt/nv/bin/spotify-boot-primer &
EOF
chmod +x /mnt/nv/rc.local
# 5. Set up PATH for interactive SSH sessions (optional but convenient)
cat > /mnt/nv/.profile << 'EOF'
export PATH="/mnt/nv/bin:$PATH"
EOF
```
## Testing
```bash
# Manual test (speaker must be running):
/mnt/nv/bin/spotify-boot-primer
# Check logs:
logread | grep spotify-primer
# Full test — reboot the speaker:
reboot
# Wait ~30s, then SSH back in and check:
logread | grep spotify-primer
curl -s "http://localhost:8200/zc?action=getInfo" | grep activeUser
```
## Related
- [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) — Comprehensive Go toolkit with migration automation
+6
View File
@@ -0,0 +1,6 @@
# Spotify Scripts
This directory contains scripts and configuration files for the Spotify OAuth integration, specifically for priming Bose SoundTouch speakers.
These files were adapted from the community gist:
https://gist.github.com/timvw/84ef8768ff876ef6805012b3eb4015b0
+318
View File
@@ -0,0 +1,318 @@
# ZeroConf Analysis - Spotify Connect Integration for Bose SoundTouch
## Overview
This document provides a comprehensive analysis of the Spotify Connect ZeroConf protocol as implemented by Bose SoundTouch speakers. ZeroConf enables seamless integration between Spotify clients and SoundTouch hardware without requiring manual configuration.
## What is ZeroConf in This Context?
ZeroConf (Zero Configuration) in the Bose SoundTouch ecosystem is a **Spotify Connect integration protocol** that allows Spotify clients (mobile apps, desktop applications) to discover and control SoundTouch speakers automatically. The speakers expose an HTTP API on **port 8200** that implements Spotify's official ZeroConf specification.
## Network Discovery
### mDNS/Bonjour Advertisement
SoundTouch speakers advertise themselves on the local network using:
- **Service Type**: `_spotify-connect._tcp`
- **Port**: 8200
- **TXT Record**: `CPath=/zc` (points to the ZeroConf endpoint)
This allows Spotify applications to automatically discover available speakers without manual configuration.
### Endpoint Structure
```
http://[SPEAKER_IP]:8200/zc?action=[ACTION]&[PARAMETERS]
```
Example: `http://192.168.1.100:8200/zc?action=getInfo`
## The getInfo Action
### Purpose
The `getInfo` action retrieves comprehensive device information and current status. This is the most commonly used ZeroConf action for:
- Device discovery and identification
- Checking Spotify authentication status
- Retrieving device capabilities
- Monitoring multiroom configurations
### Request Format
```http
GET http://[SPEAKER_IP]:8200/zc?action=getInfo&version=2.10.0
```
The `version` parameter is optional but recommended for compatibility.
### Response Properties
#### Mandatory Fields (Present in All Responses)
| Property | Type | Description |
|----------|------|-------------|
| `status` | Integer | Operation result code (101 = success) |
| `statusString` | String | Human-readable status description |
| `spotifyError` | Integer | Last Spotify SDK error code (0 = no error) |
| `responseSource` | String | Entity identifier (e.g., "Bose") |
#### Device Information Fields
| Property | Required | Type | Description |
|----------|----------|------|-------------|
| `version` | Yes | String | ZeroConf API version (e.g., "2.10.0") |
| `deviceID` | Yes | String | Unique device identifier (MAC-based) |
| `publicKey` | Yes | String | Device's public key for secure communication |
| `remoteName` | Yes | String | User-friendly device name shown in Spotify |
| `deviceType` | No | String | Device category (e.g., "SPEAKER") |
| `brandDisplayName` | Yes | String | Brand name displayed in Spotify apps |
| `modelDisplayName` | No | String | Model name for user display |
| `libraryVersion` | Yes | String | Spotify Connect library version |
| `resolverVersion` | Yes | String | DNS resolution version |
| `groupStatus` | Yes | String | Multiroom status: "NONE", "GROUP", or "SLAVE" |
| `tokenType` | Yes | String | Authentication token type ("accesstoken") |
| `clientID` | Yes | String | Spotify client identifier |
| `productID` | Yes | Integer | Spotify product identifier |
| `scope` | Yes | String | Permission scope (typically "streaming") |
| `availability` | Yes | String | Device availability status |
#### Status Fields
| Property | Required | Type | Description |
|----------|----------|------|-------------|
| `activeUser` | No | String | Currently logged-in Spotify username (if any) |
#### Advanced Fields (Optional)
| Property | Type | Description |
|----------|------|-------------|
| `aliases` | Array | Virtual devices for multiroom zones |
| `supported_drm_media_formats` | Array | Supported audio formats with DRM capabilities |
| `supported_capabilities` | Integer | Bitmasked device capabilities |
### Example Response
```json
{
"status": 101,
"statusString": "OK",
"spotifyError": 0,
"responseSource": "Bose",
"version": "2.10.0",
"deviceID": "0007F537F5ED",
"deviceType": "SPEAKER",
"remoteName": "Living Room Speaker",
"publicKey": "BgIwVfz9ZXQG...",
"brandDisplayName": "Bose",
"modelDisplayName": "SoundTouch 30",
"libraryVersion": "master-v3.15.1-g7890abcd",
"resolverVersion": "1",
"groupStatus": "NONE",
"tokenType": "accesstoken",
"clientID": "65b708073fc0480ea92a077233ca87bd",
"productID": 0,
"scope": "streaming",
"availability": "",
"activeUser": "spotify_username",
"supported_drm_media_formats": [
{"drm": 0, "formats": 35},
{"drm": 1, "formats": 35},
{"drm": 3, "formats": 1168}
],
"supported_capabilities": 1
}
```
## Key Properties Analysis
### Critical Status Indicators
- **`activeUser`**: Most important field for determining if Spotify is active
- Present and non-empty: Spotify is authenticated and ready
- Empty or missing: No active Spotify session
- **`remoteName`**: The display name users see in Spotify Connect device lists
- Should be descriptive and user-friendly
- Can contain UTF-8 characters and special symbols
### Device Identification
- **`deviceID`**: Unique identifier for targeting specific speakers
- Typically derived from MAC address
- Used for device-specific API calls
- **`groupStatus`**: Critical for multiroom functionality
- `"NONE"`: Standalone device
- `"GROUP"`: Multiroom master/coordinator
- `"SLAVE"`: Member of a multiroom group
### Display Properties
- **`brandDisplayName`** and **`modelDisplayName`**: Shown in Spotify client UIs
- Should be marketing-appropriate names
- Support UTF-8 for international markets
## Practical Usage Examples
### 1. Status Checking
```bash
# Check if Spotify is active
curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
grep -o '"activeUser" *: *"[^"]*"' | \
sed 's/"activeUser" *: *"//;s/"$//'
```
### 2. Device Discovery
```bash
# Get device name and ID
info=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo")
device_name=$(echo "$info" | grep -o '"remoteName" *: *"[^"]*"' | sed 's/"remoteName" *: *"//;s/"$//')
device_id=$(echo "$info" | grep -o '"deviceID" *: *"[^"]*"' | sed 's/"deviceID" *: *"//;s/"$//')
```
### 3. Multiroom Detection
```bash
# Check multiroom status
group_status=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
grep -o '"groupStatus" *: *"[^"]*"' | \
sed 's/"groupStatus" *: *"//;s/"$//')
```
## Authentication Flow
The ZeroConf API supports the `addUser` action for Spotify authentication:
```bash
curl -X POST "http://192.168.1.100:8200/zc" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=addUser&userName=${SPOTIFY_USER}&blob=${ACCESS_TOKEN}&clientKey=&tokenType=accesstoken"
```
### Token Requirements
- **Access Token**: Valid Spotify OAuth access token
- **Username**: Spotify username associated with the token
- **Token Type**: Always "accesstoken" for current implementations
- **Client Key**: Empty string for current protocol version
### Token Lifecycle
1. Tokens expire after 1 hour (3600 seconds)
2. Speakers must be re-primed after reboot
3. Use `getInfo` to verify successful authentication via `activeUser` field
## Security Considerations
### Communication Security
- **Protocol**: HTTP (plain text) is standard, HTTPS supported but optional
- **Network Scope**: Local network only (port 8200 typically not exposed externally)
- **Authentication**: Token-based, no permanent credentials stored
### Best Practices
1. **Token Management**:
- Never store long-lived tokens on devices
- Implement token refresh mechanisms
- Use centralized token servers when possible
2. **Network Security**:
- Ensure port 8200 is not accessible from external networks
- Consider HTTPS for enhanced security
- Implement proper firewall rules
3. **Error Handling**:
- Always check `status` and `spotifyError` fields
- Implement retry mechanisms for network failures
- Log authentication failures for debugging
## Integration Patterns
### Boot-time Automation
See `spotify-boot-primer.sh` for a complete example of:
1. Waiting for ZeroConf endpoint availability
2. Checking current authentication status
3. Fetching fresh tokens from a management server
4. Automatically priming speakers at startup
### Manual Priming
See `spotify-prime-speaker.sh` for standalone token injection:
1. Validate access tokens against Spotify API
2. Extract username from token metadata
3. Prime individual speakers
4. Verify successful authentication
### Monitoring and Health Checks
```bash
#!/bin/bash
# Health check script
SPEAKER_IP="192.168.1.100"
info=$(curl -sf --max-time 5 "http://${SPEAKER_IP}:8200/zc?action=getInfo" 2>/dev/null)
if [ $? -eq 0 ]; then
active_user=$(echo "$info" | grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//')
if [ -n "$active_user" ]; then
echo "✅ Spotify active (user: $active_user)"
else
echo "⚠️ Speaker reachable but Spotify not active"
fi
else
echo "❌ Speaker unreachable"
fi
```
## Troubleshooting
### Common Issues
1. **Port 8200 Unreachable**
- Check network connectivity
- Verify speaker is powered on
- Confirm IP address is correct
2. **Empty `activeUser` After Authentication**
- Wait 2-5 seconds after `addUser` request
- Verify access token is valid and not expired
- Check `spotifyError` field for SDK errors
3. **Authentication Failures**
- Ensure token has correct scopes
- Verify username matches token owner
- Check token expiration time
### Diagnostic Commands
```bash
# Test basic connectivity
curl -sf --max-time 5 "http://192.168.1.100:8200/zc?action=getInfo"
# Check detailed response
curl -s "http://192.168.1.100:8200/zc?action=getInfo" | jq .
# Monitor authentication status
while true; do
active=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//')
echo "$(date): activeUser = '$active'"
sleep 10
done
```
## References
- [Spotify ZeroConf API Documentation](https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf)
- [Bose SoundTouch Toolkit](https://github.com/gesellix/Bose-SoundTouch)
- Scripts in this directory:
- `spotify-boot-primer.sh`: Automated boot-time priming
- `spotify-prime-speaker.sh`: Manual speaker priming
- `spotify-primer.conf.example`: Configuration template
---
*This analysis is based on Spotify's official ZeroConf specification and practical implementation experience with Bose SoundTouch speakers.*
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# /mnt/nv/rc.local — runs at boot via shelby_local (S97)
# Launches Spotify boot primer in background since SoundTouch starts at S99
/mnt/nv/bin/spotify-boot-primer &
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
#
# spotify-boot-primer — Self-contained Spotify primer for Bose SoundTouch speakers
#
# Runs at boot (via /mnt/nv/rc.local), waits for the ZeroConf endpoint to
# come up, fetches a fresh Spotify token from a soundtouch-service server, and
# primes the speaker. No Spotify credentials stored on the device.
#
# Only needs: curl, grep, sed (all available on the speaker via busybox).
#
# Install:
# 1. mkdir -p /mnt/nv/soundtouch-service
# 2. Copy this script to /mnt/nv/soundtouch-service/spotify-boot-primer
# 3. Create /mnt/nv/soundtouch-service/spotify-primer.conf
# 4. Create /mnt/nv/rc.local that backgrounds this script
# 5. chmod +x /mnt/nv/rc.local /mnt/nv/soundtouch-service/spotify-boot-primer
#
# Config file format (/mnt/nv/soundtouch-service/spotify-primer.conf):
# SOUNDTOUCH_URL=https://soundtouch.example.com
# SOUNDTOUCH_USER=admin
# SOUNDTOUCH_PASS=secret
#
# Related:
# https://github.com/gesellix/Bose-SoundTouch
#
set -uo pipefail
CONF="/mnt/nv/soundtouch-service/spotify-primer.conf"
LOG_TAG="spotify-primer[$$]"
ZC_URL="http://localhost:8200/zc"
MAX_WAIT=120 # max seconds to wait for port 8200
RETRY_DELAY=3 # seconds between retries
# --- Logging ---
log() {
logger -s -t "$LOG_TAG" -p "$1" "$2"
}
# --- JSON parsing without jq ---
# Extract a string value: echo '{"key":"val"}' | json_str key
json_str() {
grep -o "\"$1\" *: *\"[^\"]*\"" | sed "s/\"$1\" *: *\"//;s/\"$//"
}
# Extract a numeric value: echo '{"key":123}' | json_num key
json_num() {
grep -o "\"$1\" *: *[0-9]*" | sed "s/\"$1\" *: *//"
}
# --- Load config ---
if [ ! -f "$CONF" ]; then
log err "Config not found: $CONF"
exit 1
fi
. "$CONF"
for var in SOUNDTOUCH_URL SOUNDTOUCH_USER SOUNDTOUCH_PASS; do
if [ -z "${!var:-}" ]; then
log err "Missing $var in $CONF"
exit 1
fi
done
log info "Config loaded (server=${SOUNDTOUCH_URL})"
# --- Wait for ZeroConf endpoint (port 8200) ---
log info "Waiting for ZeroConf endpoint (max ${MAX_WAIT}s)..."
waited=0
while true; do
if curl -sf --max-time 2 "${ZC_URL}?action=getInfo" >/dev/null 2>&1; then
break
fi
waited=$((waited + RETRY_DELAY))
if [ $waited -ge $MAX_WAIT ]; then
log err "ZeroConf endpoint not available after ${MAX_WAIT}s — giving up"
exit 1
fi
sleep $RETRY_DELAY
done
log info "ZeroConf endpoint is up (waited ${waited}s)"
# --- Check if already primed ---
info=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" 2>/dev/null)
active_user=$(echo "$info" | json_str activeUser)
device_name=$(echo "$info" | json_str remoteName)
if [ -n "$active_user" ]; then
log info "Already primed (device=$device_name, activeUser=$active_user) — nothing to do"
exit 0
fi
log info "Speaker '$device_name' has no active Spotify user — priming..."
# --- Get token from soundtouch-service server ---
log info "Requesting Spotify token from soundtouch-service..."
token_response=$(curl -sf --max-time 15 \
-u "${SOUNDTOUCH_USER}:${SOUNDTOUCH_PASS}" \
"${SOUNDTOUCH_URL}/mgmt/spotify/token" \
2>&1)
if [ $? -ne 0 ] || [ -z "$token_response" ]; then
log err "Failed to get token from soundtouch-service (is the server reachable?)"
exit 1
fi
access_token=$(echo "$token_response" | json_str accessToken)
user=$(echo "$token_response" | json_str username)
if [ -z "$access_token" ] || [ -z "$user" ]; then
error_msg=$(echo "$token_response" | json_str detail)
log err "soundtouch-service returned error: ${error_msg:-no token/username in response}"
exit 1
fi
log info "Got token for user $user (${access_token:0:10}...)"
# --- Prime the speaker ---
result=$(curl -sf --max-time 10 -X POST "$ZC_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=addUser&userName=${user}&blob=${access_token}&clientKey=&tokenType=accesstoken" \
2>&1)
status=$(echo "$result" | json_num status)
status_str=$(echo "$result" | json_str statusString)
if [ "$status" != "101" ]; then
log err "addUser failed: status=$status ($status_str)"
exit 1
fi
# --- Verify (retry — speaker needs a few seconds after cold boot) ---
log info "addUser accepted (status 101) — verifying..."
for i in 1 2 3 4 5; do
sleep $((i * 2))
active_user=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" | json_str activeUser)
if [ -n "$active_user" ]; then
log info "Speaker primed successfully (activeUser=$active_user)"
exit 0
fi
done
log warning "Speaker accepted addUser but activeUser still empty after 30s"
exit 1
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# spotify-prime-speaker — Prime a Bose SoundTouch speaker for Spotify playback
#
# Activates Spotify on a SoundTouch speaker by sending an access token
# via the Spotify Connect ZeroConf endpoint (port 8200). This is the
# same mechanism the Spotify desktop app uses internally.
#
# Works standalone — no soundtouch-service, ueberboese, or other server required.
#
# Requirements: curl, jq
#
# Usage:
# ./spotify-prime-speaker SPEAKER_IP ACCESS_TOKEN
#
# Example:
# ./spotify-prime-speaker 192.168.1.143 BQDj...your_token...
#
# How to get an access token:
# - Spotify Developer Console: https://developer.spotify.com
# (create an app, use the "Get Token" button)
# - Via soundtouch-service management API: POST /mgmt/spotify/auth/init
# - Via ueberboese management API: POST /mgmt/spotify/init
# - Any Spotify OAuth Authorization Code flow with user-read-email scope
#
# Notes:
# - Access tokens expire after 1 hour (3600 seconds)
# - The speaker must be on the same network and reachable on port 8200
# - After priming, Spotify presets on the speaker should work immediately
# - Re-run after each speaker reboot (or use a server like soundtouch-service
# to automate this)
#
# How it works:
# The Bose SoundTouch speaker exposes a Spotify Connect ZeroConf API
# on port 8200. By sending an addUser request with a valid Spotify
# access token, the speaker activates its built-in Spotify Connect
# client. No encryption is needed — the token is sent as plain text,
# exactly like the Spotify desktop app does it.
#
# Related:
# - https://github.com/gesellix/Bose-SoundTouch (comprehensive toolkit)
set -euo pipefail
# --- Argument parsing ---
if [ $# -lt 2 ]; then
echo "Usage: $0 SPEAKER_IP ACCESS_TOKEN"
echo ""
echo "Prime a Bose SoundTouch speaker for Spotify playback."
echo ""
echo "Arguments:"
echo " SPEAKER_IP IP address of the SoundTouch speaker"
echo " ACCESS_TOKEN Spotify access token (starts with BQ...)"
echo ""
echo "Get a token at https://developer.spotify.com or via a server's OAuth flow."
exit 1
fi
SPEAKER_IP="$1"
TOKEN="$2"
ZC_URL="http://${SPEAKER_IP}:8200/zc"
# --- Dependency check ---
for cmd in curl jq; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd is required but not installed." >&2
exit 1
fi
done
# --- Step 1: Discover Spotify username from token ---
echo "Discovering Spotify user from token..."
ME_RESPONSE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
https://api.spotify.com/v1/me 2>&1) || {
echo "Error: Failed to call Spotify /me API. Is the token valid?" >&2
echo " (tokens expire after 1 hour)" >&2
exit 1
}
USER=$(echo "$ME_RESPONSE" | jq -r '.id // empty')
if [ -z "$USER" ]; then
echo "Error: Could not extract user ID from Spotify response." >&2
echo "$ME_RESPONSE" >&2
exit 1
fi
echo " Spotify user: $USER"
# --- Step 2: Check current speaker status ---
echo "Checking speaker at ${SPEAKER_IP}:8200..."
INFO=$(curl -sf "${ZC_URL}?action=getInfo" 2>&1) || {
echo "Error: Could not reach speaker at ${SPEAKER_IP}:8200." >&2
echo " Is the speaker on and on the same network?" >&2
exit 1
}
ACTIVE=$(echo "$INFO" | jq -r '.activeUser // empty')
DEVICE_NAME=$(echo "$INFO" | jq -r '.remoteName // empty')
if [ -n "$DEVICE_NAME" ]; then
echo " Speaker: $DEVICE_NAME"
fi
if [ -n "$ACTIVE" ]; then
echo " Already primed (activeUser=$ACTIVE)"
echo "Done — speaker is ready for Spotify playback."
exit 0
fi
echo " No active Spotify user — priming now..."
# --- Step 3: Send addUser ---
RESULT=$(curl -sf -X POST "${ZC_URL}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=addUser&userName=${USER}&blob=${TOKEN}&clientKey=&tokenType=accesstoken" \
2>&1) || {
echo "Error: addUser request failed." >&2
exit 1
}
STATUS=$(echo "$RESULT" | jq -r '.status // -1')
STATUS_STR=$(echo "$RESULT" | jq -r '.statusString // empty')
if [ "$STATUS" != "101" ]; then
echo "Error: Speaker returned status $STATUS ($STATUS_STR)" >&2
echo "$RESULT" | jq . >&2
exit 1
fi
echo " Speaker accepted the token (status 101)."
# --- Step 4: Verify ---
echo " Verifying (waiting 2 seconds)..."
sleep 2
ACTIVE=$(curl -sf "${ZC_URL}?action=getInfo" | jq -r '.activeUser // empty')
if [ -n "$ACTIVE" ]; then
echo "Done — speaker primed for Spotify (activeUser=$ACTIVE)"
else
echo "Warning: Speaker returned 101 but activeUser is still empty."
echo " The speaker may need more time. Try pressing a Spotify preset."
exit 1
fi
@@ -0,0 +1,6 @@
# /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf — service connection for boot primer
# The speaker fetches a fresh Spotify token from the service at boot.
# No Spotify credentials needed on the device.
SOUNDTOUCH_URL=https://soundtouch.example.com
SOUNDTOUCH_USER=admin
SOUNDTOUCH_PASS=secret