mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a39c14b35 | ||
|
|
c9f648096e | ||
|
|
408753c33e | ||
|
|
dff060565e | ||
|
|
b5df6ab91f | ||
|
|
c7e055eb51 | ||
|
|
00d5bfcb69 | ||
|
|
0186fead6e | ||
|
|
bf4ead033c | ||
|
|
5eee3ec31e | ||
|
|
30e09ab7a0 | ||
|
|
e429d92124 | ||
|
|
f3162b7ed9 |
@@ -60,6 +60,7 @@ Thumbs.db
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.output.txt
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM golang:1.25.7-alpine AS builder
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -123,6 +123,18 @@ docker run -d \
|
||||
|
||||
**macOS / Windows (with port mapping):**
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-p 8000:8000 -p 8443:8443 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--env SERVER_URL=http://soundtouch.local:8000 \
|
||||
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
> **Note**: The hostnames configured via `SERVER_URL` and `HTTPS_SERVER_URL` are automatically added as Subject Alternative Names (SAN) to the generated TLS certificate, ensuring valid SSL connections.
|
||||
|
||||
Alternatively, without explicit server URLs:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
-p 8000:8000 \
|
||||
@@ -144,14 +156,19 @@ services:
|
||||
# macOS/Windows users: use port mapping (discovery will be manual)
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
environment:
|
||||
- PORT=8000
|
||||
- SERVER_URL=http://soundtouch.local:8000
|
||||
- HTTPS_SERVER_URL=https://soundtouch.local:8443
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
> **Note**: Hostnames from `SERVER_URL` and `HTTPS_SERVER_URL` are automatically included in the TLS certificate's Subject Alternative Names (SAN).
|
||||
|
||||
And run:
|
||||
|
||||
```bash
|
||||
@@ -575,14 +592,14 @@ This project builds upon the excellent work of several community projects:
|
||||
|
||||
### SoundCork 🍾
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Gu and contributors
|
||||
- **Authors**: Deborah Kaplan and contributors
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by and based on SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
|
||||
- **License**: MIT License
|
||||
|
||||
### ÜberBöse API 🎵
|
||||
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
|
||||
- **Author**: Julius D.
|
||||
- **Author**: Julius
|
||||
- **Our Implementation**: This project provided valuable insights into advanced SoundTouch API endpoints and helped make our implementation more complete, particularly for content navigation and advanced device features.
|
||||
- **Key Contributions**: Extended API endpoint documentation, advanced feature discovery
|
||||
- **License**: MIT License
|
||||
@@ -595,6 +612,13 @@ This project builds upon the excellent work of several community projects:
|
||||
- **Key Contributions**: Extensive API endpoint documentation, real-world usage patterns
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundTouch Hook 🪝
|
||||
- **Project**: [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)
|
||||
- **Author**: Adrian Böckenkamp
|
||||
- **Our Implementation**: This project provides a powerful framework for intercepting and hooking into internal device processes using `LD_PRELOAD`. It was instrumental in verifying internal function calls and understanding how the device validates cloud domains.
|
||||
- **Key Contributions**: Reverse engineering framework, process hooking, cross-compilation toolchain
|
||||
- **License**: GPL-3.0 License
|
||||
|
||||
### Community Ecosystem
|
||||
|
||||
These projects together form a comprehensive ecosystem for SoundTouch device management:
|
||||
@@ -603,6 +627,7 @@ These projects together form a comprehensive ecosystem for SoundTouch device man
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundTouch Plus**: Home Assistant integration with extensive device support
|
||||
- **ÜberBöse**: API research and advanced endpoint discovery
|
||||
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
|
||||
|
||||
We are grateful to these projects and their maintainers for paving the way and providing the foundation that made this comprehensive Go implementation possible. The SoundTouch community's collaborative approach to reverse engineering and documentation has been invaluable.
|
||||
|
||||
@@ -616,6 +641,12 @@ If you discover new endpoints, features, or improvements through this library, p
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- 📖 **Documentation**: Browse the [docs/](docs/) directory
|
||||
- 🔍 **New Discoveries**: See [Undocumented Community Features](docs/UNDOCUMENTED-COMMUNITY-FEATURES.md) for advanced API research
|
||||
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](docs/UPSTREAM-URLS-ANALYSIS.md) for cloud dependency research
|
||||
- 🔧 **Redirection Guide**: [Device Redirect Methods](docs/DEVICE-REDIRECT-METHODS.md) for custom service setup
|
||||
- 🐣 **Initial Setup**: [Device Initial Setup Variants](docs/DEVICE-INITIAL-SETUP.md) for out-of-the-box configuration
|
||||
- 📜 **Logging & Debugging**: [Device Logging Guide](docs/DEVICE-LOGGING.md) for accessing system and traffic logs
|
||||
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](docs/HTTPS-SETUP.md) for secure `/etc/hosts` redirection
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -147,10 +147,16 @@ func playURL(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
|
||||
func playNotificationBeep(c *cli.Context) error {
|
||||
// playNotification plays a notification sound or a local file on the speaker
|
||||
func playNotification(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
|
||||
path := c.String("path")
|
||||
|
||||
if path != "" {
|
||||
PrintDeviceHeader(fmt.Sprintf("Playing notification file: %s", path), clientConfig.Host, clientConfig.Port)
|
||||
} else {
|
||||
PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
@@ -158,18 +164,31 @@ func playNotificationBeep(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use the existing playNotification endpoint
|
||||
err = client.PlayNotificationBeep()
|
||||
err = client.PlayNotification(path)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
|
||||
if path != "" {
|
||||
PrintError(fmt.Sprintf("Failed to play notification file: %v", err))
|
||||
} else {
|
||||
PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Notification beep played successfully\n")
|
||||
if path != "" {
|
||||
fmt.Printf("✅ Notification file sent successfully: %s\n", path)
|
||||
} else {
|
||||
fmt.Printf("✅ Notification beep played successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
|
||||
func playNotificationBeep(c *cli.Context) error {
|
||||
return playNotification(c)
|
||||
}
|
||||
|
||||
// showSpeakerHelp displays help information about speaker functionality
|
||||
func showSpeakerHelp(_ *cli.Context) error {
|
||||
fmt.Println("SoundTouch Speaker Playback Commands")
|
||||
@@ -189,6 +208,10 @@ func showSpeakerHelp(_ *cli.Context) error {
|
||||
fmt.Println(" Play a simple notification sound")
|
||||
fmt.Println(" Example: soundtouch-cli speaker beep")
|
||||
fmt.Println()
|
||||
fmt.Println("• Custom Notification:")
|
||||
fmt.Println(" Play a device-local PCM file as notification")
|
||||
fmt.Println(" Example: soundtouch-cli speaker notify --path \"/opt/Bose/chimes/grouped.pcm\"")
|
||||
fmt.Println()
|
||||
fmt.Println("Notes:")
|
||||
fmt.Println("• Only ST-10 (Series III) speakers support the /speaker endpoint")
|
||||
fmt.Println("• ST-300 and other models may not support this functionality")
|
||||
|
||||
@@ -1707,6 +1707,19 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "notify",
|
||||
Usage: "Play a notification sound or local file",
|
||||
Action: playNotification,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "path",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Device-local path to a PCM file (e.g. /opt/Bose/chimes/grouped.pcm)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "beep",
|
||||
Usage: "Play a notification beep sound",
|
||||
|
||||
+162
-32
@@ -4,14 +4,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
@@ -21,13 +24,54 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := loadConfig()
|
||||
ds := initDataStore(config.dataDir)
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
}
|
||||
|
||||
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody)
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server, pyProxy)
|
||||
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
}
|
||||
|
||||
log.Fatal(http.ListenAndServe(config.addr, r))
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
targetURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
domains []string
|
||||
}
|
||||
|
||||
func loadConfig() serviceConfig {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8000"
|
||||
}
|
||||
|
||||
bindAddr := os.Getenv("BIND_ADDR")
|
||||
// If BIND_ADDR is explicitly set, use it. Otherwise, bind to all interfaces (IPv4 and IPv6).
|
||||
|
||||
addr := bindAddr + ":" + port
|
||||
if bindAddr == "" {
|
||||
addr = ":" + port
|
||||
@@ -38,24 +82,13 @@ func main() {
|
||||
targetURL = "http://localhost:8001"
|
||||
}
|
||||
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse target URL: %v", err)
|
||||
}
|
||||
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "data"
|
||||
}
|
||||
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
serverURL := os.Getenv("SERVER_URL")
|
||||
if serverURL == "" {
|
||||
// Try to guess the server URL
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
@@ -64,24 +97,103 @@ func main() {
|
||||
serverURL = "http://" + strings.ToLower(hostname) + ":" + port
|
||||
}
|
||||
|
||||
sm := setup.NewManager(serverURL, ds)
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
redact := os.Getenv("REDACT_PROXY_LOGS") != "false"
|
||||
logBody := os.Getenv("LOG_PROXY_BODY") == "true"
|
||||
httpsAddr := bindAddr + ":" + httpsPort
|
||||
if bindAddr == "" {
|
||||
httpsAddr = ":" + httpsPort
|
||||
}
|
||||
|
||||
server := handlers.NewServer(ds, sm, serverURL, redact, logBody)
|
||||
httpsServerURL := os.Getenv("HTTPS_SERVER_URL")
|
||||
if httpsServerURL == "" {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
httpsServerURL = "https://" + strings.ToLower(hostname) + ":" + httpsPort
|
||||
}
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
domainsMap := map[string]bool{
|
||||
"streaming.bose.com": true,
|
||||
"updates.bose.com": true,
|
||||
"stats.bose.com": true,
|
||||
"bmx.bose.com": true,
|
||||
"content.api.bose.io": true,
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
}
|
||||
|
||||
if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" {
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
if u, err := url.Parse(httpsServerURL); err == nil && u.Hostname() != "" {
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
domains := make([]string, 0, len(domainsMap))
|
||||
for d := range domainsMap {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
targetURL: targetURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: os.Getenv("REDACT_PROXY_LOGS") != "false",
|
||||
logBody: os.Getenv("LOG_PROXY_BODY") == "true",
|
||||
domains: domains,
|
||||
}
|
||||
}
|
||||
|
||||
func initDataStore(dataDir string) *datastore.DataStore {
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
log.Printf("Warning: Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
return cm
|
||||
}
|
||||
|
||||
func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseProxy {
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse target URL: %v", err)
|
||||
}
|
||||
|
||||
pyProxy := httputil.NewSingleHostReverseProxy(target)
|
||||
pyProxy.ModifyResponse = func(res *http.Response) error {
|
||||
// Generic Header Preservation:
|
||||
// Go's net/http canonicalizes headers (e.g., ETag becomes Etag).
|
||||
// We ensure ETag specifically uses uppercase 'T' as some Bose devices are case-sensitive.
|
||||
if etags, ok := res.Header["Etag"]; ok {
|
||||
delete(res.Header, "Etag")
|
||||
res.Header["ETag"] = etags
|
||||
}
|
||||
// Also restore other potentially sensitive headers if needed, but for now we focus on ETag
|
||||
// as it's the most common culprit.
|
||||
|
||||
currentLp := proxy.NewLoggingProxy(target.String(), redact)
|
||||
currentLp.LogBody = logBody
|
||||
@@ -89,6 +201,7 @@ func main() {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
originalPyDirector := pyProxy.Director
|
||||
pyProxy.Director = func(req *http.Request) {
|
||||
originalPyDirector(req)
|
||||
@@ -98,19 +211,23 @@ func main() {
|
||||
currentLp.LogRequest(req)
|
||||
}
|
||||
|
||||
// Phase 5: Device Discovery
|
||||
return pyProxy
|
||||
}
|
||||
|
||||
func startDeviceDiscovery(server *handlers.Server) {
|
||||
go func() {
|
||||
for {
|
||||
server.DiscoverDevices(context.Background())
|
||||
time.Sleep(5 * time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Phase 2: Root endpoint implemented in Go
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -118,10 +235,9 @@ func main() {
|
||||
server.HandleMedia()(w, r)
|
||||
})
|
||||
|
||||
// Phase 2: Static file serving for /media
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/web/*", server.HandleWeb())
|
||||
|
||||
// Phase 3: BMX endpoints
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
@@ -130,7 +246,6 @@ func main() {
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Phase 4: Marge endpoints
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
@@ -146,16 +261,13 @@ func main() {
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
})
|
||||
|
||||
// Phase 10: Stats endpoints
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
// Proxy route integrated into main router
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
// Phase 7: Setup and Discovery endpoints
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
@@ -164,19 +276,37 @@ func main() {
|
||||
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
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("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
})
|
||||
|
||||
// Delegation Logic: Proxy everything else to Python
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
pyProxy.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
log.Printf("Go service starting on %s, proxying to %s", addr, targetURL)
|
||||
log.Fatal(http.ListenAndServe(addr, r))
|
||||
return r
|
||||
}
|
||||
|
||||
func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, httpsServerURL string) {
|
||||
httpsServer := &http.Server{
|
||||
Addr: httpsAddr,
|
||||
Handler: r,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
|
||||
|
||||
go func() {
|
||||
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("HTTPS server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
certs/
|
||||
default/
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# SoundTouch Device Initial Setup Variants
|
||||
|
||||
Based on community research from the **SoundCork** and **ÜberBöse API** projects, as well as analysis of the Stockholm firmware (`firmware/Stockholm/.../setup/`), this document outlines the methods used for the "out-of-the-box" setup of SoundTouch devices.
|
||||
|
||||
## Setup Overview
|
||||
|
||||
Initial setup is the process of connecting a new or factory-reset device to a local Wi-Fi network and a Bose (or custom) account. This is distinct from the "Migration" process (handled by `soundtouch-service`), which redirects an already-configured device to a new server.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bluetooth Low Energy (BLE) Setup
|
||||
Used by most modern SoundTouch devices (ST-10, ST-20/30 Series III, SoundTouch 300).
|
||||
|
||||
- **Mechanism**: The SoundTouch app communicates with the device over BLE to exchange Wi-Fi credentials.
|
||||
- **Protocol**: Internal research refers to this as the **Gabbo** protocol (see `gabbo_setup_bco.js` in firmware).
|
||||
- **Process**:
|
||||
1. Put the device in setup mode (usually by holding the '2' and '-' buttons).
|
||||
2. The app discovers the device via BLE.
|
||||
3. The app sends the Wi-Fi SSID and Password to the device.
|
||||
4. The device connects to Wi-Fi and disables BLE setup.
|
||||
|
||||
---
|
||||
|
||||
## 2. Access Point (AP) Mode / Web Setup
|
||||
The classic "failover" or "alternate" setup method.
|
||||
|
||||
- **Mechanism**: The device creates its own Wi-Fi network (SSID: `Bose SoundTouch ...` or `Bose Home Speaker ...`).
|
||||
- **IP Address**: Typically `192.168.1.1` or `10.0.0.1` (device-side).
|
||||
- **Web Interface**: The device hosts a web server on port 80.
|
||||
- **Process**:
|
||||
1. Connect a PC/Phone to the device's Wi-Fi.
|
||||
2. Open a browser to `http://192.168.1.1`.
|
||||
3. The device serves `setup.html`, which redirects to a setup wizard (`setup/index.html`).
|
||||
4. Use the `gabbo_wifi` form to select a network and enter credentials.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wireless Accessory Configuration (WAC)
|
||||
Specific to Apple iOS devices.
|
||||
|
||||
- **Mechanism**: Uses Apple's MFi/WAC protocol to pass Wi-Fi settings from an iPhone/iPad directly to the device without manual password entry.
|
||||
- **Status**: Detected automatically by iOS when a new SoundTouch device is in setup mode.
|
||||
|
||||
---
|
||||
|
||||
## 4. USB Setup (Legacy)
|
||||
Primarily used for older SoundTouch Series I and II devices or as a last resort.
|
||||
|
||||
- **Mechanism**: Physical connection via Micro-USB to a computer running the SoundTouch Setup application.
|
||||
- **Process**:
|
||||
1. Connect USB cable.
|
||||
2. The desktop app communicates via a proprietary HID or Serial-over-USB protocol.
|
||||
3. The app pushes Wi-Fi credentials.
|
||||
4. References to this exist in the firmware as `lost_USB_connection` and `connect_device` (see `setup_wizard.xml`).
|
||||
|
||||
---
|
||||
|
||||
## Technical Details: The "Gabbo" Protocol
|
||||
The Stockholm firmware contains references to a communication layer called **Gabbo**.
|
||||
- **File**: `setup/js/gabbo_setup_bco.js`
|
||||
- **Function**: Handles the state machine for Wi-Fi connection, account pairing, and error handling during setup.
|
||||
- **Relationship**: It appears to be an internal wrapper for the messages sent between the setup client (App or Browser) and the device firmware.
|
||||
|
||||
## Redirection during Setup
|
||||
While the `soundtouch-service` focuses on migrating existing devices, a truly "clean" setup to a custom service would require:
|
||||
1. Intercepting the initial account pairing request.
|
||||
2. Providing a mock "Marge" service that accepts any credentials.
|
||||
3. Patching the `SoundTouchSdkPrivateCfg.xml` during or immediately after the Wi-Fi connection phase.
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
| Feature | Initial Setup | Migration (soundtouch-service) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
|
||||
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
|
||||
| **Access** | Web UI / App protocol | SSH (root) |
|
||||
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
|
||||
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
|
||||
@@ -0,0 +1,107 @@
|
||||
# Device Logging & Troubleshooting
|
||||
|
||||
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
|
||||
|
||||
## Log Types
|
||||
|
||||
1. **System Logs**: Internal OS logs (Linux-based) including `dmesg`, `syslog`, and process-specific logs.
|
||||
2. **Traffic Logs**: Real-time HTTP/HTTPS requests sent by the device to cloud or local services.
|
||||
3. **Proxy Logs**: Logs generated by the `soundtouch-service` when it acts as a man-in-the-middle.
|
||||
|
||||
---
|
||||
|
||||
## 1. Accessing System Logs (Requires Root)
|
||||
|
||||
Most SoundTouch devices run a modified Linux distribution. Accessing these logs requires root SSH or Telnet access.
|
||||
|
||||
### Enabling Root Access (Remote Services)
|
||||
|
||||
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
|
||||
|
||||
1. **USB Method**:
|
||||
- Format a USB stick to **FAT32**.
|
||||
- Create an empty file named `remote_services` (no extension) in the root of the USB stick.
|
||||
- Insert the stick into the SoundTouch device.
|
||||
- Reboot the device (power cycle).
|
||||
- On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check.
|
||||
2. **TAP Command (Legacy)**:
|
||||
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
|
||||
|
||||
### Making Root Access Persistent
|
||||
Once you have logged in as `root` (usually no password or a well-known community password), you can make the access survive reboots without the USB stick:
|
||||
```bash
|
||||
touch /mnt/nv/remote_services
|
||||
/etc/init.d/sshd start
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
Once inside via SSH:
|
||||
- **Kernel Logs**: `dmesg`
|
||||
- **System Logs**: `cat /var/log/messages` or `tail -f /tmp/soundtouch.log` (paths vary by firmware).
|
||||
- **Real-time Monitoring**: `logread -f`
|
||||
- **Process List**: `ps w`
|
||||
|
||||
#### Pro-Tip: Filtered Real-time Monitoring
|
||||
To focus on cloud service and preset interactions (Marge), use the following command on the device:
|
||||
```bash
|
||||
logread -f | grep -Ei '(marge|preset)'
|
||||
```
|
||||
This is particularly useful for debugging preset synchronization and service redirection issues.
|
||||
|
||||
---
|
||||
|
||||
## 2. Traffic Logging & Interception
|
||||
|
||||
If you cannot or do not want to root the device, you can monitor its outbound traffic by redirecting it to a proxy.
|
||||
|
||||
### Via `soundtouch-service`
|
||||
The `soundtouch-service` included in this repository includes a built-in proxy. When a device is migrated to use this service, all of its cloud-bound traffic is logged to the service console.
|
||||
|
||||
**Key Traffic to Monitor**:
|
||||
- `POST /v1/scmudc/{deviceId}`: Real-time telemetry events.
|
||||
- `GET /marge/...`: Account and streaming configuration requests.
|
||||
- `POST /streaming/support/power_on`: Boot-time diagnostics.
|
||||
|
||||
### Via Packet Sniffing (Advanced)
|
||||
If you have a managed switch or a router capable of port mirroring, you can use **Wireshark** or `tcpdump` to capture traffic.
|
||||
- **Filter**: `tcp port 80 or tcp port 443`
|
||||
- **Target**: The IP address of your SoundTouch device.
|
||||
|
||||
---
|
||||
|
||||
## 3. Troubleshooting Common Issues
|
||||
|
||||
### "IsItBose" Validation Failures
|
||||
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
|
||||
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
|
||||
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
|
||||
|
||||
### Disappearing Sources (TuneIn/Local Radio)
|
||||
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
|
||||
- **Cause**: These sources are validated against the cloud only during the initial boot sequence.
|
||||
- **Solution**: Ensure your emulated service is reachable and responding correctly to `/streaming/support/power_on` and `/streaming/sourceproviders` during the device's boot-up.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTP Protocol Quirks
|
||||
|
||||
### ETag Case-Sensitivity
|
||||
Research in **SoundCork Issue #129** revealed a significant bug in the SoundTouch device firmware regarding HTTP `ETag` headers.
|
||||
|
||||
- **The Issue**: The device firmware expects the `ETag` header to be exactly title-cased (`ETag`). Many modern web servers or frameworks (like FastAPI/Uvicorn) return headers in all lowercase (`etag`) per HTTP/2 or standard case-insensitive conventions.
|
||||
- **The Symptom**: If the server returns a lowercase `etag`, the device fails to recognize it. Consequently, the device will never send an `If-None-Match` header in subsequent requests, breaking preset synchronization and efficient caching.
|
||||
- **The Workaround**: If you are using a custom service, you may need to use a reverse proxy (like **Nginx**) or a middleware to force the header casing to `ETag`.
|
||||
|
||||
**Example Nginx Fix**:
|
||||
```nginx
|
||||
proxy_hide_header etag;
|
||||
add_header ETag $upstream_http_etag;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
- [SoundCork Issue #112: Enabling Remote Services](https://github.com/deborahgu/soundcork/issues/112)
|
||||
- [SoundCork Issue #149: Debugging with Systemd/Gunicorn](https://github.com/deborahgu/soundcork/issues/149)
|
||||
- [ÜberBöse API: Telemetry Documentation](https://github.com/julius-d/ueberboese-api)
|
||||
- [SoundCork Issue #129: ETag Case-Sensitivity & Preset Sync](https://github.com/deborahgu/soundcork/issues/129)
|
||||
@@ -0,0 +1,195 @@
|
||||
# Device Redirect Methods & Custom Service Setup
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
## Overview of Redirection Targets
|
||||
|
||||
SoundTouch devices primarily communicate with the following domains:
|
||||
- `streaming.bose.com`: Marge (Account and streaming services)
|
||||
- `updates.bose.com`: Software updates
|
||||
- `stats.bose.com`: Telemetry and analytics
|
||||
- `bmx.bose.com`: Bose Media eXchange registry
|
||||
|
||||
---
|
||||
|
||||
## Method 1: XML Configuration Modification (Recommended)
|
||||
|
||||
The most robust and granular method involves modifying the device's private configuration file. This is the primary method used by **SoundCork**'s migration logic to redirect devices to a local service instance.
|
||||
|
||||
### Technical Details
|
||||
- **File Path**: `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml`
|
||||
- **Mechanism**: The device firmware reads this XML file at boot to determine service URLs.
|
||||
- **Fields to Modify**:
|
||||
- `<margeServerUrl>`: Redirects account/streaming calls.
|
||||
- `<statsServerUrl>`: Redirects telemetry.
|
||||
- `<swUpdateUrl>`: Redirects update checks.
|
||||
- `<bmxRegistryUrl>`: Redirects service discovery.
|
||||
|
||||
### Implementation
|
||||
Requires SSH access to the device.
|
||||
```xml
|
||||
<SoundTouchSdkPrivateCfg>
|
||||
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
|
||||
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
</SoundTouchSdkPrivateCfg>
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
|
||||
---
|
||||
|
||||
## Method 2: `/etc/hosts` DNS Override
|
||||
|
||||
This method uses the standard Linux hosts file to redirect traffic at the network level within the device. It is often used as a quick alternative in the **ÜberBöse API** community for global redirection.
|
||||
|
||||
### Technical Details
|
||||
- **File Path**: `/etc/hosts`
|
||||
- **Mechanism**: Overrides DNS resolution for Bose domains to point to a local IP.
|
||||
- **Resolution Order**: SoundTouch devices use the standard Linux Name Service Switch (`/etc/nsswitch.conf`). The default configuration (`hosts: files dns`) ensures that `/etc/hosts` is consulted *before* any external DNS lookups. This makes the redirection highly reliable for all system processes, including `curl`, `BoseApp`, and `IoT`.
|
||||
|
||||
### Implementation
|
||||
Requires SSH access. Add entries for the target domains:
|
||||
```text
|
||||
192.168.1.10 streaming.bose.com
|
||||
192.168.1.10 updates.bose.com
|
||||
192.168.1.10 stats.bose.com
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
|
||||
---
|
||||
|
||||
## Method 3: Binary Patching
|
||||
|
||||
A low-level approach where the actual compiled binaries (e.g., `BoseApp`, `IoT`) are modified to change hardcoded URL patterns. Research into these patterns has been documented in both **SoundCork** (Issue #128) and **ÜberBöse API** research.
|
||||
|
||||
### Technical Details
|
||||
- **Target Binaries**: `/opt/Bose/BoseApp`, `/opt/Bose/IoT`, `/opt/Bose/lib/libBmxAccountHsm.so`
|
||||
- **Mechanism**:
|
||||
- **URL Replacement**: Using a hex editor to search for string patterns like `https://streaming.bose.com` and replacing them with a custom URL of the **exact same length**.
|
||||
- **Regex Neutralization**: Some libraries (like `libBmxAccountHsm.so`) perform a validation check called `IsItBose` using a hardcoded regex. This regex prevents the device from connecting to non-Bose domains even if the URL is changed in the configuration.
|
||||
|
||||
#### The `IsItBose` Regex Patch
|
||||
Research in the **SoundCork** community (Issue #62) identified a specific regex in `libBmxAccountHsm.so` that enforces Bose/Apigee domain usage:
|
||||
`^https:\/\/bose-[a-zA-Z0-9\.\_\-\$\%]\+\.apigee\.net\/`
|
||||
|
||||
By patching this regex to be more "lax", the device can be made to accept any custom domain.
|
||||
|
||||
**Example Patch**:
|
||||
Using `sed` to replace the strict regex with a broad match while preserving the original string length:
|
||||
```bash
|
||||
sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]*#g" \
|
||||
< libBmxAccountHsm.so.orig > libBmxAccountHsm.so.patched
|
||||
```
|
||||
|
||||
### Implementation
|
||||
1. Copy the target binary or library from the device to a PC.
|
||||
2. Use a hex editor or `sed` to locate and patch the URL strings or regex patterns.
|
||||
3. Copy the patched file back to the device.
|
||||
4. Restore execution permissions and reboot.
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
|
||||
---
|
||||
|
||||
## Comparison & Usage Strategy
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
| :--- | :--- | :---: | :---: | :---: | :---: |
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## Combining Methods: When is one not enough?
|
||||
|
||||
A common question is whether these methods can be used in isolation or if they must be combined. The answer depends on your specific firmware version and the target service.
|
||||
|
||||
### Scenario A: XML Config Only (The Ideal Case)
|
||||
If your firmware does not strictly enforce the `IsItBose` check for the specific URLs you are changing, **Method 1 (XML)** is sufficient. This is the cleanest approach and is used by the `soundtouch-service` migration tool.
|
||||
|
||||
### Scenario B: XML Config + Binary Patching (The "Locked" Case)
|
||||
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.168.1.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
|
||||
* **Symptom**: The device ignores the XML setting or fails to connect despite the correct URL being present.
|
||||
* **Solution**: You **must** apply the **Binary Patch (Method 3)** to neutralize the `IsItBose` check *in addition* to the XML change.
|
||||
|
||||
### Scenario C: `/etc/hosts` + Custom CA (The "Clean Deep Redirect")
|
||||
If you use `/etc/hosts` to point `streaming.bose.com` to a local IP and want to avoid binary patching.
|
||||
* **Requirement 1**: Your local server must handle HTTPS (port 443).
|
||||
* **Requirement 2**: You must inject your Root CA into the device's trust store.
|
||||
* **Automated Tool**: The `soundtouch-service` now supports this via the `/setup/migrate/{deviceIP}?method=hosts` endpoint.
|
||||
* **CA Download**: You can download the auto-generated Root CA from `http://<your-server>:8000/setup/ca.crt`.
|
||||
* **Benefit**: Maintains system integrity (no binary changes) and full end-to-end encryption.
|
||||
|
||||
### Scenario D: `/etc/hosts` + Binary Patching (The "Legacy Deep Redirect")
|
||||
If you cannot or do not want to manage certificates, but still use `/etc/hosts` for DNS redirection.
|
||||
* **Requirement 1**: Your local server must handle HTTPS (port 443).
|
||||
* **Requirement 2**: Since the certificate will be invalid (mismatched domain/CA), you must patch the binary to **skip SSL verification** (see [Option 2](#option-2-ssl-verification-bypass) below).
|
||||
* **Risk**: Less secure and higher risk of bricking due to binary modification.
|
||||
|
||||
### Scenario E: The Triple-Threat (Total Control)
|
||||
For developers creating a completely isolated "dark" environment (no internet at all):
|
||||
1. **XML**: Point all URLs to local services.
|
||||
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
|
||||
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
|
||||
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time.
|
||||
|
||||
---
|
||||
|
||||
## Handling HTTPS & SSL Certificates
|
||||
|
||||
When redirecting HTTPS traffic to a custom service, SoundTouch devices will fail the SSL handshake because they do not trust your local server's certificate.
|
||||
|
||||
### Option 1: Custom CA Certificate (Recommended)
|
||||
|
||||
As suggested by community members, you can configure the device to trust your own Root CA. This allows for secure HTTPS communication without patching binaries.
|
||||
|
||||
**Technical Steps**:
|
||||
1. **Generate a Root CA** and issue a certificate for the target domain (e.g., `streaming.bose.com`).
|
||||
2. **SSH into the device** and copy your `rootCA.crt` to `/usr/share/ca-certificates/custom/`.
|
||||
3. **Update the Trust Store**:
|
||||
- **Method A (Append to Bundle)**: `cat /usr/share/ca-certificates/custom/rootCA.crt >> /etc/pki/tls/certs/ca-bundle.crt`
|
||||
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
|
||||
|
||||
**Pros & Cons**:
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
|
||||
|
||||
### Option 2: SSL Verification Bypass
|
||||
|
||||
If you cannot or do not want to manage certificates, you can patch the binary to skip certificate verification.
|
||||
|
||||
**Target**: `libBmxAccountHsm.so` or `BoseApp`
|
||||
**Mechanism**: Locating the SSL verification function (often in the internal curl-based or openssl-based logic) and forcing it to return "Success" regardless of the certificate status.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. **Start with Method 1 (XML Modification)**. It is the least invasive and most likely to work across different models.
|
||||
2. **Verify connectivity**. If the device refuses to connect to your custom endpoint, check logs for "IsItBose" or validation failures.
|
||||
3. **Apply Method 3 (Binary Patching)** only if Method 1 is being actively blocked by the firmware's validation logic.
|
||||
4. **Avoid Method 2 (`/etc/hosts`)** unless you are prepared to handle SSL certificate complexities or are performing quick temporary tests.
|
||||
@@ -0,0 +1,102 @@
|
||||
# HTTPS Setup & Custom CA Certificate
|
||||
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the Root CA certificate used by the local `soundtouch-service`.
|
||||
|
||||
## 1. Automated Migration (Hosts Method)
|
||||
|
||||
The `soundtouch-service` can automatically configure a device to use the `/etc/hosts` method:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Connect to the device via SSH.
|
||||
2. Update `/etc/hosts` to point Bose domains to the service IP.
|
||||
3. Inject the auto-generated Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
4. Reboot the device.
|
||||
|
||||
## 2. Managing the Root CA
|
||||
|
||||
The `soundtouch-service` automatically generates a Root CA when it first starts.
|
||||
|
||||
- **CA Certificate**: `data/certs/ca.crt`
|
||||
- **CA Private Key**: `data/certs/ca.key`
|
||||
|
||||
### Downloading the CA Certificate
|
||||
You can download the CA certificate for manual installation on other devices (like your phone or PC) from:
|
||||
`http://<server-ip>:8000/setup/ca.crt`
|
||||
|
||||
### 3. Built-in HTTPS Support
|
||||
|
||||
The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies the `/etc/hosts` redirection method by automatically presenting the correct certificates for Bose domains.
|
||||
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your local Root CA.
|
||||
|
||||
#### TLS Security
|
||||
|
||||
The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2).
|
||||
|
||||
- **Minimum TLS Version**: TLS 1.2
|
||||
- **Preferred Cipher Suites**:
|
||||
- `ECDHE-RSA-AES128-GCM-SHA256`
|
||||
- `ECDHE-RSA-AES256-GCM-SHA384`
|
||||
- `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- `RSA-AES128-GCM-SHA256` (Legacy support)
|
||||
- `RSA-AES256-GCM-SHA384` (Legacy support)
|
||||
|
||||
#### Binding to Port 443
|
||||
SoundTouch devices expect HTTPS on the default port 443. Since binding to port 443 usually requires root privileges, you have two options:
|
||||
|
||||
1. **Port Forwarding (Recommended)**: Run the service on a high port (e.g., 8443) and use `iptables` or your firewall to forward traffic from 443 to 8443.
|
||||
2. **Capabilities**: Grant the binary permission to bind to low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`.
|
||||
3. **Reverse Proxy**: Use Nginx or Caddy as described below.
|
||||
|
||||
### 4. Reverse Proxy (Optional)
|
||||
|
||||
1. **Generate a certificate** for the Bose domains signed by your Root CA.
|
||||
2. **Configure Nginx** to use this certificate and proxy requests to `soundtouch-service`.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name streaming.bose.com bmx.bose.com stats.bose.com updates.bose.com;
|
||||
|
||||
ssl_certificate /path/to/generated-cert.crt;
|
||||
ssl_certificate_key /path/to/generated-cert.key;
|
||||
|
||||
# Secure TLS configuration (matches soundtouch-service defaults)
|
||||
ssl_protocols TLSv1.2;
|
||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:AES128-GCM-SHA256:AES256-GCM-SHA384';
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Manual CA Injection (Legacy/Manual)
|
||||
|
||||
If you prefer to inject the CA certificate manually:
|
||||
|
||||
1. Copy `ca.crt` to the device:
|
||||
```bash
|
||||
scp data/certs/ca.crt root@{deviceIP}:/tmp/
|
||||
```
|
||||
2. Append it to the trust store on the device:
|
||||
```bash
|
||||
ssh root@{deviceIP} "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
|
||||
```
|
||||
|
||||
## 6. Verifying Connectivity
|
||||
|
||||
You can verify that your device can correctly reach the `soundtouch-service` over HTTPS using the management web UI.
|
||||
|
||||
In the **Migration Summary** for a device, you will find an **HTTPS Connection Test** section:
|
||||
- **Test with Explicit CA.crt**: Uploads a temporary copy of the Root CA to the device and uses `curl --cacert` to verify the connection. Use this to verify your HTTPS setup *before* modifying the device's shared trust store.
|
||||
- **Test with Shared Trust Store**: Uses the device's default trust store. Use this to verify that your CA injection was successful and the device now natively trusts your local server.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Undocumented Community Features & API Discoveries
|
||||
This document captures advanced API endpoints and device behaviors discovered by the SoundTouch community through reverse engineering projects like **SoundCork** and **ÜberBöse API**. These features are not documented in the official Bose SoundTouch Web API v1.0 but are crucial for full device emulation and offline operation.
|
||||
## Cloud Emulation (Marge/BMX) Discoveries
|
||||
While the local `/8090` API is well-documented, the cloud-side service emulation reveals deeper device integration points.
|
||||
### 1. Stereo Pairing & Cloud-Side Grouping
|
||||
SoundCork has pioneered the emulation of "Marge" group endpoints, which differ from the local `/getGroup` API. These are primarily used for persistent configurations like **Stereo Pairs** (e.g., two ST-10s).
|
||||
- **GET** `/marge/streaming/account/{account}/device/{device}/group`
|
||||
Returns `<group/>` if ungrouped, or full group configuration for stereo pairs.
|
||||
- **POST** `/marge/streaming/account/{account}/group`
|
||||
Creates a new group (returns a 7-digit group ID). Used for initial pairing.
|
||||
- **DELETE** `/marge/streaming/account/{account}/group/{group}`
|
||||
Dissolves a group configuration.
|
||||
### 2. Device Analytics & Event Reporting
|
||||
Devices report real-time telemetry to the cloud. Intercepting these provides a window into device usage without polling.
|
||||
- **Endpoint**: `POST /v1/scmudc/{deviceId}`
|
||||
- **Function**: Submits event data including `play-state-changed`, `preset-pressed`, `power-pressed`, `source-state-changed`, and `art-changed` (Metadata updates). This endpoint was first extensively documented in the **ÜberBöse API** specification.
|
||||
### 3. Power-On Lifecycle
|
||||
When a SoundTouch device boots or "powers on" (distinct from waking from standby), it contacts specific support endpoints.
|
||||
- **Endpoint**: `POST /streaming/support/power_on`
|
||||
- **Behavior**: Reports device serial number, IP address, and diagnostic data.
|
||||
- **Critical Finding**: SoundTouch devices fetch `TUNEIN` and `LOCAL_INTERNET_RADIO` source availability from the cloud **ONLY at boot time**. If the cloud is unreachable during a hard reboot (power cycle), these sources will disappear from the device's `/sources` list and become unavailable, even if the local API is working. This behavior was analyzed and reported by the **ÜberBöse API** project (Issue #3).
|
||||
### 4. OAuth & Service Tokens
|
||||
Integration with music services (Spotify, Pandora, etc.) involves specific token management endpoints.
|
||||
- **Endpoint**: `POST /oauth/device/{deviceId}/music/musicprovider/{providerId}/token/{tokenType}`
|
||||
- **Usage**: Used to refresh or validate session tokens for cloud-based music providers.
|
||||
## Community-Driven Extensions
|
||||
The community is working on extending SoundTouch functionality beyond its original design.
|
||||
### 1. Radio-Browser.info Integration
|
||||
There is an active effort to add `radio-browser.info` as a native `sourceprovider`. This would allow devices to browse a massive directory of thousands of stations without relying on the TuneIn cloud service.
|
||||
- **Status**: Research phase in SoundCork (Issue #150).
|
||||
- **Implementation**: Requires adding a new source provider entry in the emulated `/streaming/sourceproviders` response.
|
||||
### 2. Stockholm Internal App Analysis
|
||||
Deep analysis of the Stockholm (device firmware) internal web application reveals a set of internal AJAX/XML calls used by the device's own control interface.
|
||||
- **Internal Domains**: `Marge` (XML-based) and `Gabbo` (App-send based).
|
||||
- **Reference**: See SoundCork Issue #128 for a comprehensive list of internal JS controllers and their functions.
|
||||
### 3. ETag Case-Sensitivity Bug
|
||||
The SoundTouch device firmware has a case-sensitivity bug regarding HTTP `ETag` headers.
|
||||
- **Discovery**: SoundCork Issue #129.
|
||||
- **Detail**: The device expects the `ETag` header to be exactly title-cased. If a server returns `etag` (lowercase), the device fails to use it for `If-None-Match` requests, breaking efficient preset synchronization.
|
||||
- **Solution**: Force title-casing of the header via a reverse proxy like Nginx or mitmproxy.
|
||||
## References
|
||||
- [SoundCork GitHub Repo](https://github.com/deborahgu/soundcork)
|
||||
- [ÜberBöse API Spec](https://github.com/julius-d/ueberboese-api)
|
||||
- [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [IsItBose Regex Research](https://github.com/deborahgu/soundcork/issues/62#issuecomment-3610563908)
|
||||
- [SoundTouch Hook Repo](https://github.com/CodeFinder2/bose-soundtouch-hook)
|
||||
@@ -0,0 +1,84 @@
|
||||
# Upstream URLs & Domains Analysis
|
||||
|
||||
This document provides a comprehensive overview of the upstream Bose cloud services and domains that SoundTouch devices communicate with. These details were gathered from firmware analysis of ST10/ST20 devices, binary string extraction, and community research from the **SoundCork** project (Issue #128).
|
||||
|
||||
## Core Service Domains
|
||||
|
||||
SoundTouch devices use a set of primary domains for their operation. These are often configurable via the `SoundTouchSdkPrivateCfg.xml` file.
|
||||
|
||||
| Service | Primary Domain | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| **Marge** | `streaming.bose.com` | Account management, streaming source providers, and preset sync. |
|
||||
| **BMX Registry** | `content.api.bose.io` | Bose Media eXchange service discovery and registry. |
|
||||
| **Stats/Analytics** | `events.api.bosecm.com` | Telemetry, device events, and usage statistics. |
|
||||
| **Software Update** | `worldwide.bose.com` | Firmware update checks and downloads (path: `/updates/soundtouch`). |
|
||||
| **Voice/Alexa** | `voice.api.bose.io` | Token management for Amazon Alexa integration. |
|
||||
|
||||
## Internal & Development Domains
|
||||
|
||||
Analysis of device binaries (`BoseApp`, `IoT`) and community findings revealed several internal, integration, and development domains used by Bose.
|
||||
|
||||
### Marge & Auth Proxies
|
||||
- `bose-test.apigee.net/margeproxy` (Integration/Test proxy)
|
||||
- `bose-test.apigee.net/margeproxyefe`
|
||||
- `streamingstg.bose.com` (Staging)
|
||||
- `streamingintoauth.bose.com` (Internal Auth)
|
||||
- `streamingefeintoauth.bose.com` (Internal EFE Auth)
|
||||
- `streamingefeint.bose.com`
|
||||
|
||||
### BMX & Content Registry
|
||||
- `test.content.api.bose.io`
|
||||
- `content.api.bose.io/bmx/registry/v1/services`
|
||||
- `test.content.api.bose.io/bmx/int-registry/v1/services`
|
||||
- `test.content.api.bose.io/bmx/efe-registry/v1/services`
|
||||
|
||||
### Stats & Analytics
|
||||
- `eventsdev.api.bosecm.com`
|
||||
- `eventsefe.api.bosecm.com`
|
||||
- `eventsdev.bosecm.com`
|
||||
|
||||
### Software Updates
|
||||
- `worldwide.bose.com/updates/soundtouch-int`
|
||||
- `worldwide.bose.com/updates/soundtouch-efe`
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
Devices also communicate directly with third-party providers for specific features.
|
||||
|
||||
- **Pandora**:
|
||||
- `device-tuner.pandora.com`
|
||||
- `device-tuner-beta.savagebeast.com`
|
||||
- **Amazon AVS**:
|
||||
- `avs.na.amazonalexa.com`
|
||||
|
||||
## Hardcoded Validation (IsItBose)
|
||||
|
||||
As documented in [DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching), the `libBmxAccountHsm.so` library contains a hardcoded regex to validate these URLs:
|
||||
|
||||
`^https:\/\/bose-[a-zA-Z0-9\.\_\-\$\%]\+\.apigee\.net\/`
|
||||
|
||||
This regex ensures that certain critical services must reside on the `apigee.net` domain under a `bose-` prefix, unless patched.
|
||||
|
||||
## Configuration File References
|
||||
|
||||
On-device, these URLs are primarily managed in the following files:
|
||||
|
||||
1. **`/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml`**:
|
||||
* `<margeServerUrl>`
|
||||
* `<statsServerUrl>`
|
||||
* `<swUpdateUrl>`
|
||||
* `<bmxRegistryUrl>`
|
||||
2. **`/opt/Bose/etc/Voice.xml`**:
|
||||
* `<TPDATokenUrl>` (Points to `voice.api.bose.io`)
|
||||
3. **`/opt/Bose/etc/HandCraftedWebServer-SoundTouch.xml`**:
|
||||
* Contains internal local API mapping.
|
||||
|
||||
## Conclusion for Offline Operation
|
||||
|
||||
To achieve full offline operation or redirection to a custom service (like `soundtouch-service`), all of the above domains must either be redirected via DNS (`/etc/hosts`) or updated in the device's XML configuration files. For domains not exposed in XML, binary patching or DNS-level redirection is the only option.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
- [SoundCork Issue #128: Endpoint and URL Listing](https://github.com/deborahgu/soundcork/issues/128#issuecomment-3892933337)
|
||||
- [Bose SoundTouch Web API v1.0 Specification](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
|
||||
@@ -18,6 +18,6 @@ require (
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -67,8 +67,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
||||
+23
-2
@@ -1801,8 +1801,29 @@ func (c *Client) PlayCustom(playInfo *models.PlayInfo) error {
|
||||
|
||||
// PlayNotificationBeep plays a notification beep on the device
|
||||
func (c *Client) PlayNotificationBeep() error {
|
||||
var status models.StationResponse
|
||||
return c.get("/playNotification", &status)
|
||||
return c.PlayNotification("")
|
||||
}
|
||||
|
||||
// PlayNotification plays a notification. If a non-empty local path is provided,
|
||||
// it will be sent as XML body to play that specific device-local PCM file.
|
||||
// When path is empty, the device's default beep is triggered.
|
||||
func (c *Client) PlayNotification(path string) error {
|
||||
// Empty path -> trigger default beep via GET
|
||||
if strings.TrimSpace(path) == "" {
|
||||
var status models.StationResponse
|
||||
return c.get("/playNotification", &status)
|
||||
}
|
||||
|
||||
// Non-empty path -> POST minimal XML payload as required by the device
|
||||
payload := struct {
|
||||
XMLName xml.Name `xml:"audioSource"`
|
||||
PathToFile string `xml:"pathToFile,attr"`
|
||||
}{
|
||||
XMLName: xml.Name{Local: "audioSource"},
|
||||
PathToFile: path,
|
||||
}
|
||||
|
||||
return c.post("/playNotification", payload)
|
||||
}
|
||||
|
||||
// Introspect retrieves introspect data for a specified music service
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -1160,3 +1161,71 @@ func TestClient_RequestToken_Error(t *testing.T) {
|
||||
t.Errorf("Error should mention 'failed to request token', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PlayNotificationBeep(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/playNotification" {
|
||||
t.Errorf("Expected path '/playNotification', got '%s'", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("Expected GET method, got %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>success</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create test client
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
// Test PlayNotificationBeep
|
||||
err := client.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
t.Fatalf("PlayNotificationBeep() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PlayNotification_Path(t *testing.T) {
|
||||
testPath := "/opt/Bose/chimes/grouped.pcm"
|
||||
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/playNotification" {
|
||||
t.Errorf("Expected path '/playNotification', got '%s'", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
expectedXML := `<audioSource pathToFile="` + testPath + `"></audioSource>`
|
||||
if string(body) != expectedXML {
|
||||
t.Errorf("Expected body '%s', got '%s'", expectedXML, string(body))
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create test client
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
// Test PlayNotification with path
|
||||
err := client.PlayNotification(testPath)
|
||||
if err != nil {
|
||||
t.Fatalf("PlayNotification() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Package certmanager provides tools for managing Root CAs and generating SSL certificates.
|
||||
package certmanager
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertificateManager handles CA and certificate generation.
|
||||
type CertificateManager struct {
|
||||
CertsDir string
|
||||
}
|
||||
|
||||
// NewCertificateManager creates a new CertificateManager.
|
||||
func NewCertificateManager(certsDir string) *CertificateManager {
|
||||
return &CertificateManager{CertsDir: certsDir}
|
||||
}
|
||||
|
||||
// GetCACertPath returns the path to the CA certificate.
|
||||
func (cm *CertificateManager) GetCACertPath() string {
|
||||
return filepath.Join(cm.CertsDir, "ca.crt")
|
||||
}
|
||||
|
||||
// GetCAKeyPath returns the path to the CA private key.
|
||||
func (cm *CertificateManager) GetCAKeyPath() string {
|
||||
return filepath.Join(cm.CertsDir, "ca.key")
|
||||
}
|
||||
|
||||
// EnsureCA ensures that a CA certificate and key exist.
|
||||
func (cm *CertificateManager) EnsureCA() error {
|
||||
certPath := cm.GetCACertPath()
|
||||
keyPath := cm.GetCAKeyPath()
|
||||
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return cm.GenerateCA()
|
||||
}
|
||||
|
||||
// GetServerCertPEMPath returns the path to the server certificate PEM.
|
||||
func (cm *CertificateManager) GetServerCertPEMPath() string {
|
||||
return filepath.Join(cm.CertsDir, "server.crt")
|
||||
}
|
||||
|
||||
// GetServerKeyPEMPath returns the path to the server private key PEM.
|
||||
func (cm *CertificateManager) GetServerKeyPEMPath() string {
|
||||
return filepath.Join(cm.CertsDir, "server.key")
|
||||
}
|
||||
|
||||
// GetServerTLSConfig returns a TLS config with the server certificate.
|
||||
// If the certificate doesn't exist, it generates one for the given domains.
|
||||
func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config, error) {
|
||||
certPath := cm.GetServerCertPEMPath()
|
||||
keyPath := cm.GetServerKeyPEMPath()
|
||||
|
||||
generate := false
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
generate = true
|
||||
} else {
|
||||
// Check if the current certificate covers all requested domains
|
||||
certBytes, err := os.ReadFile(certPath)
|
||||
if err == nil {
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block != nil {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
domainMap := make(map[string]bool)
|
||||
for _, d := range cert.DNSNames {
|
||||
domainMap[d] = true
|
||||
}
|
||||
|
||||
for _, d := range domains {
|
||||
if !domainMap[d] {
|
||||
generate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
generate = true
|
||||
}
|
||||
} else {
|
||||
generate = true
|
||||
}
|
||||
} else {
|
||||
generate = true
|
||||
}
|
||||
}
|
||||
|
||||
if generate {
|
||||
certPEM, keyPEM, err := cm.GenerateCertificate(domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateCA generates a new CA certificate and key.
|
||||
func (cm *CertificateManager) GenerateCA() error {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(10 * 365 * 24 * time.Hour) // 10 years
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"SoundTouch Local Service"},
|
||||
CommonName: "SoundTouch Local Root CA",
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
certPath := cm.GetCACertPath()
|
||||
if mkdirErr := os.MkdirAll(cm.CertsDir, 0755); mkdirErr != nil {
|
||||
return mkdirErr
|
||||
}
|
||||
|
||||
certOut, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if encodeErr := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
|
||||
certOut.Close()
|
||||
|
||||
keyOut, err := os.OpenFile(cm.GetCAKeyPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyOut.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateCertificate generates a certificate for the given domains signed by the CA.
|
||||
func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []byte, error) {
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
caCertPEM, err := os.ReadFile(cm.GetCACertPath())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
caKeyPEM, err := os.ReadFile(cm.GetCAKeyPath())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
caBlock, _ := pem.Decode(caCertPEM)
|
||||
|
||||
caCert, err := x509.ParseCertificate(caBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keyBlock, _ := pem.Decode(caKeyPEM)
|
||||
|
||||
caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"SoundTouch Local Service"},
|
||||
CommonName: domains[0],
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: domains,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &priv.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package certmanager
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCertificateManager(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "crypto-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
|
||||
// Test CA generation
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cm.GetCACertPath()); os.IsNotExist(err) {
|
||||
t.Errorf("CA certificate not created")
|
||||
}
|
||||
if _, err := os.Stat(cm.GetCAKeyPath()); os.IsNotExist(err) {
|
||||
t.Errorf("CA key not created")
|
||||
}
|
||||
|
||||
// Test loading CA
|
||||
caCertPEM, err := os.ReadFile(cm.GetCACertPath())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read CA cert: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode(caCertPEM)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
t.Errorf("Invalid CA certificate PEM")
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CA cert: %v", err)
|
||||
}
|
||||
if !caCert.IsCA {
|
||||
t.Errorf("Generated certificate is not a CA")
|
||||
}
|
||||
|
||||
// Test certificate generation
|
||||
domains := []string{"streaming.bose.com", "updates.bose.com"}
|
||||
certPEM, keyPEM, err := cm.GenerateCertificate(domains)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate certificate: %v", err)
|
||||
}
|
||||
|
||||
if len(certPEM) == 0 || len(keyPEM) == 0 {
|
||||
t.Errorf("Generated certificate or key is empty")
|
||||
}
|
||||
|
||||
// Verify generated certificate
|
||||
block, _ = pem.Decode(certPEM)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
t.Errorf("Invalid certificate PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse certificate: %v", err)
|
||||
}
|
||||
|
||||
if cert.Subject.CommonName != domains[0] {
|
||||
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
|
||||
}
|
||||
|
||||
// Check DNS names
|
||||
if len(cert.DNSNames) != len(domains) {
|
||||
t.Errorf("Expected %d DNS names, got %d", len(domains), len(cert.DNSNames))
|
||||
}
|
||||
|
||||
// Verify against CA
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(caCert)
|
||||
opts := x509.VerifyOptions{
|
||||
DNSName: domains[0],
|
||||
Roots: roots,
|
||||
}
|
||||
|
||||
if _, err := cert.Verify(opts); err != nil {
|
||||
t.Errorf("Failed to verify certificate against CA: %v", err)
|
||||
}
|
||||
|
||||
// Test GetServerTLSConfig
|
||||
tlsConfig, err := cm.GetServerTLSConfig(domains)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get TLS config: %v", err)
|
||||
}
|
||||
|
||||
if tlsConfig == nil {
|
||||
t.Fatal("TLS config is nil")
|
||||
}
|
||||
|
||||
if len(tlsConfig.Certificates) == 0 {
|
||||
t.Fatal("TLS config has no certificates")
|
||||
}
|
||||
|
||||
// Test certificate regeneration if domains change
|
||||
newDomains := append(domains, "mac.fritz.box")
|
||||
tlsConfig2, err := cm.GetServerTLSConfig(newDomains)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated TLS config: %v", err)
|
||||
}
|
||||
if len(tlsConfig2.Certificates[0].Leaf.DNSNames) < 3 {
|
||||
// Note: tls.LoadX509KeyPair doesn't populate Leaf by default.
|
||||
// We should parse it manually or rely on the file existence/content.
|
||||
certBytes, _ := os.ReadFile(cm.GetServerCertPEMPath())
|
||||
block, _ := pem.Decode(certBytes)
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
found := false
|
||||
for _, d := range cert.DNSNames {
|
||||
if d == "mac.fritz.box" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Regenerated certificate does not contain new domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/css/* web/js/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed soundcork/media/*
|
||||
var mediaFS embed.FS
|
||||
|
||||
@@ -34,6 +37,14 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(indexHTML)
|
||||
}
|
||||
|
||||
// HandleWeb returns a handler for serving web resources.
|
||||
func (s *Server) HandleWeb() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fs := http.FileServer(http.FS(webFS))
|
||||
fs.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMedia returns a handler for serving media files.
|
||||
func (s *Server) HandleMedia() http.HandlerFunc {
|
||||
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
|
||||
|
||||
@@ -97,3 +97,39 @@ func TestStaticMedia(t *testing.T) {
|
||||
t.Errorf("Expected image/svg+xml content type, got %s", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticWeb(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -126,6 +128,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
method := setup.MigrationMethod(r.URL.Query().Get("method"))
|
||||
|
||||
options := make(map[string]string)
|
||||
|
||||
@@ -135,7 +138,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options); err != nil {
|
||||
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
@@ -155,6 +158,41 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
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 {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sm.TrustCACert(deviceIP); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Root CA trusted"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -273,6 +311,21 @@ func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetCACert returns the Root CA certificate.
|
||||
func (s *Server) HandleGetCACert(w http.ResponseWriter, _ *http.Request) {
|
||||
caCertPath := s.sm.Crypto.GetCACertPath()
|
||||
|
||||
content, err := os.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read CA certificate", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=soundtouch-ca.crt")
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
|
||||
// HandleUpdateProxySettings updates the proxy settings.
|
||||
func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
@@ -294,3 +347,86 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
if targetURL == "" {
|
||||
targetURL = s.serverURL
|
||||
}
|
||||
|
||||
output, err := s.sm.TestHostsRedirection(deviceIP, targetURL)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"message": err.Error(),
|
||||
"output": output,
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Hosts redirection test successful",
|
||||
"output": output,
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
if targetURL == "" {
|
||||
http.Error(w, "Target URL is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
useExplicitCA := r.URL.Query().Get("use_explicit_ca") == "true"
|
||||
|
||||
output, err := s.sm.TestConnection(deviceIP, targetURL, useExplicitCA)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"message": err.Error(),
|
||||
"output": output,
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Connection test successful",
|
||||
"output": output,
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
func TestProxySettingsAPI(t *testing.T) {
|
||||
@@ -81,3 +87,89 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
t.Errorf("GET (after update): Unexpected settings: %+v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationAndCA(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "handlers-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
_ = cm.EnsureCA()
|
||||
|
||||
sm := setup.NewManager("http://localhost:8000", ds, cm)
|
||||
// Mock SSH to avoid real connections
|
||||
sm.NewSSH = func(host string) setup.SSHClient {
|
||||
return &mockSSH{}
|
||||
}
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
server.sm = sm // Inject our manager with mock SSH
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test GET /setup/ca.crt
|
||||
res, err := http.Get(ts.URL + "/setup/ca.crt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CA: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if res.Header.Get("Content-Type") != "application/x-x509-ca-cert" {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migrate: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Migrate: Failed to decode response: %v", err)
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("Migrate: Expected ok=true, got %v", result["ok"])
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("TrustCA: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("TrustCA: Failed to decode response: %v", err)
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("TrustCA: Expected ok=true, got %v", result["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
type mockSSH struct{}
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Soundcork Management</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
button { padding: 5px 10px; cursor: pointer; }
|
||||
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
|
||||
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
|
||||
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
|
||||
.diff-container { display: flex; gap: 10px; }
|
||||
.diff-pane { flex: 1; min-width: 0; }
|
||||
.config-header { font-weight: bold; margin-bottom: 5px; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Soundcork Management</h1>
|
||||
<h2>Discovered Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
|
||||
<div id="manual-entry" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
|
||||
<h3>Manual Entry</h3>
|
||||
<input type="text" id="manual-ip" placeholder="Device IP (e.g. 192.168.1.100)">
|
||||
<button onclick="showSummary(document.getElementById('manual-ip').value)">Check Migration</button>
|
||||
|
||||
<h3 style="margin-top: 20px;">Settings</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="proxy-domain">Proxy Domain:</label>
|
||||
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
Proxy Logging:
|
||||
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="migration-summary" class="summary-box">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<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>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div class="diff-pane">
|
||||
<span class="config-header">Planned Config (Soundcork)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/settings');
|
||||
const settings = await response.json();
|
||||
if (settings.server_url) {
|
||||
document.getElementById('target-domain').value = settings.server_url;
|
||||
}
|
||||
if (settings.proxy_url) {
|
||||
document.getElementById('proxy-domain').value = settings.proxy_url;
|
||||
}
|
||||
fetchProxySettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProxySettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/proxy-settings');
|
||||
const settings = await response.json();
|
||||
document.getElementById('proxy-redact').checked = settings.redact;
|
||||
document.getElementById('proxy-log-body').checked = settings.log_body;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch proxy settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProxySettings() {
|
||||
const settings = {
|
||||
redact: document.getElementById('proxy-redact').checked,
|
||||
log_body: document.getElementById('proxy-log-body').checked
|
||||
};
|
||||
try {
|
||||
await fetch('/setup/proxy-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update proxy settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/setup/devices');
|
||||
const devices = await response.json();
|
||||
const container = document.getElementById('device-list');
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = 'No devices found.';
|
||||
} else {
|
||||
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Action</th></tr>';
|
||||
devices.forEach(d => {
|
||||
html += `
|
||||
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
|
||||
<td class="col-name">${d.name}</td>
|
||||
<td class="col-ip">${d.ip_address}</td>
|
||||
<td class="col-model">${d.product_code}</td>
|
||||
<td class="col-serial">${d.device_serial_number}</td>
|
||||
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
|
||||
<td><button onclick="showSummary('${d.ip_address}')">Prepare Migration</button></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Asynchronously fetch live info for each device
|
||||
devices.forEach(d => updateDeviceInfo(d.ip_address));
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDiscovery() {
|
||||
const indicator = document.getElementById('discovery-indicator');
|
||||
indicator.style.display = 'inline';
|
||||
try {
|
||||
await fetch('/setup/discover', { method: 'POST' });
|
||||
pollDiscoveryStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger discovery', error);
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDiscoveryStatus() {
|
||||
const indicator = document.getElementById('discovery-indicator');
|
||||
try {
|
||||
const response = await fetch('/setup/discovery-status');
|
||||
const data = await response.json();
|
||||
if (data.discovering) {
|
||||
setTimeout(pollDiscoveryStatus, 2000);
|
||||
} else {
|
||||
indicator.style.display = 'none';
|
||||
fetchDevices();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check discovery status', error);
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceInfo(ip) {
|
||||
try {
|
||||
const response = await fetch('/setup/info/' + ip);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
if (info.name) row.querySelector('.col-name').innerText = info.name;
|
||||
if (info.type) row.querySelector('.col-model').innerText = info.type;
|
||||
if (info.serialNumber) row.querySelector('.col-serial').innerText = info.serialNumber;
|
||||
if (info.softwareVersion) row.querySelector('.col-firmware').innerText = info.softwareVersion;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch live info for ' + ip, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showSummary(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('proxy-domain').value;
|
||||
|
||||
const opts = {
|
||||
marge: document.getElementById('opt-marge').value,
|
||||
stats: document.getElementById('opt-stats').value,
|
||||
sw_update: document.getElementById('opt-sw_update').value,
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/summary/' + ip + query);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
const summary = await response.json();
|
||||
|
||||
statusDiv.style.display = 'none';
|
||||
document.getElementById('summary-ip').innerText = ip;
|
||||
|
||||
// Update table row if it exists
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
if (summary.device_name) row.querySelector('.col-name').innerText = summary.device_name;
|
||||
if (summary.device_model) row.querySelector('.col-model').innerText = summary.device_model;
|
||||
if (summary.device_serial) row.querySelector('.col-serial').innerText = summary.device_serial;
|
||||
if (summary.firmware_version) row.querySelector('.col-firmware').innerText = summary.firmware_version;
|
||||
}
|
||||
|
||||
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
|
||||
document.getElementById('ssh-status').style.color = summary.ssh_success ? 'green' : 'red';
|
||||
|
||||
document.getElementById('original-config-status').style.display = summary.original_config ? 'block' : 'none';
|
||||
document.getElementById('no-original-config-status').style.display = summary.original_config ? 'none' : 'block';
|
||||
document.getElementById('original-config-content').innerText = summary.original_config || '';
|
||||
document.getElementById('original-config-pane').style.display = 'none';
|
||||
|
||||
if (summary.parsed_current_config) {
|
||||
document.getElementById('service-options').style.display = 'block';
|
||||
document.getElementById('orig-marge').innerText = summary.parsed_current_config.margeServerUrl;
|
||||
document.getElementById('orig-stats').innerText = summary.parsed_current_config.statsServerUrl;
|
||||
document.getElementById('orig-sw_update').innerText = summary.parsed_current_config.swUpdateUrl;
|
||||
document.getElementById('orig-bmx').innerText = summary.parsed_current_config.bmxRegistryUrl;
|
||||
} else {
|
||||
document.getElementById('service-options').style.display = 'none';
|
||||
}
|
||||
|
||||
const remoteStatus = document.getElementById('remote-services-status');
|
||||
const remoteFound = document.getElementById('remote-services-found');
|
||||
if (summary.ssh_success) {
|
||||
if (summary.remote_services_enabled) {
|
||||
remoteStatus.innerText = summary.remote_services_persistent ? '✅ Yes' : '⚠️ Yes (non-persistent)';
|
||||
remoteStatus.style.color = summary.remote_services_persistent ? 'green' : 'orange';
|
||||
} else {
|
||||
remoteStatus.innerText = '❌ No';
|
||||
remoteStatus.style.color = 'red';
|
||||
}
|
||||
remoteFound.innerText = summary.remote_services_found && summary.remote_services_found.length > 0
|
||||
? '(' + summary.remote_services_found.join(', ') + ')'
|
||||
: '';
|
||||
} else {
|
||||
remoteStatus.innerText = '❓ Unknown';
|
||||
remoteStatus.style.color = 'gray';
|
||||
remoteFound.innerText = '';
|
||||
}
|
||||
|
||||
const currentConfigElem = document.getElementById('current-config');
|
||||
currentConfigElem.innerText = summary.current_config;
|
||||
currentConfigElem.style.color = summary.ssh_success ? 'black' : 'red';
|
||||
|
||||
document.getElementById('planned-config').innerText = summary.planned_config;
|
||||
|
||||
const migrateBtn = document.getElementById('confirm-migrate-btn');
|
||||
migrateBtn.onclick = () => migrate(ip);
|
||||
migrateBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const remoteBtn = document.getElementById('ensure-remote-btn');
|
||||
remoteBtn.onclick = () => ensureRemoteServices(ip);
|
||||
remoteBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const removeRemoteBtn = document.getElementById('remove-remote-btn');
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
|
||||
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
|
||||
|
||||
const backupBtn = document.getElementById('backup-config-btn');
|
||||
backupBtn.onclick = () => backupConfig(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;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSummary() {
|
||||
const ip = document.getElementById('summary-ip').innerText;
|
||||
if (ip) {
|
||||
showSummary(ip);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('proxy-domain').value;
|
||||
|
||||
const opts = {
|
||||
marge: document.getElementById('opt-marge').value,
|
||||
stats: document.getElementById('opt-stats').value,
|
||||
sw_update: document.getElementById('opt-sw_update').value,
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Migrating ' + ip + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function backupConfig(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
|
||||
showSummary(ip); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOriginalConfig() {
|
||||
const pane = document.getElementById('original-config-pane');
|
||||
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
fetchDevices();
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,8 +16,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", server.HandleRoot)
|
||||
|
||||
// Setup media directory for tests
|
||||
// Setup media and web directories for tests
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/web/*", server.HandleWeb())
|
||||
|
||||
// Setup BMX for tests
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
@@ -50,6 +51,11 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
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("/trust-ca/{deviceIP}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
button { padding: 5px 10px; cursor: pointer; }
|
||||
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
|
||||
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
|
||||
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
|
||||
.diff-container { display: flex; gap: 10px; }
|
||||
.diff-pane { flex: 1; min-width: 0; }
|
||||
.config-header { font-weight: bold; margin-bottom: 5px; display: block; }
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Soundcork Management</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Soundcork Management</h1>
|
||||
<h2>Discovered Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
|
||||
<div id="manual-entry" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
|
||||
<h3>Manual Entry</h3>
|
||||
<input type="text" id="manual-ip" placeholder="Device IP (e.g. 192.168.1.100)">
|
||||
<button onclick="showSummary(document.getElementById('manual-ip').value)">Check Migration</button>
|
||||
|
||||
<h3 style="margin-top: 20px;">Settings</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="proxy-domain">Proxy Domain:</label>
|
||||
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
Proxy Logging:
|
||||
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="migration-summary" class="summary-box">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<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>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
URL: <code id="test-url"></code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
|
||||
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
|
||||
</div>
|
||||
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
|
||||
</div>
|
||||
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
|
||||
<label for="migration-method"><strong>Migration Method:</strong></label>
|
||||
<select id="migration-method" onchange="toggleMigrationMethod()">
|
||||
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
|
||||
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (Soundcork)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/hosts Entries</span>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method also injects the local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,518 @@
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/settings');
|
||||
const settings = await response.json();
|
||||
if (settings.server_url) {
|
||||
document.getElementById('target-domain').value = settings.server_url;
|
||||
}
|
||||
if (settings.proxy_url) {
|
||||
document.getElementById('proxy-domain').value = settings.proxy_url;
|
||||
}
|
||||
fetchProxySettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProxySettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/proxy-settings');
|
||||
const settings = await response.json();
|
||||
document.getElementById('proxy-redact').checked = settings.redact;
|
||||
document.getElementById('proxy-log-body').checked = settings.log_body;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch proxy settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProxySettings() {
|
||||
const settings = {
|
||||
redact: document.getElementById('proxy-redact').checked,
|
||||
log_body: document.getElementById('proxy-log-body').checked
|
||||
};
|
||||
try {
|
||||
await fetch('/setup/proxy-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update proxy settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/setup/devices');
|
||||
const devices = await response.json();
|
||||
const container = document.getElementById('device-list');
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = 'No devices found.';
|
||||
} else {
|
||||
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Action</th></tr>';
|
||||
devices.forEach(d => {
|
||||
html += `
|
||||
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
|
||||
<td class="col-name">${d.name}</td>
|
||||
<td class="col-ip">${d.ip_address}</td>
|
||||
<td class="col-model">${d.product_code}</td>
|
||||
<td class="col-serial">${d.device_serial_number}</td>
|
||||
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
|
||||
<td><button onclick="showSummary('${d.ip_address}')">Prepare Migration</button></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Asynchronously fetch live info for each device
|
||||
devices.forEach(d => updateDeviceInfo(d.ip_address));
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDiscovery() {
|
||||
const indicator = document.getElementById('discovery-indicator');
|
||||
indicator.style.display = 'inline';
|
||||
try {
|
||||
await fetch('/setup/discover', { method: 'POST' });
|
||||
pollDiscoveryStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger discovery', error);
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDiscoveryStatus() {
|
||||
const indicator = document.getElementById('discovery-indicator');
|
||||
try {
|
||||
const response = await fetch('/setup/discovery-status');
|
||||
const data = await response.json();
|
||||
if (data.discovering) {
|
||||
setTimeout(pollDiscoveryStatus, 2000);
|
||||
} else {
|
||||
indicator.style.display = 'none';
|
||||
fetchDevices();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check discovery status', error);
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceInfo(ip) {
|
||||
try {
|
||||
const response = await fetch('/setup/info/' + ip);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
if (info.name) row.querySelector('.col-name').innerText = info.name;
|
||||
if (info.type) row.querySelector('.col-model').innerText = info.type;
|
||||
if (info.serialNumber) row.querySelector('.col-serial').innerText = info.serialNumber;
|
||||
if (info.softwareVersion) row.querySelector('.col-firmware').innerText = info.softwareVersion;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch live info for ' + ip, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showSummary(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('proxy-domain').value;
|
||||
|
||||
const opts = {
|
||||
marge: document.getElementById('opt-marge').value,
|
||||
stats: document.getElementById('opt-stats').value,
|
||||
sw_update: document.getElementById('opt-sw_update').value,
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/summary/' + ip + query);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
const summary = await response.json();
|
||||
|
||||
statusDiv.style.display = 'none';
|
||||
document.getElementById('summary-ip').innerText = ip;
|
||||
|
||||
// Update table row if it exists
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
if (summary.device_name) row.querySelector('.col-name').innerText = summary.device_name;
|
||||
if (summary.device_model) row.querySelector('.col-model').innerText = summary.device_model;
|
||||
if (summary.device_serial) row.querySelector('.col-serial').innerText = summary.device_serial;
|
||||
if (summary.firmware_version) row.querySelector('.col-firmware').innerText = summary.firmware_version;
|
||||
}
|
||||
|
||||
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
|
||||
document.getElementById('ssh-status').style.color = summary.ssh_success ? 'green' : 'red';
|
||||
|
||||
document.getElementById('original-config-status').style.display = summary.original_config ? 'block' : 'none';
|
||||
document.getElementById('no-original-config-status').style.display = summary.original_config ? 'none' : 'block';
|
||||
document.getElementById('original-config-content').innerText = summary.original_config || '';
|
||||
document.getElementById('original-config-pane').style.display = 'none';
|
||||
|
||||
if (summary.parsed_current_config) {
|
||||
document.getElementById('service-options').style.display = 'block';
|
||||
document.getElementById('orig-marge').innerText = summary.parsed_current_config.margeServerUrl;
|
||||
document.getElementById('orig-stats').innerText = summary.parsed_current_config.statsServerUrl;
|
||||
document.getElementById('orig-sw_update').innerText = summary.parsed_current_config.swUpdateUrl;
|
||||
document.getElementById('orig-bmx').innerText = summary.parsed_current_config.bmxRegistryUrl;
|
||||
} else {
|
||||
document.getElementById('service-options').style.display = 'none';
|
||||
}
|
||||
|
||||
const remoteStatus = document.getElementById('remote-services-status');
|
||||
const remoteFound = document.getElementById('remote-services-found');
|
||||
if (summary.ssh_success) {
|
||||
if (summary.remote_services_enabled) {
|
||||
remoteStatus.innerText = summary.remote_services_persistent ? '✅ Yes' : '⚠️ Yes (non-persistent)';
|
||||
remoteStatus.style.color = summary.remote_services_persistent ? 'green' : 'orange';
|
||||
} else {
|
||||
remoteStatus.innerText = '❌ No';
|
||||
remoteStatus.style.color = 'red';
|
||||
}
|
||||
remoteFound.innerText = summary.remote_services_found && summary.remote_services_found.length > 0
|
||||
? '(' + summary.remote_services_found.join(', ') + ')'
|
||||
: '';
|
||||
|
||||
const caTrustStatus = document.getElementById('ca-trust-status');
|
||||
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);
|
||||
} else {
|
||||
remoteStatus.innerText = '❓ Unknown';
|
||||
remoteStatus.style.color = 'gray';
|
||||
remoteFound.innerText = '';
|
||||
|
||||
const caTrustStatus = document.getElementById('ca-trust-status');
|
||||
caTrustStatus.innerText = '❓ Unknown';
|
||||
caTrustStatus.style.color = 'gray';
|
||||
}
|
||||
|
||||
const currentConfigElem = document.getElementById('current-config');
|
||||
currentConfigElem.innerText = summary.current_config;
|
||||
currentConfigElem.style.color = summary.ssh_success ? 'black' : 'red';
|
||||
|
||||
document.getElementById('planned-config').innerText = summary.planned_config;
|
||||
document.getElementById('planned-hosts').innerText = summary.planned_hosts || '';
|
||||
|
||||
const testUrlElem = document.getElementById('test-url');
|
||||
testUrlElem.innerText = summary.server_https_url || 'N/A';
|
||||
const testResultDiv = document.getElementById('test-result');
|
||||
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);
|
||||
|
||||
toggleMigrationMethod();
|
||||
|
||||
const migrateBtn = document.getElementById('confirm-migrate-btn');
|
||||
migrateBtn.onclick = () => migrate(ip);
|
||||
migrateBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const remoteBtn = document.getElementById('ensure-remote-btn');
|
||||
remoteBtn.onclick = () => ensureRemoteServices(ip);
|
||||
remoteBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const removeRemoteBtn = document.getElementById('remove-remote-btn');
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
|
||||
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
|
||||
|
||||
const backupBtn = document.getElementById('backup-config-btn');
|
||||
backupBtn.onclick = () => backupConfig(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;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSummary() {
|
||||
const ip = document.getElementById('summary-ip').innerText;
|
||||
if (ip) {
|
||||
showSummary(ip);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('proxy-domain').value;
|
||||
const method = document.getElementById('migration-method').value;
|
||||
|
||||
const opts = {
|
||||
marge: document.getElementById('opt-marge').value,
|
||||
stats: document.getElementById('opt-stats').value,
|
||||
sw_update: document.getElementById('opt-sw_update').value,
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
|
||||
|
||||
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function trustCA(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
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 + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
|
||||
showSummary(ip); // Refresh to update status
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function backupConfig(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
|
||||
showSummary(ip); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(ip, useExplicitCA) {
|
||||
const testUrl = document.getElementById('test-url').innerText;
|
||||
const testResultDiv = document.getElementById('test-result');
|
||||
|
||||
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)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||||
const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
testResultDiv.style.backgroundColor = '#ccffcc';
|
||||
testResultDiv.innerText = '✅ ' + result.message + '\n\nOutput:\n' + result.output;
|
||||
} else {
|
||||
testResultDiv.style.backgroundColor = '#ffcccc';
|
||||
testResultDiv.innerText = '❌ Connection failed: ' + result.message + '\n\nOutput:\n' + result.output;
|
||||
}
|
||||
} catch (error) {
|
||||
testResultDiv.style.backgroundColor = '#ffcccc';
|
||||
testResultDiv.innerText = '❌ Error triggering test: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function testHostsRedirection(ip) {
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const testResultDiv = document.getElementById('hosts-test-result');
|
||||
|
||||
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)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
testResultDiv.style.backgroundColor = '#ccffcc';
|
||||
testResultDiv.innerText = '✅ ' + result.message + '\n\nOutput:\n' + result.output;
|
||||
} else {
|
||||
testResultDiv.style.backgroundColor = '#ffcccc';
|
||||
testResultDiv.innerText = '❌ Test failed: ' + result.message + '\n\nOutput:\n' + result.output;
|
||||
}
|
||||
} catch (error) {
|
||||
testResultDiv.style.backgroundColor = '#ffcccc';
|
||||
testResultDiv.innerText = '❌ Error triggering test: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOriginalConfig() {
|
||||
const pane = document.getElementById('original-config-pane');
|
||||
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function toggleMigrationMethod() {
|
||||
const method = document.getElementById('migration-method').value;
|
||||
const xmlDiffPane = document.getElementById('xml-diff-pane');
|
||||
const plannedXmlPane = document.getElementById('planned-xml-pane');
|
||||
const plannedHostsPane = document.getElementById('planned-hosts-pane');
|
||||
const serviceOptions = document.getElementById('service-options');
|
||||
const hostsTestPane = document.getElementById('hosts-redirection-test');
|
||||
|
||||
if (method === 'hosts') {
|
||||
xmlDiffPane.style.display = 'none';
|
||||
plannedXmlPane.style.display = 'none';
|
||||
plannedHostsPane.style.display = 'block';
|
||||
serviceOptions.style.display = 'none';
|
||||
hostsTestPane.style.display = 'block';
|
||||
} else {
|
||||
xmlDiffPane.style.display = 'block';
|
||||
plannedXmlPane.style.display = 'block';
|
||||
plannedHostsPane.style.display = 'none';
|
||||
hostsTestPane.style.display = 'none';
|
||||
// Only show service options if we have a parsed config
|
||||
const currentConfig = document.getElementById('current-config').innerText;
|
||||
if (currentConfig && !currentConfig.startsWith('Error') && currentConfig !== 'loading...') {
|
||||
serviceOptions.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchDevices();
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
});
|
||||
+512
-9
@@ -6,11 +6,25 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/ssh"
|
||||
)
|
||||
|
||||
// MigrationMethod represents the method used to migrate a speaker.
|
||||
type MigrationMethod string
|
||||
|
||||
const (
|
||||
// MigrationMethodXML redirects services by modifying SoundTouchSdkPrivateCfg.xml.
|
||||
MigrationMethodXML MigrationMethod = "xml"
|
||||
// MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store.
|
||||
MigrationMethodHosts MigrationMethod = "hosts"
|
||||
)
|
||||
|
||||
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
|
||||
const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml"
|
||||
|
||||
@@ -33,6 +47,7 @@ type MigrationSummary struct {
|
||||
PlannedConfig string `json:"planned_config"`
|
||||
OriginalConfig string `json:"original_config,omitempty"`
|
||||
ParsedCurrentConfig *PrivateCfg `json:"parsed_current_config,omitempty"`
|
||||
PlannedHosts string `json:"planned_hosts,omitempty"`
|
||||
RemoteServicesEnabled bool `json:"remote_services_enabled"`
|
||||
RemoteServicesPersistent bool `json:"remote_services_persistent"`
|
||||
RemoteServicesFound []string `json:"remote_services_found"`
|
||||
@@ -41,17 +56,34 @@ type MigrationSummary struct {
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
DeviceSerial string `json:"device_serial,omitempty"`
|
||||
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||
CACertTrusted bool `json:"ca_cert_trusted"`
|
||||
ServerHTTPSURL string `json:"server_https_url,omitempty"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
type SSHClient interface {
|
||||
Run(command string) (string, error)
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
}
|
||||
|
||||
// Manager handles the migration of speakers to the soundcork service.
|
||||
type Manager struct {
|
||||
ServerURL string
|
||||
DataStore *datastore.DataStore
|
||||
Crypto *certmanager.CertificateManager
|
||||
NewSSH func(host string) SSHClient
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager with the given base server URL.
|
||||
func NewManager(serverURL string, ds *datastore.DataStore) *Manager {
|
||||
return &Manager{ServerURL: serverURL, DataStore: ds}
|
||||
func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.CertificateManager) *Manager {
|
||||
return &Manager{
|
||||
ServerURL: serverURL,
|
||||
DataStore: ds,
|
||||
Crypto: cm,
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return ssh.NewClient(host)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceInfoXML represents the XML structure from :8090/info
|
||||
@@ -162,9 +194,50 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
|
||||
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
|
||||
|
||||
// 2b. Initial planned hosts config
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName != "" && hostName != "localhost" {
|
||||
client := m.NewSSH(deviceIP)
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
}
|
||||
|
||||
var hostsLines []string
|
||||
for _, domain := range domains {
|
||||
hostsLines = append(hostsLines, fmt.Sprintf("%s\t%s", hostIP, domain))
|
||||
}
|
||||
|
||||
summary.PlannedHosts = strings.Join(hostsLines, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for remote services files
|
||||
m.checkRemoteServices(summary, deviceIP)
|
||||
|
||||
// 4. Check if CA certificate is trusted
|
||||
m.checkCACertTrusted(summary, deviceIP)
|
||||
|
||||
// 5. Provide HTTPS URL for testing
|
||||
if parsedURL, err := url.Parse(targetURL); err == nil {
|
||||
hostIP := parsedURL.Hostname()
|
||||
if hostIP != "" {
|
||||
// Find HTTPS port from environment or default
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
summary.ServerHTTPSURL = fmt.Sprintf("https://%s:%s/health", hostIP, httpsPort)
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -212,7 +285,7 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
|
||||
// checkCurrentConfig reads and validates the current speaker configuration
|
||||
func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) {
|
||||
path := SoundTouchSdkPrivateCfgPath
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// Check if .original exists
|
||||
if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil {
|
||||
@@ -282,7 +355,7 @@ func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, opt
|
||||
|
||||
// checkRemoteServices checks for remote services files on the device
|
||||
func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
locations := []string{
|
||||
"/etc/remote_services",
|
||||
"/mnt/nv/remote_services",
|
||||
@@ -301,12 +374,65 @@ func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string
|
||||
}
|
||||
}
|
||||
|
||||
// checkCACertTrusted checks if the local CA certificate is already in the device's trust store.
|
||||
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
|
||||
if m.Crypto == nil {
|
||||
return
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
|
||||
// First, check for the label
|
||||
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
|
||||
if err == nil && strings.Contains(output, CALabel) {
|
||||
summary.CACertTrusted = true
|
||||
return
|
||||
}
|
||||
|
||||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We look for the first part of the certificate (e.g. the first 64 chars of the base64 data)
|
||||
// to see if it's already in the bundle.
|
||||
lines := strings.Split(string(caCertPEM), "\n")
|
||||
|
||||
var certData string
|
||||
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") && line != "" {
|
||||
certData = line
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if certData == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Use grep to check for the certificate data in the bundle
|
||||
_, err = client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath))
|
||||
if err == nil {
|
||||
summary.CACertTrusted = true
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateSpeaker configures the speaker at the given IP to use this soundcork service.
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string) error {
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) error {
|
||||
if targetURL == "" {
|
||||
targetURL = m.ServerURL
|
||||
}
|
||||
|
||||
if method == "" {
|
||||
method = MigrationMethodXML
|
||||
}
|
||||
|
||||
if method == MigrationMethodHosts {
|
||||
return m.migrateViaHosts(deviceIP, targetURL)
|
||||
}
|
||||
|
||||
if err := m.EnsureRemoteServices(deviceIP); err != nil {
|
||||
// Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'"
|
||||
// Let's log it.
|
||||
@@ -324,7 +450,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
}
|
||||
|
||||
// If we have a proxyURL and can read current config, use it
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
if currentConfig, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && currentConfig != "" {
|
||||
var currentCfg PrivateCfg
|
||||
if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil {
|
||||
@@ -388,7 +514,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
|
||||
// BackupConfig creates a backup of the current configuration on the speaker.
|
||||
func (m *Manager) BackupConfig(deviceIP string) error {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
remotePath := SoundTouchSdkPrivateCfgPath
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
@@ -423,7 +549,7 @@ func (m *Manager) BackupConfig(deviceIP string) error {
|
||||
// EnsureRemoteServices ensures that remote services are enabled on the device.
|
||||
// It tries to create an empty file in one of the known valid locations.
|
||||
func (m *Manager) EnsureRemoteServices(deviceIP string) error {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
// Try locations in order of preference
|
||||
@@ -450,9 +576,149 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) error {
|
||||
return fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
|
||||
}
|
||||
|
||||
// TrustCACert injects the local CA certificate into the device's shared trust store.
|
||||
func (m *Manager) TrustCACert(deviceIP string) error {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA certificate: %w", err)
|
||||
}
|
||||
|
||||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
_, _ = client.Run(rwCmd)
|
||||
|
||||
// Backup bundle if it doesn't exist
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil {
|
||||
_, _ = client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
|
||||
}
|
||||
|
||||
// Check if the label already exists in the bundle
|
||||
bundleContent, _ := client.Run(fmt.Sprintf("cat %s", bundlePath))
|
||||
if strings.Contains(bundleContent, CALabel) {
|
||||
// Label found, let's replace the whole block between labels if we used them,
|
||||
// or just remove the lines containing the label and re-append.
|
||||
// For simplicity, let's remove everything between CALabel tags if we had them,
|
||||
// but since we only had one line before, let's just remove lines containing CALabel
|
||||
// and the cert data if possible.
|
||||
// A better way is to rebuild the bundle without our CA.
|
||||
lines := strings.Split(bundleContent, "\n")
|
||||
|
||||
var newLines []string
|
||||
|
||||
inOurCA := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, CALabel) {
|
||||
inOurCA = !inOurCA
|
||||
continue
|
||||
}
|
||||
|
||||
if !inOurCA {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
bundleContent = strings.Join(newLines, "\n")
|
||||
if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
|
||||
bundleContent += "\n"
|
||||
}
|
||||
} else if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
|
||||
bundleContent += "\n"
|
||||
}
|
||||
|
||||
// Append with labels
|
||||
labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
|
||||
newBundleContent := bundleContent + labeledCert
|
||||
|
||||
if err := client.UploadContent([]byte(newBundleContent), bundlePath); err != nil {
|
||||
return fmt.Errorf("failed to update bundle: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
// 1. Parse targetURL to get IP for /etc/hosts
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName == "" || hostName == "localhost" {
|
||||
// Use a better guess if needed, but for now expect valid IP/hostname
|
||||
return fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
|
||||
// 2. Prepare /etc/hosts entries
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
}
|
||||
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
for _, domain := range domains {
|
||||
if !strings.Contains(hostsContent, domain) {
|
||||
entry := fmt.Sprintf("%s\t%s", hostIP, domain)
|
||||
|
||||
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
|
||||
hostsContent += "\n"
|
||||
}
|
||||
|
||||
hostsContent += entry + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Upload new /etc/hosts
|
||||
_, _ = client.Run(rwCmd)
|
||||
// Backup /etc/hosts if it doesn't exist
|
||||
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
|
||||
_, _ = client.Run("cp /etc/hosts /etc/hosts.original")
|
||||
}
|
||||
|
||||
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
|
||||
|
||||
// 4. Inject CA Certificate
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, deviceIP)
|
||||
|
||||
if !summary.CACertTrusted {
|
||||
if err := m.TrustCACert(deviceIP); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
|
||||
}
|
||||
|
||||
// 5. Reboot
|
||||
if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil {
|
||||
return fmt.Errorf("failed to reboot speaker: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
|
||||
func (m *Manager) RemoveRemoteServices(deviceIP string) error {
|
||||
client := ssh.NewClient(deviceIP)
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
locations := []string{
|
||||
@@ -481,3 +747,240 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestDomain is the fake domain used for preliminary redirection tests.
|
||||
const TestDomain = "custom-test-api.bose.fake"
|
||||
|
||||
// CALabel is the label used to identify the local CA certificate in the trust store.
|
||||
const CALabel = "# Soundcork Local Root CA"
|
||||
|
||||
// TestHostsRedirection performs a preliminary check to see if /etc/hosts redirection works.
|
||||
func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
hostIP, parsedURL, err := m.parseTargetURLAndResolveIP(targetURL, client)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
testDomain := TestDomain
|
||||
testEntry := fmt.Sprintf("%s\t%s", hostIP, testDomain)
|
||||
|
||||
if addErr := m.addTemporaryHostEntry(client, deviceIP, testDomain, testEntry, rwCmd); addErr != nil {
|
||||
return "", addErr
|
||||
}
|
||||
|
||||
defer m.cleanupTemporaryHostEntry(client, testDomain, rwCmd)
|
||||
|
||||
output, err := m.runHTTPRedirectionTest(client, parsedURL, testDomain)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
httpsOutput, httpsErr := m.runHTTPSRedirectionTest(client, testDomain)
|
||||
|
||||
combinedOutput := output + "\n---\n" + httpsOutput
|
||||
if httpsErr != nil {
|
||||
return combinedOutput, fmt.Errorf("hosts redirection HTTPS test failed: %w", httpsErr)
|
||||
}
|
||||
|
||||
return combinedOutput, nil
|
||||
}
|
||||
|
||||
func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient) (string, *url.URL, error) {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName == "" || hostName == "localhost" {
|
||||
return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
return m.resolveIP(hostName, client), parsedURL, nil
|
||||
}
|
||||
|
||||
func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error {
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(hostsContent, testDomain) {
|
||||
lines := strings.Split(hostsContent, "\n")
|
||||
|
||||
var newLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if line != "" && !strings.Contains(line, testDomain) {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
hostsContent = strings.Join(newLines, "\n")
|
||||
if len(newLines) > 0 {
|
||||
hostsContent += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = client.Run(rwCmd)
|
||||
|
||||
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
|
||||
hostsContent += "\n"
|
||||
}
|
||||
|
||||
newHostsContent := hostsContent + testEntry + "\n"
|
||||
if uploadErr := client.UploadContent([]byte(newHostsContent), "/etc/hosts"); uploadErr != nil {
|
||||
return fmt.Errorf("failed to add test entry to /etc/hosts: %w", uploadErr)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated /etc/hosts on %s with test entry:\n%s\n", deviceIP, newHostsContent)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupTemporaryHostEntry(client SSHClient, testDomain, rwCmd string) {
|
||||
currentContent, _ := client.Run("cat /etc/hosts")
|
||||
lines := strings.Split(currentContent, "\n")
|
||||
|
||||
var newLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if line != "" && !strings.Contains(line, testDomain) {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
finalContent := strings.Join(newLines, "\n")
|
||||
if len(newLines) > 0 {
|
||||
finalContent += "\n"
|
||||
}
|
||||
|
||||
_, _ = client.Run(rwCmd)
|
||||
_ = client.UploadContent([]byte(finalContent), "/etc/hosts")
|
||||
}
|
||||
|
||||
func (m *Manager) runHTTPRedirectionTest(client SSHClient, parsedURL *url.URL, testDomain string) (string, error) {
|
||||
httpTestURL := fmt.Sprintf("http://%s:%s/health", testDomain, parsedURL.Port())
|
||||
if parsedURL.Port() == "" || parsedURL.Port() == "80" {
|
||||
httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", httpTestURL)
|
||||
|
||||
output, err := client.Run(cmd)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("hosts redirection HTTP test failed: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (m *Manager) runHTTPSRedirectionTest(client SSHClient, testDomain string) (string, error) {
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
httpsTestURL := fmt.Sprintf("https://%s:%s/health", testDomain, httpsPort)
|
||||
if httpsPort == "443" {
|
||||
httpsTestURL = fmt.Sprintf("https://%s/health", testDomain)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read CA cert for HTTPS test: %w", err)
|
||||
}
|
||||
|
||||
caPath := "/tmp/soundtouch-test-ca.crt"
|
||||
if err := client.UploadContent(caPEM, caPath); err != nil {
|
||||
return "", fmt.Errorf("failed to upload temporary CA for HTTPS test: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_, _ = client.Run("rm " + caPath)
|
||||
}()
|
||||
|
||||
httpsCmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L --cacert %s %s", caPath, httpsTestURL)
|
||||
|
||||
return client.Run(httpsCmd)
|
||||
}
|
||||
|
||||
// TestConnection performs a connection check from the device to the server.
|
||||
func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
caPath := ""
|
||||
|
||||
if useExplicitCA {
|
||||
// Temporary upload CA to device
|
||||
caPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read CA cert: %w", err)
|
||||
}
|
||||
|
||||
caPath = "/tmp/soundtouch-test-ca.crt"
|
||||
if err := client.UploadContent(caPEM, caPath); err != nil {
|
||||
return "", fmt.Errorf("failed to upload temporary CA: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_, _ = client.Run("rm " + caPath)
|
||||
}()
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", targetURL)
|
||||
if useExplicitCA {
|
||||
cmd += " --cacert " + caPath
|
||||
}
|
||||
|
||||
output, err := client.Run(cmd)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("connection test failed: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (m *Manager) resolveIP(host string, client SSHClient) string {
|
||||
if net.ParseIP(host) != nil {
|
||||
return host
|
||||
}
|
||||
|
||||
// 1. Try resolving FROM the device via SSH (best for containers/NAT)
|
||||
if client != nil {
|
||||
// Use ping to resolve hostname on the device.
|
||||
// Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes
|
||||
output, err := client.Run(fmt.Sprintf("ping -c 1 %s", host))
|
||||
if err == nil {
|
||||
// Extract IP from parentheses: (1.2.3.4)
|
||||
start := strings.Index(output, "(")
|
||||
|
||||
end := strings.Index(output, ")")
|
||||
if start != -1 && end > start {
|
||||
ip := output[start+1 : end]
|
||||
if net.ParseIP(ip) != nil {
|
||||
fmt.Printf("Resolved %s to %s from device\n", host, ip)
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: resolve FROM the service itself
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return host // Fallback to host if resolution fails
|
||||
}
|
||||
|
||||
// Prefer IPv4
|
||||
for _, ip := range ips {
|
||||
if ip.To4() != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
|
||||
return ips[0].String()
|
||||
}
|
||||
|
||||
@@ -4,10 +4,110 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
)
|
||||
|
||||
type mockSSH struct {
|
||||
runFunc func(command string) (string, error)
|
||||
uploadContentFunc func(content []byte, remotePath string) error
|
||||
}
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
if m.runFunc != nil {
|
||||
return m.runFunc(command)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
if m.uploadContentFunc != nil {
|
||||
return m.uploadContentFunc(content, remotePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMigrateViaHosts(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://192.168.1.100:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
return "", fmt.Errorf("file not found")
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
if remotePath == "/etc/hosts" {
|
||||
if !strings.Contains(string(content), "192.168.1.100\tstreaming.bose.com") {
|
||||
t.Errorf("Expected hosts content to contain redirect, got %s", string(content))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaHosts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify backups were attempted
|
||||
foundHostsBackup := false
|
||||
foundBundleBackup := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "cp /etc/hosts /etc/hosts.original") {
|
||||
foundHostsBackup = true
|
||||
}
|
||||
if strings.Contains(call, "cp /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt.original") {
|
||||
foundBundleBackup = true
|
||||
}
|
||||
}
|
||||
if !foundHostsBackup {
|
||||
t.Errorf("Expected /etc/hosts backup to be attempted")
|
||||
}
|
||||
if !foundBundleBackup {
|
||||
t.Errorf("Expected ca-bundle.crt backup to be attempted")
|
||||
}
|
||||
|
||||
// Verify reboot was called
|
||||
foundReboot := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "reboot") {
|
||||
foundReboot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundReboot {
|
||||
t.Errorf("Expected reboot to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLiveDeviceInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
@@ -34,7 +134,7 @@ func TestGetLiveDeviceInfo(t *testing.T) {
|
||||
// The test server URL is like http://127.0.0.1:54321
|
||||
host := server.Listener.Addr().String()
|
||||
|
||||
manager := NewManager("http://localhost:8000", nil)
|
||||
manager := NewManager("http://localhost:8000", nil, nil)
|
||||
|
||||
info, err := manager.GetLiveDeviceInfo(host)
|
||||
if err != nil {
|
||||
@@ -58,7 +158,7 @@ func TestGetMigrationSummary_SSHFailure(t *testing.T) {
|
||||
// Use an IP that is unlikely to have an SSH server running or reachable
|
||||
// or use a local port that is closed.
|
||||
// We'll use a local port that we know is closed.
|
||||
manager := NewManager("http://localhost:8000", nil)
|
||||
manager := NewManager("http://localhost:8000", nil, nil)
|
||||
summary, err := manager.GetMigrationSummary("127.0.0.1", "", "", nil)
|
||||
|
||||
// Currently it might return an error OR it might return a summary with SSHSuccess: false
|
||||
@@ -86,7 +186,7 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
host := server.Listener.Addr().String()
|
||||
manager := NewManager("http://soundcork:8000", nil)
|
||||
manager := NewManager("http://soundcork:8000", nil, nil)
|
||||
|
||||
// Since we can't easily mock SSH here without a full SSH server,
|
||||
// we are testing the logic that depends on ParsedCurrentConfig being nil or not.
|
||||
@@ -109,6 +209,429 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
if !contains(summary.PlannedConfig, "http://target:8000/marge") {
|
||||
t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig)
|
||||
}
|
||||
|
||||
// Test PlannedHosts
|
||||
if !contains(summary.PlannedHosts, "target\tstreaming.bose.com") {
|
||||
t.Errorf("Expected PlannedHosts to contain redirect for target, got: %s", summary.PlannedHosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCACertTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "ca-trust-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
// Test 1: Found via label
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") && strings.Contains(command, CALabel) {
|
||||
return CALabel, nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.168.1.10")
|
||||
if !summary.CACertTrusted {
|
||||
t.Errorf("Expected CACertTrusted to be true when label is found")
|
||||
}
|
||||
|
||||
// Test 2: Found via data snippet (label missing)
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
if strings.Contains(command, CALabel) {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
// Searching for cert data
|
||||
return "found data", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary = &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.168.1.10")
|
||||
if !summary.CACertTrusted {
|
||||
t.Errorf("Expected CACertTrusted to be true when cert data is found")
|
||||
}
|
||||
|
||||
// Test 3: Not found
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary = &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.168.1.10")
|
||||
if summary.CACertTrusted {
|
||||
t.Errorf("Expected CACertTrusted to be false when nothing is found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnection(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-connection")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
uploadCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if strings.Contains(command, "curl") {
|
||||
return "HTTP/1.1 200 OK", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
uploadCalls = append(uploadCalls, remotePath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Shared trust store (no explicit CA)
|
||||
output, err := m.TestConnection("192.168.1.10", "https://localhost:8443/health", false)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConnection failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "200 OK") {
|
||||
t.Errorf("Expected output to contain '200 OK', got %s", output)
|
||||
}
|
||||
if len(uploadCalls) != 0 {
|
||||
t.Errorf("Expected no uploads for shared trust store test, got %v", uploadCalls)
|
||||
}
|
||||
|
||||
// Test 2: Explicit CA
|
||||
output, err = m.TestConnection("192.168.1.10", "https://localhost:8443/health", true)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConnection failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "200 OK") {
|
||||
t.Errorf("Expected output to contain '200 OK', got %s", output)
|
||||
}
|
||||
foundUpload := false
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/tmp/soundtouch-test-ca.crt" {
|
||||
foundUpload = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundUpload {
|
||||
t.Errorf("Expected CA to be uploaded to /tmp/soundtouch-test-ca.crt")
|
||||
}
|
||||
|
||||
foundCurlWithCA := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "curl") && strings.Contains(call, "--cacert /tmp/soundtouch-test-ca.crt") {
|
||||
foundCurlWithCA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCurlWithCA {
|
||||
t.Errorf("Expected curl command to use --cacert")
|
||||
}
|
||||
|
||||
// Verify cleanup
|
||||
foundRm := false
|
||||
for _, call := range runCalls {
|
||||
if call == "rm /tmp/soundtouch-test-ca.crt" {
|
||||
foundRm = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRm {
|
||||
t.Errorf("Expected cleanup command 'rm /tmp/soundtouch-test-ca.crt' to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestHostsRedirection(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "hosts-redirection-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
uploadCalls := []string{}
|
||||
var currentHostsContent = "127.0.0.1 localhost\n"
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /etc/hosts" {
|
||||
return currentHostsContent, nil
|
||||
}
|
||||
if strings.Contains(command, "curl") {
|
||||
return "HTTP/1.1 200 OK", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
uploadCalls = append(uploadCalls, remotePath)
|
||||
if remotePath == "/etc/hosts" {
|
||||
currentHostsContent = string(content)
|
||||
if strings.Contains(string(content), "custom-test-api.bose.fake") {
|
||||
if !strings.Contains(string(content), "1.2.3.4\tcustom-test-api.bose.fake") {
|
||||
t.Errorf("Expected hosts content to contain test redirect with IP 1.2.3.4, got %s", string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return mock
|
||||
}
|
||||
|
||||
output, err := m.TestHostsRedirection("192.168.1.10", "http://1.2.3.4:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("TestHostsRedirection failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "200 OK") {
|
||||
t.Errorf("Expected output to contain '200 OK', got %s", output)
|
||||
}
|
||||
|
||||
// Verify upload of test hosts
|
||||
foundHostsUpload := false
|
||||
foundCAUpload := false
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/hosts" {
|
||||
foundHostsUpload = true
|
||||
}
|
||||
if path == "/tmp/soundtouch-test-ca.crt" {
|
||||
foundCAUpload = true
|
||||
}
|
||||
}
|
||||
if !foundHostsUpload {
|
||||
t.Errorf("Expected /etc/hosts to be uploaded")
|
||||
}
|
||||
if !foundCAUpload {
|
||||
t.Errorf("Expected CA to be uploaded to /tmp/soundtouch-test-ca.crt")
|
||||
}
|
||||
|
||||
// Verify curl calls for both HTTP and HTTPS
|
||||
foundHTTP := false
|
||||
foundHTTPSWithCA := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "curl") {
|
||||
if strings.Contains(call, "http://") {
|
||||
foundHTTP = true
|
||||
}
|
||||
if strings.Contains(call, "https://") && strings.Contains(call, "--cacert /tmp/soundtouch-test-ca.crt") {
|
||||
foundHTTPSWithCA = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundHTTP {
|
||||
t.Errorf("Expected HTTP curl call")
|
||||
}
|
||||
if !foundHTTPSWithCA {
|
||||
t.Errorf("Expected HTTPS curl call with --cacert")
|
||||
}
|
||||
|
||||
// Verify cleanup
|
||||
foundRmCA := false
|
||||
for _, call := range runCalls {
|
||||
if call == "rm /tmp/soundtouch-test-ca.crt" {
|
||||
foundRmCA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRmCA {
|
||||
t.Errorf("Expected cleanup command 'rm /tmp/soundtouch-test-ca.crt' to be called")
|
||||
}
|
||||
|
||||
cleanupHostsCount := 0
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/hosts" {
|
||||
cleanupHostsCount++
|
||||
}
|
||||
}
|
||||
if cleanupHostsCount < 2 {
|
||||
t.Errorf("Expected at least 2 uploads to /etc/hosts (one for test, one for cleanup), got %d", cleanupHostsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIP(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
// Test with IP
|
||||
if m.resolveIP("1.2.3.4", nil) != "1.2.3.4" {
|
||||
t.Errorf("Expected 1.2.3.4, got %s", m.resolveIP("1.2.3.4", nil))
|
||||
}
|
||||
|
||||
// Test with localhost
|
||||
if m.resolveIP("localhost", nil) != "127.0.0.1" && m.resolveIP("localhost", nil) != "::1" {
|
||||
t.Errorf("Expected localhost resolution, got %s", m.resolveIP("localhost", nil))
|
||||
}
|
||||
|
||||
// Test with device resolution (mocked)
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, "ping -c 1 myhost") {
|
||||
return "PING myhost (10.0.0.5): 56 data bytes", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
if m.resolveIP("myhost", mock) != "10.0.0.5" {
|
||||
t.Errorf("Expected 10.0.0.5 from device, got %s", m.resolveIP("myhost", mock))
|
||||
}
|
||||
|
||||
// Test with non-existent host (should fallback to input)
|
||||
if m.resolveIP("non-existent.host.fake", nil) != "non-existent.host.fake" {
|
||||
t.Errorf("Expected fallback to input, got %s", m.resolveIP("non-existent.host.fake", nil))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test-skip-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://192.168.1.100:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
// Simulate CA already trusted
|
||||
return "found", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaHosts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify CA injection was skipped
|
||||
foundCAInjection := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "cat /tmp/local-ca.crt >> /etc/pki/tls/certs/ca-bundle.crt") {
|
||||
foundCAInjection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundCAInjection {
|
||||
t.Errorf("Expected CA injection to be skipped when already trusted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustCACert(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "trust-ca-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
|
||||
runCalls := []string{}
|
||||
uploadCalls := []string{}
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
return "", fmt.Errorf("file not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
uploadCalls = append(uploadCalls, remotePath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = m.TrustCACert("192.168.1.10")
|
||||
if err != nil {
|
||||
t.Fatalf("TrustCACert failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify CA backup and injection
|
||||
foundBackup := false
|
||||
for _, call := range runCalls {
|
||||
if strings.Contains(call, "cp /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt.original") {
|
||||
foundBackup = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundBackup {
|
||||
t.Errorf("Expected ca-bundle.crt backup")
|
||||
}
|
||||
|
||||
// Verify CA upload
|
||||
foundUpload := false
|
||||
for _, path := range uploadCalls {
|
||||
if path == "/etc/pki/tls/certs/ca-bundle.crt" {
|
||||
foundUpload = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundUpload {
|
||||
t.Errorf("Expected updated bundle to be uploaded to /etc/pki/tls/certs/ca-bundle.crt")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
Reference in New Issue
Block a user