mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
certs/
|
||||
default/
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/css/* web/js/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed soundcork/media/*
|
||||
var mediaFS embed.FS
|
||||
|
||||
@@ -34,6 +37,14 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(indexHTML)
|
||||
}
|
||||
|
||||
// HandleWeb returns a handler for serving web resources.
|
||||
func (s *Server) HandleWeb() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fs := http.FileServer(http.FS(webFS))
|
||||
fs.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMedia returns a handler for serving media files.
|
||||
func (s *Server) HandleMedia() http.HandlerFunc {
|
||||
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
|
||||
|
||||
@@ -97,3 +97,39 @@ func TestStaticMedia(t *testing.T) {
|
||||
t.Errorf("Expected image/svg+xml content type, got %s", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticWeb(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -126,6 +128,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
method := setup.MigrationMethod(r.URL.Query().Get("method"))
|
||||
|
||||
options := make(map[string]string)
|
||||
|
||||
@@ -135,7 +138,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options); err != nil {
|
||||
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Soundcork Management</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
button { padding: 5px 10px; cursor: pointer; }
|
||||
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
|
||||
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
|
||||
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
|
||||
.diff-container { display: flex; gap: 10px; }
|
||||
.diff-pane { flex: 1; min-width: 0; }
|
||||
.config-header { font-weight: bold; margin-bottom: 5px; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Soundcork Management</h1>
|
||||
<h2>Discovered Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
|
||||
<div id="manual-entry" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
|
||||
<h3>Manual Entry</h3>
|
||||
<input type="text" id="manual-ip" placeholder="Device IP (e.g. 192.168.1.100)">
|
||||
<button onclick="showSummary(document.getElementById('manual-ip').value)">Check Migration</button>
|
||||
|
||||
<h3 style="margin-top: 20px;">Settings</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="proxy-domain">Proxy Domain:</label>
|
||||
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
Proxy Logging:
|
||||
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="migration-summary" class="summary-box">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<p>SSH Connection: <span id="ssh-status"></span></p>
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div class="diff-pane">
|
||||
<span class="config-header">Planned Config (Soundcork)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/settings');
|
||||
const settings = await response.json();
|
||||
if (settings.server_url) {
|
||||
document.getElementById('target-domain').value = settings.server_url;
|
||||
}
|
||||
if (settings.proxy_url) {
|
||||
document.getElementById('proxy-domain').value = settings.proxy_url;
|
||||
}
|
||||
fetchProxySettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProxySettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/proxy-settings');
|
||||
const settings = await response.json();
|
||||
document.getElementById('proxy-redact').checked = settings.redact;
|
||||
document.getElementById('proxy-log-body').checked = settings.log_body;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch proxy settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProxySettings() {
|
||||
const settings = {
|
||||
redact: document.getElementById('proxy-redact').checked,
|
||||
log_body: document.getElementById('proxy-log-body').checked
|
||||
};
|
||||
try {
|
||||
await fetch('/setup/proxy-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update proxy settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/setup/devices');
|
||||
const devices = await response.json();
|
||||
const container = document.getElementById('device-list');
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = 'No devices found.';
|
||||
} else {
|
||||
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Action</th></tr>';
|
||||
devices.forEach(d => {
|
||||
html += `
|
||||
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
|
||||
<td class="col-name">${d.name}</td>
|
||||
<td class="col-ip">${d.ip_address}</td>
|
||||
<td class="col-model">${d.product_code}</td>
|
||||
<td class="col-serial">${d.device_serial_number}</td>
|
||||
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
|
||||
<td><button onclick="showSummary('${d.ip_address}')">Prepare Migration</button></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Asynchronously fetch live info for each device
|
||||
devices.forEach(d => updateDeviceInfo(d.ip_address));
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDiscovery() {
|
||||
const indicator = document.getElementById('discovery-indicator');
|
||||
indicator.style.display = 'inline';
|
||||
try {
|
||||
await fetch('/setup/discover', { method: 'POST' });
|
||||
pollDiscoveryStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger discovery', error);
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDiscoveryStatus() {
|
||||
const indicator = document.getElementById('discovery-indicator');
|
||||
try {
|
||||
const response = await fetch('/setup/discovery-status');
|
||||
const data = await response.json();
|
||||
if (data.discovering) {
|
||||
setTimeout(pollDiscoveryStatus, 2000);
|
||||
} else {
|
||||
indicator.style.display = 'none';
|
||||
fetchDevices();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check discovery status', error);
|
||||
indicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceInfo(ip) {
|
||||
try {
|
||||
const response = await fetch('/setup/info/' + ip);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
if (info.name) row.querySelector('.col-name').innerText = info.name;
|
||||
if (info.type) row.querySelector('.col-model').innerText = info.type;
|
||||
if (info.serialNumber) row.querySelector('.col-serial').innerText = info.serialNumber;
|
||||
if (info.softwareVersion) row.querySelector('.col-firmware').innerText = info.softwareVersion;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch live info for ' + ip, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showSummary(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('proxy-domain').value;
|
||||
|
||||
const opts = {
|
||||
marge: document.getElementById('opt-marge').value,
|
||||
stats: document.getElementById('opt-stats').value,
|
||||
sw_update: document.getElementById('opt-sw_update').value,
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/summary/' + ip + query);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
const summary = await response.json();
|
||||
|
||||
statusDiv.style.display = 'none';
|
||||
document.getElementById('summary-ip').innerText = ip;
|
||||
|
||||
// Update table row if it exists
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
if (summary.device_name) row.querySelector('.col-name').innerText = summary.device_name;
|
||||
if (summary.device_model) row.querySelector('.col-model').innerText = summary.device_model;
|
||||
if (summary.device_serial) row.querySelector('.col-serial').innerText = summary.device_serial;
|
||||
if (summary.firmware_version) row.querySelector('.col-firmware').innerText = summary.firmware_version;
|
||||
}
|
||||
|
||||
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
|
||||
document.getElementById('ssh-status').style.color = summary.ssh_success ? 'green' : 'red';
|
||||
|
||||
document.getElementById('original-config-status').style.display = summary.original_config ? 'block' : 'none';
|
||||
document.getElementById('no-original-config-status').style.display = summary.original_config ? 'none' : 'block';
|
||||
document.getElementById('original-config-content').innerText = summary.original_config || '';
|
||||
document.getElementById('original-config-pane').style.display = 'none';
|
||||
|
||||
if (summary.parsed_current_config) {
|
||||
document.getElementById('service-options').style.display = 'block';
|
||||
document.getElementById('orig-marge').innerText = summary.parsed_current_config.margeServerUrl;
|
||||
document.getElementById('orig-stats').innerText = summary.parsed_current_config.statsServerUrl;
|
||||
document.getElementById('orig-sw_update').innerText = summary.parsed_current_config.swUpdateUrl;
|
||||
document.getElementById('orig-bmx').innerText = summary.parsed_current_config.bmxRegistryUrl;
|
||||
} else {
|
||||
document.getElementById('service-options').style.display = 'none';
|
||||
}
|
||||
|
||||
const remoteStatus = document.getElementById('remote-services-status');
|
||||
const remoteFound = document.getElementById('remote-services-found');
|
||||
if (summary.ssh_success) {
|
||||
if (summary.remote_services_enabled) {
|
||||
remoteStatus.innerText = summary.remote_services_persistent ? '✅ Yes' : '⚠️ Yes (non-persistent)';
|
||||
remoteStatus.style.color = summary.remote_services_persistent ? 'green' : 'orange';
|
||||
} else {
|
||||
remoteStatus.innerText = '❌ No';
|
||||
remoteStatus.style.color = 'red';
|
||||
}
|
||||
remoteFound.innerText = summary.remote_services_found && summary.remote_services_found.length > 0
|
||||
? '(' + summary.remote_services_found.join(', ') + ')'
|
||||
: '';
|
||||
} else {
|
||||
remoteStatus.innerText = '❓ Unknown';
|
||||
remoteStatus.style.color = 'gray';
|
||||
remoteFound.innerText = '';
|
||||
}
|
||||
|
||||
const currentConfigElem = document.getElementById('current-config');
|
||||
currentConfigElem.innerText = summary.current_config;
|
||||
currentConfigElem.style.color = summary.ssh_success ? 'black' : 'red';
|
||||
|
||||
document.getElementById('planned-config').innerText = summary.planned_config;
|
||||
|
||||
const migrateBtn = document.getElementById('confirm-migrate-btn');
|
||||
migrateBtn.onclick = () => migrate(ip);
|
||||
migrateBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const remoteBtn = document.getElementById('ensure-remote-btn');
|
||||
remoteBtn.onclick = () => ensureRemoteServices(ip);
|
||||
remoteBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const removeRemoteBtn = document.getElementById('remove-remote-btn');
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
|
||||
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
|
||||
|
||||
const backupBtn = document.getElementById('backup-config-btn');
|
||||
backupBtn.onclick = () => backupConfig(ip);
|
||||
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
|
||||
|
||||
document.getElementById('migration-summary').style.display = 'block';
|
||||
document.getElementById('migration-summary').scrollIntoView();
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSummary() {
|
||||
const ip = document.getElementById('summary-ip').innerText;
|
||||
if (ip) {
|
||||
showSummary(ip);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('proxy-domain').value;
|
||||
|
||||
const opts = {
|
||||
marge: document.getElementById('opt-marge').value,
|
||||
stats: document.getElementById('opt-stats').value,
|
||||
sw_update: document.getElementById('opt-sw_update').value,
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Migrating ' + ip + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function backupConfig(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
|
||||
showSummary(ip); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOriginalConfig() {
|
||||
const pane = document.getElementById('original-config-pane');
|
||||
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
fetchDevices();
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,8 +16,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", server.HandleRoot)
|
||||
|
||||
// Setup media directory for tests
|
||||
// Setup media and web directories for tests
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/web/*", server.HandleWeb())
|
||||
|
||||
// Setup BMX for tests
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
@@ -50,6 +51,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) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
button { padding: 5px 10px; cursor: pointer; }
|
||||
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
|
||||
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
|
||||
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
|
||||
.diff-container { display: flex; gap: 10px; }
|
||||
.diff-pane { flex: 1; min-width: 0; }
|
||||
.config-header { font-weight: bold; margin-bottom: 5px; display: block; }
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Soundcork Management</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Soundcork Management</h1>
|
||||
<h2>Discovered Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
|
||||
<div id="manual-entry" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
|
||||
<h3>Manual Entry</h3>
|
||||
<input type="text" id="manual-ip" placeholder="Device IP (e.g. 192.168.1.100)">
|
||||
<button onclick="showSummary(document.getElementById('manual-ip').value)">Check Migration</button>
|
||||
|
||||
<h3 style="margin-top: 20px;">Settings</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="proxy-domain">Proxy Domain:</label>
|
||||
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
Proxy Logging:
|
||||
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="migration-summary" class="summary-box">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<p>SSH Connection: <span id="ssh-status"></span></p>
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>Local Root CA Trusted: <span id="ca-trust-status"></span></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
URL: <code id="test-url"></code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
|
||||
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
|
||||
</div>
|
||||
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
|
||||
</div>
|
||||
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
|
||||
<label for="migration-method"><strong>Migration Method:</strong></label>
|
||||
<select id="migration-method" onchange="toggleMigrationMethod()">
|
||||
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
|
||||
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (Soundcork)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/hosts Entries</span>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method also injects the local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration & Reboot</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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
@@ -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), ¤tCfg) == 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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user