Compare commits

...
37 Commits
Author SHA1 Message Date
Tobias Gesellchen e438db05d9 Fix release workflow to avoid +dirty version suffix by building in isolated directory 2026-02-15 17:27:41 +01:00
Tobias Gesellchen 8c02a009dc Update documentation for interaction session management 2026-02-15 16:52:44 +01:00
Tobias Gesellchen f20cfcb319 Enhance interaction session management and cleanup UI 2026-02-15 16:52:44 +01:00
Tobias Gesellchen a453059d6d Enhance interaction recording and analysis features 2026-02-15 16:52:44 +01:00
Tobias Gesellchen 505e6dd760 Refactor data storage to use account-based hierarchy and update Web UI 2026-02-15 15:36:01 +01:00
Tobias Gesellchen 735187cae8 docs: link README.md in SUMMARY.md to fix TestDocsConsistency 2026-02-15 00:50:29 +01:00
Tobias Gesellchen e8622cc382 docs: add Jekyll build step to workflow 2026-02-15 00:38:04 +01:00
Tobias Gesellchen c59052bdb4 docs: improve Jekyll configuration with minimal theme and relative links plugin 2026-02-15 00:35:32 +01:00
Tobias Gesellchen 15a6c4b0a0 docs: add Jekyll configuration with Cayman theme 2026-02-15 00:35:19 +01:00
Tobias Gesellchen ae3a3765db docs: add landing page for GitHub Pages 2026-02-15 00:32:32 +01:00
Tobias Gesellchen 59019cf55c docs: deploy documentation to GitHub Pages and update links in Web UI and README 2026-02-15 00:30:28 +01:00
Tobias Gesellchen 5e612e57ec Fix golangci-lint issues in main.go 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 02026a9f3a Add configurable shortcuts and log them on startup 2026-02-15 00:27:25 +01:00
Tobias Gesellchen aaf067088a Auto-create missing configuration files with default values 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 358ea18138 Implement device merging logic and web-based device removal 2026-02-15 00:15:09 +01:00
Tobias Gesellchen 0c5c1803a5 docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen cdf80a793e docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b511e052e2 docs: fix broken documentation links and update CI workflow paths 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b7197a8679 Add version visibility and discovery controls to Web UI and API 2026-02-14 23:03:53 +01:00
Tobias Gesellchen 5bfc24b7fb Use v0.18.1 version as default 2026-02-14 22:21:43 +01:00
Tobias Gesellchen 1e61adbb46 Integrate self-update logic into Raspberry Pi installer and simplify update workflow 2026-02-14 22:21:43 +01:00
Tobias Gesellchen b8ab4b5723 Enhance Raspberry Pi installer and modernize systemd deployment documentation 2026-02-14 21:53:24 +01:00
Tobias Gesellchen 5da7e001b2 Add a Systemd install script 2026-02-14 21:53:24 +01:00
Tobias Gesellchen 5269c05e56 Enhance settings management with persistence and explicit saving, including SAN updates and unit tests 2026-02-14 21:50:36 +01:00
Tobias Gesellchen d7a15c4dbe Minor cleanup and formatting fixes in docs handler and setup manager 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 701889076d Refactor documentation structure, add SUMMARY.md sidebar, and automated consistency checks 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 3acc983183 Cleanup the web ui/flow 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 7d40c61cad Fix variable shadowing in setup methods
Renamed shadowed 'err' and 'out' variables in MigrateSpeaker and TrustCACert to comply with linting rules.
2026-02-14 14:24:46 +01:00
Tobias Gesellchen 93cfd9dbbc Implement safe migration revert with backup validation
- Added strict guardrails for RevertMigration: now fails if .original backup is missing.
- Revert now uses copy (cp) instead of move (mv) to preserve original backups on the device.
- Decoupled reboot from migration/revert processes, making it a manual operation.
- Added standalone Reboot API and manual 'Reboot Speaker' button in the Web UI.
- Separated 'Remove Remote Services' from the revert process to allow independent management.
- Implemented command output capture and display in the Web UI for all setup actions (Migrate, Revert, Trust CA, Backup, Reboot, Remove Remote Services).
- Updated doc.go with a modern overview of the library and SoundTouch service features.
- Fixed several tests to align with new method signatures and behavior changes.
2026-02-14 14:24:46 +01:00
Tobias Gesellchen e084f8db1f Enhance Docker configuration for Swarm and consolidate environment templates 2026-02-14 13:09:52 +01:00
Tobias Gesellchen a19d34b55e Align soundtouch-service CLI with soundtouch-cli 2026-02-14 12:55:14 +01:00
Tobias Gesellchen dcf2e29c16 Fix linting issues and refactor for improved code quality 2026-02-14 12:39:39 +01:00
Tobias Gesellchen 9be1c7d588 Allow toggling HTTP interaction recording via CLI, environment, and Web UI 2026-02-14 12:39:39 +01:00
Tobias Gesellchen 133c07fefa Improve visibility of multiple devices in recordings by adding original value comments to .http files 2026-02-14 12:39:39 +01:00
Tobias Gesellchen ef90b4e848 Improve structure and re-usability of HTTP interaction recordings 2026-02-14 12:39:39 +01:00
Tobias Gesellchen c8ef1a9de4 Update README and documentation to reflect Toolkit expansion and Docker support 2026-02-14 00:14:29 +01:00
Tobias Gesellchen e47fa4c92c Complete local Bose SoundTouch emulation service with guided migration UI 2026-02-14 00:14:29 +01:00
96 changed files with 6767 additions and 947 deletions
+3
View File
@@ -1,6 +1,9 @@
# Bose SoundTouch Configuration
# Copy this file to .env and customize for your setup
# Docker/Service Settings
SOUNDTOUCH_HOSTNAME=soundtouch.local
# Discovery Settings
DISCOVERY_TIMEOUT=5s
UPNP_ENABLED=true
+3 -3
View File
@@ -149,8 +149,8 @@ jobs:
# Check that all documented endpoints exist in code
echo "Validating API documentation consistency..."
# Extract endpoint patterns from cookbook
if [ -f "docs/API-COOKBOOK.md" ]; then
# Check API cookbook
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
echo "✓ API Cookbook exists"
else
echo "✗ API Cookbook missing"
@@ -158,7 +158,7 @@ jobs:
fi
# Check getting started guide
if [ -f "docs/GETTING-STARTED.md" ]; then
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
echo "✓ Getting Started guide exists"
else
echo "✗ Getting Started guide missing"
+37
View File
@@ -0,0 +1,37 @@
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- 'docs/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+7 -4
View File
@@ -133,10 +133,13 @@ jobs:
local CMD_PATH=$2
local OUTPUT_NAME
# Ensure build directory exists
mkdir -p build
if [[ "${{ matrix.goos }}" == "windows" ]]; then
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
else
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
fi
echo "Building $BINARY_NAME: $OUTPUT_NAME"
@@ -193,8 +196,8 @@ jobs:
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: |
soundtouch-cli-v*
soundtouch-service-v*
build/soundtouch-cli-v*
build/soundtouch-service-v*
retention-days: 1
checksums:
+3
View File
@@ -19,14 +19,17 @@ dist/
/example-unified
/mdns-scanner
/websocket-demo
/main
# Environment configuration
.env
.env.local
.env.*.local
docker-compose.override.yml
# Test coverage reports
coverage.out
coverage*.out
coverage.html
*.prof
+4 -4
View File
@@ -76,7 +76,7 @@ When filing a bug report, include:
Feature requests are welcome! Please:
1. **Check if the feature already exists** in documentation
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/API-Endpoints-Overview.md))
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
3. **Explain the use case** and how it benefits users
### 🔧 Contributing Code
@@ -469,10 +469,10 @@ Contributors will be:
- [Go Documentation](https://golang.org/doc/)
- [Effective Go](https://golang.org/doc/effective_go.html)
- [Bose SoundTouch API Documentation](docs/API-Endpoints-Overview.md)
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/PROJECT-PATTERNS.md)
- [Development Status](docs/STATUS.md)
- [Development Status](docs/archive/STATUS.md)
---
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
+46 -158
View File
@@ -1,6 +1,6 @@
# Bose SoundTouch API Client
# Bose SoundTouch Toolkit
A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices via their Web API.
A comprehensive solution for controlling and preserving Bose SoundTouch devices, including a Go library, CLI tool, and a local service for cloud emulation.
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
@@ -21,7 +21,9 @@ A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices
- 🖥️ **CLI Tool**: Comprehensive command-line interface
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
- 📊 **Traffic Analysis**: Proxy and log device communications for debugging
- 📊 **Traffic Analysis**: Proxy and log device communications
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
@@ -42,165 +44,51 @@ go get github.com/gesellix/bose-soundtouch
### CLI Usage
#### Discover Devices
Find SoundTouch devices on your network:
```bash
# Find SoundTouch devices on your network
soundtouch-cli discover devices
```
# Control a Device
Control a device (replace `192.168.1.100` with your speaker's IP):
```bash
# Basic device information
soundtouch-cli --host 192.168.1.100 info get
# Basic information
soundtouch-cli --host 192.168.1.100 info
# Media controls
soundtouch-cli --host 192.168.1.100 play start
soundtouch-cli --host 192.168.1.100 volume set --level 50
soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
# Preset management
soundtouch-cli --host 192.168.1.100 preset list
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
soundtouch-cli --host 192.168.1.100 preset select --slot 1
# Browse and discover content
soundtouch-cli --host 192.168.1.100 browse tunein
soundtouch-cli --host 192.168.1.100 station search-tunein --query "jazz"
soundtouch-cli --host 192.168.1.100 station add --source TUNEIN --token <token> --name "Jazz Radio"
# Speaker notifications (ST-10 only)
soundtouch-cli --host 192.168.1.100 speaker tts --text "Welcome home" --app-key YOUR_KEY
soundtouch-cli --host 192.168.1.100 speaker url --url "https://example.com/doorbell.mp3" --app-key YOUR_KEY
soundtouch-cli --host 192.168.1.100 speaker beep
# Real-time monitoring
soundtouch-cli --host 192.168.1.100 events subscribe
```
### SoundTouch Service
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
The `soundtouch-service` is a local server that emulates Bose's cloud services, enabling offline operation and custom integrations. This is particularly valuable as Bose has announced the discontinuation of cloud support in May 2026.
### SoundTouch Service (Cloud Shutdown Protection)
#### Key Features
The `soundtouch-service` is a local server that emulates Bose's cloud services. This is critical for keeping your speakers functional after the **Bose Cloud Shutdown in May 2026**.
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
#### Quick Start
#### Key Features:
- **🏠 Local Emulation**: BMX and Marge service implementation
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
#### Quick Start:
```bash
# Install the service
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
# Start with default settings (http://localhost:8000, proxying to http://localhost:8001)
# Start the service
soundtouch-service
# Or configure with environment variables
PORT=9000 PYTHON_BACKEND_URL=http://your-python-backend:8001 DATA_DIR=/my/data soundtouch-service
```
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
#### Running with Docker
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
You can also run the SoundTouch service using Docker or Docker Compose.
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
##### Using Docker
**Linux (with host networking for discovery):**
```bash
docker run -d \
--name soundtouch-service \
--network host \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
**macOS / Windows (with port mapping):**
```bash
docker run --rm -it \
-p 8000:8000 -p 8443:8443 \
-v $(pwd)/data:/app/data \
--env SERVER_URL=http://soundtouch.local:8000 \
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
ghcr.io/gesellix/bose-soundtouch:latest
```
> **Note**: The hostnames configured via `SERVER_URL` and `HTTPS_SERVER_URL` are automatically added as Subject Alternative Names (SAN) to the generated TLS certificate, ensuring valid SSL connections.
Alternatively, without explicit server URLs:
```bash
docker run -d \
--name soundtouch-service \
-p 8000:8000 \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
##### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
services:
soundtouch-service:
image: ghcr.io/gesellix/bose-soundtouch:latest
container_name: soundtouch-service
# Linux users: use host networking for device discovery
# network_mode: host
# macOS/Windows users: use port mapping (discovery will be manual)
ports:
- "8000:8000"
- "8443:8443"
environment:
- PORT=8000
- SERVER_URL=http://soundtouch.local:8000
- HTTPS_SERVER_URL=https://soundtouch.local:8443
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
restart: unless-stopped
```
> **Note**: Hostnames from `SERVER_URL` and `HTTPS_SERVER_URL` are automatically included in the TLS certificate's Subject Alternative Names (SAN).
And run:
```bash
docker-compose up -d
```
> **Note**: `--network host` is required for device discovery via UPnP and mDNS to work correctly within the container.
#### Device Migration Example
```bash
# 1. Start the service
soundtouch-service
# 2. Open web UI at http://localhost:8000
# 3. Discover your devices
# 4. Click "Migrate" to configure devices to use local services
# Or use the API directly:
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
```
#### Service Endpoints
- **Web UI**: `http://localhost:8000/` - Device management interface
- **Discovery**: `GET /setup/devices` - List discovered devices
- **Migration**: `POST /setup/migrate/{deviceIP}` - Switch device to local services
- **BMX Services**: `/bmx/*` - Music service emulation (TuneIn, etc.)
- **Marge Services**: `/marge/*` - Account and device management
- **Proxy**: `/proxy/*` - Traffic inspection and debugging
See [docs/SOUNDTOUCH-SERVICE.md](docs/SOUNDTOUCH-SERVICE.md) for detailed configuration and API reference.
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
### Library Usage
@@ -493,19 +381,19 @@ This library supports all Bose SoundTouch-compatible devices, including:
## Documentation
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
- 📚 [API Reference](docs/API-Endpoints-Overview.md) - Complete endpoint documentation
- 🔧 [CLI Reference](docs/CLI-REFERENCE.md) - Command-line tool guide
- 🌐 [SoundTouch Service Guide](docs/SOUNDTOUCH-SERVICE.md) - Local service setup and migration
- 🎯 [Getting Started](docs/GETTING-STARTED.md) - Detailed setup and usage
- 📻 [Preset Quick Start](docs/PRESET-QUICKSTART.md) - Favorite content management
- 🧭 [Navigation Guide](docs/NAVIGATION-GUIDE.md) - Content browsing and station management
- 📋 [Navigation API Reference](docs/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](docs/SYSTEM-ENDPOINTS.md) - Advanced functionality
- 🏠 [Multiroom Setup](docs/zone-management.md) - Zone configuration guide
- ⚡ [WebSocket Events](docs/websocket-events.md) - Real-time event handling
- 🔔 [Speaker Notifications](docs/SPEAKER_ENDPOINT.md) - TTS and audio notifications guide
- 🔍 [Device Discovery](docs/DISCOVERY.md) - Discovery configuration
- 🛠️ [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
## Development
@@ -640,13 +528,13 @@ If you discover new endpoints, features, or improvements through this library, p
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
- 📖 **Documentation**: Browse the [docs/](docs/) directory
- 🔍 **New Discoveries**: See [Undocumented Community Features](docs/UNDOCUMENTED-COMMUNITY-FEATURES.md) for advanced API research
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](docs/UPSTREAM-URLS-ANALYSIS.md) for cloud dependency research
- 🔧 **Redirection Guide**: [Device Redirect Methods](docs/DEVICE-REDIRECT-METHODS.md) for custom service setup
- 🐣 **Initial Setup**: [Device Initial Setup Variants](docs/DEVICE-INITIAL-SETUP.md) for out-of-the-box configuration
- 📜 **Logging & Debugging**: [Device Logging Guide](docs/DEVICE-LOGGING.md) for accessing system and traffic logs
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](docs/HTTPS-SETUP.md) for secure `/etc/hosts` redirection
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
---
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"os"
"regexp"
"runtime"
"strconv"
@@ -331,7 +332,7 @@ func PrintWarning(message string) {
// showVersionInfo displays detailed version information including build details
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("soundtouch-cli version %s\n", version)
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
+1 -1
View File
@@ -104,7 +104,7 @@ func main() {
Version: version,
Authors: []*cli.Author{
{
Name: "Tobias Gesellchen, and the SoundTouch CLI Contributors",
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
},
},
Flags: CommonFlags,
+337 -92
View File
@@ -5,12 +5,16 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"time"
@@ -21,101 +25,281 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
)
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
commit = setting.Value
case "vcs.time":
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
date = t.Format("2006-01-02_15:04:05")
}
}
}
}
}
func main() {
config := loadConfig()
ds := initDataStore(config.dataDir)
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody)
updateBuildInfo()
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
app := &cli.App{
Name: "soundtouch-service",
Usage: "Local service for Bose SoundTouch cloud emulation and management",
Description: ` A local server that emulates Bose cloud services (BMX, Marge).
It enables offline operation, device migration, and HTTP interaction recording.`,
Version: version,
Authors: []*cli.Author{
{
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "HTTP port to bind the service to",
Value: "8000",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "target-url",
Usage: "URL for Python-based service components (legacy)",
Value: "http://localhost:8001",
EnvVars: []string{"PYTHON_BACKEND_URL", "TARGET_URL"},
},
&cli.StringFlag{
Name: "data-dir",
Usage: "Directory for persistent data",
Value: "data",
EnvVars: []string{"DATA_DIR"},
},
&cli.StringFlag{
Name: "server-url",
Aliases: []string{"s"},
Usage: "External URL of this service",
EnvVars: []string{"SERVER_URL"},
},
&cli.StringFlag{
Name: "https-port",
Usage: "HTTPS port to bind the service to",
Value: "8443",
EnvVars: []string{"HTTPS_PORT"},
},
&cli.StringFlag{
Name: "https-server-url",
Aliases: []string{"S"},
Usage: "External HTTPS URL",
EnvVars: []string{"HTTPS_SERVER_URL"},
},
&cli.BoolFlag{
Name: "redact-logs",
Usage: "Redact sensitive data in proxy logs",
Value: true,
EnvVars: []string{"REDACT_PROXY_LOGS"},
},
&cli.BoolFlag{
Name: "log-bodies",
Usage: "Log full request/response bodies",
EnvVars: []string{"LOG_PROXY_BODY"},
},
&cli.BoolFlag{
Name: "record-interactions",
Usage: "Record HTTP interactions to disk",
Value: true,
EnvVars: []string{"RECORD_INTERACTIONS"},
},
&cli.StringFlag{
Name: "discovery-interval",
Usage: "Device discovery interval",
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
ds := initDataStore(config.dataDir)
// Load settings from datastore
persisted, err := ds.GetSettings()
settingsExist := err == nil && persisted.ServerURL != ""
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
if persisted.ProxyURL != "" {
config.targetURL = persisted.ProxyURL
}
if persisted.HTTPServerURL != "" {
config.httpsServerURL = persisted.HTTPServerURL
}
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
if !settingsExist {
log.Printf("Creating default settings.json in %s", config.dataDir)
persisted.ServerURL = config.serverURL
persisted.ProxyURL = config.targetURL
persisted.HTTPServerURL = config.httpsServerURL
persisted.RedactLogs = config.redact
persisted.LogBodies = config.logBody
persisted.RecordInteractions = config.record
persisted.DiscoveryInterval = config.discoveryInterval.String()
persisted.DiscoveryEnabled = true
persisted.Shortcuts = map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
}
_ = ds.SaveSettings(persisted)
}
// Recalculate domains if settings changed
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
}
recorder := proxy.NewRecorder(config.dataDir)
recorder.Redact = config.redact
patternsPath := filepath.Join(config.dataDir, "patterns.json")
patterns, err := proxy.LoadPatterns(patternsPath)
if err != nil {
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
}
if len(patterns) == 0 {
log.Printf("Creating default patterns at %s", patternsPath)
patterns = proxy.DefaultPatterns()
patternsData, jsonErr := json.MarshalIndent(patterns, "", " ")
if jsonErr != nil {
log.Printf("Warning: Failed to marshal default patterns: %v", jsonErr)
} else {
_ = os.WriteFile(patternsPath, patternsData, 0644)
}
}
if len(patterns) > 0 {
recorder.Patterns = patterns
}
server.SetRecorder(recorder)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
startDeviceDiscovery(server)
r := setupRouter(server, pyProxy)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
if tlsConfig != nil {
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
}
return http.ListenAndServe(config.addr, r)
},
Commands: []*cli.Command{
{
Name: "version",
Aliases: []string{"v"},
Usage: "Show detailed version information",
Action: showVersionInfo,
},
},
}
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody)
startDeviceDiscovery(server)
r := setupRouter(server, pyProxy)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
if tlsConfig != nil {
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
log.Fatal(http.ListenAndServe(config.addr, r))
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
}
type serviceConfig struct {
port string
bindAddr string
addr string
targetURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
domains []string
port string
bindAddr string
addr string
targetURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
discoveryInterval time.Duration
domains []string
}
func loadConfig() serviceConfig {
port := os.Getenv("PORT")
if port == "" {
port = "8000"
}
bindAddr := os.Getenv("BIND_ADDR")
func loadConfig(c *cli.Context) serviceConfig {
port := c.String("port")
bindAddr := c.String("bind")
addr := bindAddr + ":" + port
if bindAddr == "" {
addr = ":" + port
}
targetURL := os.Getenv("PYTHON_BACKEND_URL")
if targetURL == "" {
targetURL = "http://localhost:8001"
}
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "data"
}
serverURL := os.Getenv("SERVER_URL")
if serverURL == "" {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
serverURL = "http://" + strings.ToLower(hostname) + ":" + port
}
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
httpsPort = "8443"
}
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
httpsServerURL := os.Getenv("HTTPS_SERVER_URL")
if httpsServerURL == "" {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
httpsServerURL = "https://" + strings.ToLower(hostname) + ":" + httpsPort
}
targetURL := c.String("target-url")
dataDir := c.String("data-dir")
hostname, _ := os.Hostname()
if hostname == "" {
@@ -124,6 +308,56 @@ func loadConfig() serviceConfig {
hostname = strings.ToLower(hostname)
serverURL := c.String("server-url")
if serverURL == "" {
serverURL = "http://" + hostname + ":" + port
}
httpsPort := c.String("https-port")
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
httpsServerURL := c.String("https-server-url")
if httpsServerURL == "" {
httpsServerURL = "https://" + hostname + ":" + httpsPort
}
domains := getDomains(serverURL, httpsServerURL, hostname)
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
record := c.Bool("record-interactions")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", discoveryIntervalStr, err)
discoveryInterval = 5 * time.Minute
}
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
targetURL: targetURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
discoveryInterval: discoveryInterval,
domains: domains,
}
}
func getDomains(serverURL, httpsServerURL, hostname string) []string {
domainsMap := map[string]bool{
"streaming.bose.com": true,
"updates.bose.com": true,
@@ -149,19 +383,7 @@ func loadConfig() serviceConfig {
domains = append(domains, d)
}
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
targetURL: targetURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: os.Getenv("REDACT_PROXY_LOGS") != "false",
logBody: os.Getenv("LOG_PROXY_BODY") == "true",
domains: domains,
}
return domains
}
func initDataStore(dataDir string) *datastore.DataStore {
@@ -182,7 +404,7 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager {
return cm
}
func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseProxy {
func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("Failed to parse target URL: %v", err)
@@ -197,6 +419,8 @@ func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseP
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogResponse(res)
return nil
@@ -208,6 +432,8 @@ func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseP
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogRequest(req)
}
@@ -217,8 +443,12 @@ func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseP
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
server.DiscoverDevices(context.Background())
time.Sleep(5 * time.Minute)
currentInterval, enabled := server.GetDiscoverySettings()
if enabled {
server.DiscoverDevices(context.Background())
}
time.Sleep(currentInterval)
}
}()
}
@@ -227,6 +457,8 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
@@ -237,6 +469,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/media/*", server.HandleMedia())
r.Get("/web/*", server.HandleWeb())
r.Get("/docs/*", server.HandleDocs)
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
@@ -270,21 +503,33 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
+4
View File
@@ -1,2 +1,6 @@
accounts/
certs/
default/
interactions/
patterns.json
settings.json
+24 -76
View File
@@ -1,8 +1,11 @@
// Package soundtouch provides a comprehensive Go library and CLI tool for controlling Bose SoundTouch devices.
// Package soundtouch provides a comprehensive Go library, CLI tool, and local service for controlling and emulating Bose SoundTouch devices.
//
// This library implements the complete Bose SoundTouch Web API, enabling programmatic control
// This project implements the complete Bose SoundTouch Web API, enabling programmatic control
// of SoundTouch speakers including playback control, volume management, source selection,
// multiroom zone management, and real-time event monitoring via WebSocket connections.
// multiroom zone management, and real-time event monitoring.
//
// It also provides a local service (`soundtouch-service`) that can emulate the Bose Cloud,
// allowing for offline control and enhanced debugging through HTTP interaction recording.
//
// # Quick Start
//
@@ -41,63 +44,20 @@
// if err != nil {
// log.Fatal(err)
// }
//
// // Set volume
// err = client.SetVolume(50)
// if err != nil {
// log.Fatal(err)
// }
// }
//
// # Device Discovery
// # SoundTouch Service
//
// Automatically discover SoundTouch devices on your network:
// The `soundtouch-service` provides several advanced features:
//
// import "github.com/gesellix/bose-soundtouch/pkg/discovery"
// - Bose Cloud Emulation: Allows speakers to work without an internet connection.
// - HTTP Interaction Recording: Captures all traffic as IntelliJ-compatible .http files.
// - Speaker Migration: Automated tools to redirect speakers to the local service.
// - Web Interface: A management dashboard for proxy settings and speaker setup.
//
// // Discover devices using UPnP/SSDP
// service := discovery.NewService(5*time.Second)
// devices, err := service.DiscoverDevices(ctx)
// if err != nil {
// log.Fatal(err)
// }
// Install the service:
//
// for _, device := range devices {
// fmt.Printf("Found device: %s at %s\n", device.Name, device.Host)
// }
//
// # Real-time Events
//
// Monitor device state changes in real-time using WebSocket connections:
//
// // Subscribe to device events
// events, err := client.SubscribeToEvents(ctx)
// if err != nil {
// log.Fatal(err)
// }
//
// for event := range events {
// switch e := event.(type) {
// case *models.NowPlayingUpdated:
// fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
// case *models.VolumeUpdated:
// fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
// }
// }
//
// # Multiroom Zone Management
//
// Create and manage multiroom zones:
//
// // Create a zone with multiple speakers
// zone := &models.Zone{
// Master: "192.168.1.100",
// Members: []models.ZoneMember{
// {IPAddress: "192.168.1.101"},
// {IPAddress: "192.168.1.102"},
// },
// }
// err = client.SetZone(zone)
// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
//
// # CLI Tool
//
@@ -111,45 +71,33 @@
//
// # Control a device
// soundtouch-cli --host 192.168.1.100 play start
// soundtouch-cli --host 192.168.1.100 volume set --level 50
// soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
//
// # Supported Features
//
// - ✅ Device Information & Capabilities
// - ✅ Playback Control (Play/Pause/Stop/Next/Previous)
// - ✅ Volume, Bass, and Balance Control
// - ✅ Source Selection (Spotify, Bluetooth, AUX, etc.)
// - ✅ Preset Management
// - ✅ Clock/Time Management
// - ✅ Network Information
// - ✅ Playback, Volume, Bass, and Balance Control
// - ✅ Source Selection & Preset Management
// - ✅ Real-time WebSocket Events
// - ✅ Multiroom Zone Management
// - ✅ Device Discovery (UPnP/SSDP and mDNS)
// - ✅ Cross-platform Support (Windows, macOS, Linux)
// - ✅ Local Cloud Emulation (soundtouch-service)
// - ✅ HTTP Traffic Recording & Sanitization
// - ✅ Automated Speaker Migration & Revert
//
// # Package Structure
//
// - client: HTTP client for SoundTouch Web API
// - discovery: Device discovery using UPnP/SSDP and mDNS
// - models: Data structures for API requests/responses
// - config: Configuration management
// - service: Core logic for the soundtouch-service (proxy, recording, setup)
// - cmd/soundtouch-cli: Command-line interface tool
//
// # Hardware Compatibility
//
// This library has been tested with real Bose SoundTouch hardware and supports
// all SoundTouch-compatible devices including:
// - SoundTouch 10, 20, 30 series
// - SoundTouch Portable
// - Wave SoundTouch music system
// - And other SoundTouch-enabled Bose speakers
// - cmd/soundtouch-service: Local cloud emulation service
//
// # Implementation Notes
//
// This implementation is based on the official Bose SoundTouch Web API documentation
// and provides 90% coverage of all available endpoints. It is an independent project
// and is not affiliated with or endorsed by Bose Corporation.
// This project is an independent effort to preserve the functionality of Bose SoundTouch
// devices and provide enhanced debugging and control capabilities. It is not
// affiliated with or endorsed by Bose Corporation.
//
// For detailed API documentation, examples, and advanced usage patterns, visit:
// https://pkg.go.dev/github.com/gesellix/bose-soundtouch
+29 -3
View File
@@ -1,15 +1,41 @@
services:
soundtouch-service:
build: .
image: ghcr.io/gesellix/bose-soundtouch:latest
# build: .
container_name: soundtouch-service
# network_mode: host # Linux only, required for discovery
# Linux only, required for discovery. Swarm requires host network at the task level.
# network_mode: host
ports:
- "8000:8000"
- "8443:8443"
environment:
- PORT=8000
- HTTPS_PORT=8443
- DATA_DIR=/app/data
- LOG_PROXY_BODY=false
- REDACT_PROXY_LOGS=true
- RECORD_INTERACTIONS=true
- DISCOVERY_INTERVAL=5m
- SERVER_URL=http://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8000
- HTTPS_SERVER_URL=https://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8443
volumes:
- ./data:/app/data
- soundtouch-data:/app/data
# Use host volume for local development if preferred:
# - ./data:/app/data
restart: unless-stopped
deploy:
replicas: 1
restart_policy:
condition: on-failure
resources:
limits:
cpus: '0.50'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes:
soundtouch-data:
# Named volumes are preferred in Swarm. For multi-node persistence,
# consider using a volume driver like NFS or GlusterFS.
+2 -3
View File
@@ -4,9 +4,9 @@
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
- **[PLAN.md](PLAN.md)** - Project planning and roadmap
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
## Development Guidelines
@@ -95,4 +95,3 @@ When creating test data for API endpoints, prefer real device responses over hyp
- **Documentation**: Completely in English for international accessibility
- Conduct regular code reviews
- Consider performance from the beginning
+4 -3
View File
@@ -195,8 +195,9 @@ soundtouch-cli --host 192.168.1.100 source internet-radio \
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
- [Content Selection Example](/examples/content-selection/)
- [CLI Reference](/docs/CLI-REFERENCE.md)
- [Content Selection Example](../examples/content-selection/README.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](../examples/content-selection/)
## ✅ Verification
@@ -208,4 +209,4 @@ This implementation has been verified to:
5. ✅ Include complete documentation and examples
6. ✅ Maintain backward compatibility
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
+1 -1
View File
@@ -74,7 +74,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
### "IsItBose" Validation Failures
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
### Disappearing Sources (TuneIn/Local Radio)
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
+1 -1
View File
@@ -895,4 +895,4 @@ For additional help:
---
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](PRESET-MANAGEMENT.md).*
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
+5 -5
View File
@@ -332,14 +332,14 @@ soundtouch-cli --host 192.168.1.100 info
## Next Steps
- 📖 [Complete CLI Reference](CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](preset-store.md)
- 📡 [WebSocket Events Documentation](websocket-events.md)
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
- 💻 [Preset Management Example](../examples/preset-management/)
- 📚 [API Endpoints Overview](API-Endpoints-Overview.md)
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
## Need Help?
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues)
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
- ❓ **Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
- ❓ **Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
+32
View File
@@ -0,0 +1,32 @@
# Bose SoundTouch Toolkit Documentation
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
## 📖 Quick Links
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Getting Started](guides/GETTING-STARTED.md)
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
## 🗂 Documentation Structure
### User Guides
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
- [HTTPS Setup](guides/HTTPS-SETUP.md)
- [Deployment Guide](guides/DEPLOYMENT.md)
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
- [Troubleshooting](guides/TROUBLESHOOTING.md)
### Technical Reference
- [API Endpoints](reference/API-ENDPOINTS.md)
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
- [Zone Management](reference/ZONE-MANAGEMENT.md)
- [Preset Management](reference/PRESET-MANAGEMENT.md)
### Analysis & Research
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
For a complete list of all documents, see the [Summary](SUMMARY.md).
+2 -2
View File
@@ -23,7 +23,7 @@ This document summarizes the implementation of the `/serviceAvailability` endpoi
### Modified Files
1. **`pkg/client/client.go`** - Added `GetServiceAvailability()` method
2. **`docs/API-Endpoints-Overview.md`** - Updated implementation status
2. **`docs/reference/API-ENDPOINTS.md`** - Updated implementation status
3. **`docs/UNIMPLEMENTED-ENDPOINTS.md`** - Marked as implemented
## API Interface
@@ -263,4 +263,4 @@ BenchmarkGetServiceAvailability-8 1000 1.2ms/op
**Performance benchmarks established**
**Error handling verified**
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
+8 -8
View File
@@ -144,10 +144,10 @@ LOG_PROXY_BODY=true soundtouch-service
## 📚 Documentation
- **[Complete Service Guide](SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
## 🤝 Contributing
@@ -172,9 +172,9 @@ The collaborative spirit of reverse engineering and documentation in the SoundTo
## 🔗 Links
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
- **[Service Documentation](SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](CLI-REFERENCE.md)**
- **[Getting Started Guide](GETTING-STARTED.md)**
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
@@ -187,4 +187,4 @@ go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
soundtouch-service
```
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
+64
View File
@@ -0,0 +1,64 @@
# Table of Contents
* [Introduction](README.md)
## User Guides
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
* [CLI Reference](guides/CLI-REFERENCE.md)
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
* [Troubleshooting](guides/TROUBLESHOOTING.md)
* [Useful Links](#useful-links)
### Useful Links
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
* [CLI Reference](guides/CLI-REFERENCE.md)
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
* [Discovery](reference/DISCOVERY.md)
* [Zone Management](reference/ZONE-MANAGEMENT.md)
* [Preset Management](reference/PRESET-MANAGEMENT.md)
* [Source Selection](reference/SOURCE-SELECTION.md)
* [Volume Controls](reference/VOLUME-CONTROLS.md)
* [Bass Controls](reference/BASS-CONTROLS.md)
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
* [Supported URLs](analysis/SUPPORTED-URLS.md)
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
* [Device Logging](DEVICE-LOGGING.md)
* [Feature History](FEATURE_HISTORY.md)
* [Host/Port Parsing](HOST-PORT-PARSING.md)
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
* [Navigation Guide](NAVIGATION-GUIDE.md)
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
* [Preset Quickstart](PRESET-QUICKSTART.md)
* [Project Patterns](PROJECT-PATTERNS.md)
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
* [Preset Store](preset-store.md)
+11
View File
@@ -0,0 +1,11 @@
title: Bose SoundTouch Toolkit
description: Documentation for controlling and preserving Bose SoundTouch devices
remote_theme: pages-themes/minimal@v0.2.0
plugins:
- jekyll-remote-theme
- jekyll-relative-links
relative_links:
enabled: true
collections: true
include:
- SUMMARY.md
+1 -1
View File
@@ -784,4 +784,4 @@ docker-compose up # Mock devices + web app
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [Go Embed Directive](https://pkg.go.dev/embed)
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
- [PROJECT-PATTERNS.md](./PROJECT-PATTERNS.md) - Detailed pattern documentation
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
+6 -6
View File
@@ -206,14 +206,14 @@ This project implements a comprehensive Go client library and CLI tool for Bose
### ✅ Complete Documentation
- `README.md` - Project overview and usage examples ✅
- `docs/API-Endpoints-Overview.md` - API reference with status ✅
- `docs/KEY-CONTROLS.md` - Media control implementation ✅
- `docs/VOLUME-CONTROLS.md` - Volume management guide ✅
- `docs/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
- `docs/reference/API-ENDPOINTS.md` - API reference with status ✅
- `docs/reference/KEY-CONTROLS.md` - Media control implementation ✅
- `docs/guides/VOLUME-CONTROLS.md` - Volume management guide ✅
- `docs/reference/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
- `docs/HOST-PORT-PARSING.md` - Enhanced CLI feature ✅
- `docs/PLAN.md` - Development roadmap (updated) ✅
- `docs/archive/PLAN.md` - Development roadmap (updated) ✅
- `docs/PROJECT-PATTERNS.md` - Development guidelines ✅
- `SPEAKER_ENDPOINT.md` - Complete speaker notification documentation ✅
- `docs/reference/SPEAKER-ENDPOINT.md` - Complete speaker notification documentation ✅
### 📝 Documentation Notes
- All docs are synchronized with current implementation
@@ -1248,6 +1248,6 @@ SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
## See Also
- [Getting Started Guide](GETTING-STARTED.md) - Basic setup and usage
- [WebSocket Events](websocket-events.md) - Real-time monitoring
- [Zone Management](zone-management.md) - Multi-room setup
- [API Endpoints](API-Endpoints-Overview.md) - Complete API reference
- [WebSocket Events](../reference/WEBSOCKET-EVENTS.md) - Real-time monitoring
- [Zone Management](../reference/ZONE-MANAGEMENT.md) - Multi-room setup
- [API Endpoints](../reference/API-ENDPOINTS.md) - Complete API reference
@@ -13,6 +13,10 @@ This guide covers everything you need to know to deploy robust, scalable SoundTo
- [Performance Optimization](#performance-optimization)
- [Error Handling Recovery](#error-handling-recovery)
- [Deployment Strategies](#deployment-strategies)
- [Docker Deployment](#docker-deployment)
- [Kubernetes Deployment](#kubernetes-deployment)
- [Systemd Service](#systemd-service)
- [Raspberry Pi Installer](#raspberry-pi-installer)
- [Maintenance Operations](#maintenance-operations)
---
@@ -926,39 +930,49 @@ data:
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
```
### Systemd Service
#### Systemd Service
A standard systemd unit for manual installation. This example assumes the binary is at `/usr/local/bin/soundtouch-service` and data is stored in `/var/lib/soundtouch-service`.
```ini
# /etc/systemd/system/soundtouch.service
# /etc/systemd/system/soundtouch-service.service
[Unit]
Description=SoundTouch Control Service
After=network.target
Wants=network.target
Description=Bose SoundTouch Service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=soundtouch
Group=soundtouch
WorkingDirectory=/opt/soundtouch
ExecStart=/opt/soundtouch/bin/soundtouch-app
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
Environment=DEVICE_HOSTS=192.168.1.100,192.168.1.101
Environment=LOG_LEVEL=info
Environment=CONFIG_FILE=/opt/soundtouch/config/production.yaml
WorkingDirectory=/var/lib/soundtouch-service
ExecStart=/usr/local/bin/soundtouch-service
Environment=PORT=80
Environment=SERVER_URL=http://soundtouch.local
# Security settings
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/soundtouch/logs
# Allow binding to privileged ports (80/443) without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Restart=on-failure
RestartSec=5
# Security hardening
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/soundtouch-service
[Install]
WantedBy=multi-user.target
```
#### Raspberry Pi Installer
For users deploying on a Raspberry Pi, we provide a specialized automated installer that handles everything from architecture detection to security hardening.
See the [Raspberry Pi Installation Guide](RASPBERRY-PI.md) for step-by-step instructions.
---
## Maintenance Operations
@@ -1071,4 +1085,4 @@ func init() {
// Set GC target percentage
if os.Getenv("GOGC") == "" {
debug.SetGCPerc
debug.SetGCPerc
+41
View File
@@ -0,0 +1,41 @@
### Professional Migration & Safety Guide
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
#### 🛠 Technical Safety Enhancements
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
#### 📋 Professional Migration Checklist
Before you proceed with the actual migration, follow these steps:
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
- Create an empty file named `remote_services` on a USB stick.
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
- Reboot the speaker (unplug and replug).
- The speaker will now allow SSH connections as `root` with no password.
- **Verify**: Run `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>` to confirm access. (Note: older devices may require enabling `ssh-rsa` support).
2. **Network Isolation (Optional but Recommended)**: Ensure the device is on a stable wired connection if possible, or a dedicated 2.4GHz SSID to avoid drops during SSH operations.
3. **Initial Discovery & Sync**:
- Run `soundtouch-cli discover devices` to ensure connectivity.
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
If something goes wrong or you want to return to the original Bose cloud services:
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
By using the built-in off-device backups and pre-flight checks, the risk of "bricking" or losing configuration during the transition is significantly reduced.
+69
View File
@@ -0,0 +1,69 @@
# Raspberry Pi Installation Guide
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
## Automated Installer
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
### Features
* **Automatic start on boot**: Installs a systemd unit.
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
* **Easy Updates**: Re-running the script updates the binary to the latest version.
### Installation Steps
1. **Download the installer**:
```bash
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
```
2. **Run with sudo**:
```bash
sudo bash install.sh
```
### Overriding Defaults
You can customize the installation using environment variables:
```bash
sudo \
VERSION=v0.17.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
bash install.sh
```
### Updating the Service
To update the service to a specific version, run the installer with the version as an argument:
```bash
sudo bash install.sh v0.18.1
```
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
## Management
Once installed, use standard `systemctl` commands to manage the service:
```bash
# Check status
systemctl status soundtouch-service
# Follow logs
journalctl -u soundtouch-service -f
# Restart
sudo systemctl restart soundtouch-service
```
## Configuration
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
For more details, see the [scripts/raspberry-pi/README.md](../../scripts/raspberry-pi/README.md) in the repository.
@@ -11,6 +11,7 @@ The service provides:
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
@@ -49,10 +50,67 @@ cd Bose-SoundTouch
go build -o soundtouch-service ./cmd/soundtouch-service
```
### Docker (coming soon)
### Docker Support
You can run the SoundTouch service using Docker or Docker Compose.
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
#### Using Docker
**Linux (with host networking for discovery):**
```bash
# Docker support planned for future release
docker run -p 8000:8000 gesellix/soundtouch-service
docker run -d \
--name soundtouch-service \
--network host \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
**macOS / Windows (with port mapping):**
```bash
docker run --rm -it \
-p 8000:8000 -p 8443:8443 \
-v $(pwd)/data:/app/data \
--env SERVER_URL=http://soundtouch.local:8000 \
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
ghcr.io/gesellix/bose-soundtouch:latest
```
> **Note**: The hostnames configured via `SERVER_URL` and `HTTPS_SERVER_URL` are automatically added as Subject Alternative Names (SAN) to the generated TLS certificate, ensuring valid SSL connections.
#### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
services:
soundtouch-service:
image: ghcr.io/gesellix/bose-soundtouch:latest
container_name: soundtouch-service
# Linux users: use host networking for device discovery
# network_mode: host
# macOS/Windows users: use port mapping (discovery will be manual)
ports:
- "8000:8000"
- "8443:8443"
environment:
- PORT=8000
- SERVER_URL=http://soundtouch.local:8000
- HTTPS_SERVER_URL=https://soundtouch.local:8443
- DATA_DIR=/app/data
volumes:
- soundtouch-data:/app/data
restart: unless-stopped
volumes:
soundtouch-data:
```
And run:
```bash
docker-compose up -d
```
## Quick Start
@@ -78,17 +136,32 @@ Use the web interface or API to migrate devices from Bose cloud services to your
## Configuration
The service can be configured via environment variables or command-line flags:
### Configuration Precedence
| Variable | Flag | Description | Default |
|----------|------|-------------|---------|
| `PORT` | `--port` | Port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url` | External URL of this service | `http://<hostname>:8000` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
The service supports multiple ways to configure its behavior. When multiple sources provide the same setting, the following precedence rules apply (highest to lowest):
1. **`settings.json`**: Settings saved via the Web UI (stored in the data directory) take the highest precedence. This ensures that changes made in the browser persist across service restarts even if environment variables or flags change.
2. **Environment Variables / CLI Flags**: If a setting is not present in `settings.json`, environment variables and flags are used.
3. **Default Values**: If no configuration is provided, the service uses its built-in defaults.
> **Tip**: If you find that changes to environment variables are not taking effect, check the **Settings** tab in the Web UI or inspect the `settings.json` file in your data directory, as it might be overriding your manual configuration.
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|--------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
@@ -302,6 +375,13 @@ The web management interface provides a comprehensive dashboard for managing you
- **Statistics Dashboard**: Usage and error analytics
- **Debug Tools**: Device communication testing utilities
#### Interactions & Traffic Analysis
- **Traffic Overview**: View aggregate request counts for self-handled and proxied traffic.
- **Session Browsing**: Browse recorded interactions grouped by session.
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
### Usage Tips
1. **First Time Setup**: The interface will guide you through initial device discovery
@@ -309,6 +389,49 @@ The web management interface provides a comprehensive dashboard for managing you
3. **Troubleshooting**: Use the debug tools to diagnose device connectivity issues
4. **Log Analysis**: Enable detailed logging for development and troubleshooting
## HTTP Interaction Recording
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
### Key Features
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
- **Chronological Order**: Files are prefixed with a sequential number (e.g., `0001-`, `0002-`) to preserve the exact order of requests across the entire session.
- **Path-Based Structure**: Recordings are organized into subdirectories based on their URL path for better discoverability.
- **Automatic Sanitization**: Variable path segments like IP addresses, Device IDs, and Account IDs are automatically identified and replaced with placeholders (e.g., `{{ip}}`, `{{deviceId}}`). The original values are preserved as comments at the top of the recorded `.http` files for easy identification.
- **Re-playability**: An `http-client.env.json` file is generated for each session, allowing you to re-play the recorded requests immediately in IntelliJ IDEA.
- **Management UI**: The **5. Interactions** tab provides a built-in viewer and management tools for all recorded data.
### Configuration
#### Redaction
By default, the service redacts sensitive information from the recorded `.http` files, including:
- `Authorization` headers
- `Cookie` headers
- `X-Bose-Token` headers
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
#### Custom Patterns
The service uses regex patterns to identify variable segments in URL paths. These patterns are loaded from `data/patterns.json`. You can add custom patterns to this file to support additional variable segments:
```json
[
{
"name": "MyVariable",
"regexp": "^[0-9]{5}$",
"replacement": "{myVar}"
}
]
```
Variables found via these patterns will be:
1. Used as directory names in the `interactions/` folder.
2. Parameterized as `{{myVar}}` within the `.http` files.
3. Added to the `http-client.env.json` file with their actual values.
## Persistent Data
### Data Directory Structure
@@ -327,6 +450,15 @@ data/
│ ├── Sources.xml
│ ├── Presets.xml
│ └── Recents.xml
├── interactions/
│ └── {SESSION_ID}/
│ ├── self/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ ├── upstream/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ └── http-client.env.json
├── stats/
│ ├── usage/
│ │ └── *.json
@@ -355,6 +487,14 @@ data/
#### Events (`events/`)
- **device_events_*.log**: Device event history and debugging logs
#### HTTP Interactions (`interactions/`)
- **{SESSION_ID}/**: A unique directory per server run (format: `YYYYMMDD-HHMMSS-PID`).
- **self/**: Requests handled directly by the service.
- **upstream/**: Requests proxied to external Bose services.
- **{PATH}/**: Nested subdirectories reflecting the URL path (sanitized).
- **http-client.env.json**: IntelliJ IDEA HTTP Client environment file with session variables.
- **{SEQ}-{TIME}-{METHOD}.http**: Individual interaction recordings in standard HTTP Client format.
### Data Management
#### Backup Strategy
@@ -383,6 +523,49 @@ find data/events/ -name "*.log" -mtime +30 -delete
find data/stats/ -name "*.json" -mtime +90 -delete
```
## API Endpoints
### Management UI
- **URL**: `http://localhost:8000/` or `http://localhost:8000/web/`
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
- `POST /setup/devices`: Manually add a device by IP.
- `POST /setup/discover`: Trigger a new network discovery scan.
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
Lists recorded interactions with optional filtering.
**Query Parameters:**
- `session`: Filter by session ID (optional)
- `category`: Filter by category (`self` or `upstream`) (optional)
- `since`: Filter by timestamp (e.g., `2026-02-15 15:00:00`) (optional)
#### `GET /setup/interaction-stats`
Returns aggregate statistics about recorded interactions across all sessions.
#### `GET /setup/interaction-content?file={path}`
Returns the raw content of a specific recorded `.http` file.
#### `DELETE /setup/interactions/sessions/{sessionID}`
Deletes all recordings associated with a specific session.
#### `DELETE /setup/interactions/sessions?keep={N}`
Bulk cleanup: deletes all but the most recent `N` sessions.
### Emulated Services
- `/bmx/registry/v1/services`: BMX service registry.
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
- `/marge/accounts/*`: Account and device management.
- `/marge/updates/soundtouch`: Software update emulation.
- `/proxy/*`: Logging proxy for original Bose services.
## Troubleshooting
### Common Issues
+85
View File
@@ -0,0 +1,85 @@
### Bose Cloud Shutdown: Survival Guide for SoundTouch
With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service.
This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers.
---
### Supported Use Cases
1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks.
2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`.
3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers.
4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally.
---
### Setup Steps
To set up your SoundTouch system for local-only operation, follow these steps:
#### 1. Install and Start the Service
Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network.
```bash
# Install the service
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
# Start the service (defaults to http://localhost:8000)
soundtouch-service
```
#### 2. Access the Management UI
Open your web browser and navigate to the service's web interface:
`http://<your-server-ip>:8000/`, e.g. `http://localhost:8000/`
*Note: The service also supports a `/web/` path for management.*
#### 3. Enable SSH on Your Speakers
To migrate your speakers, the service needs SSH access. You can enable it by:
1. Creating an empty file named `remote_services` on a USB stick.
2. Inserting the USB stick into the SoundTouch speaker's service port.
3. Rebooting the speaker (unplug/replug).
**Verify SSH Access:**
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
Once enabled, you can log in as `root` (no password).
#### 4. Setup Through the Web UI
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
#### 5. Verify Your Local Data
Once migrated, your speaker will use the data captured during the Sync step.
* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices/<SERIAL>/`).
* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files.
---
### Comparison with other implementations (soundcork)
Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages:
- **Web UI**: Integrated management interface for discovery and migration.
- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`.
- **Automated SSL**: Handles Root CA injection automatically for secure communication.
- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration.
---
### Alternative: DNS Redirection (No SSH)
If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP:
* `bmx.bose.com`
* `streaming.bose.com`
* `updates.bose.com`
* `stats.bose.com`
* `content.api.bose.io`
*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.*
---
@@ -418,10 +418,10 @@ soundtouch-cli -host <discovered-ip> -bass # Verify final state
## Related Documentation
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Volume Controls](VOLUME-CONTROLS.md)** - Related audio control documentation
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[Models](../pkg/models/bass.go)** - Bass model implementation
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[Models](../../pkg/models/bass.go)** - Bass model implementation
## API Compliance
@@ -443,4 +443,4 @@ The implementation follows the official SoundTouch API:
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
**API Compliance**: Full compliance with SoundTouch Web API specification
**API Compliance**: Full compliance with SoundTouch Web API specification
@@ -366,7 +366,7 @@ This implementation now provides the full preset management lifecycle:
## Related Documentation
- [API Endpoints Overview](API-Endpoints-Overview.md) - Complete API reference
- [API Endpoints Overview](API-ENDPOINTS.md) - Complete API reference
- [Volume Controls](VOLUME-CONTROLS.md) - Volume management
- [Key Controls](KEY-CONTROLS.md) - Media control commands
- [Source Selection](SOURCE-SELECTION.md) - Audio source management
@@ -375,4 +375,4 @@ This implementation now provides the full preset management lifecycle:
Preset management in the Bose SoundTouch API is **intentionally read-only** by design. The API provides excellent capabilities for analyzing and understanding preset configurations, but preset creation must be done through official channels (app or device). This is a deliberate design decision that respects user control over their personal preset configurations.
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
@@ -345,13 +345,13 @@ The implementation follows the official SoundTouch API:
## Related Documentation
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
- **[Sources](../pkg/models/sources.go)** - Source model implementation
- **[Now Playing](../pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Sources](../../pkg/models/sources.go)** - Source model implementation
- **[Now Playing](../../pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
---
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
**Real Device Validation**: SoundTouch 10, SoundTouch 20
+1 -1
View File
@@ -149,4 +149,4 @@ After configuring accounts:
3. Use `browse` commands to explore content
4. Use `play` commands to start playback
See the [CLI Reference](../../docs/CLI-REFERENCE.md) for complete documentation.
See the [CLI Reference](../../docs/guides/CLI-REFERENCE.md) for complete documentation.
+2 -2
View File
@@ -176,5 +176,5 @@ The example gracefully handles missing services:
## Related Documentation
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [CLI Reference](../../docs/CLI-REFERENCE.md)
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md)
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md)
+4 -4
View File
@@ -177,11 +177,11 @@ if err != nil {
This introspect data is useful before:
- [Preset Management](../preset-management/) - Verify service state before storing presets
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Check capabilities before switching sources
- [Zone Management](../../docs/zone-management.md) - Ensure all devices support the service
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Check capabilities before switching sources
- [Zone Management](../../docs/reference/ZONE-MANAGEMENT.md) - Ensure all devices support the service
## API Documentation
For complete API documentation, see:
- [API Reference](../../docs/API-Endpoints-Overview.md)
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
+4 -4
View File
@@ -275,10 +275,10 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 info
## Related Documentation
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Browse and station commands
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md) - Comprehensive navigation documentation
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Browse and station commands
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md) - Comprehensive navigation documentation
- [Navigation API Reference](../../docs/API-NAVIGATION-REFERENCE.md) - Technical API details
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
## Use Cases
@@ -288,4 +288,4 @@ This example demonstrates patterns for:
- **Direct Playback**: Play content without storing as presets first
- **Content Exploration**: Browse large music libraries efficiently
- **Smart Home Integration**: Programmatically start specific content
- **Personalized Experiences**: Access account-specific content from streaming services
- **Personalized Experiences**: Access account-specific content from streaming services
+4 -4
View File
@@ -256,10 +256,10 @@ Error: All preset slots are occupied
## Related Documentation
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Command-line usage
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Command-line usage
- [Preset Implementation Guide](../../docs/preset-store.md) - Technical details
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
- [API Reference](../../docs/API-Endpoints-Overview.md) - Complete API documentation
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
- [API Reference](../../docs/reference/API-ENDPOINTS.md) - Complete API documentation
## Use Cases
@@ -269,4 +269,4 @@ This example demonstrates patterns for:
- **Music Management**: Organize favorite content into quick-access presets
- **Family Scenarios**: Each person gets their own preset slots
- **Party Mode**: Pre-configure playlists for different moods
- **Radio Favorites**: Save frequently listened radio stations
- **Radio Favorites**: Save frequently listened radio stations
+5 -5
View File
@@ -252,8 +252,8 @@ go run main.go -host 192.168.1.100 -type unknown
This recents data is useful for:
- [Preset Management](../preset-management/) - Finding presetable content to save
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Understanding usage patterns
- [Navigation](../../docs/NAVIGATION-GUIDE.md) - Quickly accessing recently played content
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Understanding usage patterns
- [Navigation](../../docs/guides/SURVIVAL-GUIDE.md) - Quickly accessing recently played content
## Related CLI Commands
@@ -274,6 +274,6 @@ soundtouch-cli --host 192.168.1.100 recents latest
## API Documentation
For complete API documentation, see:
- [API Reference](../../docs/API-Endpoints-Overview.md)
- [CLI Reference](../../docs/CLI-REFERENCE.md)
- [Recents Models](../../pkg/models/recents.go)
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
- [Recents Models](../../pkg/models/recents.go)
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/russross/blackfriday/v2 v2.1.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.47.0
)
@@ -13,7 +14,6 @@ require (
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
+15 -6
View File
@@ -160,12 +160,19 @@ type ServiceRecent struct {
// ConfiguredSource represents a configured media source with authentication details.
type ConfiguredSource struct {
DisplayName string `json:"display_name" xml:"sourcename"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"credential"`
SecretType string `json:"secret_type" xml:"credential_type,attr"`
SourceKeyType string `json:"source_key_type" xml:"sourceproviderid"`
SourceKeyAccount string `json:"source_key_account" xml:"username"`
DisplayName string `json:"display_name" xml:"displayName,attr"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"secret,attr"`
SecretType string `json:"secret_type" xml:"secretType,attr"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `json:"source_key" xml:"sourceKey"`
// Legacy fields for backward compatibility in code if needed,
// though it's better to update the code to use SourceKey.
SourceKeyType string `json:"source_key_type" xml:"-"`
SourceKeyAccount string `json:"source_key_account" xml:"-"`
}
// ServiceDeviceInfo represents information about a SoundTouch device.
@@ -177,6 +184,8 @@ type ServiceDeviceInfo struct {
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
AccountID string `json:"account_id,omitempty"`
}
// CustomerSupportDevice represents device information for customer support purposes.
+138 -114
View File
@@ -42,12 +42,12 @@ func NewDataStore(dataDir string) *DataStore {
// AccountDir returns the directory path for a specific account.
func (ds *DataStore) AccountDir(account string) string {
return filepath.Join(ds.DataDir, account)
return filepath.Join(ds.DataDir, "accounts", account)
}
// AccountDevicesDir returns the devices directory path for a specific account.
func (ds *DataStore) AccountDevicesDir(account string) string {
return filepath.Join(ds.DataDir, account, constants.DevicesDir)
return filepath.Join(ds.AccountDir(account), constants.DevicesDir)
}
// AccountDeviceDir returns the directory path for a specific device within an account.
@@ -132,7 +132,10 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
}
accDevices := ds.listDevicesInAccount(dir, acc.Name())
for _, info := range accDevices {
for i := range accDevices {
info := accDevices[i]
info.AccountID = acc.Name()
key := info.DeviceID
if key == "" {
key = info.IPAddress
@@ -151,13 +154,13 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
func (ds *DataStore) getPossibleDataDirs() []string {
dirs := []string{}
if exists(ds.DataDir) {
dirs = append(dirs, ds.DataDir)
if exists(filepath.Join(ds.DataDir, "accounts")) {
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
}
// Also check soundcork-go/data if it's different and exists
altDir := "soundcork-go/data"
if ds.DataDir != altDir && exists(altDir) {
// Also check soundcork-go/data/accounts if it's different and exists
altDir := "soundcork-go/data/accounts"
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
dirs = append(dirs, altDir)
}
@@ -219,6 +222,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
} `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod"`
}
if err := xml.Unmarshal(data, &info); err != nil {
@@ -226,9 +230,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
}
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
Name: info.Name,
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
Name: info.Name,
DiscoveryMethod: info.DiscoveryMethod,
}
for _, comp := range info.Components {
@@ -250,9 +255,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
return deviceInfo, nil
}
// GetPresets retrieves all presets for the specified account.
func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) {
path := filepath.Join(ds.AccountDir(account), constants.PresetsFile)
// GetPresets retrieves all presets for the specified account and device.
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
data, err := os.ReadFile(path)
if err != nil {
@@ -303,9 +308,9 @@ func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error)
return presets, nil
}
// SavePresets saves the preset list for the specified account.
func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) error {
path := filepath.Join(ds.AccountDir(account), constants.PresetsFile)
// SavePresets saves the preset list for the specified account and device.
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
type PresetXML struct {
ID string `xml:"id,attr"`
@@ -357,9 +362,9 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset)
return os.WriteFile(path, append(header, data...), 0644)
}
// GetRecents retrieves all recent items for the specified account.
func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) {
path := filepath.Join(ds.AccountDir(account), constants.RecentsFile)
// GetRecents retrieves all recent items for the specified account and device.
func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
data, err := os.ReadFile(path)
if err != nil {
@@ -410,9 +415,9 @@ func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error)
return recents, nil
}
// SaveRecents saves the recent items list for the specified account.
func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) error {
path := filepath.Join(ds.AccountDir(account), constants.RecentsFile)
// SaveRecents saves the recent items list for the specified account and device.
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
type RecentXML struct {
ID string `xml:"id,attr"`
@@ -494,13 +499,14 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
}
type InfoXML struct {
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
Name string `xml:"name"`
Type string `xml:"type"`
ModuleType string `xml:"moduleType"`
Components []ComponentXML `xml:"components>component"`
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
Name string `xml:"name"`
Type string `xml:"type"`
ModuleType string `xml:"moduleType"`
Components []ComponentXML `xml:"components>component"`
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
}
// Parsing product code back to type and moduleType (best effort)
@@ -539,6 +545,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
IPAddress: info.IPAddress,
},
},
DiscoveryMethod: info.DiscoveryMethod,
}
data, err := xml.MarshalIndent(ix, "", " ")
@@ -557,9 +564,9 @@ func (ds *DataStore) RemoveDevice(account, device string) error {
return os.RemoveAll(dir)
}
// GetConfiguredSources retrieves all configured sources for the specified account.
func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDir(account), constants.SourcesFile)
// GetConfiguredSources retrieves all configured sources for the specified account and device.
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := os.ReadFile(path)
if err != nil {
@@ -567,81 +574,52 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo
}
var sourcesWrap struct {
Sources []struct {
DisplayName string `xml:"displayName,attr"`
ID string `xml:"id,attr"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
SourceKey struct {
Account string `xml:"account,attr"`
Type string `xml:"type,attr"`
} `xml:"sourceKey"`
} `xml:"source"`
Sources []models.ConfiguredSource `xml:"source"`
}
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
}
var sources []models.ConfiguredSource
lastID := 100001
for _, s := range sourcesWrap.Sources {
id := s.ID
if id == "" {
id = strconv.Itoa(lastID)
lastID++
for i := range sourcesWrap.Sources {
s := &sourcesWrap.Sources[i]
if s.ID == "" {
s.ID = strconv.Itoa(100001 + i)
}
sources = append(sources, models.ConfiguredSource{
DisplayName: s.DisplayName,
ID: id,
Secret: s.Secret,
SecretType: s.SecretType,
SourceKeyType: s.SourceKey.Type,
SourceKeyAccount: s.SourceKey.Account,
})
// Sync legacy fields
s.SourceKeyType = s.SourceKey.Type
s.SourceKeyAccount = s.SourceKey.Account
}
return sources, nil
return sourcesWrap.Sources, nil
}
// SaveConfiguredSources saves the configured sources list for the specified account.
func (ds *DataStore) SaveConfiguredSources(account string, sources []models.ConfiguredSource) error {
path := filepath.Join(ds.AccountDir(account), constants.SourcesFile)
// SaveConfiguredSources saves the configured sources list for the specified account and device.
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
type sourceXML struct {
DisplayName string `xml:"displayName,attr"`
ID string `xml:"id,attr"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
SourceKey struct {
Account string `xml:"account,attr"`
Type string `xml:"type,attr"`
} `xml:"sourceKey"`
}
type sourcesWrap struct {
XMLName xml.Name `xml:"sources"`
Sources []sourceXML `xml:"source"`
XMLName xml.Name `xml:"sources"`
Sources []models.ConfiguredSource `xml:"source"`
}
wrap := sourcesWrap{}
for _, s := range sources {
sx := sourceXML{
DisplayName: s.DisplayName,
ID: s.ID,
Secret: s.Secret,
SecretType: s.SecretType,
// Ensure SourceKey is populated from legacy fields if necessary before saving
for i := range sources {
s := &sources[i]
if s.SourceKey.Type == "" && s.SourceKeyType != "" {
s.SourceKey.Type = s.SourceKeyType
}
sx.SourceKey.Account = s.SourceKeyAccount
sx.SourceKey.Type = s.SourceKeyType
wrap.Sources = append(wrap.Sources, sx)
if s.SourceKey.Account == "" && s.SourceKeyAccount != "" {
s.SourceKey.Account = s.SourceKeyAccount
}
}
wrap := sourcesWrap{
Sources: sources,
}
data, err := xml.MarshalIndent(wrap, "", " ")
@@ -661,23 +639,12 @@ func (ds *DataStore) Initialize() error {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Ensure default account exists
defaultDir := ds.AccountDir("default")
if err := os.MkdirAll(defaultDir, 0755); err != nil {
return fmt.Errorf("failed to create default account directory: %w", err)
}
// Ensure devices subdirectory for default account
if err := os.MkdirAll(ds.AccountDevicesDir("default"), 0755); err != nil {
return fmt.Errorf("failed to create default devices directory: %w", err)
}
return nil
}
// GetETagForPresets returns the ETag (modification time) for the presets file.
func (ds *DataStore) GetETagForPresets(account string) int64 {
path := filepath.Join(ds.AccountDir(account), constants.PresetsFile)
// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
func (ds *DataStore) GetETagForPresets(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
info, err := os.Stat(path)
if err != nil {
@@ -687,9 +654,9 @@ func (ds *DataStore) GetETagForPresets(account string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// GetETagForSources returns the ETag (modification time) for the sources file.
func (ds *DataStore) GetETagForSources(account string) int64 {
path := filepath.Join(ds.AccountDir(account), constants.SourcesFile)
// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
func (ds *DataStore) GetETagForSources(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
info, err := os.Stat(path)
if err != nil {
@@ -699,9 +666,9 @@ func (ds *DataStore) GetETagForSources(account string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// GetETagForRecents returns the ETag (modification time) for the recents file.
func (ds *DataStore) GetETagForRecents(account string) int64 {
path := filepath.Join(ds.AccountDir(account), constants.RecentsFile)
// GetETagForRecents returns the ETag (modification time) for the recents file for a specific device.
func (ds *DataStore) GetETagForRecents(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
info, err := os.Stat(path)
if err != nil {
@@ -711,11 +678,11 @@ func (ds *DataStore) GetETagForRecents(account string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account.
func (ds *DataStore) GetETagForAccount(account string) int64 {
e1 := ds.GetETagForPresets(account)
e2 := ds.GetETagForSources(account)
e3 := ds.GetETagForRecents(account)
// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account and device.
func (ds *DataStore) GetETagForAccount(account, device string) int64 {
e1 := ds.GetETagForPresets(account, device)
e2 := ds.GetETagForSources(account, device)
e3 := ds.GetETagForRecents(account, device)
maxETag := e1
if e2 > maxETag {
@@ -729,6 +696,63 @@ func (ds *DataStore) GetETagForAccount(account string) int64 {
return maxETag
}
// Settings represents the global service settings.
type Settings struct {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
// GetSettings retrieves the global service settings.
func (ds *DataStore) GetSettings() (Settings, error) {
if ds == nil || ds.DataDir == "" {
return Settings{}, nil
}
path := filepath.Join(ds.DataDir, "settings.json")
if !exists(path) {
return Settings{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return Settings{}, err
}
var settings Settings
if err := json.Unmarshal(data, &settings); err != nil {
return Settings{}, err
}
return settings, nil
}
// SaveSettings saves the global service settings.
func (ds *DataStore) SaveSettings(settings Settings) error {
if ds == nil || ds.DataDir == "" {
return nil
}
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(ds.DataDir, "settings.json")
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
+75 -21
View File
@@ -22,8 +22,9 @@ func TestDataStore(t *testing.T) {
// Test Save/Get DeviceInfo
info := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "Test Speaker",
DeviceID: device,
Name: "Test Speaker",
AccountID: account,
}
err = ds.SaveDeviceInfo(account, device, info)
@@ -49,12 +50,12 @@ func TestDataStore(t *testing.T) {
},
}
err = ds.SavePresets(account, presets)
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Errorf("SavePresets failed: %v", err)
}
loadedPresets, err := ds.GetPresets(account)
loadedPresets, err := ds.GetPresets(account, device)
if err != nil {
t.Errorf("GetPresets failed: %v", err)
}
@@ -72,12 +73,12 @@ func TestDataStore(t *testing.T) {
},
}
err = ds.SaveRecents(account, recents)
err = ds.SaveRecents(account, device, recents)
if err != nil {
t.Errorf("SaveRecents failed: %v", err)
}
loadedRecents, err := ds.GetRecents(account)
loadedRecents, err := ds.GetRecents(account, device)
if err != nil {
t.Errorf("GetRecents failed: %v", err)
}
@@ -87,7 +88,7 @@ func TestDataStore(t *testing.T) {
}
// Test path helpers
expectedAccountDir := filepath.Join(tempDir, account)
expectedAccountDir := filepath.Join(tempDir, "accounts", account)
if ds.AccountDir(account) != expectedAccountDir {
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
}
@@ -151,6 +152,7 @@ func TestListAllDevices(t *testing.T) {
DeviceSerialNumber: deviceID,
ProductCode: "SoundTouch 10",
FirmwareVersion: "1.2.3",
AccountID: account,
}
err = ds.SaveDeviceInfo(account, deviceID, info)
@@ -185,8 +187,9 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
deviceID := ""
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
Name: "Empty ID Speaker",
DeviceID: deviceID,
Name: "Empty ID Speaker",
AccountID: account,
}
// Use IP as fallback for device ID if it is empty
@@ -230,11 +233,13 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
DeviceID: "",
Name: "Speaker 1",
IPAddress: "192.168.1.1",
AccountID: account,
}
info2 := &models.ServiceDeviceInfo{
DeviceID: "",
Name: "Speaker 2",
IPAddress: "192.168.1.2",
AccountID: account,
}
// We use the same logic as in main.go: use IP as fallback for directory name
@@ -294,29 +299,37 @@ func TestConfiguredSources(t *testing.T) {
sources := []models.ConfiguredSource{
{
DisplayName: "Source 1",
ID: "101",
Secret: "secret1",
SecretType: "type1",
DisplayName: "Source 1",
ID: "101",
Secret: "secret1",
SecretType: "type1",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "TUNEIN", Account: "user1"},
SourceKeyType: "TUNEIN",
SourceKeyAccount: "user1",
},
{
DisplayName: "Source 2",
ID: "102",
Secret: "secret2",
SecretType: "type2",
DisplayName: "Source 2",
ID: "102",
Secret: "secret2",
SecretType: "type2",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "PANDORA", Account: "user2"},
SourceKeyType: "PANDORA",
SourceKeyAccount: "user2",
},
}
err := ds.SaveConfiguredSources(account, sources)
err := ds.SaveConfiguredSources(account, "any", sources)
if err != nil {
t.Fatalf("SaveConfiguredSources failed: %v", err)
}
loadedSources, err := ds.GetConfiguredSources(account)
loadedSources, err := ds.GetConfiguredSources(account, "any")
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
@@ -343,12 +356,12 @@ func TestConfiguredSources(t *testing.T) {
},
}
err = ds.SaveConfiguredSources(account, sources2)
err = ds.SaveConfiguredSources(account, "any", sources2)
if err != nil {
t.Fatal(err)
}
loadedSources2, err := ds.GetConfiguredSources(account)
loadedSources2, err := ds.GetConfiguredSources(account, "any")
if err != nil {
t.Fatal(err)
}
@@ -357,3 +370,44 @@ func TestConfiguredSources(t *testing.T) {
t.Error("Expected auto-assigned ID for source with empty ID")
}
}
func TestSettingsPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "settings-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
settings := Settings{
ServerURL: "http://myserver:8000",
ProxyURL: "http://myproxy:8001",
LogBodies: true,
DiscoveryInterval: "10m",
DiscoveryEnabled: true,
}
err = ds.SaveSettings(settings)
if err != nil {
t.Fatalf("SaveSettings failed: %v", err)
}
loaded, err := ds.GetSettings()
if err != nil {
t.Fatalf("GetSettings failed: %v", err)
}
if loaded.ServerURL != settings.ServerURL {
t.Errorf("Expected ServerURL %s, got %s", settings.ServerURL, loaded.ServerURL)
}
if loaded.LogBodies != settings.LogBodies {
t.Errorf("Expected LogBodies %v, got %v", settings.LogBodies, loaded.LogBodies)
}
if loaded.DiscoveryInterval != settings.DiscoveryInterval {
t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval)
}
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
}
}
@@ -0,0 +1,71 @@
package handlers
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDocsConsistency(t *testing.T) {
// Root of the project relative to this test file
// The test runs in the directory of the package
projectRoot := "../../.."
docsDir := filepath.Join(projectRoot, "docs")
summaryPath := filepath.Join(docsDir, "SUMMARY.md")
summaryContent, err := os.ReadFile(summaryPath)
if err != nil {
t.Fatalf("Failed to read SUMMARY.md: %v", err)
}
summaryText := string(summaryContent)
// List of directories to check
dirsToCheck := []string{".", "guides", "reference", "analysis"}
for _, dir := range dirsToCheck {
dirPath := filepath.Join(docsDir, dir)
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Don't recurse into subdirectories if we are checking the root,
// as they are handled separately or ignored (like archive)
if dir == "." && path != dirPath {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
// Skip SUMMARY.md itself
if d.Name() == "SUMMARY.md" {
return nil
}
// Get relative path from docs/
relPath, err := filepath.Rel(docsDir, path)
if err != nil {
return err
}
// Check if this file is linked in SUMMARY.md
// We look for [Label](relPath)
linkPattern := "(" + relPath + ")"
if !strings.Contains(summaryText, linkPattern) {
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
}
return nil
})
if err != nil {
t.Errorf("Error walking directory %s: %v", dir, err)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package handlers
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/russross/blackfriday/v2"
)
// HandleDocs returns a handler for serving documentation files as HTML.
func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/docs")
path = strings.TrimPrefix(path, "/")
if path == "" {
path = "guides/SURVIVAL-GUIDE.md"
}
// Ensure we only serve files from the docs directory
filePath := filepath.Join("docs", path)
if !strings.HasPrefix(filepath.Clean(filePath), "docs") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
content, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Load sidebar (SUMMARY.md)
summaryContent, _ := os.ReadFile(filepath.Join("docs", "SUMMARY.md"))
sidebar := ""
if len(summaryContent) > 0 {
// Render summary to HTML
sidebar = string(blackfriday.Run(summaryContent))
// Adjust links in sidebar to be relative to /docs/
sidebar = strings.ReplaceAll(sidebar, "href=\"guides/", "href=\"/docs/guides/")
sidebar = strings.ReplaceAll(sidebar, "href=\"reference/", "href=\"/docs/reference/")
sidebar = strings.ReplaceAll(sidebar, "href=\"analysis/", "href=\"/docs/analysis/")
// Fix relative links that don't have a directory prefix (root docs)
// We look for href="filename.md" and replace with href="/docs/filename.md"
// This avoids manual listing of every file.
sidebar = s.fixSidebarLinks(sidebar)
}
// Render markdown to HTML
output := blackfriday.Run(content)
// Wrap in a documentation template with sidebar
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s - Bose SoundTouch Toolkit Docs</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
<style>
body { margin: 0; padding: 0; display: flex; font-family: sans-serif; height: 100vh; overflow: hidden; }
.sidebar { width: 300px; background: #f8f9fa; border-right: 1px solid #dee2e6; padding: 20px; overflow-y: auto; flex-shrink: 0; }
.content-area { flex-grow: 1; overflow-y: auto; padding: 40px; }
.markdown-body { max-width: 800px; margin: 0 auto; line-height: 1.6; color: #333; }
h1, h2, h3 { color: #2196F3; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
code { font-family: monospace; background: #eee; padding: 2px 4px; border-radius: 3px; }
pre code { background: none; padding: 0; }
a { color: #2196F3; text-decoration: none; }
a:hover { text-decoration: underline; }
.back-link { margin-bottom: 20px; display: block; font-weight: bold; }
.sidebar h2 { font-size: 1.1em; margin-top: 20px; color: #666; text-transform: uppercase; letter-spacing: 1px; }
.sidebar ul { list-style: none; padding: 0; }
.sidebar li { margin-bottom: 8px; }
.sidebar a { color: #444; font-size: 0.95em; }
.sidebar a:hover { color: #2196F3; }
</style>
</head>
<body>
<div class="sidebar">
<a href="/" class="back-link">&larr; Back to Toolkit</a>
%s
</div>
<div class="content-area">
<div class="markdown-body">
%s
</div>
</div>
</body>
</html>`, path, sidebar, output)
}
// fixSidebarLinks ensures that relative links in the SUMMARY.md (sidebar)
// are correctly prefixed with /docs/ for the web UI.
func (s *Server) fixSidebarLinks(sidebar string) string {
// Root links like [Label](file.md) become href="file.md"
// We want href="/docs/file.md", but only if it doesn't already start with /docs/
// and isn't an external link.
// Since blackfriday renders [Label](file.md) as <a href="file.md">
// A simple but effective way is to use a regex or just check for common patterns.
// We already handled subdirectories. Now we handle files in the root of docs/
// We'll look for href="filename.md" where filename doesn't contain a slash
// and isn't already prefixed.
// Since we know our doc files always end in .md, we can look for that.
lines := strings.Split(sidebar, "\n")
for i, line := range lines {
if strings.Contains(line, "href=\"") && !strings.Contains(line, "href=\"/docs/") && !strings.Contains(line, "://") {
// Extract filename
start := strings.Index(line, "href=\"") + 6
end := strings.Index(line[start:], "\"") + start
filename := line[start:end]
if strings.HasSuffix(filename, ".md") && !strings.Contains(filename, "/") {
lines[i] = strings.ReplaceAll(line, "href=\""+filename+"\"", "href=\"/docs/"+filename+"\"")
}
}
}
return strings.Join(lines, "\n")
}
+7 -5
View File
@@ -23,17 +23,19 @@ func TestMargeETags(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
accountDir := filepath.Join(tempDir, account)
_ = os.MkdirAll(accountDir, 0755)
deviceID := "DEV1"
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
_ = os.MkdirAll(deviceDir, 0755)
// Create some initial data
presetsFile := filepath.Join(accountDir, "Presets.xml")
presetsFile := filepath.Join(deviceDir, "Presets.xml")
_ = os.WriteFile(presetsFile, []byte("<presets/>"), 0644)
sourcesFile := filepath.Join(accountDir, "Sources.xml")
sourcesFile := filepath.Join(deviceDir, "Sources.xml")
_ = os.WriteFile(sourcesFile, []byte("<sources/>"), 0644)
recentsFile := filepath.Join(accountDir, "Recents.xml")
recentsFile := filepath.Join(deviceDir, "Recents.xml")
_ = os.WriteFile(recentsFile, []byte("<recents/>"), 0644)
// Ensure devices directory exists for AccountFull
+8 -5
View File
@@ -36,7 +36,9 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque
func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
etag := strconv.FormatInt(s.ds.GetETagForAccount(account), 10)
device := r.URL.Query().Get("device")
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
@@ -79,14 +81,15 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
// HandleMargePresets returns the Marge presets for a device.
func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10)
etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := marge.PresetsToXML(s.ds, account)
data, err := marge.PresetsToXML(s.ds, account, device)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -102,7 +105,7 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10)
etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10)
w.Header()["ETag"] = []string{etag}
presetNumberStr := chi.URLParam(r, "presetNumber")
@@ -134,7 +137,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
etag := strconv.FormatInt(s.ds.GetETagForRecents(account), 10)
etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
w.Header()["ETag"] = []string{etag}
body, err := io.ReadAll(r.Body)
+33 -34
View File
@@ -72,7 +72,7 @@ func TestMargeAccountFull(t *testing.T) {
account := "12345"
deviceID := "ABCDE"
accountDir := filepath.Join(tempDir, account)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
@@ -135,12 +135,14 @@ func TestMargePresets(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "any"
accountDir := filepath.Join(tempDir, account)
err = os.MkdirAll(accountDir, 0755)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatalf("Failed to create account dir: %v", err)
t.Fatalf("Failed to create device dir: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
@@ -149,24 +151,17 @@ func TestMargePresets(t *testing.T) {
defer ts.Close()
// Mock Sources.xml and Presets.xml
if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
<sources>
<source id="123" type="Audio">
<createdOn>2012-09-19T12:43:00.000+00:00</createdOn>
<credential type="token"></credential>
<name>TUNEIN</name>
<sourceproviderid>1</sourceproviderid>
<sourcename>TUNEIN</sourcename>
<sourcesettings></sourcesettings>
<updatedOn>2012-09-19T12:43:00.000+00:00</updatedOn>
<username></username>
<source id="123" displayName="TUNEIN" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>
`), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`
<presets>
<preset id="1">
<ContentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
@@ -211,26 +206,28 @@ func TestMargeUpdatePreset(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "DEV1"
accountDir := filepath.Join(tempDir, account)
err = os.MkdirAll(accountDir, 0755)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatalf("Failed to create account dir: %v", err)
t.Fatalf("Failed to create device dir: %v", err)
}
// Mock Sources.xml
if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
<sources>
<source id="SRC1" type="Audio">
<sourcename>TUNEIN</sourcename>
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>
`), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(`<presets></presets>`), 0644); err != nil {
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`<presets></presets>`), 0644); err != nil {
t.Fatalf("Failed to write Presets.xml: %v", err)
}
@@ -248,7 +245,7 @@ func TestMargeUpdatePreset(t *testing.T) {
<containerArt>http://example.com/new.jpg</containerArt>
</preset>`
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/presets/1", "application/xml", strings.NewReader(payload))
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/"+deviceID+"/presets/1", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
@@ -261,7 +258,7 @@ func TestMargeUpdatePreset(t *testing.T) {
}
// Verify file was saved
presetData, _ := os.ReadFile(filepath.Join(accountDir, "Presets.xml"))
presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml"))
if !strings.Contains(string(presetData), "New Preset") {
t.Error("Preset was not saved to datastore")
}
@@ -278,26 +275,28 @@ func TestMargeDeviceInfo(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "DEV1"
accountDir := filepath.Join(tempDir, account)
err = os.MkdirAll(accountDir, 0755)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatalf("Failed to create account dir: %v", err)
t.Fatalf("Failed to create device dir: %v", err)
}
// Mock Sources.xml
if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
<sources>
<source id="SRC1" type="Audio">
<sourcename>TUNEIN</sourcename>
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>
`), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(`<recents></recents>`), 0644); err != nil {
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(`<recents></recents>`), 0644); err != nil {
t.Fatalf("Failed to write Recents.xml: %v", err)
}
@@ -314,7 +313,7 @@ func TestMargeDeviceInfo(t *testing.T) {
<contentItemType>station</contentItemType>
</recent>`
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/recents", "application/xml", strings.NewReader(payload))
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/"+deviceID+"/recents", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
@@ -326,7 +325,7 @@ func TestMargeDeviceInfo(t *testing.T) {
}
// Verify file was saved
recentData, _ := os.ReadFile(filepath.Join(accountDir, "Recents.xml"))
recentData, _ := os.ReadFile(filepath.Join(deviceDir, "Recents.xml"))
if !strings.Contains(string(recentData), "Recent Station") {
t.Error("Recent was not saved to datastore")
}
@@ -344,7 +343,7 @@ func TestMargeAddRemoveDevice(t *testing.T) {
account := "12345"
accountDir := filepath.Join(tempDir, account)
accountDir := filepath.Join(tempDir, "accounts", account)
err = os.MkdirAll(accountDir, 0755)
if err != nil {
+2 -2
View File
@@ -35,8 +35,8 @@ func TestRootEndpoint(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "Soundcork Management") {
t.Errorf("Expected body to contain 'Soundcork Management', got %s", string(body))
if !strings.Contains(string(body), "Bose SoundTouch Toolkit") {
t.Errorf("Expected body to contain 'Bose SoundTouch Toolkit', got %s", string(body))
}
}
+2
View File
@@ -35,6 +35,8 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
lp.LogBody = s.proxyLogBody
lp.RecordEnabled = s.recordEnabled
lp.SetRecorder(s.recorder)
proxy := httputil.NewSingleHostReverseProxy(target)
// Update director to set the correct host and path
+449 -22
View File
@@ -1,10 +1,16 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
@@ -25,9 +31,54 @@ func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, _ *http.Requ
}
}
// HandleAddManualDevice adds a device manually by IP.
func (s *Server) HandleAddManualDevice(w http.ResponseWriter, r *http.Request) {
var body struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if body.IP == "" {
http.Error(w, "IP address is required", http.StatusBadRequest)
return
}
// Try to get live info
liveInfo, err := s.sm.GetLiveDeviceInfo(body.IP)
if err != nil {
// Even if we can't get live info, we might want to add it?
// But usually we need at least the serial for proper account management.
http.Error(w, "Failed to reach device at "+body.IP+": "+err.Error(), http.StatusBadGateway)
return
}
// Reuse handleDiscoveredDevice logic via a fake models.DiscoveredDevice
d := models.DiscoveredDevice{
Name: liveInfo.Name,
Host: body.IP,
ModelID: liveInfo.Type,
SerialNo: liveInfo.SerialNumber,
DiscoveryMethod: "manual",
}
s.handleDiscoveredDevice(d)
s.mergeOverlappingDevices()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleTriggerDiscovery triggers a new device discovery scan.
func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) {
go s.DiscoverDevices(r.Context())
func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, _ *http.Request) {
//nolint:contextcheck
go s.DiscoverDevices(context.Background())
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"status": "Discovery started"}`))
@@ -43,19 +94,138 @@ func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, _ *http.Request
}
}
// HandleRemoveDevice removes a device from the datastore.
func (s *Server) HandleRemoveDevice(w http.ResponseWriter, r *http.Request) {
deviceId := chi.URLParam(r, "deviceId")
if deviceId == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
// Find which account this device belongs to.
devices, err := s.ds.ListAllDevices()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var found bool
for i := range devices {
if devices[i].DeviceID == deviceId {
err = s.ds.RemoveDevice(devices[i].AccountID, devices[i].DeviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
found = true
break
}
}
if !found {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleGetSettings returns the current service settings.
func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"server_url": s.serverURL,
"proxy_url": s.proxyURL,
s.mu.RLock()
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
s.mu.RUnlock()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"proxy_url": proxyURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleUpdateSettings updates the service settings.
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
interval, err := time.ParseDuration(settings.DiscoveryInterval)
if err != nil && settings.DiscoveryInterval != "" {
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.serverURL = settings.ServerURL
s.proxyURL = settings.ProxyURL
if settings.DiscoveryInterval != "" {
s.discoveryInterval = interval
}
s.discoveryEnabled = settings.DiscoveryEnabled
if s.sm != nil {
s.sm.ServerURL = settings.ServerURL
}
// Persist to datastore
// Access fields directly since we already hold the lock
currentRedact := s.proxyRedact
currentLogBody := s.proxyLogBody
currentRecord := s.recordEnabled
currentHTTPS := s.httpsServerURL
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
err = s.ds.SaveSettings(datastore.Settings{
ServerURL: s.serverURL,
ProxyURL: s.proxyURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
})
s.mu.Unlock()
if err != nil {
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Settings updated"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleGetDeviceInfo returns live information for a device.
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
@@ -138,11 +308,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
}
}
if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method); err != nil {
output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -152,7 +323,43 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleRevertMigration reverts the migration for a device.
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
output, err := s.sm.RevertMigration(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Revert started", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -173,11 +380,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.sm.TrustCACert(deviceIP); err != nil {
output, err := s.sm.TrustCACert(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -187,7 +395,7 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Root CA trusted"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Root CA trusted", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -208,11 +416,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
return
}
if err := s.sm.EnsureRemoteServices(deviceIP); err != nil {
output, err := s.sm.EnsureRemoteServices(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -222,7 +431,7 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services enabled", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -243,11 +452,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
return
}
if err := s.sm.RemoveRemoteServices(deviceIP); err != nil {
output, err := s.sm.RemoveRemoteServices(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -257,7 +467,7 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services removed"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services removed", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -278,11 +488,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.sm.BackupConfig(deviceIP); err != nil {
output, err := s.sm.BackupConfig(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -292,7 +503,7 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config backed up", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -302,9 +513,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
redact, logBody, record := s.GetProxySettings()
if err := json.NewEncoder(w).Encode(map[string]bool{
"redact": s.proxyRedact,
"log_body": s.proxyLogBody,
"redact": redact,
"log_body": logBody,
"record": record,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -331,14 +545,41 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
var settings struct {
Redact bool `json:"redact"`
LogBody bool `json:"log_body"`
Record bool `json:"record"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.proxyRedact = settings.Redact
s.proxyLogBody = settings.LogBody
s.recordEnabled = settings.Record
// Persist to datastore
// Access fields directly since we already hold the lock
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
err := s.ds.SaveSettings(datastore.Settings{
ServerURL: serverURL,
ProxyURL: proxyURL,
HTTPServerURL: httpsServerURL,
RedactLogs: s.proxyRedact,
LogBodies: s.proxyLogBody,
RecordInteractions: s.recordEnabled,
DiscoveryInterval: discoveryInterval,
DiscoveryEnabled: discoveryEnabled,
})
s.mu.Unlock()
if err != nil {
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
@@ -388,6 +629,59 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque
}
}
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Missing deviceIP", http.StatusBadRequest)
return
}
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}
// HandleRebootDevice reboots a device.
func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
output, err := s.sm.Reboot(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Reboot started", "output": output}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// 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")
@@ -430,3 +724,136 @@ func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleGetVersionInfo returns version information for the service.
func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"version": s.Version,
"commit": s.Commit,
"date": s.Date,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleGetInteractionStats returns statistics about recorded interactions.
func (s *Server) HandleGetInteractionStats(w http.ResponseWriter, _ *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
stats, err := s.recorder.GetInteractionStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleListInteractions returns a list of recorded interactions.
func (s *Server) HandleListInteractions(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
session := r.URL.Query().Get("session")
category := r.URL.Query().Get("category")
since := r.URL.Query().Get("since")
interactions, err := s.recorder.ListInteractions(session, category, since)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(interactions); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleGetInteractionContent returns the raw content of a recorded interaction.
func (s *Server) HandleGetInteractionContent(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
file := r.URL.Query().Get("file")
if file == "" {
http.Error(w, "File parameter is required", http.StatusBadRequest)
return
}
content, err := s.recorder.GetInteractionContent(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(content)
}
// HandleDeleteSession deletes a recorded interaction session.
func (s *Server) HandleDeleteSession(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
session := chi.URLParam(r, "session")
if session == "" {
http.Error(w, "Session ID is required", http.StatusBadRequest)
return
}
if err := s.recorder.DeleteSession(session); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok": true}`))
}
// HandleCleanupSessions deletes all but the most recent N sessions.
func (s *Server) HandleCleanupSessions(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
keep := 10
keepStr := r.URL.Query().Get("keep")
if keepStr != "" {
if k, err := strconv.Atoi(keepStr); err == nil {
keep = k
}
}
if err := s.recorder.CleanupSessions(keep); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok": true}`))
}
+175 -1
View File
@@ -15,7 +15,16 @@ import (
)
func TestProxySettingsAPI(t *testing.T) {
r, server := setupRouter("http://localhost:8001", nil)
tempDir, err := os.MkdirTemp("", "proxy-settings-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, server := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
@@ -86,6 +95,34 @@ func TestProxySettingsAPI(t *testing.T) {
if settings["redact"] != false || settings["log_body"] != true {
t.Errorf("GET (after update): Unexpected settings: %+v", settings)
}
// 3. Test System Settings POST
sysUpdate := map[string]string{
"server_url": "http://new-server:8000",
"proxy_url": "http://new-proxy:8001",
}
sysBody, err := json.Marshal(sysUpdate)
if err != nil {
t.Fatalf("Failed to marshal system settings data: %v", err)
}
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(sysBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("POST /setup/settings: Expected status OK, got %v", res.Status)
}
// Verify server state
sURL, pURL, _ := server.GetSettings()
if sURL != "http://new-server:8000" || pURL != "http://new-proxy:8001" {
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, proxyURL=%s", sURL, pURL)
}
}
func TestMigrationAndCA(t *testing.T) {
@@ -144,6 +181,9 @@ func TestMigrationAndCA(t *testing.T) {
if result["ok"] != true {
t.Errorf("Migrate: Expected ok=true, got %v", result["ok"])
}
if _, ok := result["output"]; !ok {
t.Errorf("Migrate: Expected output field in response")
}
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
@@ -162,6 +202,140 @@ func TestMigrationAndCA(t *testing.T) {
if result["ok"] != true {
t.Errorf("TrustCA: Expected ok=true, got %v", result["ok"])
}
if _, ok := result["output"]; !ok {
t.Errorf("TrustCA: Expected output field in response")
}
// 4. Test POST /setup/reboot/{deviceIP}
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Reboot: Expected status OK, got %v", res.Status)
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatalf("Reboot: Failed to decode response: %v", err)
}
if result["ok"] != true {
t.Errorf("Reboot: Expected ok=true, got %v", result["ok"])
}
if _, ok := result["output"]; !ok {
t.Errorf("Reboot: Expected output field in response")
}
// 5. Test POST /setup/remove-remote-services/{deviceIP}
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("RemoveRemote: Expected status OK, got %v", res.Status)
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatalf("RemoveRemote: Failed to decode response: %v", err)
}
if result["ok"] != true {
t.Errorf("RemoveRemote: Expected ok=true, got %v", result["ok"])
}
if _, ok := result["output"]; !ok {
t.Errorf("RemoveRemote: Expected output field in response")
}
}
func TestRemoveDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "remove-device-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// Setup a dummy device in the datastore
account := "test-account"
deviceID := "TEST-DEVICE-ID"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
t.Fatalf("Failed to create device info file: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Verify device exists
res, err := http.Get(ts.URL + "/setup/devices")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var devices []map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&devices); err != nil {
t.Fatalf("Failed to decode devices: %v", err)
}
found := false
for _, d := range devices {
if d["device_id"] == deviceID {
found = true
break
}
}
if !found {
t.Fatalf("Device not found in list before removal")
}
// 2. Remove device
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
if err != nil {
t.Fatal(err)
}
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
// 3. Verify device is gone
res, err = http.Get(ts.URL + "/setup/devices")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if err := json.NewDecoder(res.Body).Decode(&devices); err != nil {
t.Fatalf("Failed to decode devices after removal: %v", err)
}
for _, d := range devices {
if d["device_id"] == deviceID {
t.Errorf("Device still exists in list after removal")
}
}
// 4. Verify directory is gone
if _, err := os.Stat(deviceDir); !os.IsNotExist(err) {
t.Errorf("Device directory still exists after removal")
}
}
type mockSSH struct{}
+159
View File
@@ -0,0 +1,159 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/go-chi/chi/v5"
)
func TestInteractionHandlers(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "interaction-handlers-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := &Server{ds: ds}
t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
w := httptest.NewRecorder()
server.HandleGetInteractionStats(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
})
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
// Create a dummy interaction file
sessionID := recorder.SessionID
relPath := filepath.Join(sessionID, "self", "test", "0001-12-00-00.000-GET.http")
fullPath := filepath.Join(tmpDir, "interactions", relPath)
os.MkdirAll(filepath.Dir(fullPath), 0755)
os.WriteFile(fullPath, []byte("### GET /test\n\n> {% \n // Response: 200 OK\n%}\n"), 0644)
r := chi.NewRouter()
r.Route("/setup", func(r chi.Router) {
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
})
t.Run("HandleGetInteractionStats", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var stats proxy.InteractionStats
if err := json.NewDecoder(w.Body).Decode(&stats); err != nil {
t.Fatalf("Failed to decode stats: %v", err)
}
if stats.TotalRequests != 1 {
t.Errorf("Expected 1 total request, got %d", stats.TotalRequests)
}
})
t.Run("HandleListInteractions", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interactions?category=self", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var interactions []proxy.Interaction
if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil {
t.Fatalf("Failed to decode interactions: %v", err)
}
if len(interactions) != 1 {
t.Errorf("Expected 1 interaction, got %d", len(interactions))
}
})
t.Run("HandleGetInteractionContent", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "### GET /test") {
t.Errorf("Unexpected content: %s", w.Body.String())
}
})
t.Run("HandleGetInteractionContent_MissingFile", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-content", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
func TestRecordMiddleware(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "record-middleware-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := &Server{
ds: ds,
recordEnabled: true,
}
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
r := chi.NewRouter()
r.Use(server.RecordMiddleware)
r.Get("/test-middleware", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test", "Value")
w.WriteHeader(http.StatusCreated)
w.Write([]byte("created"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
})
req := httptest.NewRequest("GET", "/test-middleware", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Expected status 201, got %d", w.Code)
}
t.Run("HandleRecordMiddleware_Disabled", func(t *testing.T) {
server.recordEnabled = false
req := httptest.NewRequest("GET", "/test-middleware", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Expected status 201, got %d", w.Code)
}
})
}
+6
View File
@@ -47,11 +47,17 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
@@ -0,0 +1,99 @@
package handlers
import (
"bufio"
"bytes"
"fmt"
"io"
"net"
"net/http"
)
// RecordMiddleware returns a middleware that records "self" requests and responses.
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil || !s.recordEnabled {
next.ServeHTTP(w, r)
return
}
// Buffer the request body if it exists
var reqBody []byte
if r.Body != nil {
var err error
reqBody, err = io.ReadAll(r.Body)
if err == nil {
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
}
// wrap ResponseWriter to capture the response
rw := &responseWriter{
ResponseWriter: w,
body: &bytes.Buffer{},
}
next.ServeHTTP(rw, r)
// Create a response object for the recorder
res := rw.getRecordedResponse(r)
if res.Body != nil {
defer func() { _ = res.Body.Close() }()
}
// Put back the original request body for recording
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
_ = s.recorder.Record("self", r, res)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
}
func (rw *responseWriter) Header() http.Header {
return rw.ResponseWriter.Header()
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
rw.body.Write(b)
return rw.ResponseWriter.Write(b)
}
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
statusCode := rw.statusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
return &http.Response{
StatusCode: statusCode,
Header: rw.ResponseWriter.Header(),
Body: io.NopCloser(bytes.NewBuffer(rw.body.Bytes())),
Request: r,
}
}
func (rw *responseWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := rw.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("ResponseWriter does not support Hijacker")
}
+234 -30
View File
@@ -3,37 +3,131 @@ package handlers
import (
"context"
"log"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// Server handles HTTP requests for the SoundTouch service.
type Server struct {
ds *datastore.DataStore
sm *setup.Manager
serverURL string
proxyURL string
discovering bool
proxyRedact bool
proxyLogBody bool
ds *datastore.DataStore
sm *setup.Manager
mu sync.RWMutex
serverURL string
proxyURL string
httpsServerURL string
discovering bool
proxyRedact bool
proxyLogBody bool
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
shortcuts map[string]int
recorder *proxy.Recorder
Version string
Commit string
Date string
}
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody bool) *Server {
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
return &Server{
ds: ds,
sm: sm,
serverURL: serverURL,
proxyURL: serverURL,
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
ds: ds,
sm: sm,
serverURL: serverURL,
proxyURL: serverURL,
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
}
}
// SetVersionInfo sets the version information for the server.
func (s *Server) SetVersionInfo(version, commit, date string) {
s.mu.Lock()
defer s.mu.Unlock()
s.Version = version
s.Commit = commit
s.Date = date
}
// SetDiscoverySettings sets the discovery settings for the server.
func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.discoveryInterval = interval
s.discoveryEnabled = enabled
}
// SetShortcuts sets the request shortcuts for the server.
func (s *Server) SetShortcuts(shortcuts map[string]int) {
s.mu.Lock()
defer s.mu.Unlock()
s.shortcuts = shortcuts
}
// GetShortcuts returns the current request shortcuts.
func (s *Server) GetShortcuts() map[string]int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.shortcuts
}
// GetDiscoverySettings returns the current discovery settings.
func (s *Server) GetDiscoverySettings() (time.Duration, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.discoveryInterval, s.discoveryEnabled
}
// SetHTTPServerURL sets the external HTTPS URL of the service.
func (s *Server) SetHTTPServerURL(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.httpsServerURL = url
}
// SetRecorder sets the recorder for the server.
func (s *Server) SetRecorder(r *proxy.Recorder) {
s.recorder = r
}
// GetRecordEnabled returns whether recording is enabled.
func (s *Server) GetRecordEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.recordEnabled
}
// GetSettings returns the current server settings.
func (s *Server) GetSettings() (string, string, string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.serverURL, s.proxyURL, s.httpsServerURL
}
// GetProxySettings returns the current proxy settings.
func (s *Server) GetProxySettings() (bool, bool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.proxyRedact, s.proxyLogBody, s.recordEnabled
}
// DiscoverDevices starts a background device discovery process.
//
//nolint:contextcheck
@@ -44,16 +138,18 @@ func (s *Server) DiscoverDevices(ctx context.Context) {
log.Println("Scanning for Bose devices...")
// Use background context if none provided or if it's likely a request context
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ctx = context.Background()
}
// Always wrap in a timeout to prevent hanging forever
discoveryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
svc := discovery.NewService(10 * time.Second)
devices, err := svc.DiscoverDevices(ctx)
devices, err := svc.DiscoverDevices(discoveryCtx)
if err != nil {
log.Printf("Discovery error: %v", err)
return
@@ -62,12 +158,15 @@ func (s *Server) DiscoverDevices(ctx context.Context) {
for _, d := range devices {
s.handleDiscoveredDevice(*d)
}
// Post-discovery cleanup: merge overlapping IP/Serial entries
s.mergeOverlappingDevices()
}
func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo)
// 1. Check if we already have this device by serial number (best identifier)
// 1. Check if we already have this device
existingID := s.findExistingDeviceID(d)
// Use SerialNo if available, otherwise fallback to IP for the datastore directory name
@@ -87,37 +186,142 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
deviceID = d.Host
}
accountID := ""
if liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host); err == nil {
if liveInfo.MargeAccountUUID != "" {
accountID = liveInfo.MargeAccountUUID
}
if liveInfo.SerialNumber != "" {
d.SerialNo = liveInfo.SerialNumber
deviceID = d.SerialNo
}
}
if accountID == "" {
// Try to find account ID from existing device entries if live info failed
if existing := s.findExistingDeviceInfo(d); existing != nil {
accountID = existing.AccountID
}
}
if accountID == "" {
accountID = "default"
}
info := &models.ServiceDeviceInfo{
DeviceID: d.SerialNo,
DeviceID: deviceID,
AccountID: accountID,
Name: d.Name,
IPAddress: d.Host,
DeviceSerialNumber: d.SerialNo,
ProductCode: d.ModelID,
FirmwareVersion: "0.0.0", // Unknown from discovery
DiscoveryMethod: d.DiscoveryMethod,
}
// If we had an IP-based entry and now have a Serial, clean up the IP-based entry
if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo {
log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo)
_ = s.ds.RemoveDevice("default", existingID)
_ = s.ds.RemoveDevice(accountID, existingID)
}
if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil {
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("Failed to save device info: %v", err)
}
}
func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
allDevices, _ := s.ds.ListAllDevices()
for _, known := range allDevices {
if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) {
if known.DeviceID != "" {
return known.DeviceID
func (s *Server) mergeOverlappingDevices() {
allDevices, err := s.ds.ListAllDevices()
if err != nil {
return
}
// Group devices by IP
byIP := make(map[string][]models.ServiceDeviceInfo)
for i := range allDevices {
dev := allDevices[i]
if dev.IPAddress != "" {
byIP[dev.IPAddress] = append(byIP[dev.IPAddress], dev)
}
}
for ip, devices := range byIP {
if len(devices) <= 1 {
continue
}
// We have multiple entries for the same IP.
// Try to find one with a Serial Number to be the master.
var master *models.ServiceDeviceInfo
for i := range devices {
if devices[i].DeviceSerialNumber != "" {
master = &devices[i]
break
}
}
if master == nil {
// Fallback: look for one with DeviceID that isn't the IP
for i := range devices {
if devices[i].DeviceID != "" && devices[i].DeviceID != devices[i].IPAddress {
master = &devices[i]
break
}
}
}
if master == nil {
// None have serials, just keep the first one
continue
}
masterID := master.DeviceID
if masterID == "" {
masterID = master.DeviceSerialNumber
}
for i := range devices {
dev := devices[i]
devID := dev.DeviceID
if devID == "" {
devID = dev.IPAddress
}
return known.IPAddress
if devID != masterID && dev.IPAddress == ip {
log.Printf("Merging overlapping device entry %s into %s (IP: %s)", devID, masterID, ip)
_ = s.ds.RemoveDevice(dev.AccountID, devID)
}
}
}
}
func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
info := s.findExistingDeviceInfo(d)
if info != nil {
return info.DeviceID
}
return ""
}
func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo {
allDevices, _ := s.ds.ListAllDevices()
for i := range allDevices {
known := allDevices[i]
// Match by Serial
if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) {
return &known
}
// Match by IP
if d.Host != "" && known.IPAddress == d.Host {
return &known
}
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package handlers
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMergeOverlappingDevices(t *testing.T) {
tempDir, err := os.MkdirTemp("", "merge-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
s := &Server{ds: ds}
// Case 1: IP-only entry and Serial-based entry for the same IP
ip := "192.168.1.100"
serial := "SERIAL123"
// 1. Save IP-based entry
infoIP := &models.ServiceDeviceInfo{
DeviceID: ip,
Name: "Speaker IP",
IPAddress: ip,
AccountID: "default",
ProductCode: "ST10",
}
err = ds.SaveDeviceInfo("default", ip, infoIP)
if err != nil {
t.Fatalf("Failed to save IP info: %v", err)
}
// 2. Save Serial-based entry
infoSerial := &models.ServiceDeviceInfo{
DeviceID: serial,
DeviceSerialNumber: serial,
Name: "Speaker Serial",
IPAddress: ip,
AccountID: "default",
ProductCode: "ST10",
}
err = ds.SaveDeviceInfo("default", serial, infoSerial)
if err != nil {
t.Fatalf("Failed to save Serial info: %v", err)
}
// Verify both exist
devices, _ := ds.ListAllDevices()
if len(devices) != 2 {
t.Fatalf("Expected 2 devices before merge, got %d", len(devices))
}
// Run merge
s.mergeOverlappingDevices()
// Verify merge
devices, _ = ds.ListAllDevices()
if len(devices) != 1 {
t.Fatalf("Expected 1 device after merge, got %d", len(devices))
}
if devices[0].DeviceID != serial {
t.Errorf("Expected remaining device to be Serial-based (%s), got %s", serial, devices[0].DeviceID)
}
}
func TestFindExistingDeviceID(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "find-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
s := &Server{ds: ds}
ip := "192.168.1.101"
serial := "SERIAL456"
// Save IP-based
ds.SaveDeviceInfo("default", ip, &models.ServiceDeviceInfo{
DeviceID: ip,
IPAddress: ip,
Name: "IP Speaker",
AccountID: "default",
ProductCode: "ST10",
})
// Test finding by IP
foundID := s.findExistingDeviceID(models.DiscoveredDevice{
Host: ip,
})
if foundID != ip {
t.Errorf("Expected to find by IP, got %s", foundID)
}
// Save Serial-based for SAME IP
ds.SaveDeviceInfo("default", serial, &models.ServiceDeviceInfo{
DeviceID: serial,
DeviceSerialNumber: serial,
IPAddress: ip,
Name: "Serial Speaker",
AccountID: "default",
ProductCode: "ST10",
})
// Test finding by IP should now return Serial (if Serial is known)
// Actually findExistingDeviceID returns the first match it finds in allDevices.
// Since we haven't merged yet, it could be either.
// Test finding by Serial
foundID = s.findExistingDeviceID(models.DiscoveredDevice{
Host: ip,
SerialNo: serial,
})
if foundID != serial && foundID != ip {
t.Errorf("Expected to find by Serial or IP, got %s", foundID)
}
// Merge and check again
s.mergeOverlappingDevices()
foundID = s.findExistingDeviceID(models.DiscoveredDevice{
Host: ip,
})
if foundID != serial {
t.Errorf("After merge, expected to find Serial ID for IP, got %s", foundID)
}
}
@@ -0,0 +1,23 @@
package handlers
import (
"net/http"
)
// ShortcutMiddleware returns a middleware that shortcuts requests to specific paths.
func (s *Server) ShortcutMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
shortcuts := s.shortcuts
s.mu.RUnlock()
if shortcuts != nil {
if status, ok := shortcuts[r.URL.Path]; ok {
w.WriteHeader(status)
return
}
}
next.ServeHTTP(w, r)
})
}
+94 -1
View File
@@ -4,8 +4,101 @@ 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; }
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; }
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; }
/* Tabs */
.tabs { margin-top: 20px; }
.tab-buttons { display: flex; border-bottom: 1px solid #ddd; margin-bottom: 20px; }
.tab-btn { background: #f8f8f8; border: 1px solid #ddd; border-bottom: none; padding: 10px 20px; margin-right: 5px; border-top-left-radius: 4px; border-top-right-radius: 4px; }
.tab-btn:hover { background: #eee; }
.tab-btn.active { background: white; border-bottom: 2px solid #2196F3; font-weight: bold; }
.tab-content { display: none; padding: 10px; }
.tab-content.active { display: block; }
.device-selection {
margin-bottom: 20px;
padding: 10px;
background-color: #f0f7ff;
border-radius: 4px;
border: 1px solid #d0e0f0;
}
.device-selection label {
font-weight: bold;
margin-right: 10px;
}
.device-selection select {
padding: 5px;
min-width: 250px;
}
/* Guide & Overview styles */
.guide-steps li {
margin-bottom: 15px;
line-height: 1.5;
}
.guide-steps strong {
color: #2196F3;
font-size: 1.1em;
}
.info-box {
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.prerequisite-box {
background-color: #fffde7;
border-left: 4px solid #fff176;
}
.safety-box {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
margin-top: 20px;
}
.info-box a {
color: #0d47a1;
font-weight: bold;
text-decoration: underline;
}
.info-box a:hover {
text-decoration: none;
}
.btn-danger {
background-color: #f44336;
color: white;
border: none;
padding: 5px 10px;
}
.btn-danger:hover {
background-color: #d32f2f;
}
.badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
font-weight: bold;
}
.stats-list {
list-style: none;
padding: 0;
margin: 0;
}
.stats-list li {
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.stats-list li:last-child {
border-bottom: none;
}
.category-self { background-color: #e3f2fd; color: #0d47a1; }
.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
.status-success { background-color: #e8f5e9; color: #2e7d32; }
.status-error { background-color: #ffebee; color: #c62828; }
+340 -130
View File
@@ -2,158 +2,368 @@
<html>
<head>
<meta charset="UTF-8">
<title>Soundcork Management</title>
<title>Bose SoundTouch Toolkit</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>
<h1>Bose SoundTouch Toolkit</h1>
<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 class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions</button>
</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>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to Bose SoundTouch Toolkit</h2>
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
<div id="migration-summary" class="summary-box">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px;">
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
</ol>
<strong>Verify connection:</strong>
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@&lt;SPEAKER-IP&gt;</code> (no password).</li>
</ul>
</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>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
</li>
<li>
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
Ensure your speakers are powered on and connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
</li>
<li>
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
</li>
<li>
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</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>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</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>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
rather than <code>localhost</code>.
</p>
<div style="margin-bottom: 20px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</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 style="margin-bottom: 20px;">
<label for="proxy-domain">Proxy Domain:</label>
<input type="text" id="proxy-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL - usually the same as Target Domain)</span>
</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 style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (Soundcork)</span>
<pre id="planned-config"></pre>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</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 style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</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>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none;">
<h3>Sync Results</h3>
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select id="migration-device-list" onchange="showSummary(this.value)">
<option value="">-- Select a device --</option>
</select>
</div>
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
</select>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="soundcork">Soundcork (Go/Python)</option>
<option value="original">Original (Proxy via soundcork-go)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (Soundcork)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</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>
</div>
<!-- Tab 5: Interactions -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions</h2>
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<h3>Browse Recordings</h3>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
</div>
</div>
<script src="/web/js/script.js"></script>
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
<span id="version-info">SoundTouch Toolkit</span>
</footer>
</body>
</html>
+603 -22
View File
@@ -8,6 +8,12 @@ async function fetchSettings() {
if (settings.proxy_url) {
document.getElementById('proxy-domain').value = settings.proxy_url;
}
if (settings.discovery_interval) {
document.getElementById('discovery-interval').value = settings.discovery_interval;
}
if (settings.discovery_enabled !== undefined) {
document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
}
fetchProxySettings();
} catch (error) {
console.error('Failed to fetch settings', error);
@@ -20,6 +26,7 @@ async function fetchProxySettings() {
const settings = await response.json();
document.getElementById('proxy-redact').checked = settings.redact;
document.getElementById('proxy-log-body').checked = settings.log_body;
document.getElementById('proxy-record').checked = settings.record;
} catch (error) {
console.error('Failed to fetch proxy settings', error);
}
@@ -28,7 +35,8 @@ async function fetchProxySettings() {
async function updateProxySettings() {
const settings = {
redact: document.getElementById('proxy-redact').checked,
log_body: document.getElementById('proxy-log-body').checked
log_body: document.getElementById('proxy-log-body').checked,
record: document.getElementById('proxy-record').checked
};
try {
await fetch('/setup/proxy-settings', {
@@ -41,31 +49,90 @@ async function updateProxySettings() {
}
}
async function updateSettings() {
const settings = {
server_url: document.getElementById('target-domain').value,
proxy_url: document.getElementById('proxy-domain').value,
discovery_interval: document.getElementById('discovery-interval').value,
discovery_enabled: document.getElementById('discovery-enabled').checked
};
const status = document.getElementById('settings-status');
status.innerText = 'Saving...';
status.style.color = 'blue';
try {
const response = await fetch('/setup/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
status.innerText = '✅ Settings saved. Restart service to apply all changes (like certificate SANs).';
status.style.color = 'green';
setTimeout(() => fetchSettings(), 500); // Give backend a moment to settle
} else {
const err = await response.text();
status.innerText = '❌ Failed: ' + err;
status.style.color = 'red';
}
} catch (error) {
status.innerText = '❌ Error: ' + error.message;
status.style.color = 'red';
}
}
async function fetchDevices() {
try {
const response = await fetch('/setup/devices');
const devices = await response.json();
const container = document.getElementById('device-list');
const syncSelector = document.getElementById('sync-device-list');
const migrationSelector = document.getElementById('migration-device-list');
if (devices.length === 0) {
container.innerHTML = 'No devices found.';
container.innerHTML = 'No devices known yet.';
} 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>';
let html = '<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>';
// Clear and repopulate selectors
const currentSyncVal = syncSelector.value;
const currentMigrationVal = migrationSelector.value;
syncSelector.innerHTML = '<option value="">-- Select a device --</option>';
migrationSelector.innerHTML = '<option value="">-- Select a device --</option>';
devices.forEach(d => {
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<td class="col-name">${d.name}</td>
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-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>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || 'default'}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
`;
const optSync = document.createElement('option');
optSync.value = d.ip_address;
optSync.textContent = `${d.name} (${d.ip_address})`;
syncSelector.appendChild(optSync);
const optMigrate = document.createElement('option');
optMigrate.value = d.ip_address;
optMigrate.textContent = `${d.name} (${d.ip_address})`;
migrationSelector.appendChild(optMigrate);
});
html += '</table>';
container.innerHTML = html;
if (currentSyncVal) syncSelector.value = currentSyncVal;
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
}
@@ -74,15 +141,410 @@ async function fetchDevices() {
}
}
function prepareSync(ip) {
document.getElementById('sync-device-list').value = ip;
openTab(null, 'tab-sync');
}
function prepareMigration(ip) {
document.getElementById('migration-device-list').value = ip;
openTab(null, 'tab-migration');
showSummary(ip);
}
function openTab(evt, tabId) {
const tabcontents = document.getElementsByClassName("tab-content");
for (let i = 0; i < tabcontents.length; i++) {
tabcontents[i].className = tabcontents[i].className.replace(" active", "");
}
const tablinks = document.getElementsByClassName("tab-btn");
for (let i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
const content = document.getElementById(tabId);
if (content) {
content.className += " active";
}
if (tabId === 'tab-interactions') {
fetchInteractionStats();
fetchInteractions();
}
if (evt) {
evt.currentTarget.className += " active";
} else {
// Find the button that corresponds to the tabId and activate it
for (let i = 0; i < tablinks.length; i++) {
const onclick = tablinks[i].getAttribute('onclick');
if (onclick && onclick.includes(tabId)) {
tablinks[i].className += " active";
break;
}
}
}
}
async function startSync() {
const ip = document.getElementById('sync-device-list').value;
if (!ip) {
alert('Please select a device first');
return;
}
const status = document.getElementById('sync-status');
const results = document.getElementById('sync-results');
const log = document.getElementById('sync-log');
status.style.display = 'block';
status.style.backgroundColor = '#eef';
status.textContent = 'Syncing data from ' + ip + '...';
results.style.display = 'none';
log.innerHTML = '';
try {
const response = await fetch('/setup/sync/' + ip, { method: 'POST' });
if (response.ok) {
status.style.backgroundColor = '#dfd';
status.textContent = '✅ Sync completed successfully!';
results.style.display = 'block';
log.innerHTML = 'Data fetched and saved to local datastore.\nPresets: OK\nRecents: OK\nSources: OK';
} else {
const err = await response.text();
throw new Error(err);
}
} catch (error) {
status.style.backgroundColor = '#fdd';
status.textContent = '❌ Sync failed: ' + error.message;
}
}
async function fetchVersion() {
try {
const response = await fetch('/setup/version');
const data = await response.json();
const info = document.getElementById('version-info');
if (info && data.version) {
info.innerText = `SoundTouch Toolkit ${data.version} (${data.commit}) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
}
}
async function fetchInteractionStats() {
console.log('Fetching interaction stats...');
try {
const response = await fetch('/setup/interaction-stats');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const stats = await response.json();
console.log('Fetched interaction stats:', stats);
document.getElementById('total-requests').innerText = stats.total_requests || stats.TotalRequests || 0;
const statsContainer = document.getElementById('interaction-stats-container');
if (statsContainer) {
statsContainer.style.display = 'block';
}
const serviceList = document.getElementById('stats-by-service');
serviceList.innerHTML = '';
const byService = stats.by_service || stats.ByService;
if (byService) {
Object.entries(byService).forEach(([service, count]) => {
const li = document.createElement('li');
li.innerHTML = `<strong>${service || "unknown"}:</strong> ${count || 0} requests`;
serviceList.appendChild(li);
});
}
const sessionList = document.getElementById('stats-by-session');
const sessionFilter = document.getElementById('filter-session');
const currentFilter = sessionFilter.value;
sessionList.innerHTML = '';
sessionFilter.innerHTML = '<option value="">All Sessions</option>';
const bySession = stats.by_session || stats.BySession;
if (bySession) {
// Sort by session ID (timestamp) descending
const sortedSessions = Object.entries(bySession)
.sort((a, b) => {
const sessionA = a[0] || "";
const sessionB = b[0] || "";
return sessionB.localeCompare(sessionA);
});
sortedSessions.forEach(([session, count]) => {
// Session format is like 20260215-160705-99213
// Try to make it more readable: 2026-02-15 16:07:05 (PID 99213)
let sessionDisplay = session || "unknown";
if (session && session.includes('-')) {
const parts = session.split('-');
if (parts.length >= 2) {
const date = parts[0]; // 20260215
const time = parts[1]; // 160705
if (date.length === 8 && time.length === 6) {
sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`;
if (parts.length >= 3) {
sessionDisplay += ` (PID ${parts[2]})`;
}
}
}
}
const li = document.createElement('li');
li.innerHTML = `
<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span>
<div style="display: flex; gap: 5px;">
<button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>
<button onclick="deleteSession('${session || ""}')" class="btn-danger" style="font-size: 0.8em; padding: 2px 5px;">Delete</button>
</div>
`;
sessionList.appendChild(li);
const opt = document.createElement('option');
opt.value = session || "";
opt.innerText = sessionDisplay;
sessionFilter.appendChild(opt);
});
sessionFilter.value = currentFilter;
}
} catch (error) {
console.error('Failed to fetch interaction stats', error);
}
}
async function filterBySession(sessionId) {
document.getElementById('filter-session').value = sessionId;
fetchInteractions();
const browseContainer = document.getElementById('browse-recordings');
if (browseContainer) {
browseContainer.scrollIntoView({ behavior: 'smooth' });
}
}
async function deleteSession(sessionId) {
if (!sessionId) return;
if (!confirm(`Are you sure you want to delete session ${sessionId}?`)) {
return;
}
try {
const response = await fetch(`/setup/interactions/sessions/${sessionId}`, {
method: 'DELETE'
});
if (response.ok) {
// If the deleted session was selected in the filter, clear the filter
const sessionFilter = document.getElementById('filter-session');
if (sessionFilter.value === sessionId) {
sessionFilter.value = "";
fetchInteractions();
}
fetchInteractionStats();
} else {
const err = await response.text();
alert('Failed to delete session: ' + err);
}
} catch (error) {
alert('Error deleting session: ' + error.message);
}
}
async function cleanupSessions() {
if (!confirm('Are you sure you want to cleanup old sessions? Only the 10 most recent ones will be kept.')) {
return;
}
try {
const response = await fetch('/setup/interactions/sessions?keep=10', {
method: 'DELETE'
});
if (response.ok) {
// Refresh everything
document.getElementById('filter-session').value = "";
fetchInteractionStats();
fetchInteractions();
} else {
const err = await response.text();
alert('Failed to cleanup sessions: ' + err);
}
} catch (error) {
alert('Error cleaning up sessions: ' + error.message);
}
}
async function fetchInteractions() {
console.log('Fetching interactions...');
const session = document.getElementById('filter-session').value;
const category = document.getElementById('filter-category').value;
const since = document.getElementById('filter-since').value;
let url = '/setup/interactions';
const params = [];
if (session) params.push(`session=${encodeURIComponent(session)}`);
if (category) params.push(`category=${encodeURIComponent(category)}`);
if (since) params.push(`since=${encodeURIComponent(since)}`);
if (params.length > 0) url += '?' + params.join('&');
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const interactions = await response.json();
console.log('Fetched interactions:', interactions);
const list = document.getElementById('interactions-list');
if (!list) {
console.error('Could not find interactions-list element');
return;
}
// Show the parent summary box if it was hidden
const browseContainer = list.closest('.summary-box');
if (browseContainer) {
browseContainer.style.display = 'block';
}
list.innerHTML = '';
if (!interactions || interactions.length === 0) {
list.innerHTML = '<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found for current filters.</td></tr>';
return;
}
// Default sort: Session desc, then Counter asc
// If a specific session is selected, sort primarily by counter asc
interactions.sort((a, b) => {
const sessionA = a.session || a.Session || "";
const sessionB = b.session || b.Session || "";
if (sessionA !== sessionB) {
return sessionB.localeCompare(sessionA);
}
const counterA = a.counter || a.Counter || 0;
const counterB = b.counter || b.Counter || 0;
return counterA - counterB;
});
interactions.forEach(i => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #eee';
const counter = i.counter || i.Counter || 0;
const timestamp = i.timestamp || i.Timestamp || "";
const method = i.method || i.Method || "";
const path = i.path || i.Path || "";
const status = i.status || i.Status || "";
const category = i.category || i.Category || "";
const session = i.session || i.Session || "";
const file = i.file || i.File || "";
let statusClass = '';
if (status >= 200 && status < 300) statusClass = 'status-success';
else if (status >= 400) statusClass = 'status-error';
tr.innerHTML = `
<td style="padding: 8px; color: #888;">${counter}</td>
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${timestamp}</td>
<td style="padding: 8px; font-family: monospace;">${method}</td>
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
<td style="padding: 8px;"><span class="badge ${statusClass}">${status || '???'}</span></td>
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
<td style="padding: 8px;"><button onclick="viewInteraction('${file}')">View</button></td>
`;
list.appendChild(tr);
});
} catch (error) {
console.error('Failed to fetch interactions', error);
}
}
async function viewInteraction(file) {
try {
const response = await fetch(`/setup/interaction-content?file=${encodeURIComponent(file)}`);
const content = await response.text();
document.getElementById('viewer-filename').innerText = file;
document.getElementById('interaction-content').innerText = content;
document.getElementById('interaction-viewer').style.display = 'block';
document.getElementById('interaction-viewer').scrollIntoView({ behavior: 'smooth' });
} catch (error) {
alert('Failed to load interaction content: ' + error);
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchSettings();
fetchDevices();
triggerDiscovery();
fetchVersion();
const syncBtn = document.getElementById('sync-now-btn');
if (syncBtn) syncBtn.onclick = startSync;
});
async function addManualDevice() {
const ip = document.getElementById('add-manual-ip').value.trim();
if (!ip) {
alert('Please enter an IP address');
return;
}
try {
const response = await fetch('/setup/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: ip })
});
if (response.ok) {
document.getElementById('add-manual-ip').value = '';
fetchDevices();
} else {
const err = await response.text();
alert('Failed to add device: ' + err);
}
} catch (error) {
alert('Error adding device: ' + error.message);
}
}
async function removeDevice(deviceId, name) {
if (!confirm(`Are you sure you want to remove device "${name}"?`)) {
return;
}
try {
const response = await fetch(`/setup/devices/${deviceId}`, {
method: 'DELETE'
});
if (response.ok) {
fetchDevices();
} else {
const err = await response.text();
alert('Failed to remove device: ' + err);
}
} catch (error) {
alert('Error removing device: ' + error.message);
}
}
async function triggerDiscovery() {
const indicator = document.getElementById('discovery-indicator');
indicator.style.display = 'inline';
if (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';
if (indicator) indicator.style.display = 'none';
}
}
@@ -94,12 +556,12 @@ async function pollDiscoveryStatus() {
if (data.discovering) {
setTimeout(pollDiscoveryStatus, 2000);
} else {
indicator.style.display = 'none';
if (indicator) indicator.style.display = 'none';
fetchDevices();
}
} catch (error) {
console.error('Failed to check discovery status', error);
indicator.style.display = 'none';
if (indicator) indicator.style.display = 'none';
}
}
@@ -112,10 +574,23 @@ async function updateDeviceInfo(ip) {
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;
const nameEl = row.querySelector('.col-name');
if (nameEl && info.name) nameEl.innerText = info.name;
const modelEl = row.querySelector('.col-model');
if (modelEl && info.type) modelEl.innerText = info.type;
const serialEl = row.querySelector('.col-serial');
if (serialEl && info.serialNumber) serialEl.innerText = info.serialNumber;
const firmwareEl = row.querySelector('.col-firmware');
if (firmwareEl && info.softwareVersion) firmwareEl.innerText = info.softwareVersion;
const deviceIdEl = row.querySelector('.col-deviceid');
if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;
const accountIdEl = row.querySelector('.col-accountid');
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
}
} catch (error) {
console.warn('Failed to fetch live info for ' + ip, error);
@@ -124,7 +599,7 @@ async function updateDeviceInfo(ip) {
async function showSummary(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
document.getElementById('migration-summary').style.display = 'none';
return;
}
const targetUrl = document.getElementById('target-domain').value;
@@ -147,6 +622,9 @@ async function showSummary(ip) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
const outputBox = document.getElementById('command-output-box');
if (outputBox) outputBox.style.display = 'none';
try {
const response = await fetch('/setup/summary/' + ip + query);
if (!response.ok) {
@@ -162,10 +640,23 @@ async function showSummary(ip) {
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;
const nameEl = row.querySelector('.col-name');
if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;
const modelEl = row.querySelector('.col-model');
if (modelEl && summary.device_model) modelEl.innerText = summary.device_model;
const serialEl = row.querySelector('.col-serial');
if (serialEl && summary.device_serial) serialEl.innerText = summary.device_serial;
const firmwareEl = row.querySelector('.col-firmware');
if (firmwareEl && summary.firmware_version) firmwareEl.innerText = summary.firmware_version;
const deviceIdEl = row.querySelector('.col-deviceid');
if (deviceIdEl && summary.device_id) deviceIdEl.innerText = summary.device_id;
const accountIdEl = row.querySelector('.col-accountid');
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
}
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
@@ -238,6 +729,15 @@ async function showSummary(ip) {
migrateBtn.onclick = () => migrate(ip);
migrateBtn.disabled = !summary.ssh_success;
const revertBtn = document.getElementById('revert-migrate-btn');
revertBtn.onclick = () => revert(ip);
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.onclick = () => reboot(ip);
rebootBtn.disabled = !summary.ssh_success;
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.disabled = !summary.ssh_success;
@@ -265,6 +765,82 @@ function refreshSummary() {
}
}
function showCommandOutput(result) {
const outputBox = document.getElementById('command-output-box');
const outputText = document.getElementById('command-output');
if (outputBox && outputText && result.output) {
outputBox.style.display = 'block';
outputText.innerText = result.output;
} else if (outputBox) {
outputBox.style.display = 'none';
}
}
async function revert(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
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 = 'Reverting ' + ip + ' to defaults...';
try {
const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
}
}
async function reboot(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Rebooting ' + ip + '...';
try {
const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
}
}
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
@@ -297,9 +873,10 @@ async function migrate(ip) {
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. The speaker will reboot.';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
@@ -323,6 +900,7 @@ async function trustCA(ip) {
try {
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
@@ -353,6 +931,7 @@ async function ensureRemoteServices(ip) {
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
@@ -385,6 +964,7 @@ async function removeRemoteServices(ip) {
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
@@ -411,6 +991,7 @@ async function backupConfig(ip) {
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
+28 -26
View File
@@ -112,13 +112,13 @@ func GetConfiguredSourceXML(cs models.ConfiguredSource) string {
}
// PresetsToXML converts account presets to XML format for Marge responses.
func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
presets, err := ds.GetPresets(account)
func PresetsToXML(ds *datastore.DataStore, account, device string) ([]byte, error) {
presets, err := ds.GetPresets(account, device)
if err != nil {
return nil, err
}
sources, err := ds.GetConfiguredSources(account)
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return nil, err
}
@@ -135,7 +135,8 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
res += fmt.Sprintf(`<name>%s</name>`, p.Name)
// Content Item Source
for _, s := range sources {
for j := range sources {
s := sources[j]
if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
res += GetConfiguredSourceXML(s)
break
@@ -152,13 +153,13 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
}
// RecentsToXML converts account recent items to XML format for Marge responses.
func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
recents, err := ds.GetRecents(account)
func RecentsToXML(ds *datastore.DataStore, account, device string) ([]byte, error) {
recents, err := ds.GetRecents(account, device)
if err != nil {
return nil, err
}
sources, err := ds.GetConfiguredSources(account)
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return nil, err
}
@@ -181,7 +182,8 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
res += fmt.Sprintf(`<name>%s</name>`, r.Name)
// Content Item Source
for _, s := range sources {
for j := range sources {
s := sources[j]
if s.ID == r.SourceID || (s.SourceKeyType == r.Source && s.SourceKeyAccount == r.SourceAccount) {
res += GetConfiguredSourceXML(s)
break
@@ -240,12 +242,12 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, info.IPAddress)
res += fmt.Sprintf(`<name>%s</name>`, info.Name)
presets, _ := PresetsToXML(ds, account)
presets, _ := PresetsToXML(ds, account, deviceID)
if len(presets) > len(xml.Header) {
res += string(presets[len(xml.Header):]) // strip header
}
recents, _ := RecentsToXML(ds, account)
recents, _ := RecentsToXML(ds, account, deviceID)
if len(recents) > len(xml.Header) {
res += string(recents[len(xml.Header):]) // strip header
}
@@ -257,11 +259,11 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
res += ProviderSettingsToXML(account)
if lastDeviceID != "" {
sources, _ := ds.GetConfiguredSources(account)
sources, _ := ds.GetConfiguredSources(account, lastDeviceID)
res += `<sources>`
for _, s := range sources {
res += GetConfiguredSourceXML(s)
for j := range sources {
res += GetConfiguredSourceXML(sources[j])
}
res += `</sources>`
@@ -273,13 +275,13 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
}
// UpdatePreset updates or creates a preset for the specified account and device.
func UpdatePreset(ds *datastore.DataStore, account, _ string, presetNumber int, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account)
func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return nil, err
}
presets, err := ds.GetPresets(account)
presets, err := ds.GetPresets(account, device)
if err != nil {
return nil, err
}
@@ -297,9 +299,9 @@ func UpdatePreset(ds *datastore.DataStore, account, _ string, presetNumber int,
var matchingSrc *models.ConfiguredSource
for _, s := range sources {
if s.ID == newPresetElem.SourceID {
matchingSrc = &s
for i := range sources {
if sources[i].ID == newPresetElem.SourceID {
matchingSrc = &sources[i]
break
}
}
@@ -331,7 +333,7 @@ func UpdatePreset(ds *datastore.DataStore, account, _ string, presetNumber int,
presets[presetNumber-1] = presetObj
if err := ds.SavePresets(account, presets); err != nil {
if err := ds.SavePresets(account, device, presets); err != nil {
return nil, err
}
@@ -351,12 +353,12 @@ func UpdatePreset(ds *datastore.DataStore, account, _ string, presetNumber int,
// AddRecent adds or updates a recent item for the specified account and device.
func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account)
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return nil, err
}
recents, err := ds.GetRecents(account)
recents, err := ds.GetRecents(account, device)
if err != nil {
return nil, err
}
@@ -407,7 +409,7 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
}
}
if err := ds.SaveRecents(account, recents); err != nil {
if err := ds.SaveRecents(account, device, recents); err != nil {
return nil, err
}
@@ -415,9 +417,9 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
}
func findMatchingSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource {
for _, s := range sources {
if s.ID == sourceID {
return &s
for i := range sources {
if sources[i].ID == sourceID {
return &sources[i]
}
}
+18 -15
View File
@@ -30,8 +30,8 @@ func TestMargeXML(t *testing.T) {
_ = ds.SaveDeviceInfo(account, device, info)
// Save empty presets/recents to avoid index out of range when stripping header
_ = ds.SavePresets(account, []models.ServicePreset{})
_ = ds.SaveRecents(account, []models.ServiceRecent{})
_ = ds.SavePresets(account, device, []models.ServicePreset{})
_ = ds.SaveRecents(account, device, []models.ServiceRecent{})
// Test SourceProvidersToXML
xmlData, err := SourceProvidersToXML()
@@ -78,17 +78,20 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
// 1. Setup configured sources
// We need a Sources.xml file in the account directory
sourcesPath := ds.AccountDir(account)
_ = os.MkdirAll(sourcesPath, 0755)
_ = ds.SaveConfiguredSources(account, []models.ConfiguredSource{
{
ID: "101",
DisplayName: "Test Source",
SourceKeyType: "TUNEIN",
SourceKeyAccount: "test-user",
},
})
_ = ds.SaveRecents(account, []models.ServiceRecent{})
deviceDir := ds.AccountDeviceDir(account, device)
_ = os.MkdirAll(deviceDir, 0755)
src := models.ConfiguredSource{
ID: "101",
DisplayName: "Test Source",
SecretType: "Audio",
}
src.SourceKey.Type = "TUNEIN"
src.SourceKey.Account = "test-user"
src.SourceKeyType = "TUNEIN"
src.SourceKeyAccount = "test-user"
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
_ = ds.SaveRecents(account, device, []models.ServiceRecent{})
// 2. Add an initial recent
sourceXML := []byte(`
@@ -104,7 +107,7 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
t.Fatalf("AddRecent failed: %v", err)
}
recents, _ := ds.GetRecents(account)
recents, _ := ds.GetRecents(account, device)
if len(recents) != 1 {
t.Fatalf("Expected 1 recent, got %d", len(recents))
}
@@ -126,7 +129,7 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
t.Errorf("Expected preserved DateStr in createdOn, got XML: %s", string(respXML))
}
recents, _ = ds.GetRecents(account)
recents, _ = ds.GetRecents(account, device)
if len(recents) != 1 {
t.Errorf("Expected still 1 recent, got %d", len(recents))
}
+84
View File
@@ -0,0 +1,84 @@
package proxy
import (
"encoding/json"
"fmt"
"os"
"regexp"
)
// PathPattern defines a regex and its replacement for sanitizing URL paths.
type PathPattern struct {
Name string `json:"name"`
Regexp string `json:"regexp"`
Replacement string `json:"replacement"`
compiled *regexp.Regexp
}
// PathPatterns is a collection of PathPattern.
type PathPatterns []PathPattern
// LoadPatterns loads path patterns from a JSON file.
func LoadPatterns(path string) (PathPatterns, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return PathPatterns{}, nil
}
return nil, err
}
var patterns PathPatterns
if err := json.Unmarshal(data, &patterns); err != nil {
return nil, err
}
for i := range patterns {
re, err := regexp.Compile(patterns[i].Regexp)
if err != nil {
return nil, fmt.Errorf("invalid regex in pattern %s: %w", patterns[i].Name, err)
}
patterns[i].compiled = re
}
return patterns, nil
}
// Sanitize sanitizes a segment using the configured patterns.
func (pp PathPatterns) Sanitize(segment string) (string, string) {
for _, p := range pp {
if p.compiled != nil && p.compiled.MatchString(segment) {
return p.Replacement, p.Replacement
}
}
return segment, ""
}
// DefaultPatterns returns the default set of path patterns.
func DefaultPatterns() PathPatterns {
return PathPatterns{
{
Name: "IPv4",
Regexp: `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`,
Replacement: "{ip}",
},
{
Name: "UUID",
Regexp: `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`,
Replacement: "{uuid}",
},
{
Name: "AccountID",
Regexp: `^\d{5,10}$`,
Replacement: "{accountId}",
},
{
Name: "DeviceID",
Regexp: `^[0-9a-fA-F]{12}$`,
Replacement: "{device_id}",
},
}
}
+61
View File
@@ -0,0 +1,61 @@
package proxy
import (
"regexp"
"testing"
)
func TestDefaultPatterns(t *testing.T) {
patterns := DefaultPatterns()
if len(patterns) != 4 {
t.Errorf("Expected 4 default patterns, got %d", len(patterns))
}
expectedNames := []string{"IPv4", "UUID", "AccountID", "DeviceID"}
for i, name := range expectedNames {
if patterns[i].Name != name {
t.Errorf("Expected pattern %d name %s, got %s", i, name, patterns[i].Name)
}
}
}
func TestPathPatterns_Sanitize(t *testing.T) {
patterns := DefaultPatterns()
// Need to compile them as DefaultPatterns() in its new form doesn't compile them (main.go or LoadPatterns does it)
// Wait, actually the new DefaultPatterns() I wrote doesn't compile them.
// But PathPatterns.Sanitize checks for compiled != nil.
// Let's manually compile for the test
for i := range patterns {
patterns[i].compiled = mustCompile(patterns[i].Regexp)
}
tests := []struct {
segment string
wantRepl string
}{
{"192.168.1.100", "{ip}"},
{"1234567", "{accountId}"},
{"12345", "{accountId}"},
{"12345678-1234-5678-9012-123456789012", "{uuid}"},
{"D05FB8A848E5", "{device_id}"},
{"some-other-segment", ""},
}
for _, tt := range tests {
repl, _ := patterns.Sanitize(tt.segment)
if tt.wantRepl == "" {
if repl != tt.segment {
t.Errorf("Sanitize(%q) = %q, want %q (no change)", tt.segment, repl, tt.segment)
}
} else {
if repl != tt.wantRepl {
t.Errorf("Sanitize(%q) = %q, want %q", tt.segment, repl, tt.wantRepl)
}
}
}
}
func mustCompile(re string) *regexp.Regexp {
return regexp.MustCompile(re)
}
+15 -4
View File
@@ -20,10 +20,12 @@ var sensitiveHeaders = []string{
// LoggingProxy wraps a ReverseProxy to provide instrumentation.
type LoggingProxy struct {
Proxy *httputil.ReverseProxy
Redact bool
LogBody bool
MaxBodySize int64
Proxy *httputil.ReverseProxy
Redact bool
LogBody bool
RecordEnabled bool
MaxBodySize int64
Recorder *Recorder
}
// NewLoggingProxy creates a lightweight logger for HTTP requests/responses.
@@ -36,6 +38,11 @@ func NewLoggingProxy(_ string, redact bool) *LoggingProxy {
}
}
// SetRecorder sets the recorder for the proxy.
func (lp *LoggingProxy) SetRecorder(r *Recorder) {
lp.Recorder = r
}
// LogRequest prints an abbreviated request with optional header/body redaction.
func (lp *LoggingProxy) LogRequest(r *http.Request) {
headers := formatHeaders(r.Header, lp.Redact)
@@ -82,6 +89,10 @@ func (lp *LoggingProxy) LogResponse(r *http.Response) {
}
log.Printf("[PROXY_RES] %d %s\n Headers:\n%s\n Body: %s", r.StatusCode, r.Request.URL.String(), headers, bodyStr)
if lp.Recorder != nil && lp.RecordEnabled {
_ = lp.Recorder.Record("upstream", r.Request, r)
}
}
func formatHeaders(h http.Header, redact bool) string {
+45
View File
@@ -4,6 +4,7 @@ import (
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -64,6 +65,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
defer func() { _ = os.Unsetenv("LOG_PROXY_BODY") }()
lp := NewLoggingProxy("http://example.com", true)
lp.LogBody = true
body := "test body content"
req := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader(body))
@@ -77,4 +79,47 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
if string(readBody) != body {
t.Errorf("Request body was consumed or changed, got %q, want %q", string(readBody), body)
}
// Test truncation
lp.MaxBodySize = 4
req2 := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader("1234567890"))
req2.Header.Set("Content-Type", "text/plain")
lp.LogRequest(req2)
}
func TestLoggingProxy_LogResponse(t *testing.T) {
lp := NewLoggingProxy("http://example.com", true)
lp.LogBody = true
body := "response content"
req := httptest.NewRequest("GET", "http://example.com/api", nil)
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "text/plain")
_, _ = w.WriteString(body)
res := w.Result()
res.Request = req
lp.LogResponse(res)
// Check if body is still readable
readBody, _ := io.ReadAll(res.Body)
if string(readBody) != body {
t.Errorf("Response body was consumed or changed, got %q, want %q", string(readBody), body)
}
// Test with recorder
tmpDir, _ := os.MkdirTemp("", "proxy-recorder-test")
defer os.RemoveAll(tmpDir)
recorder := NewRecorder(tmpDir)
lp.SetRecorder(recorder)
lp.RecordEnabled = true
lp.LogResponse(res)
// Verify recording exists
interactionsDir := filepath.Join(tmpDir, "interactions", recorder.SessionID, "upstream", "api")
files, _ := os.ReadDir(interactionsDir)
if len(files) == 0 {
t.Error("LogResponse did not record the interaction")
}
}
+487
View File
@@ -0,0 +1,487 @@
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Recorder handles persisting HTTP interactions as .http files.
type Recorder struct {
BaseDir string
SessionID string
SessionDir string
Patterns PathPatterns
Redact bool
counter uint64
variables map[string]string
mu sync.Mutex
}
// InteractionStats represents statistics for recorded interactions.
type InteractionStats struct {
TotalRequests int `json:"total_requests"`
ByService map[string]int `json:"by_service"`
BySession map[string]int `json:"by_session"`
}
// Interaction represents a single recorded HTTP interaction.
type Interaction struct {
ID string `json:"id"`
Session string `json:"session"`
Category string `json:"category"`
Method string `json:"method"`
Path string `json:"path"`
File string `json:"file"`
Counter int `json:"counter"`
Status int `json:"status"`
Timestamp string `json:"timestamp"`
}
// NewRecorder creates a new HTTP interaction recorder.
func NewRecorder(baseDir string) *Recorder {
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
return &Recorder{
BaseDir: baseDir,
SessionID: sessionID,
Patterns: DefaultPatterns(),
variables: make(map[string]string),
}
}
// Record persists a request and response to a .http file in the specified category (e.g., "self" or "upstream").
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
if r.BaseDir == "" {
return nil
}
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
dir := r.getRecordingDir(category, sanitizedSegments)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
path := r.getRecordingPath(dir, req.Method)
var buf bytes.Buffer
r.writeRequest(&buf, req, replacements)
if res != nil {
r.writeResponse(&buf, res)
}
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return err
}
return r.updateEnvFile(replacements)
}
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
sanitizedSegments := make([]string, 0, len(pathSegments))
replacements := make(map[string]string)
for _, segment := range pathSegments {
if segment == "" {
continue
}
sanitized, replacement := r.Patterns.Sanitize(segment)
sanitizedSegments = append(sanitizedSegments, sanitized)
if replacement != "" {
replacements[segment] = replacement
}
}
return sanitizedSegments, replacements
}
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) string {
subDir := "root"
if len(sanitizedSegments) > 0 {
subDir = filepath.Join(sanitizedSegments...)
}
return filepath.Join(r.BaseDir, "interactions", r.SessionID, category, subDir)
}
func (r *Recorder) getRecordingPath(dir, method string) string {
timestamp := time.Now().Format("15-04-05.000")
count := atomic.AddUint64(&r.counter, 1)
filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method)
return filepath.Join(dir, filename)
}
func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacements map[string]string) {
displayURL := req.URL.String()
for orig, repl := range replacements {
displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}")
}
fmt.Fprintf(buf, "### %s %s\n", req.Method, displayURL)
for orig, repl := range replacements {
key := strings.Trim(repl, "{}")
fmt.Fprintf(buf, "// %s: %s\n", key, orig)
}
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
for k, vv := range req.Header {
if r.Redact && isSensitive(k) {
fmt.Fprintf(buf, "%s: [REDACTED]\n", k)
continue
}
for _, v := range vv {
val := v
for orig, repl := range replacements {
val = strings.ReplaceAll(val, orig, "{{"+strings.Trim(repl, "{}")+"}}")
}
fmt.Fprintf(buf, "%s: %s\n", k, val)
}
}
buf.WriteString("\n")
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err == nil {
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
buf.Write(bodyBytes)
buf.WriteString("\n")
}
}
}
func (r *Recorder) writeResponse(buf *bytes.Buffer, res *http.Response) {
buf.WriteString("\n")
buf.WriteString("> {% \n")
fmt.Fprintf(buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
buf.WriteString(" // Headers:\n")
for k, vv := range res.Header {
if r.Redact && isSensitive(k) {
fmt.Fprintf(buf, " // %s: [REDACTED]\n", k)
continue
}
for _, v := range vv {
fmt.Fprintf(buf, " // %s: %s\n", k, v)
}
}
buf.WriteString("%}\n")
if res.Body != nil {
bodyBytes, err := io.ReadAll(res.Body)
if err == nil {
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
contentType := res.Header.Get("Content-Type")
if strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") {
buf.WriteString("\n/*\n")
buf.Write(bodyBytes)
buf.WriteString("\n*/\n")
} else {
fmt.Fprintf(buf, "\n// [Binary response body: %d bytes]\n", len(bodyBytes))
}
}
}
}
func (r *Recorder) updateEnvFile(newVars map[string]string) error {
if len(newVars) == 0 {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
changed := false
for orig, repl := range newVars {
key := strings.Trim(repl, "{}")
if r.variables[key] != orig {
r.variables[key] = orig
changed = true
}
}
if !changed {
return nil
}
envFile := filepath.Join(r.BaseDir, "interactions", r.SessionID, "http-client.env.json")
// Create the structure: {"session": {"key": "val"}}
content := map[string]map[string]string{
"session": r.variables,
}
data, err := json.MarshalIndent(content, "", " ")
if err != nil {
return err
}
return os.WriteFile(envFile, data, 0644)
}
// GetInteractionStats returns statistics about recorded interactions.
func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
stats := &InteractionStats{
ByService: make(map[string]int),
BySession: make(map[string]int),
}
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
return stats, nil
}
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
stats.TotalRequests++
// Extract category (self/upstream) and session from path
// Path is like: .../interactions/<session>/<category>/...
rel, err := filepath.Rel(interactionsDir, path)
if err != nil {
return err
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) >= 2 {
sessionID := parts[0]
category := parts[1]
stats.BySession[sessionID]++
stats.ByService[category]++
}
}
return nil
})
return stats, err
}
// ListInteractions returns a list of recorded interactions.
func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
interactions := make([]Interaction, 0)
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
return interactions, nil
}
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
return nil
}
rel, err := filepath.Rel(interactionsDir, path)
if err != nil {
return err
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) < 3 {
return nil
}
sessionID, category := parts[0], parts[1]
if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
return nil
}
interaction, ok := r.parseInteractionFile(rel, path, parts)
if !ok {
return nil
}
if sinceFilter != "" && interaction.Timestamp != "" {
fullTS := r.getFullTimestamp(sessionID, interaction.ID)
normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
if fullTS != "" && fullTS < normalizedSince {
return nil
}
}
interactions = append(interactions, interaction)
return nil
})
return interactions, err
}
func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
sessionID, category := parts[0], parts[1]
filename := parts[len(parts)-1]
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
date := ""
if len(sessionID) >= 8 {
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
}
timestamp := ""
if len(fnParts) >= 4 {
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
timestamp = timeStr
if date != "" {
timestamp = date + " " + timeStr
}
}
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
if requestPath == "/root" {
requestPath = "/"
}
method, counter := "UNKNOWN", 0
if len(fnParts) >= 1 {
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
}
if len(fnParts) >= 5 {
method = fnParts[4]
}
return Interaction{
ID: filename,
Session: sessionID,
Category: category,
Method: method,
Path: requestPath,
File: rel,
Counter: counter,
Status: r.peekStatus(path),
Timestamp: timestamp,
}, true
}
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
if len(sessionID) < 8 {
return ""
}
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
if len(fnParts) < 4 {
return ""
}
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
}
func (r *Recorder) peekStatus(path string) int {
content, err := os.ReadFile(path)
if err != nil {
return 0
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
if !strings.Contains(line, "// Response:") {
continue
}
trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
trimmedLine = strings.TrimSpace(trimmedLine)
status := 0
_, _ = fmt.Sscanf(trimmedLine, "%d", &status)
return status
}
return 0
}
// DeleteSession deletes a specific recording session.
func (r *Recorder) DeleteSession(sessionID string) error {
if sessionID == "" {
return fmt.Errorf("session ID is required")
}
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
return os.RemoveAll(sessionDir)
}
// CleanupSessions deletes all but the most recent keepCount sessions.
func (r *Recorder) CleanupSessions(keepCount int) error {
interactionsDir := filepath.Join(r.BaseDir, "interactions")
entries, err := os.ReadDir(interactionsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var sessions []os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
sessions = append(sessions, entry)
}
}
if len(sessions) <= keepCount {
return nil
}
// Sort sessions by name (timestamp) descending to keep the newest ones
// Session ID format: 20260102-150405-PID
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Name() > sessions[j].Name()
})
for i := keepCount; i < len(sessions); i++ {
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
if err := os.RemoveAll(sessionDir); err != nil {
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
}
}
return nil
}
// GetInteractionContent returns the raw content of a recorded interaction.
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
return os.ReadFile(fullPath)
}
+773
View File
@@ -0,0 +1,773 @@
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestRecorder_Record_Structure(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Compile default patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
tests := []struct {
name string
category string
path string
expected string // Expected subdirectory after interactions/{sessionID}/{category}/
}{
{
name: "root_path",
category: "self",
path: "/",
expected: "root",
},
{
name: "simple_path",
category: "self",
path: "/setup/info",
expected: "setup/info",
},
{
name: "path_with_ip",
category: "self",
path: "/setup/info/192.168.178.35",
expected: "setup/info/{ip}",
},
{
name: "upstream_path",
category: "upstream",
path: "/v1/playback/station/s123",
expected: "v1/playback/station/s123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: tt.path,
},
Header: make(http.Header),
}
err := r.Record(tt.category, req, nil)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, tt.category, tt.expected)
if _, err := os.Stat(expectedDir); os.IsNotExist(err) {
t.Errorf("Expected directory %s does not exist", expectedDir)
}
// Check if file was created
files, _ := os.ReadDir(expectedDir)
if len(files) == 0 {
t.Errorf("No files created in %s", expectedDir)
}
for _, f := range files {
if !strings.Contains(f.Name(), "-GET.http") {
t.Errorf("Unexpected filename: %s", f.Name())
}
// Verify prefix is 4 digits
if len(f.Name()) < 5 || !isDigit(f.Name()[0]) || !isDigit(f.Name()[1]) || !isDigit(f.Name()[2]) || !isDigit(f.Name()[3]) || f.Name()[4] != '-' {
t.Errorf("Filename %s does not have correct 0000- prefix", f.Name())
}
}
})
}
}
func TestRecorder_Record_Sanitization(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-sanitization-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Add a custom pattern
r.Patterns = append(r.Patterns, PathPattern{
Name: "CustomDeviceID",
Regexp: `^A81B\w{8}$`,
Replacement: "{deviceId}",
})
// Compile all patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/info/192.168.178.35/A81B6A536A98",
},
Header: make(http.Header),
}
req.Header.Set("X-Device", "A81B6A536A98")
err = r.Record("self", req, nil)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "info", "{ip}", "{device_id}")
if _, err := os.Stat(expectedDir); os.IsNotExist(err) {
t.Errorf("Expected directory %s does not exist", expectedDir)
}
files, _ := os.ReadDir(expectedDir)
if len(files) == 0 {
t.Fatalf("No files created in %s", expectedDir)
}
content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "### GET /info/{{ip}}/{{device_id}}") {
t.Errorf("Expected sanitized comment in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "GET /info/{{ip}}/{{device_id}}") {
t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "X-Device: {{device_id}}") {
t.Errorf("Expected sanitized Header in .http file, got:\n%s", contentStr)
}
}
func TestRecorder_Record_Sanitization_Account(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-sanitization-account-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Use default patterns which now include AccountID
r.Patterns = DefaultPatterns()
// Compile all patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
accountID := "1234567"
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/marge/accounts/" + accountID + "/full",
},
Header: make(http.Header),
}
err = r.Record("self", req, nil)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "marge", "accounts", "{accountId}", "full")
if _, err := os.Stat(expectedDir); os.IsNotExist(err) {
t.Errorf("Expected directory %s does not exist", expectedDir)
}
files, _ := os.ReadDir(expectedDir)
if len(files) == 0 {
t.Fatalf("No files created in %s", expectedDir)
}
content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "### GET /marge/accounts/{{accountId}}/full") {
t.Errorf("Expected sanitized comment in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "GET /marge/accounts/{{accountId}}/full") {
t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "// accountId: "+accountID) {
t.Errorf("Expected accountId comment in .http file, got:\n%s", contentStr)
}
}
func TestRecorder_Record_Redaction(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-redaction-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = true
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/test",
},
Header: make(http.Header),
}
req.Header.Set("Authorization", "Bearer sensitive-token")
req.Header.Set("Cookie", "session=secret")
req.Header.Set("X-Normal", "public-info")
err = r.Record("self", req, nil)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test")
files, _ := os.ReadDir(expectedDir)
content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "Authorization: [REDACTED]") {
t.Errorf("Expected Authorization header to be redacted, got:\n%s", contentStr)
}
if strings.Contains(contentStr, "sensitive-token") {
t.Errorf("Sensitive token still present in content:\n%s", contentStr)
}
if !strings.Contains(contentStr, "Cookie: [REDACTED]") {
t.Errorf("Expected Cookie header to be redacted, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "X-Normal: public-info") {
t.Errorf("Expected normal header to be present, got:\n%s", contentStr)
}
}
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
func TestRecorder_IncreasingPrefix(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-prefix-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Compile default patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/test",
},
Header: make(http.Header),
}
for i := 1; i <= 3; i++ {
err := r.Record("self", req, nil)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
}
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test")
files, _ := os.ReadDir(expectedDir)
if len(files) != 3 {
t.Fatalf("Expected 3 files, got %d", len(files))
}
expectedPrefixes := []string{"0001-", "0002-", "0003-"}
for i, f := range files {
if !strings.HasPrefix(f.Name(), expectedPrefixes[i]) {
t.Errorf("File %d: expected prefix %s, got %s", i, expectedPrefixes[i], f.Name())
}
}
}
func TestRecorder_EnvFile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-env-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Compile default patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/info/192.168.178.35",
},
Header: make(http.Header),
}
err = r.Record("self", req, nil)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
envFile := filepath.Join(tmpDir, "interactions", r.SessionID, "http-client.env.json")
if _, err := os.Stat(envFile); os.IsNotExist(err) {
t.Fatalf("Expected env file %s does not exist", envFile)
}
data, _ := os.ReadFile(envFile)
var content map[string]map[string]string
if err := json.Unmarshal(data, &content); err != nil {
t.Fatalf("Failed to unmarshal env file: %v", err)
}
if content["session"]["ip"] != "192.168.178.35" {
t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"])
}
}
func TestRecorder_GetInteractionStats(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-stats-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.SessionID = "20260215-120000-12345"
// Create some dummy interactions
files := []string{
"interactions/20260215-120000-12345/self/setup/0001-12-00-01.000-GET.http",
"interactions/20260215-120000-12345/upstream/marge/0002-12-00-02.000-POST.http",
"interactions/20260215-130000-67890/self/setup/0001-13-00-01.000-GET.http",
}
for _, f := range files {
path := filepath.Join(tmpDir, f)
os.MkdirAll(filepath.Dir(path), 0755)
os.WriteFile(path, []byte("test"), 0644)
}
stats, err := r.GetInteractionStats()
if err != nil {
t.Fatalf("GetInteractionStats failed: %v", err)
}
if stats.TotalRequests != 3 {
t.Errorf("Expected 3 total requests, got %d", stats.TotalRequests)
}
if stats.ByService["self"] != 2 {
t.Errorf("Expected 2 self requests, got %d", stats.ByService["self"])
}
if stats.ByService["upstream"] != 1 {
t.Errorf("Expected 1 upstream request, got %d", stats.ByService["upstream"])
}
if stats.BySession["20260215-120000-12345"] != 2 {
t.Errorf("Expected 2 requests for session 1, got %d", stats.BySession["20260215-120000-12345"])
}
}
func TestRecorder_ListInteractions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-list-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
session1 := "20260215-120000-12345"
session2 := "20260215-130000-67890"
// Create some dummy interactions
files := []struct {
path string
content string
}{
{
path: filepath.Join("interactions", session1, "self", "setup", "0001-12-00-01.555-GET.http"),
content: "### GET /setup\n\n> {% \n // Response: 200 OK\n%}\n",
},
{
path: filepath.Join("interactions", session1, "upstream", "marge", "0002-12-00-02.000-POST.http"),
content: "### POST /marge\n\n> {% \n // Response: 201 Created\n%}\n",
},
{
path: filepath.Join("interactions", session2, "self", "info", "0001-13-00-05.000-GET.http"),
content: "### GET /info\n\n> {% \n // Response: 404 Not Found\n%}\n",
},
}
for _, f := range files {
path := filepath.Join(tmpDir, f.path)
os.MkdirAll(filepath.Dir(path), 0755)
os.WriteFile(path, []byte(f.content), 0644)
}
t.Run("List_all", func(t *testing.T) {
list, err := r.ListInteractions("", "", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 3 {
t.Errorf("Expected 3 interactions, got %d", len(list))
}
})
t.Run("Filter_by_session", func(t *testing.T) {
list, err := r.ListInteractions(session1, "", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 2 {
t.Errorf("Expected 2 interactions for session1, got %d", len(list))
}
for _, i := range list {
if i.Session != session1 {
t.Errorf("Expected session %s, got %s", session1, i.Session)
}
}
})
t.Run("Filter_by_category", func(t *testing.T) {
list, err := r.ListInteractions("", "upstream", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 1 {
t.Errorf("Expected 1 upstream interaction, got %d", len(list))
}
if list[0].Category != "upstream" {
t.Errorf("Expected category upstream, got %s", list[0].Category)
}
})
t.Run("Check_enhanced_fields", func(t *testing.T) {
list, err := r.ListInteractions(session1, "self", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) == 0 {
t.Fatal("Expected at least one interaction")
}
i := list[0]
if i.Counter != 1 {
t.Errorf("Expected counter 1, got %d", i.Counter)
}
if i.Status != 200 {
t.Errorf("Expected status 200, got %d", i.Status)
}
if i.Method != "GET" {
t.Errorf("Expected method GET, got %s", i.Method)
}
if i.Timestamp != "2026-02-15 12:00:01.555" {
t.Errorf("Expected timestamp 2026-02-15 12:00:01.555, got %s", i.Timestamp)
}
if i.Path != "/setup" {
t.Errorf("Expected path /setup, got %s", i.Path)
}
})
t.Run("Filter_by_since", func(t *testing.T) {
// session1 has 2026-02-15 12:00:01.555 and 12:00:02.000
// session2 has 2026-02-15 13:00:05.000
list, err := r.ListInteractions("", "", "2026-02-15 12:30:00")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 1 {
t.Errorf("Expected 1 interaction since 12:30:00, got %d", len(list))
}
if list[0].Session != session2 {
t.Errorf("Expected session2, got %s", list[0].Session)
}
list, err = r.ListInteractions("", "", "2026-02-15 12:00:01.600")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
// Should include 12:00:02.000 and 13:00:05.000
if len(list) != 2 {
t.Errorf("Expected 2 interactions since 12:00:01.600, got %d", len(list))
}
})
}
func TestRecorder_DeleteAndCleanup(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-delete-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Create 15 dummy sessions
for i := 1; i <= 15; i++ {
// Use a format that sorts correctly: YYYYMMDD-HHmmss-PID
sessionID := fmt.Sprintf("20260215-12%02d00-12345", i)
sessionDir := filepath.Join(tmpDir, "interactions", sessionID)
os.MkdirAll(sessionDir, 0755)
os.WriteFile(filepath.Join(sessionDir, "test.http"), []byte("test"), 0644)
}
t.Run("Delete_specific_session", func(t *testing.T) {
sessionToDelete := "20260215-120500-12345"
err := r.DeleteSession(sessionToDelete)
if err != nil {
t.Fatalf("DeleteSession failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "interactions", sessionToDelete)); !os.IsNotExist(err) {
t.Errorf("Session %s still exists after deletion", sessionToDelete)
}
})
t.Run("Cleanup_sessions", func(t *testing.T) {
err := r.CleanupSessions(10)
if err != nil {
t.Fatalf("CleanupSessions failed: %v", err)
}
entries, _ := os.ReadDir(filepath.Join(tmpDir, "interactions"))
if len(entries) != 10 {
t.Errorf("Expected 10 sessions to remain, got %d", len(entries))
}
// Ensure newest sessions are kept
// We created 1 to 15, deleted 5. Remaining: 1-4, 6-15 (14 sessions)
// Cleanup(10) should keep 15, 14, 13, 12, 11, 10, 9, 8, 7, 6.
expectedRemaining := []string{
"20260215-120600-12345",
"20260215-120700-12345",
"20260215-120800-12345",
"20260215-120900-12345",
"20260215-121000-12345",
"20260215-121100-12345",
"20260215-121200-12345",
"20260215-121300-12345",
"20260215-121400-12345",
"20260215-121500-12345",
}
for _, sessionID := range expectedRemaining {
if _, err := os.Stat(filepath.Join(tmpDir, "interactions", sessionID)); os.IsNotExist(err) {
t.Errorf("Expected session %s to remain, but it was deleted", sessionID)
}
}
// Check one that should be deleted
if _, err := os.Stat(filepath.Join(tmpDir, "interactions", "20260215-120100-12345")); !os.IsNotExist(err) {
t.Errorf("Session 20260215-120100-12345 should have been cleaned up")
}
})
}
func TestRecorder_GetInteractionContent(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-content-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
relPath := filepath.Join(r.SessionID, "self", "test", "0001-GET.http")
fullPath := filepath.Join(tmpDir, "interactions", relPath)
os.MkdirAll(filepath.Dir(fullPath), 0755)
expectedContent := "test content"
os.WriteFile(fullPath, []byte(expectedContent), 0644)
content, err := r.GetInteractionContent(relPath)
if err != nil {
t.Fatalf("GetInteractionContent failed: %v", err)
}
if string(content) != expectedContent {
t.Errorf("Expected %s, got %s", expectedContent, string(content))
}
_, err = r.GetInteractionContent("non-existent")
if err == nil {
t.Error("Expected error for non-existent file, got nil")
}
}
func TestRecorder_Record_FullExchange(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-full-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
req := &http.Request{
Method: "POST",
URL: &url.URL{
Path: "/test",
},
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("request body")),
}
req.Header.Set("Content-Type", "text/plain")
res := &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("response body")),
Request: req,
}
res.Header.Set("Content-Type", "application/json")
err = r.Record("self", req, res)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
// Verify file content
interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test")
files, _ := os.ReadDir(interactionsDir)
if len(files) == 0 {
t.Fatal("No recording file found")
}
content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "request body") {
t.Error("Recording does not contain request body")
}
if !strings.Contains(contentStr, "Response: 200 OK") {
t.Error("Recording does not contain response status")
}
if !strings.Contains(contentStr, "response body") {
t.Error("Recording does not contain response body")
}
}
func TestRecorder_Record_BinaryResponse(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-binary-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
req := &http.Request{
Method: "GET",
URL: &url.URL{Path: "/image"},
}
res := &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBuffer([]byte{0x00, 0x01, 0x02, 0x03})),
Request: req,
}
res.Header.Set("Content-Type", "image/png")
err = r.Record("self", req, res)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "image")
files, _ := os.ReadDir(interactionsDir)
content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "[Binary response body: 4 bytes]") {
t.Error("Recording does not correctly report binary response")
}
}
func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-full-ts-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
sessionID := "20260215-100000-12345"
r.SessionID = sessionID
// Create some dummy recordings
basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test")
os.MkdirAll(basePath, 0755)
files := []string{
"0001-10-00-01.000-GET.http",
"0002-11-00-00.000-GET.http",
}
for _, f := range files {
os.WriteFile(filepath.Join(basePath, f), []byte("test"), 0644)
}
t.Run("Check_Full_Timestamp_Display", func(t *testing.T) {
interactions, err := r.ListInteractions(sessionID, "", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(interactions) != 2 {
t.Fatalf("Expected 2 interactions, got %d", len(interactions))
}
expectedTS := "2026-02-15 10:00:01.000"
if interactions[0].Timestamp != expectedTS {
t.Errorf("Expected timestamp %s, got %s", expectedTS, interactions[0].Timestamp)
}
})
t.Run("Filter_By_Full_Date_Time", func(t *testing.T) {
// Filter for interactions since 10:30:00 on that day
interactions, err := r.ListInteractions(sessionID, "", "2026-02-15 10:30:00")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(interactions) != 1 {
t.Fatalf("Expected 1 interaction, got %d", len(interactions))
}
if interactions[0].ID != "0002-11-00-00.000-GET.http" {
t.Errorf("Expected 0002-..., got %s", interactions[0].ID)
}
})
t.Run("Filter_By_Date_Only", func(t *testing.T) {
// Filter for interactions since the day before
interactions, err := r.ListInteractions(sessionID, "", "2026-02-14")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(interactions) != 2 {
t.Fatalf("Expected 2 interactions, got %d", len(interactions))
}
})
}
+519 -64
View File
@@ -8,8 +8,12 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/ssh"
@@ -55,6 +59,8 @@ type MigrationSummary struct {
DeviceName string `json:"device_name,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
DeviceSerial string `json:"device_serial,omitempty"`
DeviceID string `json:"device_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
CACertTrusted bool `json:"ca_cert_trusted"`
ServerHTTPSURL string `json:"server_https_url,omitempty"`
@@ -88,14 +94,15 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
// DeviceInfoXML represents the XML structure from :8090/info
type DeviceInfoXML struct {
XMLName xml.Name `xml:"info" json:"-"`
DeviceID string `xml:"deviceID,attr" json:"deviceID"`
Name string `xml:"name" json:"name"`
Type string `xml:"type" json:"type"`
MaccAddress string `xml:"maccAddress" json:"maccAddress"`
SoftwareVer string `xml:"-" json:"softwareVersion"`
SerialNumber string `xml:"-" json:"serialNumber"`
Components []struct {
XMLName xml.Name `xml:"info" json:"-"`
DeviceID string `xml:"deviceID,attr" json:"deviceID"`
Name string `xml:"name" json:"name"`
Type string `xml:"type" json:"type"`
MaccAddress string `xml:"maccAddress" json:"maccAddress"`
SoftwareVer string `xml:"-" json:"softwareVersion"`
SerialNumber string `xml:"-" json:"serialNumber"`
MargeAccountUUID string `xml:"margeAccountUUID" json:"margeAccountUUID"`
Components []struct {
Category string `xml:"componentCategory"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
@@ -247,7 +254,8 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
if m.DataStore != nil {
devices, err := m.DataStore.ListAllDevices()
if err == nil {
for _, d := range devices {
for i := range devices {
d := devices[i]
if d.IPAddress != deviceIP {
continue
}
@@ -255,6 +263,8 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
summary.DeviceName = d.Name
summary.DeviceModel = d.ProductCode
summary.DeviceSerial = d.DeviceSerialNumber
summary.DeviceID = d.DeviceID
summary.AccountID = d.AccountID
summary.FirmwareVersion = d.FirmwareVersion
break
@@ -279,6 +289,14 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
if infoXML.SoftwareVer != "" {
summary.FirmwareVersion = infoXML.SoftwareVer
}
if infoXML.DeviceID != "" {
summary.DeviceID = infoXML.DeviceID
}
if infoXML.MargeAccountUUID != "" {
summary.AccountID = infoXML.MargeAccountUUID
}
}
}
@@ -420,7 +438,7 @@ func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string)
}
// 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, method MigrationMethod) error {
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
if targetURL == "" {
targetURL = m.ServerURL
}
@@ -429,11 +447,35 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
method = MigrationMethodXML
}
if method == MigrationMethodHosts {
return m.migrateViaHosts(deviceIP, targetURL)
var logs string
// 0. Off-device backup for safety
if backupErr := m.BackupConfigOffDevice(deviceIP); backupErr != nil {
logs += fmt.Sprintf("Warning: Failed to create off-device backup: %v\n", backupErr)
// We continue, but this is a warning
} else {
logs += "Successfully created off-device backup of current configuration.\n"
}
if err := m.EnsureRemoteServices(deviceIP); err != nil {
// 0b. Pre-flight check for SSH /rw permissions
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
if rwTest, rwErr := client.Run(rwCmd); rwErr != nil {
return logs, fmt.Errorf("pre-flight check failed: cannot gain write access (cmd: %s, output: %s): %w", rwCmd, rwTest, rwErr)
}
logs += "Pre-flight: Write access verified.\n"
if method == MigrationMethodHosts {
out, err := m.migrateViaHosts(deviceIP, targetURL)
return logs + out, err
}
out, err := m.EnsureRemoteServices(deviceIP)
logs += "Ensuring remote services:\n" + out + "\n"
if err != nil {
// Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'"
// Let's log it.
fmt.Printf("Warning: failed to ensure remote services: %v\n", err)
@@ -450,10 +492,11 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
}
// If we have a proxyURL and can read current config, use it
client := m.NewSSH(deviceIP)
if currentConfig, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && currentConfig != "" {
if curCfg, curCfgErr := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); curCfgErr == nil && curCfg != "" {
logs += "Read current configuration\n"
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(currentConfig), &currentCfg) == nil {
if xml.Unmarshal([]byte(curCfg), &currentCfg) == nil {
if proxyURL == "" {
proxyURL = targetURL
}
@@ -471,7 +514,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
xmlContent, err := xml.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal XML: %w", err)
return logs, fmt.Errorf("failed to marshal XML: %w", err)
}
// Add XML header
@@ -479,76 +522,154 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
// 0. Backup original config if it doesn't exist
remotePath := SoundTouchSdkPrivateCfgPath
rwCmd := "(rw || mount -o remount,rw /)"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
fmt.Printf("Backing up original config to %s.original\n", remotePath)
// Try to copy existing config to .original, ensuring filesystem is writable
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
logs += fmt.Sprintf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
fmt.Printf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
// Fallback to manual upload if cp failed (might not have cp?)
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
logs += "Warning: failed to upload backup config: " + err.Error() + "\n"
fmt.Printf("Warning: failed to upload backup config: %v\n", err)
} else {
logs += "Uploaded backup config via fallback\n"
}
}
} else {
logs += "Copied backup config to .original\n"
}
} else {
logs += "Backup .original already exists\n"
}
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
// Actually, let's call rw before UploadContent here.
_, _ = client.Run(rwCmd)
out, _ = client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent(xmlContent, remotePath); err != nil {
return fmt.Errorf("failed to upload config: %w", err)
return logs, fmt.Errorf("failed to upload config: %w", err)
}
// 2. Reboot the speaker (requires 'rw' command first to make filesystem writable)
if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil {
return fmt.Errorf("failed to reboot speaker: %w", err)
logs += "Uploaded new configuration to " + remotePath + "\n"
return logs, nil
}
// BackupConfigOffDevice creates a local backup of the speaker's configuration files in the DataStore.
func (m *Manager) BackupConfigOffDevice(deviceIP string) error {
if m.DataStore == nil {
return fmt.Errorf("datastore not configured")
}
client := m.NewSSH(deviceIP)
// We need the serial number and account identifier to find the right directory in DataStore
info, err := m.GetLiveDeviceInfo(deviceIP)
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
accountID := info.MargeAccountUUID
deviceID := info.SerialNumber
if deviceID == "" {
deviceID = info.DeviceID
}
if deviceID == "" {
deviceID = deviceIP
}
if accountID == "" {
// Try to find account ID from existing device entries if info didn't have it
devices, _ := m.DataStore.ListAllDevices()
for i := range devices {
if devices[i].DeviceSerialNumber == info.SerialNumber || (info.DeviceID != "" && devices[i].DeviceID == info.DeviceID) {
accountID = devices[i].AccountID
break
}
}
}
if accountID == "" {
accountID = "default"
}
deviceDir := m.DataStore.AccountDeviceDir(accountID, deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
return fmt.Errorf("failed to create device directory: %w", err)
}
// 1. Backup SoundTouchSdkPrivateCfg.xml
if config, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && config != "" {
backupPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak")
if err := os.WriteFile(backupPath, []byte(config), 0644); err != nil {
return fmt.Errorf("failed to write config backup: %w", err)
}
}
// 2. Backup /etc/hosts
if hosts, err := client.Run("cat /etc/hosts"); err == nil && hosts != "" {
backupPath := filepath.Join(deviceDir, "hosts.bak")
if err := os.WriteFile(backupPath, []byte(hosts), 0644); err != nil {
return fmt.Errorf("failed to write hosts backup: %w", err)
}
}
return nil
}
// BackupConfig creates a backup of the current configuration on the speaker.
func (m *Manager) BackupConfig(deviceIP string) error {
func (m *Manager) BackupConfig(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
remotePath := SoundTouchSdkPrivateCfgPath
rwCmd := "(rw || mount -o remount,rw /)"
// Check if .original already exists
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
return fmt.Errorf("backup already exists at %s.original", remotePath)
return "", fmt.Errorf("backup already exists at %s.original", remotePath)
}
// Try to copy on the device first (more reliable), ensuring filesystem is writable
output, cpErr := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath))
if cpErr == nil {
return nil
return output, nil
}
logs := output + "\n"
fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", cpErr, output)
// Fallback to cat + upload
config, err := client.Run(fmt.Sprintf("cat %s", remotePath))
logs += "cat " + remotePath + ": " + config + "\n"
if err != nil || config == "" {
return fmt.Errorf("failed to read current config: %w", err)
return logs, fmt.Errorf("failed to read current config: %w", err)
}
// Ensure rw before upload fallback
_, _ = client.Run(rwCmd)
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
return fmt.Errorf("failed to upload backup config: %w", err)
return logs, fmt.Errorf("failed to upload backup config: %w", err)
}
return nil
logs += "Uploaded backup to " + remotePath + ".original\n"
return logs, nil
}
// 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 {
func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
@@ -559,43 +680,59 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) error {
"/tmp/remote_services",
}
var logs string
for _, loc := range locations {
// Try to make filesystem writable for each location that might need it
// Combining rw && touch ensures it's attempted in the same sequence
_, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc))
out, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc))
logs += fmt.Sprintf("touch %s (with rw): %s\n", loc, out)
if err == nil {
return nil
return logs, nil
}
// If rw && touch failed, try just touch (e.g. for /tmp which doesn't need rw)
_, err = client.Run(fmt.Sprintf("touch %s", loc))
out, err = client.Run(fmt.Sprintf("touch %s", loc))
logs += fmt.Sprintf("touch %s: %s\n", loc, out)
if err == nil {
return nil
return logs, nil
}
}
return fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
}
// TrustCACert injects the local CA certificate into the device's shared trust store.
func (m *Manager) TrustCACert(deviceIP string) error {
func (m *Manager) TrustCACert(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return fmt.Errorf("failed to read CA certificate: %w", err)
return "", fmt.Errorf("failed to read CA certificate: %w", err)
}
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
_, _ = client.Run(rwCmd)
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// 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))
if _, backupErr := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); backupErr != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
logs += fmt.Sprintf("cp %s %s.original: %s\n", bundlePath, bundlePath, out)
}
// Check if the label already exists in the bundle
bundleContent, _ := client.Run(fmt.Sprintf("cat %s", bundlePath))
bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath))
logs += "cat " + bundlePath + " (check existing)\n"
if err != nil {
return logs, fmt.Errorf("failed to read bundle: %w", err)
}
if strings.Contains(bundleContent, CALabel) {
// Label found, let's replace the whole block between labels if we used them,
// or just remove the lines containing the label and re-append.
@@ -633,29 +770,34 @@ func (m *Manager) TrustCACert(deviceIP string) error {
newBundleContent := bundleContent + labeledCert
if err := client.UploadContent([]byte(newBundleContent), bundlePath); err != nil {
return fmt.Errorf("failed to update bundle: %w", err)
return logs, fmt.Errorf("failed to update bundle: %w", err)
}
return nil
logs += "Uploaded updated bundle to " + bundlePath + "\n"
return logs, nil
}
func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
// 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)
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)
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /etc/hosts entries
domains := []string{
@@ -667,8 +809,10 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
}
hostsContent, err := client.Run("cat /etc/hosts")
logs += "cat /etc/hosts: " + hostsContent + "\n"
if err != nil {
return fmt.Errorf("failed to read /etc/hosts: %w", err)
return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
}
for _, domain := range domains {
@@ -684,16 +828,20 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
}
// 3. Upload new /etc/hosts
_, _ = client.Run(rwCmd)
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// 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")
out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
}
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
return fmt.Errorf("failed to update /etc/hosts: %w", err)
return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
}
logs += "Uploaded updated /etc/hosts\n"
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
// 4. Inject CA Certificate
@@ -701,23 +849,101 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) error {
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
if err := m.TrustCACert(deviceIP); err != nil {
return err
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
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 logs, nil
}
// RevertMigration reverts the speaker to its original Bose cloud configuration.
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
// 1. Revert SoundTouchSdkPrivateCfg.xml
remotePath := SoundTouchSdkPrivateCfgPath
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", remotePath)
fmt.Printf("Reverting %s from backup\n", remotePath)
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, remotePath, remotePath))
logs += fmt.Sprintf("cp %s.original %s: %s\n", remotePath, remotePath, out)
if err != nil {
return logs, fmt.Errorf("failed to revert %s: %w", remotePath, err)
}
} else {
return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath)
}
return nil
// 2. Revert /etc/hosts
hostsPath := "/etc/hosts"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", hostsPath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", hostsPath)
fmt.Printf("Reverting %s from backup\n", hostsPath)
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, hostsPath, hostsPath))
logs += fmt.Sprintf("cp %s.original %s: %s\n", hostsPath, hostsPath, out)
if err != nil {
// Don't return error here, try to continue with other reverts
fmt.Printf("Warning: failed to revert %s: %v\n", hostsPath, err)
}
}
// 3. Remove CA certificate from trust store if it exists
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) {
logs += fmt.Sprintf("Removing local CA certificate from %s\n", bundlePath)
fmt.Printf("Removing local CA certificate from %s\n", bundlePath)
lines := strings.Split(bundleContent, "\n")
var newLines []string
inOurCA := false
for _, line := range lines {
if strings.Contains(line, CALabel) {
inOurCA = !inOurCA
continue
}
if !inOurCA {
newLines = append(newLines, line)
}
}
bundleContent = strings.Join(newLines, "\n")
if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
bundleContent += "\n"
}
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent([]byte(bundleContent), bundlePath); err != nil {
logs += "Warning: failed to remove CA from " + bundlePath + ": " + err.Error() + "\n"
fmt.Printf("Warning: failed to remove CA from %s: %v\n", bundlePath, err)
} else {
logs += "Uploaded updated bundle (CA removed)\n"
}
}
return logs, nil
}
// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
func (m *Manager) RemoveRemoteServices(deviceIP string) error {
func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
@@ -727,14 +953,21 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
"/tmp/remote_services",
}
var errors []error
var (
logs string
errors []error
)
for _, loc := range locations {
// Try to make filesystem writable and remove the file
_, err := client.Run(fmt.Sprintf("%s && rm -f %s", rwCmd, loc))
out, err := client.Run(fmt.Sprintf("%s && rm -v %s", rwCmd, loc))
logs += fmt.Sprintf("Removing %s: %s\n", loc, out)
if err != nil {
// If rw && rm failed, try just rm (e.g. for /tmp)
_, err = client.Run(fmt.Sprintf("rm -f %s", loc))
out, err = client.Run(fmt.Sprintf("rm -v %s", loc))
logs += fmt.Sprintf("Fallback removing %s: %s\n", loc, out)
if err != nil {
errors = append(errors, fmt.Errorf("failed to remove %s: %w", loc, err))
}
@@ -742,10 +975,25 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) error {
}
if len(errors) == len(locations) {
return fmt.Errorf("failed to remove remote services from any location: %v", errors)
return logs, fmt.Errorf("failed to remove remote services from any location: %v", errors)
}
return nil
return logs, nil
}
// Reboot reboots the speaker at the given IP.
func (m *Manager) Reboot(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
fmt.Printf("Rebooting speaker at %s\n", deviceIP)
out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
if err != nil {
return out, fmt.Errorf("failed to reboot speaker: %w", err)
}
return out, nil
}
// TestDomain is the fake domain used for preliminary redirection tests.
@@ -984,3 +1232,210 @@ func (m *Manager) resolveIP(host string, client SSHClient) string {
return ips[0].String()
}
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
func (m *Manager) SyncDeviceData(deviceIP string) error {
// 1. Fetch info to get Serial Number (account identifier)
info, err := m.GetLiveDeviceInfo(deviceIP)
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
accountID := ""
deviceID := info.SerialNumber
if deviceID == "" {
deviceID = deviceIP
}
if info.MargeAccountUUID != "" {
accountID = info.MargeAccountUUID
}
if accountID == "" {
// Try to find account ID from existing device entries if info didn't have it
devices, _ := m.DataStore.ListAllDevices()
for i := range devices {
if devices[i].DeviceSerialNumber == info.SerialNumber || devices[i].DeviceID == info.DeviceID {
accountID = devices[i].AccountID
break
}
}
}
if accountID == "" {
accountID = "default"
}
// 2. Fetch Presets from :8090
m.syncPresets(deviceIP, accountID, deviceID)
// 3. Fetch Recents from :8090
m.syncRecents(deviceIP, accountID, deviceID)
// 4. Fetch Sources
m.syncSources(deviceIP, accountID, deviceID)
// 5. Create off-device backup of system configuration
_ = m.BackupConfigOffDevice(deviceIP)
return nil
}
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
presetsURL := fmt.Sprintf("http://%s:8090/presets", deviceIP)
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
}
resp, err := http.Get(presetsURL)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
var ps models.Presets
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
return
}
var servicePresets []models.ServicePreset
for _, p := range ps.Preset {
if p.ContentItem == nil {
continue
}
createdOn := ""
if p.CreatedOn != nil {
createdOn = strconv.FormatInt(*p.CreatedOn, 10)
}
updatedOn := ""
if p.UpdatedOn != nil {
updatedOn = strconv.FormatInt(*p.UpdatedOn, 10)
}
servicePresets = append(servicePresets, models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: strconv.Itoa(p.ID),
Name: p.ContentItem.ItemName,
Source: p.ContentItem.Source,
Type: p.ContentItem.Type,
Location: p.ContentItem.Location,
SourceAccount: p.ContentItem.SourceAccount,
SourceID: "", // Preset doesn't have SourceID in ContentItem usually
IsPresetable: strconv.FormatBool(p.ContentItem.IsPresetable),
},
ContainerArt: p.ContentItem.ContainerArt,
CreatedOn: createdOn,
UpdatedOn: updatedOn,
})
}
_ = m.DataStore.SavePresets(accountID, deviceID, servicePresets)
}
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP)
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
}
resp, err := http.Get(recentsURL)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
var rr models.RecentsResponse
if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil {
return
}
var serviceRecents []models.ServiceRecent
for _, r := range rr.Items {
if r.ContentItem == nil {
continue
}
serviceRecents = append(serviceRecents, models.ServiceRecent{
ServiceContentItem: models.ServiceContentItem{
ID: r.ID,
Name: r.ContentItem.ItemName,
Source: r.ContentItem.Source,
Type: r.ContentItem.Type,
Location: r.ContentItem.Location,
SourceAccount: r.ContentItem.SourceAccount,
SourceID: "", // RecentsResponseItem doesn't have SourceID usually
IsPresetable: strconv.FormatBool(r.ContentItem.IsPresetable),
},
DeviceID: r.DeviceID,
UtcTime: strconv.FormatInt(r.UTCTime, 10),
ContainerArt: r.ContentItem.ContainerArt,
})
}
_ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents)
}
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
client := m.NewSSH(deviceIP)
sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
if err == nil && sourcesXML != "" {
var srs struct {
Sources []models.ConfiguredSource `xml:"source"`
}
if xmlErr := xml.Unmarshal([]byte(sourcesXML), &srs); xmlErr == nil {
// After unmarshaling from SSH, ensure legacy fields are synced for internal use
for i := range srs.Sources {
s := &srs.Sources[i]
s.SourceKeyType = s.SourceKey.Type
s.SourceKeyAccount = s.SourceKey.Account
}
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
return
}
}
// Fallback to :8090/sources
sourcesURL := fmt.Sprintf("http://%s:8090/sources", deviceIP)
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
sourcesURL = fmt.Sprintf("http://%s/sources", deviceIP)
}
resp, err := http.Get(sourcesURL)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
var srs models.Sources
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
var configuredSources []models.ConfiguredSource
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
ID: s.Source,
SecretType: string(s.Status),
}
cs.SourceKey.Type = s.Source
cs.SourceKey.Account = s.SourceAccount
// Also set legacy fields for now
cs.SourceKeyType = s.Source
cs.SourceKeyAccount = s.SourceAccount
configuredSources = append(configuredSources, cs)
}
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
}
}
+227 -6
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
type mockSSH struct {
@@ -72,7 +73,7 @@ func TestMigrateViaHosts(t *testing.T) {
}
}
err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
_, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaHosts failed: %v", err)
}
@@ -95,7 +96,7 @@ func TestMigrateViaHosts(t *testing.T) {
t.Errorf("Expected ca-bundle.crt backup to be attempted")
}
// Verify reboot was called
// Verify reboot was NOT called
foundReboot := false
for _, call := range runCalls {
if strings.Contains(call, "reboot") {
@@ -103,8 +104,8 @@ func TestMigrateViaHosts(t *testing.T) {
break
}
}
if !foundReboot {
t.Errorf("Expected reboot to be called")
if foundReboot {
t.Errorf("Expected reboot NOT to be called automatically")
}
}
@@ -554,7 +555,7 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
}
}
err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
_, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaHosts failed: %v", err)
}
@@ -604,7 +605,7 @@ func TestTrustCACert(t *testing.T) {
}
}
err = m.TrustCACert("192.168.1.10")
_, err = m.TrustCACert("192.168.1.10")
if err != nil {
t.Fatalf("TrustCACert failed: %v", err)
}
@@ -634,6 +635,226 @@ func TestTrustCACert(t *testing.T) {
}
}
func TestRevertMigration(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
runCalls := []string{}
uploadCalls := make(map[string]string)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /etc/pki/tls/certs/ca-bundle.crt" {
return "existing content\n" + CALabel + "\nCERT DATA\n" + CALabel + "\nmore content", nil
}
// Mock file existence checks for .original files
if strings.HasPrefix(command, "[ -f") && strings.Contains(command, ".original") {
return "", nil // file exists
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploadCalls[remotePath] = string(content)
return nil
},
}
}
_, err := m.RevertMigration("192.168.1.10")
if err != nil {
t.Fatalf("RevertMigration failed: %v", err)
}
// Verify revert commands
foundXMLRevert := false
foundHostsRevert := false
foundReboot := false
for _, call := range runCalls {
if strings.Contains(call, "cp "+SoundTouchSdkPrivateCfgPath+".original "+SoundTouchSdkPrivateCfgPath) {
foundXMLRevert = true
}
if strings.Contains(call, "cp /etc/hosts.original /etc/hosts") {
foundHostsRevert = true
}
if strings.Contains(call, "reboot") {
foundReboot = true
}
}
if !foundXMLRevert {
t.Errorf("Expected XML config revert")
}
if !foundHostsRevert {
t.Errorf("Expected /etc/hosts revert")
}
if foundReboot {
t.Errorf("Expected reboot NOT to be called automatically during revert")
}
// Verify RemoveRemoteServices was NOT called
for _, call := range runCalls {
if strings.Contains(call, "rm -f /etc/remote_services") {
t.Errorf("Remote services should NOT be removed during revert")
}
}
// Verify CA removal
if content, ok := uploadCalls["/etc/pki/tls/certs/ca-bundle.crt"]; ok {
if strings.Contains(content, CALabel) {
t.Errorf("Expected CA label to be removed from bundle, got: %s", content)
}
if !strings.Contains(content, "existing content") || !strings.Contains(content, "more content") {
t.Errorf("Expected existing content to be preserved in bundle, got: %s", content)
}
} else {
t.Errorf("Expected updated bundle to be uploaded")
}
}
func TestRevertMigration_NoBackup(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
}
}
_, err := m.RevertMigration("192.168.1.10")
if err == nil {
t.Errorf("Expected error when backup is missing, got nil")
} else if !strings.Contains(err.Error(), "backup") {
t.Errorf("Expected error about missing backup, got: %v", err)
}
}
func TestReboot(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
return "", nil
},
}
}
_, err := m.Reboot("192.168.1.10")
if err != nil {
t.Fatalf("Reboot failed: %v", err)
}
foundReboot := false
for _, call := range runCalls {
if strings.Contains(call, "reboot") {
foundReboot = true
break
}
}
if !foundReboot {
t.Errorf("Expected reboot command to be called")
}
}
func TestBackupConfigOffDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "backup-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
m := NewManager("http://localhost:8000", ds, nil)
serial := "08DF1F0BA325"
accountID := "3230304"
// Mock info server
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprintf(w, `<info deviceID="%s"><name>Test</name><margeAccountUUID>%s</margeAccountUUID><components><component><componentCategory>SCM</componentCategory><serialNumber>%s</serialNumber></component></components></info>`, serial, accountID, serial)
}))
defer infoServer.Close()
// Extract IP and port
deviceIP := infoServer.Listener.Addr().String()
// Mock SSH to return some config and hosts content
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.Contains(command, SoundTouchSdkPrivateCfgPath) {
return "<SoundTouchSdkPrivateCfg><margeServerUrl>http://original</margeServerUrl></SoundTouchSdkPrivateCfg>", nil
}
if strings.Contains(command, "/etc/hosts") {
return "127.0.0.1 localhost\n192.168.1.1 bmx.bose.com", nil
}
return "", nil
},
}
}
err = m.BackupConfigOffDevice(deviceIP)
if err != nil {
t.Fatalf("BackupConfigOffDevice failed: %v", err)
}
// Verify files were created in datastore
deviceDir := m.DataStore.AccountDeviceDir(accountID, serial)
configPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak")
hostsPath := filepath.Join(deviceDir, "hosts.bak")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Errorf("Expected config backup at %s, but it doesn't exist", configPath)
}
if _, err := os.Stat(hostsPath); os.IsNotExist(err) {
t.Errorf("Expected hosts backup at %s, but it doesn't exist", hostsPath)
}
// Verify content
configContent, _ := os.ReadFile(configPath)
if !strings.Contains(string(configContent), "http://original") {
t.Errorf("Unexpected config backup content: %s", string(configContent))
}
hostsContent, _ := os.ReadFile(hostsPath)
if !strings.Contains(string(hostsContent), "bmx.bose.com") {
t.Errorf("Unexpected hosts backup content: %s", string(hostsContent))
}
}
func TestMigrateSpeaker_PreFlightFailure(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.Contains(command, "mount -o remount,rw /") {
return "mount: / is read-only", fmt.Errorf("remount failed")
}
return "", nil
},
}
}
_, err := m.MigrateSpeaker("192.168.1.10", "", "", nil, MigrationMethodXML)
if err == nil {
t.Errorf("Expected error during pre-flight write check, got nil")
}
if !strings.Contains(err.Error(), "pre-flight check failed") {
t.Errorf("Expected pre-flight error message, got: %v", err)
}
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
+277
View File
@@ -0,0 +1,277 @@
Here is a `README.md` you can place next to your install script (or in your repo) to document installation, configuration, updates, and debugging.
---
# SoundTouch Service (systemd install)
This setup installs `soundtouch-service` from the official GitHub release and runs it as a hardened systemd service.
It supports:
* Automatic start on boot
* Binding to privileged ports (80 / 443) without running as root
* Config via environment file
* Clean updates
* Safe re-runs of the installer
---
# Installation
Run the installer script:
```bash
sudo bash install-soundtouch-service.sh
```
You can override defaults:
```bash
sudo \
VERSION=v0.17.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
bash install-soundtouch-service.sh
```
---
# Configuration
Configuration lives in:
```
/etc/soundtouch-service/soundtouch-service.env
```
Example:
```bash
PORT=80
HTTPS_PORT=443
DATA_DIR=/var/lib/soundtouch-service
LOG_PROXY_BODY=false
REDACT_PROXY_LOGS=true
RECORD_INTERACTIONS=true
DISCOVERY_INTERVAL=5m
SERVER_URL=http://soundtouch.local
HTTPS_SERVER_URL=https://soundtouch.local
```
---
# Important: Applying Configuration Changes
If you change the environment file, you must reload and restart the service.
Full roundtrip:
```bash
sudo systemctl daemon-reload
sudo systemctl restart soundtouch-service
```
Usually `daemon-reload` is only needed if the **unit file** changed.
If only the `.env` file changed:
```bash
sudo systemctl restart soundtouch-service
```
---
# Service Management
Check status:
```bash
systemctl status soundtouch-service
```
Enable at boot:
```bash
sudo systemctl enable soundtouch-service
```
Disable:
```bash
sudo systemctl disable soundtouch-service
```
Stop / start manually:
```bash
sudo systemctl stop soundtouch-service
sudo systemctl start soundtouch-service
```
---
# Logs & Debugging
View recent logs:
```bash
journalctl -u soundtouch-service -e --no-pager
```
Follow logs live:
```bash
journalctl -u soundtouch-service -f
```
Show logs from current boot:
```bash
journalctl -u soundtouch-service -b
```
If the service fails to start:
```bash
systemctl status soundtouch-service --no-pager
```
Look for:
* `bind: permission denied` → capability issue
* `address already in use` → port conflict
* permission errors in DATA_DIR → ownership issue
---
# Port Conflicts
Check if 80/443 are in use:
```bash
sudo ss -tulpn | grep -E ':80|:443'
```
If another service is using the port, either:
* stop/disable that service
* or change `PORT` / `HTTPS_PORT` in the env file
Then restart the service.
---
# Updating to a New Version
To upgrade, simply run the installer with the desired version as an argument:
```bash
sudo bash install.sh vX.Y.Z
```
The script will:
* Automatically fetch the latest version of the installer script for that release
* Download the new service binary
* Backup the old binary to `.old`
* Overwrite the binary and restart the service
No need to reconfigure anything; your existing `.env` file and data will be preserved.
---
# Reinstall / Reset
To fully reset:
```bash
sudo systemctl stop soundtouch-service
sudo rm -rf /var/lib/soundtouch-service/*
sudo systemctl start soundtouch-service
```
To completely remove:
```bash
sudo systemctl disable --now soundtouch-service
sudo rm /etc/systemd/system/soundtouch-service.service
sudo rm -rf /etc/soundtouch-service
sudo rm -rf /var/lib/soundtouch-service
sudo rm /usr/local/bin/soundtouch-service
sudo systemctl daemon-reload
```
---
# Architecture Auto-Detection
The installer auto-detects:
* `linux-armv7`
* `linux-arm64`
* `linux-amd64`
Override manually if needed:
```bash
sudo ARCH_ASSET=linux-arm64 bash install-soundtouch-service.sh
```
---
# Security Notes
The service:
* Runs as a dedicated `soundtouch` system user
* Uses `AmbientCapabilities=CAP_NET_BIND_SERVICE`
* Does not require `setcap`
* Does not run as root
* Uses systemd sandboxing (`ProtectSystem`, `PrivateTmp`, etc.)
---
# Quick Troubleshooting Checklist
If something does not work:
1. Check status:
```
systemctl status soundtouch-service
```
2. Check logs:
```
journalctl -u soundtouch-service -e
```
3. Confirm ports:
```
ss -tulpn | grep -E ':80|:443'
```
4. Confirm env file:
```
cat /etc/soundtouch-service/soundtouch-service.env
```
5. Restart cleanly:
```
sudo systemctl restart soundtouch-service
```
---
If youd like, I can also provide:
* A `make update` style wrapper
* A rollback mechanism
* Or a self-update script with checksum verification
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-service installer (systemd, headless)
#
# Usage:
# sudo bash install.sh [vX.Y.Z]
#
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.17.0 \
# HOSTNAME_FQDN=soundtouch.local \
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
# DATA_DIR=/var/lib/soundtouch-service \
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.18.1
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
# - It installs a systemd unit that can bind privileged ports (80/443) using:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# so you do NOT need setcap and do NOT need to run as root.
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.18.1}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-service}"
ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-service.env}"
DATA_DIR="${DATA_DIR:-/var/lib/soundtouch-service}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
# Ports
HTTP_PORT="${HTTP_PORT:-80}"
HTTPS_PORT="${HTTPS_PORT:-443}"
# URLs (default uses current hostname + .local)
HOSTNAME_FQDN="${HOSTNAME_FQDN:-$(hostname).local}"
SERVER_URL="${SERVER_URL:-http://${HOSTNAME_FQDN}}"
HTTPS_SERVER_URL="${HTTPS_SERVER_URL:-https://${HOSTNAME_FQDN}}"
# Additional env vars (mirrors the project's docker-compose.yml)
LOG_PROXY_BODY="${LOG_PROXY_BODY:-false}"
REDACT_PROXY_LOGS="${REDACT_PROXY_LOGS:-true}"
RECORD_INTERACTIONS="${RECORD_INTERACTIONS:-true}"
DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}"
# Override if you want to force a specific asset suffix:
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
ARCH_ASSET="${ARCH_ASSET:-}"
# Internal variables
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")"
IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
apt_install_if_missing() {
log "Installing dependencies: $*"
apt-get update -y
apt-get install -y --no-install-recommends "$@"
}
detect_arch_asset() {
# Upstream release naming expects: linux-armv7, linux-arm64, linux-amd64
# Map uname -m to those.
local m
m="$(uname -m)"
case "$m" in
armv7l|armv6l)
echo "linux-armv7"
;;
aarch64)
echo "linux-arm64"
;;
x86_64|amd64)
echo "linux-amd64"
;;
*)
die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)"
;;
esac
}
download_url_for() {
local asset="$1"
# Release asset pattern used by you earlier:
# soundtouch-service-v0.17.0-linux-armv7
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
}
ensure_user_group() {
log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}"
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
groupadd --system "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
useradd --system \
--home "${DATA_DIR}" \
--create-home \
--shell /usr/sbin/nologin \
--gid "${SERVICE_GROUP}" \
"${SERVICE_USER}"
fi
}
ensure_dirs() {
log "Creating directories"
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
# Optimized ownership check: only chown if not already owned by service user
if [[ "$(stat -c '%U:%G' "${DATA_DIR}")" != "${SERVICE_USER}:${SERVICE_GROUP}" ]]; then
log "Adjusting ownership of ${DATA_DIR} to ${SERVICE_USER}:${SERVICE_GROUP}"
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}"
fi
chmod 0755 "${CONFIG_DIR}" "${DATA_DIR}"
}
download_binary() {
local asset url tmp
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
url="$(download_url_for "$asset")"
log "Downloading binary for ${asset}: ${url}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "${tmp}/soundtouch-service" "${url}"
else
wget -qO "${tmp}/soundtouch-service" "${url}"
fi
chmod +x "${tmp}/soundtouch-service"
# Backup existing binary if it exists
if [[ -f "${BIN_PATH}" ]]; then
log "Backing up existing binary to ${BIN_PATH}.old"
cp -p "${BIN_PATH}" "${BIN_PATH}.old"
fi
install -m 0755 "${tmp}/soundtouch-service" "${BIN_PATH}"
log "Installed binary to ${BIN_PATH}"
}
self_update() {
# If we are already a self-update re-exec, don't do it again
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
return
fi
local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install.sh"
local tmp_script="/tmp/soundtouch-install-${VERSION}.sh"
log "Checking for installer updates for ${VERSION}..."
log "URL: ${url}"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL -o "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
else
if ! wget -qO "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
fi
# Compare scripts to see if we actually need to re-exec
if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then
log "Installer is already up to date."
rm -f "${tmp_script}"
return
fi
log "Newer installer found for ${VERSION}. Re-executing..."
chmod +x "${tmp_script}"
# Export current env vars to the new script
export IS_SELF_UPDATE="true"
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
exec "${tmp_script}" "$@"
}
write_env_file() {
log "Writing env file: ${ENV_FILE}"
cat > "${ENV_FILE}" <<EOF
PORT=${HTTP_PORT}
HTTPS_PORT=${HTTPS_PORT}
DATA_DIR=${DATA_DIR}
LOG_PROXY_BODY=${LOG_PROXY_BODY}
REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}
RECORD_INTERACTIONS=${RECORD_INTERACTIONS}
DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
SERVER_URL=${SERVER_URL}
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
EOF
chmod 0640 "${ENV_FILE}"
# group-readable so you can add yourself to the group if desired
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
}
write_systemd_unit() {
log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=Bose SoundTouch Service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
EnvironmentFile=${ENV_FILE}
WorkingDirectory=${DATA_DIR}
ExecStart=${BIN_PATH}
# Allow binding to privileged ports (80/443) without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Restart=on-failure
RestartSec=2
# Sensible hardening (compatible with privileged-port binding)
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=${DATA_DIR}
[Install]
WantedBy=multi-user.target
EOF
}
reload_enable_start() {
log "Reloading systemd, enabling and starting service"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
systemctl restart "${SERVICE_NAME}.service"
log "Verifying service health..."
local health_url="http://localhost:${HTTP_PORT}/health"
local max_retries=5
local count=0
local success=false
while [[ $count -lt $max_retries ]]; do
if curl -fs "$health_url" >/dev/null 2>&1; then
success=true
break
fi
echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)"
sleep 2
count=$((count+1))
done
if [[ "$success" = true ]]; then
log "✅ Service is healthy and responding!"
else
log "⚠️ Service started but did not respond to health check at $health_url within timeout."
log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50"
fi
}
show_status() {
log "Service status"
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
log "Listening sockets (${HTTP_PORT}/${HTTPS_PORT})"
ss -tulpn | grep -E ":((${HTTP_PORT})|(${HTTPS_PORT}))\b" || true
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
log "Firewall check (UFW is active)"
if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW|${HTTPS_PORT}.*ALLOW"; then
log "⚠️ UFW is active but ports ${HTTP_PORT}/${HTTPS_PORT} might be blocked."
log "Run: sudo ufw allow ${HTTP_PORT}/tcp && sudo ufw allow ${HTTPS_PORT}/tcp"
else
log "✅ UFW rules for service ports appear to be in place."
fi
fi
cat <<EOF
Try from another machine:
${SERVER_URL}
${HTTPS_SERVER_URL}
If mDNS doesn't work, use the Pi's IP:
http://<pi-ip>/
https://<pi-ip>/
Logs:
journalctl -u ${SERVICE_NAME}.service -e --no-pager
EOF
}
main() {
need_root
ensure_cmd systemctl
ensure_cmd ss
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
apt_install_if_missing curl
fi
self_update "$@"
ensure_user_group
ensure_dirs
download_binary
write_env_file
write_systemd_unit
reload_enable_start
show_status
}
main "$@"