Compare commits

...
4 Commits
8 changed files with 254 additions and 8 deletions
+4 -4
View File
@@ -380,10 +380,10 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy || config.enableSoundcorkProxy
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
config.record = persisted.RecordInteractions
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
return persisted
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestApplyPersistedSettings(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "main-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(tmpDir)
t.Run("overrides true with false", func(t *testing.T) {
config := &serviceConfig{
redact: true,
logBody: true,
record: true,
enableSoundcorkProxy: true,
}
// Simulate the bug by using the old bitwise OR logic in the test,
// which should fail if we expect false.
// config.redact = config.redact || false -> stays true
settings := datastore.Settings{
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
EnableSoundcorkProxy: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
if config.logBody != false {
t.Errorf("Expected logBody to be false, got true")
}
if config.record != false {
t.Errorf("Expected record to be false, got true")
}
if config.enableSoundcorkProxy != false {
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
}
})
t.Run("retains false when settings are false", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
})
t.Run("overrides false with true", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: true,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != true {
t.Errorf("Expected redact to be true, got false")
}
})
}
+5
View File
@@ -12,8 +12,10 @@ The service provides:
- **🌐 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
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
## Architecture
@@ -381,6 +383,7 @@ The web management interface provides a comprehensive dashboard for managing you
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
### Usage Tips
@@ -410,6 +413,8 @@ By default, the service redacts sensitive information from the recorded `.http`
- `Authorization` headers
- `Cookie` headers
- `X-Bose-Token` headers
- `X-Bose-Key` headers
- `Proxy-Authorization` headers
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
+1 -1
View File
@@ -28,7 +28,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`)
_, _ = fmt.Fprintf(w, `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`)
return
}
+1 -2
View File
@@ -67,8 +67,7 @@ func TestRootEndpointJSON(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`
expected := `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`
if strings.TrimSpace(string(body)) != expected {
t.Errorf("Expected body %s, got %s", expected, string(body))
}
+4
View File
@@ -579,6 +579,10 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
s.recordEnabled = settings.Record
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if s.recorder != nil {
s.recorder.Redact = settings.Redact
}
// Persist to datastore
// Access fields directly since we already hold the lock
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
+9 -1
View File
@@ -39,7 +39,7 @@ type Server struct {
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
return &Server{
s := &Server{
ds: ds,
sm: sm,
serverURL: serverURL,
@@ -50,6 +50,8 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
enableSoundcorkProxy: enableSoundcorkProxy,
discoveryInterval: 5 * time.Minute,
}
return s
}
// SetVersionInfo sets the version information for the server.
@@ -113,7 +115,13 @@ func (s *Server) SetSoundcorkURL(url string) {
// SetRecorder sets the recorder for the server.
func (s *Server) SetRecorder(r *proxy.Recorder) {
s.mu.Lock()
defer s.mu.Unlock()
s.recorder = r
if r != nil {
r.Redact = s.proxyRedact
}
}
// GetRecordEnabled returns whether recording is enabled.
+133
View File
@@ -0,0 +1,133 @@
package proxy
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRecorder_Redaction(t *testing.T) {
// Disable async for testing
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-redact-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = true // Enable redaction
req := httptest.NewRequest("GET", "http://example.com/api/test", nil)
req.Header.Set("Authorization", "Bearer sensitive-token")
req.Header.Set("X-Custom", "safe-value")
w := httptest.NewRecorder()
w.Header().Set("X-Bose-Token", "sensitive-bose-token")
w.Header().Set("Content-Type", "text/plain")
_, _ = w.WriteString("hello")
res := w.Result()
res.Request = req
err = r.Record("test", req, res)
if err != nil {
t.Fatalf("Failed to record: %v", err)
}
// Find the recorded file
var recordedFile string
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
recordedFile = path
}
return nil
})
if err != nil {
t.Fatalf("Error walking temp dir: %v", err)
}
if recordedFile == "" {
t.Fatal("No recorded .http file found")
}
content, err := os.ReadFile(recordedFile)
if err != nil {
t.Fatalf("Failed to read recorded file: %v", err)
}
contentStr := string(content)
// Check for redaction in request headers
if strings.Contains(contentStr, "sensitive-token") {
t.Errorf("Recorded file contains sensitive Authorization header value:\n%s", contentStr)
}
if !strings.Contains(contentStr, "Authorization: [REDACTED]") {
t.Errorf("Recorded file does not contain redacted Authorization header:\n%s", contentStr)
}
// Check for redaction in response headers
if strings.Contains(contentStr, "sensitive-bose-token") {
t.Errorf("Recorded file contains sensitive X-Bose-Token header value:\n%s", contentStr)
}
if !strings.Contains(contentStr, "X-Bose-Token: [REDACTED]") {
t.Errorf("Recorded file does not contain redacted X-Bose-Token header:\n%s", contentStr)
}
// Check that non-sensitive headers are NOT redacted
if !strings.Contains(contentStr, "X-Custom: safe-value") {
t.Errorf("Recorded file missing non-sensitive header or it was incorrectly redacted:\n%s", contentStr)
}
}
func TestRecorder_NoRedaction(t *testing.T) {
// Disable async for testing
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-no-redact-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = false // Disable redaction
req := httptest.NewRequest("GET", "http://example.com/api/test", nil)
req.Header.Set("Authorization", "Bearer sensitive-token")
w := httptest.NewRecorder()
w.Header().Set("X-Bose-Token", "sensitive-bose-token")
_, _ = w.WriteString("hello")
res := w.Result()
res.Request = req
err = r.Record("test", req, res)
if err != nil {
t.Fatalf("Failed to record: %v", err)
}
// Find the recorded file
var recordedFile string
filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && strings.HasSuffix(path, ".http") {
recordedFile = path
}
return nil
})
content, _ := os.ReadFile(recordedFile)
contentStr := string(content)
if !strings.Contains(contentStr, "Bearer sensitive-token") {
t.Errorf("Recorded file should contain sensitive Authorization header when Redact=false:\n%s", contentStr)
}
if !strings.Contains(contentStr, "sensitive-bose-token") {
t.Errorf("Recorded file should contain sensitive X-Bose-Token header when Redact=false:\n%s", contentStr)
}
}