// 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" "encoding/json" "errors" "fmt" "log/slog" "slices" "strconv" "sync" "github.com/dariuszjaniak/warp/server/internal/storage" ) // Setting keys as stored in server_settings. const ( KeyFederationEnabled = "federation.enabled" KeyRegistrationEnabled = "registration.enabled" // KeyFederationLists holds the allow and deny lists an admin set // through the admin API, as one JSON object, so that the two are // always written and read together. KeyFederationLists = "federation.lists" ) // 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 // FederationAllowlist and FederationDenylist are the config file's // lists, in force until an admin sets others. FederationAllowlist []string FederationDenylist []string } // FederationLists are the federation allow and deny lists in force. type FederationLists struct { Allowlist []string `json:"allowlist"` Denylist []string `json:"denylist"` // Edited reports lists set through the admin API, which replace the // config file's until they are reset. Edited bool `json:"-"` } // 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 lists FederationLists 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) } // The lists an admin edited win over the file's whatever the toggle // settings say: they are not a switch the file can pin, and an admin // who set them expects them to survive a restart. s.lists = FederationLists{Allowlist: defaults.FederationAllowlist, Denylist: defaults.FederationDenylist} if raw, ok := stored[KeyFederationLists]; ok { var edited FederationLists if err := json.Unmarshal([]byte(raw), &edited); err != nil { s.log.Warn("ignoring unparseable stored federation lists", "error", err) } else { edited.Edited = true s.lists = edited } } 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 }) } // FederationLists returns the allow and deny lists in force. func (s *Service) FederationLists() FederationLists { s.mu.RLock() defer s.mu.RUnlock() return FederationLists{ Allowlist: slices.Clone(s.lists.Allowlist), Denylist: slices.Clone(s.lists.Denylist), Edited: s.lists.Edited, } } // SetFederationLists persists new allow and deny lists. Applying them to // the federation policy is the caller's job. func (s *Service) SetFederationLists(ctx context.Context, allowlist, denylist []string) error { s.writeMu.Lock() defer s.writeMu.Unlock() lists := FederationLists{Allowlist: nonNil(allowlist), Denylist: nonNil(denylist), Edited: true} raw, err := json.Marshal(lists) if err != nil { return err } if err := s.store.Set(ctx, KeyFederationLists, string(raw)); err != nil { return err } s.mu.Lock() s.lists = lists s.mu.Unlock() s.log.Info("federation lists changed", "allowlist", lists.Allowlist, "denylist", lists.Denylist) return nil } // ResetFederationLists forgets the edited lists and puts the config file's // back in force, returning them. func (s *Service) ResetFederationLists(ctx context.Context) (FederationLists, error) { s.writeMu.Lock() defer s.writeMu.Unlock() if err := s.store.Delete(ctx, KeyFederationLists); err != nil { return FederationLists{}, err } lists := FederationLists{ Allowlist: nonNil(s.defaults.FederationAllowlist), Denylist: nonNil(s.defaults.FederationDenylist), } s.mu.Lock() s.lists = lists s.mu.Unlock() s.log.Info("federation lists reset to the configuration file's") return lists, nil } func nonNil(list []string) []string { if list == nil { return []string{} } return slices.Clone(list) } // 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) }