Phase 0: bootstrap the Warp homeserver
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>
This commit is contained in:
commit
b4fb60a8ab
50 files changed
+5672
No files matched your search
@@ -0,0 +1,193 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package settings_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/settings"
|
||||
)
|
||||
|
||||
// memStore is an in-memory storage.SettingsStore.
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
values map[string]string
|
||||
failSet error
|
||||
}
|
||||
|
||||
func newMemStore(initial map[string]string) *memStore {
|
||||
values := map[string]string{}
|
||||
for k, v := range initial {
|
||||
values[k] = v
|
||||
}
|
||||
return &memStore{values: values}
|
||||
}
|
||||
|
||||
func (m *memStore) Get(_ context.Context, key string) (string, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v, ok := m.values[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetAll(context.Context) (map[string]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for k, v := range m.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Set(_ context.Context, key, value string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.failSet != nil {
|
||||
return m.failSet
|
||||
}
|
||||
m.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Delete(_ context.Context, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.values, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func togglableDefaults() settings.Defaults {
|
||||
return settings.Defaults{
|
||||
FederationEnabled: false,
|
||||
FederationAllowRuntimeToggle: true,
|
||||
RegistrationEnabled: false,
|
||||
RegistrationAllowRuntimeToggle: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredValueOverridesConfig(t *testing.T) {
|
||||
store := newMemStore(map[string]string{settings.KeyFederationEnabled: "true"})
|
||||
|
||||
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if !svc.FederationEnabled() {
|
||||
t.Error("the persisted value should win over the config default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedSettingIgnoresStoredValue(t *testing.T) {
|
||||
store := newMemStore(map[string]string{settings.KeyFederationEnabled: "true"})
|
||||
defaults := togglableDefaults()
|
||||
defaults.FederationAllowRuntimeToggle = false
|
||||
|
||||
svc, err := settings.New(t.Context(), store, defaults, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if svc.FederationEnabled() {
|
||||
t.Error("with allow_runtime_toggle false the config file is authoritative")
|
||||
}
|
||||
if err := svc.SetFederationEnabled(t.Context(), true); !errors.Is(err, settings.ErrNotToggleable) {
|
||||
t.Errorf("SetFederationEnabled = %v, want ErrNotToggleable", err)
|
||||
}
|
||||
if _, found, _ := store.Get(t.Context(), settings.KeyFederationEnabled); !found {
|
||||
t.Error("the stored value should be kept for when the toggle is re-enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnparseableStoredValueFallsBackToConfig(t *testing.T) {
|
||||
store := newMemStore(map[string]string{settings.KeyRegistrationEnabled: "maybe"})
|
||||
defaults := togglableDefaults()
|
||||
defaults.RegistrationEnabled = true
|
||||
|
||||
svc, err := settings.New(t.Context(), store, defaults, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if !svc.RegistrationEnabled() {
|
||||
t.Error("a corrupt stored value must fall back to the config value, not to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPersistsAndNotifies(t *testing.T) {
|
||||
store := newMemStore(nil)
|
||||
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got []settings.Snapshot
|
||||
svc.Subscribe(func(s settings.Snapshot) { got = append(got, s) })
|
||||
|
||||
if err := svc.SetFederationEnabled(t.Context(), true); err != nil {
|
||||
t.Fatalf("SetFederationEnabled: %v", err)
|
||||
}
|
||||
if !svc.FederationEnabled() {
|
||||
t.Error("the cached value was not updated")
|
||||
}
|
||||
if v, _, _ := store.Get(t.Context(), settings.KeyFederationEnabled); v != "true" {
|
||||
t.Errorf("stored value = %q, want \"true\"", v)
|
||||
}
|
||||
if len(got) != 1 || !got[0].FederationEnabled {
|
||||
t.Fatalf("subscriber saw %v, want one snapshot with federation on", got)
|
||||
}
|
||||
|
||||
// Setting the same value again changes nothing and must not notify.
|
||||
if err := svc.SetFederationEnabled(t.Context(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("a no-op change notified subscribers: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetKeepsCacheOnStoreFailure(t *testing.T) {
|
||||
store := newMemStore(nil)
|
||||
store.failSet = errors.New("disk on fire")
|
||||
|
||||
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SetFederationEnabled(t.Context(), true); err == nil {
|
||||
t.Fatal("expected the store failure to surface")
|
||||
}
|
||||
if svc.FederationEnabled() {
|
||||
t.Error("the cached value must not change when persistence fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotIsConsistent(t *testing.T) {
|
||||
store := newMemStore(map[string]string{
|
||||
settings.KeyFederationEnabled: "true",
|
||||
settings.KeyRegistrationEnabled: "true",
|
||||
})
|
||||
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := svc.Snapshot()
|
||||
if !snap.FederationEnabled || !snap.RegistrationEnabled {
|
||||
t.Errorf("Snapshot() = %+v, want both enabled", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsAndWrites(t *testing.T) {
|
||||
store := newMemStore(nil)
|
||||
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if i%2 == 0 {
|
||||
_ = svc.SetFederationEnabled(t.Context(), i%4 == 0)
|
||||
return
|
||||
}
|
||||
_ = svc.FederationEnabled()
|
||||
_ = svc.Snapshot()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in new issue
Block a user