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