feat: implement dual migration (XML and /etc/hosts) with custom CA and HTTPS support. Added automated /etc/hosts redirection, Root CA injection, built-in HTTPS listener, and enhanced management UI with diagnostic tests.

This commit is contained in:
Tobias Gesellchen
2026-02-12 23:41:54 +01:00
parent 0186fead6e
commit 00d5bfcb69
19 changed files with 2358 additions and 534 deletions
+9
View File
@@ -595,6 +595,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 +610,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.
@@ -621,6 +629,7 @@ If you discover new endpoints, features, or improvements through this library, p
- 🔧 **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
---
+89 -3
View File
@@ -9,9 +9,11 @@ import (
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
@@ -64,13 +66,73 @@ func main() {
serverURL = "http://" + strings.ToLower(hostname) + ":" + port
}
sm := setup.NewManager(serverURL, ds)
httpsServerURL := os.Getenv("HTTPS_SERVER_URL")
if httpsServerURL == "" {
// Guess HTTPS server URL
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
// Re-fetch httpsPort as it is defined later in the code, but let's move it up or just use the logic
guessHTTPSPort := os.Getenv("HTTPS_PORT")
if guessHTTPSPort == "" {
guessHTTPSPort = "8443"
}
httpsServerURL = "https://" + strings.ToLower(hostname) + ":" + guessHTTPSPort
}
cm := crypto.NewCertificateManager(filepath.Join(dataDir, "certs"))
if err := cm.EnsureCA(); err != nil {
log.Printf("Warning: Failed to ensure CA: %v", err)
}
sm := setup.NewManager(serverURL, ds, cm)
redact := os.Getenv("REDACT_PROXY_LOGS") != "false"
logBody := os.Getenv("LOG_PROXY_BODY") == "true"
server := handlers.NewServer(ds, sm, serverURL, redact, logBody)
// Phase 11: Setup HTTPS if CA and certificates are available
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
// We don't default to 443 because it usually requires root,
// and we want the service to start out-of-the-box for developers.
// However, 443 is needed for the device to connect via /etc/hosts without a port.
httpsPort = "8443"
}
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
hostname = strings.ToLower(hostname)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
setup.TestDomain,
hostname,
"localhost",
"127.0.0.1",
}
tlsConfig, err := cm.GetServerTLSConfig(domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
pyProxy := httputil.NewSingleHostReverseProxy(target)
pyProxy.ModifyResponse = func(res *http.Response) error {
// Generic Header Preservation:
@@ -107,6 +169,9 @@ func main() {
}()
r := chi.NewRouter()
// Update HTTPS server handler if it was initialized
// (Deferred logic in Phase 11 will use this 'r')
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -118,8 +183,9 @@ func main() {
server.HandleMedia()(w, r)
})
// Phase 2: Static file serving for /media
// Phase 2: Static file serving for /media and /web
r.Get("/media/*", server.HandleMedia())
r.Get("/web/*", server.HandleWeb())
// Phase 3: BMX endpoints
r.Route("/bmx", func(r chi.Router) {
@@ -167,6 +233,9 @@ func main() {
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)
@@ -177,6 +246,23 @@ func main() {
pyProxy.ServeHTTP(w, r)
})
log.Printf("Go service starting on %s, proxying to %s", addr, targetURL)
log.Printf("Go service starting on %s, proxying to %s", serverURL, targetURL)
if tlsConfig != nil {
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)
}
}()
}
log.Fatal(http.ListenAndServe(addr, r))
}
+1
View File
@@ -1 +1,2 @@
certs/
default/
+5 -1
View File
@@ -52,6 +52,7 @@ This method uses the standard Linux hosts file to redirect traffic at the networ
### 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:
@@ -136,7 +137,9 @@ On some newer firmware versions, even if you change the `<margeServerUrl>` in th
### 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 (see [Option 1](#option-1-custom-ca-certificate-recommended) below).
* **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")
@@ -150,6 +153,7 @@ For developers creating a completely isolated "dark" environment (no internet at
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.
---
+102
View File
@@ -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.
+1
View File
@@ -43,3 +43,4 @@ The SoundTouch device firmware has a case-sensitivity bug regarding HTTP `ETag`
- [Ü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)
+261
View File
@@ -0,0 +1,261 @@
package crypto
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 err := os.MkdirAll(cm.CertsDir, 0755); err != nil {
return err
}
certOut, err := os.Create(certPath)
if err != nil {
return err
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return err
}
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
}
+128
View File
@@ -0,0 +1,128 @@
package crypto
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")
}
}
}
+12 -1
View File
@@ -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"))
}
}
+90 -1
View File
@@ -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)
@@ -273,6 +276,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 +312,74 @@ 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
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": err.Error(),
"output": output,
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"message": "Hosts redirection test successful",
"output": output,
})
}
// 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
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": err.Error(),
"output": output,
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"message": "Connection test successful",
"output": output,
})
}
@@ -5,7 +5,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
func TestProxySettingsAPI(t *testing.T) {
@@ -81,3 +87,71 @@ 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 := crypto.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"])
}
}
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 }
-515
View File
@@ -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>
+6 -1
View File
@@ -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,10 @@ 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("/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) {
+11
View File
@@ -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; }
+159
View File
@@ -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></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>
+489
View File
@@ -0,0 +1,489 @@
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';
} 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 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();
});
+441 -9
View File
@@ -6,11 +6,25 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
"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 *crypto.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 *crypto.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,57 @@ 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
}
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
}
client := m.NewSSH(deviceIP)
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
// 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 +442,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), &currentCfg) == nil {
@@ -388,7 +506,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 +541,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 +568,107 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) error {
return fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
}
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 {
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return fmt.Errorf("failed to read CA certificate: %w", err)
}
// Append to bundle
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))
}
// We use session.Run for append or similar, but client.Run uses CombinedOutput.
// Let's use a temporary file and append it.
tmpCertPath := "/tmp/local-ca.crt"
if err := client.UploadContent(caCertPEM, tmpCertPath); err != nil {
return fmt.Errorf("failed to upload CA cert to tmp: %w", err)
}
if _, err := client.Run(fmt.Sprintf("%s && cat %s >> %s && rm %s", rwCmd, tmpCertPath, bundlePath, tmpCertPath)); err != nil {
return fmt.Errorf("failed to append CA cert to bundle: %w", 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 +697,219 @@ 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"
// 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 /)"
// 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" {
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
testDomain := TestDomain
testEntry := fmt.Sprintf("%s\t%s", hostIP, testDomain)
// 2. Add temporary entry to /etc/hosts
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) {
// Even if it's there, let's make sure it's correct (pointing to the current hostIP)
// but for now, if it's there, we just assume it's okay or from a previous failed cleanup.
// Let's remove it and re-add to be sure.
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)
// Ensure hostsContent ends with a newline if not empty
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
hostsContent += "\n"
}
newHostsContent := hostsContent + testEntry + "\n"
if err := client.UploadContent([]byte(newHostsContent), "/etc/hosts"); err != nil {
return "", fmt.Errorf("failed to add test entry to /etc/hosts: %w", err)
}
fmt.Printf("Updated /etc/hosts on %s with test entry:\n%s\n", deviceIP, newHostsContent)
defer func() {
// Clean up test entry
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")
}()
// 3. Test connection to the fake domain
// 3a. HTTP (for simplicity of redirection test)
// We use the health check endpoint on the same port but with the fake domain
httpTestURL := fmt.Sprintf("http://%s:%s/health", testDomain, parsedURL.Port())
if parsedURL.Port() == "" {
httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
} else if parsedURL.Port() == "80" {
httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
}
cmd := fmt.Sprintf("curl -v -s -L %s", httpTestURL)
output, err := client.Run(cmd)
if err != nil {
return output, fmt.Errorf("hosts redirection HTTP test failed: %w", err)
}
// 3b. HTTPS (to verify TLS reachability)
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)
}
// We now include the testDomain in our SSL certificate.
// We use the local CA certificate to verify the connection.
caPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return output, 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 output, fmt.Errorf("failed to upload temporary CA for HTTPS test: %w", err)
}
defer func() {
_, _ = client.Run("rm " + caPath)
}()
httpsCmd := fmt.Sprintf("curl -v -s -L --cacert %s %s", caPath, httpsTestURL)
httpsOutput, httpsErr := client.Run(httpsCmd)
if httpsErr != nil {
return output + "\n---\n" + httpsOutput, fmt.Errorf("hosts redirection HTTPS test failed: %w", httpsErr)
}
return output + "\n---\n" + httpsOutput, nil
}
// 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 -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()
}
+444 -3
View File
@@ -4,10 +4,110 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
)
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 := crypto.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,347 @@ 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 := crypto.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)
// Mock SSH to return "found" for grep
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.HasPrefix(command, "grep -F") {
return "found", nil
}
return "", nil
},
}
}
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, "192.168.1.10")
if !summary.CACertTrusted {
t.Errorf("Expected CACertTrusted to be true, got false")
}
// Mock SSH to return "not found" (error) for grep
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, got true")
}
}
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 := crypto.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 := crypto.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 := crypto.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 contains(s, substr string) bool {