feat(admin): add tri-state AdminAreaAuth setting with default-creds guard rail

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
This commit is contained in:
Tobias Gesellchen
2026-08-08 23:49:57 +02:00
parent bee0d25747
commit 9090fad563
5 changed files with 260 additions and 0 deletions
+17
View File
@@ -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.
+44
View File
@@ -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
+172
View File
@@ -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 {
+22
View File
@@ -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()