From 9090fad563a37766fa98b7db083a26119e652ee2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 8 Aug 2026 22:30:24 +0200 Subject: [PATCH] feat(admin): add tri-state AdminAreaAuth setting with default-creds guard rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of the #419 admin-area gate: a persisted, live-reloadable tri-state setting ("" unset / "enabled" / "disabled") so a later release can flip the default from open to gated without breaking an explicit opt-out. Rejects enabling while MGMT_USERNAME/MGMT_PASSWORD are still the published default, since that would give a false sense of security. No behavior change yet — nothing reads this field to actually gate anything. That's the next chunk. Refs #419 --- cmd/soundtouch-service/main.go | 5 + pkg/service/datastore/datastore.go | 17 ++ pkg/service/handlers/handlers_setup.go | 44 +++++ pkg/service/handlers/handlers_setup_test.go | 172 ++++++++++++++++++++ pkg/service/handlers/server.go | 22 +++ 5 files changed, 260 insertions(+) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 6892f88..b018298 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -529,6 +529,11 @@ func main() { server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI) server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword) + // Invalid values (e.g. a hand-edited settings.json) fall back to the + // unset default rather than failing startup. + adminAreaAuth, _ := handlers.NormalizeAdminAreaAuth(persisted.AdminAreaAuth) + server.SetAdminAreaAuth(adminAreaAuth) + initMusicServices(config, server) initTTSService(config, server) diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 8419576..0491615 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -2657,6 +2657,23 @@ type Settings struct { // API/speaker clients (non-HTML Accept) always get the version JSON // regardless of this setting. DefaultLanding string `json:"default_landing,omitempty"` + + // AdminAreaAuth is a tri-state toggle for gating the entire admin area + // (/admin, /setup, /api/setup — minus a small set of routes shared with + // soundtouch-cli/soundtouch-player) behind the same Basic Auth used for + // /api/mgmt/*, rather than just the Local Account / Spotify / Amazon + // linking endpoints as today. Values: + // "" — unset (default). Today this means "not enforced"; a + // later release is expected to flip the *meaning* of "" + // to "enforced" as the project moves the entire admin + // area to require login by default. See #419. + // "enabled" — the whole admin area requires Basic Auth now. + // "disabled" — explicit opt-out. Kept open even after the default + // flips, so an operator's deliberate choice survives + // the upgrade. + // The tri-state (rather than a plain bool) is what lets "never decided" + // be told apart from "explicitly chose off" once that default flips. + AdminAreaAuth string `json:"admin_area_auth,omitempty"` } // GetSettings retrieves the global service settings. diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index fc2e2b6..9236707 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -16,6 +16,7 @@ import ( "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/health" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/go-chi/chi/v5" ) @@ -170,6 +171,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { dnsBindAddr := s.dnsBindAddr internalPaths := s.internalPaths redact, logBody, record := s.redactLogs, s.logBodies, s.recordEnabled + adminAreaAuth := s.adminAreaAuth shortcuts := s.shortcuts spotifyConfigured := s.spotifyService != nil spotifyClientID := s.spotifyClientID @@ -271,6 +273,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "tts_voice": ttsVoice, "tts_volume": ttsVolume, "default_landing": defaultLanding, + "admin_area_auth": adminAreaAuth, }); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -322,6 +325,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { TTSVolume int `json:"tts_volume"` TLSExtraHosts *[]string `json:"tls_extra_hosts"` DefaultLanding string `json:"default_landing"` + AdminAreaAuth string `json:"admin_area_auth"` } if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -337,6 +341,14 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { return } + // Normalise + validate the admin-area auth mode. Empty means "unset" + // (today: not enforced — see datastore.Settings.AdminAreaAuth, #419). + adminAreaAuth, validAdminAreaAuth := NormalizeAdminAreaAuth(settings.AdminAreaAuth) + if !validAdminAreaAuth { + http.Error(w, "Invalid admin_area_auth: must be empty, enabled, or disabled", http.StatusBadRequest) + return + } + if settings.DNSEnabled && settings.DNSUpstream == "" { // No strict requirement for DNSUpstream here as SetDNSSettings will // try to fall back to system DNS. We only log it if both are empty later. @@ -364,6 +376,21 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() + + // Guard rail: refuse to enable the admin-area gate while the Management + // API credentials are still the published default — that would let + // anyone in with admin/change_me! anyway, just with extra friction. + if adminAreaAuth == "enabled" && + s.mgmtUsername == health.DefaultMgmtUsername && s.mgmtPassword == health.DefaultMgmtPassword { + s.mu.Unlock() + http.Error(w, "Cannot enable admin_area_auth while Management API credentials are still the "+ + "published default (admin/change_me!). Set MGMT_USERNAME and MGMT_PASSWORD to your own "+ + "values first.", http.StatusBadRequest) + + return + } + + s.adminAreaAuth = adminAreaAuth s.serverURL = settings.ServerURL // nil override = "field omitted, preserve"; recompute regardless, since // the Target Domain (which the derived URL follows) may have changed. @@ -461,6 +488,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { persisted.TTSVolume = s.ttsVolume persisted.TLSExtraHosts = resolvedTLSExtraHosts persisted.DefaultLanding = defaultLanding + persisted.AdminAreaAuth = s.adminAreaAuth err = s.ds.SaveSettings(persisted) dnsEnabled := s.dnsEnabled @@ -498,6 +526,22 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { } } +// NormalizeAdminAreaAuth trims/lowercases the admin-area auth mode and +// reports whether it's one of the three valid tri-state values ("", the +// unset default; "enabled"; "disabled" — see datastore.Settings.AdminAreaAuth, +// #419). Exported so main.go can apply the same validation to a persisted +// settings.json value at startup that HandleUpdateSettings applies on write. +func NormalizeAdminAreaAuth(v string) (string, bool) { + normalized := strings.ToLower(strings.TrimSpace(v)) + + switch normalized { + case "", "enabled", "disabled": + return normalized, true + default: + return "", false + } +} + // normaliseTLSExtraHosts trims whitespace from each entry, drops empty // values, and deduplicates while preserving the first occurrence's // position. The settings endpoint applies this before persisting so the diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index aa9c891..7b1d057 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -14,6 +14,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/health" "github.com/gesellix/bose-soundtouch/pkg/service/setup" ) @@ -259,6 +260,177 @@ func TestSettingsSavePreservesUnmanagedFields(t *testing.T) { } } +// TestAdminAreaAuthInvalidValue is a regression test for #419: an +// unrecognised admin_area_auth value must be rejected outright, not +// silently coerced to the unset default. +func TestAdminAreaAuthInvalidValue(t *testing.T) { + tempDir, err := os.MkdirTemp("", "admin-area-auth-invalid-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + ds := datastore.NewDataStore(tempDir) + _ = ds.Initialize() + + r, _ := setupRouter("http://127.0.0.1:8000", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + body, _ := json.Marshal(map[string]string{ + "server_url": "http://127.0.0.1:8000", + "admin_area_auth": "sometimes", + }) + + res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusBadRequest { + t.Errorf("Expected 400 for invalid admin_area_auth, got %v", res.Status) + } + + persisted, err := ds.GetSettings() + if err != nil { + t.Fatalf("Failed to reload settings: %v", err) + } + if persisted.AdminAreaAuth != "" { + t.Errorf("Invalid admin_area_auth must not be persisted, got %q", persisted.AdminAreaAuth) + } +} + +// TestAdminAreaAuthGuardRailBlocksDefaultCreds is a regression test for +// #419: enabling the admin-area gate while the Management API credentials +// are still the published default (admin/change_me!) must be rejected — +// otherwise the gate would give a false sense of security. +func TestAdminAreaAuthGuardRailBlocksDefaultCreds(t *testing.T) { + tempDir, err := os.MkdirTemp("", "admin-area-auth-guard-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://127.0.0.1:8000", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + server.mgmtUsername = health.DefaultMgmtUsername + server.mgmtPassword = health.DefaultMgmtPassword + + body, _ := json.Marshal(map[string]string{ + "server_url": "http://127.0.0.1:8000", + "admin_area_auth": "enabled", + }) + + res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusBadRequest { + t.Errorf("Expected 400 when enabling admin_area_auth with default creds, got %v", res.Status) + } + + if server.AdminAreaAuthMode() != "" { + t.Errorf("Guard rail must not flip the live mode, got %q", server.AdminAreaAuthMode()) + } + + persisted, err := ds.GetSettings() + if err != nil { + t.Fatalf("Failed to reload settings: %v", err) + } + if persisted.AdminAreaAuth != "" { + t.Errorf("Guard rail must not persist the change, got %q", persisted.AdminAreaAuth) + } +} + +// TestAdminAreaAuthRoundTrip verifies enabling (with non-default creds) and +// later disabling admin_area_auth updates both the live server field and +// the persisted settings.json, and is reflected back by GET /setup/settings. +func TestAdminAreaAuthRoundTrip(t *testing.T) { + tempDir, err := os.MkdirTemp("", "admin-area-auth-roundtrip-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://127.0.0.1:8000", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + server.mgmtUsername = "custom-admin" + server.mgmtPassword = "custom-password" + + enableBody, _ := json.Marshal(map[string]string{ + "server_url": "http://127.0.0.1:8000", + "admin_area_auth": "Enabled", // mixed case must normalise + }) + + res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(enableBody)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("POST /setup/settings (enable): expected 200, got %v", res.Status) + } + + if server.AdminAreaAuthMode() != "enabled" { + t.Errorf("Expected live mode \"enabled\", got %q", server.AdminAreaAuthMode()) + } + + persisted, err := ds.GetSettings() + if err != nil { + t.Fatalf("Failed to reload settings: %v", err) + } + if persisted.AdminAreaAuth != "enabled" { + t.Errorf("Expected persisted admin_area_auth \"enabled\", got %q", persisted.AdminAreaAuth) + } + + res, err = http.Get(ts.URL + "/setup/settings") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + var got map[string]interface{} + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatalf("Failed to decode GET /setup/settings: %v", err) + } + if got["admin_area_auth"] != "enabled" { + t.Errorf("GET /setup/settings: expected admin_area_auth \"enabled\", got %+v", got["admin_area_auth"]) + } + + disableBody, _ := json.Marshal(map[string]string{ + "server_url": "http://127.0.0.1:8000", + "admin_area_auth": "disabled", + }) + + res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(disableBody)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("POST /setup/settings (disable): expected 200, got %v", res.Status) + } + + if server.AdminAreaAuthMode() != "disabled" { + t.Errorf("Expected live mode \"disabled\" after explicit opt-out, got %q", server.AdminAreaAuthMode()) + } +} + func TestMigrationAndCA(t *testing.T) { tempDir, err := os.MkdirTemp("", "handlers-test") if err != nil { diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 63c7045..9888f01 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -68,6 +68,7 @@ type Server struct { RepoURL string mgmtUsername string mgmtPassword string + adminAreaAuth string // "" (unset) / "enabled" / "disabled" — see datastore.Settings.AdminAreaAuth spotifyClientID string spotifyClientSecret string spotifyRedirectURI string @@ -1027,6 +1028,27 @@ func (s *Server) SetMgmtConfig(username, password string) { s.mgmtPassword = password } +// SetAdminAreaAuth sets the live admin-area auth mode ("" / "enabled" / +// "disabled", see datastore.Settings.AdminAreaAuth). Does not validate — +// callers (HandleUpdateSettings, startup settings application) are +// responsible for only passing already-validated values. +func (s *Server) SetAdminAreaAuth(mode string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.adminAreaAuth = mode +} + +// AdminAreaAuthMode returns the live admin-area auth mode. Exported so +// packages that can't import handlers directly (e.g. health checks, which +// take it via a callback to avoid a circular import) can read it. +func (s *Server) AdminAreaAuthMode() string { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.adminAreaAuth +} + // SetInternalPaths sets the internal paths for the server. func (s *Server) SetInternalPaths(paths []string) { s.mu.Lock()