Scaffolds the server module and everything Phase 0 of PLAN.md calls for: a binary that boots, serves its discovery endpoints and nothing else yet. - config: one YAML file plus per-field WARP_* environment overrides, unknown fields rejected, validation that reports every problem at once - logging and metrics: slog, a Prometheus registry, per-route HTTP instrumentation labelled by pattern rather than path - httputil: the Matrix error envelope, error-returning handlers, CORS, request logging, panic recovery, and a router over http.ServeMux - endpoints: /_matrix/client/versions, /.well-known/matrix/client, /.well-known/matrix/server (gated), /healthz, /metrics, and M_UNRECOGNIZED for everything else - storage: driver registry, shared database/sql base, checksummed migration runner, sqlite and postgres drivers, pocketbase stub - settings: server_settings table behind a cached service, so the federation and registration switches change without a restart - federation: the gate that makes federation a runtime mode - cmd/warp: serve, migrate, generate-keys, version - Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests, race on Linux, storage tests against PostgreSQL, lint, boot smoke test), Dockerfile, example config and docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
// 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)
|
|
}
|