Add mutex protection for config watcher reloads (re #748)

Introduced sync.RWMutex to protect concurrent access to configuration
fields (AuthUser, AuthPass, APITokens) that are modified by the
ConfigWatcher at runtime.

- Added global config.Mu RWMutex in internal/config/config.go
- Protected config updates in ConfigWatcher.reloadConfig() and reloadAPITokens()
- Protected config reads in CheckAuth and all API token handlers
- Protected Router.SetConfig() during full config reloads

This prevents race conditions when .env file changes trigger config
reloads while authentication handlers are reading the same fields.
This commit is contained in:
courtmanr@gmail.com 2025-11-24 07:45:21 +00:00
parent 0c7af34ad2
commit 60721cbc22
6 changed files with 35 additions and 0 deletions

View file

@ -196,6 +196,9 @@ func min(a, b int) int {
// CheckAuth checks both basic auth and API token
func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool {
config.Mu.RLock()
defer config.Mu.RUnlock()
// Check proxy auth first if configured
if cfg.ProxyAuthSecret != "" {
if valid, username, _ := CheckProxyAuth(cfg, r); valid {

View file

@ -1201,6 +1201,9 @@ func (r *Router) SetConfig(cfg *config.Config) {
return
}
config.Mu.Lock()
defer config.Mu.Unlock()
if r.config == nil {
r.config = cfg
} else {
@ -2839,6 +2842,9 @@ func (r *Router) handleConfig(w http.ResponseWriter, req *http.Request) {
return
}
config.Mu.RLock()
defer config.Mu.RUnlock()
// Return public configuration
config := map[string]interface{}{
"csrfProtection": false, // Not implemented yet

View file

@ -275,11 +275,13 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
}
// Update runtime config immediately with hashed token - no restart needed!
config.Mu.Lock()
r.config.AuthUser = setupRequest.Username
r.config.AuthPass = hashedPassword
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
r.config.SortAPITokens()
r.config.APITokenEnabled = true
config.Mu.Unlock()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
@ -539,9 +541,11 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
return
}
config.Mu.Lock()
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
r.config.SortAPITokens()
r.config.APITokenEnabled = true
config.Mu.Unlock()
log.Info().Msg("Runtime config updated with new API token - active immediately")
if r.persistence != nil {
@ -672,7 +676,9 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request)
}
// Validate the token (compare hash)
config.Mu.RLock()
_, isValid := r.config.ValidateAPIToken(validateRequest.Token)
config.Mu.RUnlock()
// Log validation attempt without logging the token itself
if isValid {

View file

@ -91,6 +91,9 @@ func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) {
return
}
config.Mu.RLock()
defer config.Mu.RUnlock()
tokens := make([]apiTokenDTO, 0, len(r.config.APITokens))
for _, record := range r.config.APITokens {
tokens = append(tokens, toAPITokenDTO(record))
@ -147,6 +150,9 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
return
}
config.Mu.Lock()
defer config.Mu.Unlock()
r.config.APITokens = append(r.config.APITokens, *record)
r.config.SortAPITokens()
r.config.APITokenEnabled = true
@ -181,6 +187,9 @@ func (r *Router) handleDeleteAPIToken(w http.ResponseWriter, req *http.Request)
return
}
config.Mu.Lock()
defer config.Mu.Unlock()
removed := r.config.RemoveAPIToken(id)
if !removed {
http.Error(w, "Token not found", http.StatusNotFound)

View file

@ -20,6 +20,8 @@ import (
"strings"
"time"
"sync"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
@ -29,6 +31,10 @@ import (
"github.com/rs/zerolog/log"
)
// Mu protects concurrent access to the configuration,
// particularly for fields updated by the config watcher (AuthUser, AuthPass, APITokens).
var Mu sync.RWMutex
const (
DefaultGuestMetadataMinRefresh = 2 * time.Minute
DefaultGuestMetadataRefreshJitter = 45 * time.Second

View file

@ -310,6 +310,9 @@ func (cw *ConfigWatcher) reloadConfig() {
}
// Apply auth user
Mu.Lock()
defer Mu.Unlock()
newUser := strings.Trim(envMap["PULSE_AUTH_USER"], "'\"")
if newUser != oldAuthUser {
cw.config.AuthUser = newUser
@ -485,6 +488,8 @@ func (cw *ConfigWatcher) reloadAPITokens() {
}
// Only update if we successfully loaded tokens
Mu.Lock()
defer Mu.Unlock()
cw.config.APITokens = tokens
cw.config.SortAPITokens()
cw.config.APITokenEnabled = len(tokens) > 0