fix(config): lock Manager, harden defaults handling and path/IP helpers

Guard viper with an RWMutex, make the singleton race-free and stop
NewManager replacing the global (add SetConfigManager), write saved
configs 0600, search CWD last, add ConfigFileUsed and Config.Validate,
nil-safe PathsConfig.Set, confine PathsConfig.Join, bound GetIPs DNS
lookup, and drop dead Unmarshal in SetConfig. Mark audit status.
This commit is contained in:
Hein
2026-09-30 13:02:40 +02:00
parent 3657aa94cc
commit e1cf72834e
7 changed files with 250 additions and 57 deletions
+79 -19
View File
@@ -2,37 +2,60 @@ package config
import (
"fmt"
"os"
"strings"
"sync"
"github.com/spf13/viper"
)
// Manager handles configuration loading from multiple sources
// Manager handles configuration loading from multiple sources.
// viper.Viper is not safe for concurrent use, so every access to it is guarded by mu.
type Manager struct {
v *viper.Viper
mu sync.RWMutex
v *viper.Viper
}
var configInstance *Manager
var (
configInstance *Manager
configMu sync.Mutex
)
// GetConfigManager returns a singleton configuration manager instance
func GetConfigManager() *Manager {
configMu.Lock()
defer configMu.Unlock()
if configInstance == nil {
configInstance = NewManager()
}
return configInstance
}
// NewManager creates a new configuration manager with defaults
// SetConfigManager publishes m as the global manager returned by GetConfigManager.
// NewManager no longer does this implicitly.
func SetConfigManager(m *Manager) {
configMu.Lock()
defer configMu.Unlock()
configInstance = m
}
// NewManager creates a new, isolated configuration manager with defaults.
// It does not replace the global manager; use SetConfigManager for that.
func NewManager() *Manager {
v := viper.New()
// Set configuration file settings
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
// Most trusted location first; the working directory is the least trustworthy
// and is searched last (viper takes the first match).
v.AddConfigPath("/etc/resolvespec")
v.AddConfigPath("$HOME/.resolvespec")
v.AddConfigPath("./config")
v.AddConfigPath(".")
// Saved configs may contain secrets; never write them world-readable
v.SetConfigPermissions(0o600)
// Enable environment variable support
v.SetEnvPrefix("RESOLVESPEC")
@@ -42,8 +65,7 @@ func NewManager() *Manager {
// Set default values
setDefaults(v)
configInstance = &Manager{v: v}
return configInstance
return &Manager{v: v}
}
// NewManagerWithOptions creates a new configuration manager with custom options
@@ -61,6 +83,8 @@ type Option func(*Manager)
// WithConfigFile sets a specific config file path
func WithConfigFile(path string) Option {
return func(m *Manager) {
m.mu.Lock()
defer m.mu.Unlock()
m.v.SetConfigFile(path)
}
}
@@ -68,6 +92,8 @@ func WithConfigFile(path string) Option {
// WithConfigName sets the config file name (without extension)
func WithConfigName(name string) Option {
return func(m *Manager) {
m.mu.Lock()
defer m.mu.Unlock()
m.v.SetConfigName(name)
}
}
@@ -75,6 +101,8 @@ func WithConfigName(name string) Option {
// WithConfigPath adds a path to search for config files
func WithConfigPath(path string) Option {
return func(m *Manager) {
m.mu.Lock()
defer m.mu.Unlock()
m.v.AddConfigPath(path)
}
}
@@ -82,13 +110,19 @@ func WithConfigPath(path string) Option {
// WithEnvPrefix sets the environment variable prefix
func WithEnvPrefix(prefix string) Option {
return func(m *Manager) {
m.mu.Lock()
defer m.mu.Unlock()
m.v.SetEnvPrefix(prefix)
}
}
// Load attempts to load configuration from file and environment
// Load attempts to load configuration from file and environment.
// A missing config file is not an error (defaults and env vars are used); check
// ConfigFileUsed after Load to see whether a file was actually read.
func (m *Manager) Load() error {
// Try to read config file (not an error if it doesn't exist)
m.mu.Lock()
defer m.mu.Unlock()
if err := m.v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("error reading config file: %w", err)
@@ -99,8 +133,19 @@ func (m *Manager) Load() error {
return nil
}
// ConfigFileUsed returns the config file that was read by Load, or "" if none was
// found (i.e. the manager is running on defaults and environment variables only).
func (m *Manager) ConfigFileUsed() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v.ConfigFileUsed()
}
// GetConfig returns the complete configuration
func (m *Manager) GetConfig() (*Config, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var cfg Config
if err := m.v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
@@ -108,16 +153,11 @@ func (m *Manager) GetConfig() (*Config, error) {
return &cfg, nil
}
// SetConfig sets the complete configuration
// SetConfig sets the complete configuration atomically
func (m *Manager) SetConfig(cfg *Config) error {
configMap := make(map[string]interface{})
m.mu.Lock()
defer m.mu.Unlock()
// Marshal the config to a map structure that viper can use
if err := m.v.Unmarshal(&configMap); err != nil {
return fmt.Errorf("failed to prepare config map: %w", err)
}
// Use viper's merge to apply the config
m.v.Set("servers", cfg.Servers)
m.v.Set("tracing", cfg.Tracing)
m.v.Set("cache", cfg.Cache)
@@ -135,34 +175,54 @@ func (m *Manager) SetConfig(cfg *Config) error {
// Get returns a configuration value by key
func (m *Manager) Get(key string) interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v.Get(key)
}
// GetString returns a string configuration value
func (m *Manager) GetString(key string) string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v.GetString(key)
}
// GetInt returns an int configuration value
func (m *Manager) GetInt(key string) int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v.GetInt(key)
}
// GetBool returns a bool configuration value
func (m *Manager) GetBool(key string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v.GetBool(key)
}
// Set sets a configuration value
func (m *Manager) Set(key string, value interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.v.Set(key, value)
}
// SaveConfig writes the current configuration to the specified path
// SaveConfig writes the current configuration to the specified path.
// The file contains the entire merged configuration, including secrets
// (database/redis passwords, error-tracking DSN), so it is written with mode 0600.
// Prefer supplying secrets via RESOLVESPEC_* environment variables.
func (m *Manager) SaveConfig(path string) error {
m.mu.RLock()
defer m.mu.RUnlock()
if err := m.v.WriteConfigAs(path); err != nil {
return fmt.Errorf("failed to save config to %s: %w", path, err)
}
// viper only applies its permissions when creating the file; tighten a pre-existing one too
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("failed to restrict permissions on %s: %w", path, err)
}
return nil
}