mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user