// Package settings is the runtime-settings service: the small set of // switches an administrator can flip without restarting the server. // // Each setting has a value in the config file and, optionally, a value in // the server_settings table. The stored value wins when the config allows // the setting to be toggled at runtime; otherwise the file is authoritative // and the stored value is ignored (but kept, so that re-enabling the toggle // restores the administrator's last choice). // // Readers get a cached value — these are consulted on every federated // request — and components that must react to a change subscribe. package settings import ( "context" "errors" "fmt" "log/slog" "strconv" "sync" "github.com/dariuszjaniak/warp/server/internal/storage" ) // Setting keys as stored in server_settings. const ( KeyFederationEnabled = "federation.enabled" KeyRegistrationEnabled = "registration.enabled" ) // ErrNotToggleable is returned when a setting is pinned by the config file. var ErrNotToggleable = errors.New("setting is not toggleable at runtime") // Defaults come from the config file. type Defaults struct { FederationEnabled bool FederationAllowRuntimeToggle bool RegistrationEnabled bool RegistrationAllowRuntimeToggle bool } // Snapshot is a consistent view of every runtime setting. type Snapshot struct { FederationEnabled bool RegistrationEnabled bool } // Service caches the runtime settings and persists changes. type Service struct { store storage.SettingsStore log *slog.Logger defaults Defaults mu sync.RWMutex current Snapshot listeners []func(Snapshot) // writeMu serialises Set calls; see set. writeMu sync.Mutex } // New loads the persisted settings and returns a service. A stored value // that cannot be parsed is logged and ignored rather than failing the boot: // an unreadable switch should not keep the server down. func New(ctx context.Context, store storage.SettingsStore, defaults Defaults, log *slog.Logger) (*Service, error) { if log == nil { log = slog.Default() } s := &Service{ store: store, log: log, defaults: defaults, current: Snapshot{ FederationEnabled: defaults.FederationEnabled, RegistrationEnabled: defaults.RegistrationEnabled, }, } stored, err := store.GetAll(ctx) if err != nil { return nil, fmt.Errorf("load server settings: %w", err) } if defaults.FederationAllowRuntimeToggle { s.current.FederationEnabled = s.boolOr(stored, KeyFederationEnabled, defaults.FederationEnabled) } if defaults.RegistrationAllowRuntimeToggle { s.current.RegistrationEnabled = s.boolOr(stored, KeyRegistrationEnabled, defaults.RegistrationEnabled) } s.log.Info("runtime settings loaded", "federation_enabled", s.current.FederationEnabled, "registration_enabled", s.current.RegistrationEnabled) return s, nil } func (s *Service) boolOr(stored map[string]string, key string, fallback bool) bool { raw, ok := stored[key] if !ok { return fallback } v, err := strconv.ParseBool(raw) if err != nil { s.log.Warn("ignoring unparseable stored setting", "key", key, "value", raw, "error", err) return fallback } return v } // Snapshot returns the current values. func (s *Service) Snapshot() Snapshot { s.mu.RLock() defer s.mu.RUnlock() return s.current } // FederationEnabled reports whether the federation surface is live. It is // called on every federation request, so it only takes a read lock. func (s *Service) FederationEnabled() bool { s.mu.RLock() defer s.mu.RUnlock() return s.current.FederationEnabled } // RegistrationEnabled reports whether open registration is accepted. func (s *Service) RegistrationEnabled() bool { s.mu.RLock() defer s.mu.RUnlock() return s.current.RegistrationEnabled } // FederationToggleable reports whether SetFederationEnabled is allowed. func (s *Service) FederationToggleable() bool { return s.defaults.FederationAllowRuntimeToggle } // RegistrationToggleable reports whether SetRegistrationEnabled is allowed. func (s *Service) RegistrationToggleable() bool { return s.defaults.RegistrationAllowRuntimeToggle } // SetFederationEnabled persists and applies the federation switch. func (s *Service) SetFederationEnabled(ctx context.Context, enabled bool) error { if !s.defaults.FederationAllowRuntimeToggle { return fmt.Errorf("federation.enabled: %w (federation.allow_runtime_toggle is false)", ErrNotToggleable) } return s.set(ctx, KeyFederationEnabled, enabled, func(snap *Snapshot) { snap.FederationEnabled = enabled }) } // SetRegistrationEnabled persists and applies the registration switch. func (s *Service) SetRegistrationEnabled(ctx context.Context, enabled bool) error { if !s.defaults.RegistrationAllowRuntimeToggle { return fmt.Errorf("registration.enabled: %w (registration.allow_runtime_toggle is false)", ErrNotToggleable) } return s.set(ctx, KeyRegistrationEnabled, enabled, func(snap *Snapshot) { snap.RegistrationEnabled = enabled }) } // set writes the store first: if persistence fails the in-memory value must // not change, or a restart would silently undo what the admin API reported // as done. func (s *Service) set(ctx context.Context, key string, value bool, apply func(*Snapshot)) error { // writeMu serialises the whole write path, so two concurrent toggles // cannot persist in one order and notify subscribers in another. s.writeMu.Lock() defer s.writeMu.Unlock() if err := s.store.Set(ctx, key, strconv.FormatBool(value)); err != nil { return err } s.mu.Lock() before := s.current apply(&s.current) after := s.current listeners := make([]func(Snapshot), len(s.listeners)) copy(listeners, s.listeners) s.mu.Unlock() if before == after { return nil } s.log.Info("runtime setting changed", "key", key, "value", value) // Listeners are called outside s.mu: a federation module starting or // stopping can take a while and must not block readers. for _, fn := range listeners { fn(after) } return nil } // Subscribe registers fn to be called after any setting changes. It is // called with the new snapshot, never concurrently with itself. func (s *Service) Subscribe(fn func(Snapshot)) { s.mu.Lock() defer s.mu.Unlock() s.listeners = append(s.listeners, fn) }