Files
Warp/server/internal/federation/gate.go
T
tajniak81andClaude Opus 5 b4fb60a8ab
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
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>
2026-10-15 17:01:13 +02:00

151 lines
4.5 KiB
Go

// Package federation holds the server-to-server surface. In Phase 0 that
// is only the gate: the piece that makes federation a runtime mode rather
// than a build-time one.
//
// Every federation route is registered through Gate.Protect, so flipping
// federation.enabled through the admin API takes effect on the next request
// without re-registering routes or restarting the process. The transports
// that the switch also has to start and stop (outbound transaction queue,
// key fetcher, remote media) arrive in Phase 5 as federation.Module.
package federation
import (
"net/http"
"strings"
"sync"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// Gate reports whether federation is currently live. The callback reads the
// settings service, so the answer tracks the runtime switch.
type Gate struct {
enabled func() bool
policy *Policy
}
// NewGate builds a gate. A nil callback means permanently disabled, which
// is the safe default for a half-wired server.
func NewGate(enabled func() bool, policy *Policy) *Gate {
if enabled == nil {
enabled = func() bool { return false }
}
if policy == nil {
policy = NewPolicy(nil, nil)
}
return &Gate{enabled: enabled, policy: policy}
}
// Enabled reports the live state of the switch.
func (g *Gate) Enabled() bool { return g.enabled() }
// Policy returns the allow/deny list filter.
func (g *Gate) Policy() *Policy { return g.policy }
// Protect wraps a federation handler so that it answers M_UNRECOGNIZED
// while federation is off. The spec's answer for an endpoint a server does
// not implement is a 404, and from the outside a federation-disabled Warp
// is exactly that: a server without a federation API.
func (g *Gate) Protect(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !g.enabled() {
httputil.WriteError(w, r, httputil.Unrecognized())
return
}
h.ServeHTTP(w, r)
})
}
// ProtectFunc is Protect for an error-returning handler.
func (g *Gate) ProtectFunc(h httputil.Handler) http.Handler {
return g.Protect(httputil.Wrap(h))
}
// RejectClientRequest is the error a Client-Server endpoint returns when it
// would need federation to answer: joining a remote room, inviting a remote
// user, querying a remote directory. The message is deliberately explicit —
// "M_FORBIDDEN" alone sends operators hunting through power levels.
func RejectClientRequest(action string) error {
return httputil.Forbidden("Federation is disabled on this server; %s is not possible", action)
}
// Policy filters remote servers by name. An empty allowlist means "any
// server not on the denylist"; a non-empty one means "only these".
type Policy struct {
mu sync.RWMutex
allowlist map[string]struct{}
denylist map[string]struct{}
}
// NewPolicy builds a policy from config lists.
func NewPolicy(allowlist, denylist []string) *Policy {
p := &Policy{}
p.Replace(allowlist, denylist)
return p
}
// Replace swaps both lists atomically; the admin API edits them at runtime.
func (p *Policy) Replace(allowlist, denylist []string) {
allow := toSet(allowlist)
deny := toSet(denylist)
p.mu.Lock()
defer p.mu.Unlock()
p.allowlist, p.denylist = allow, deny
}
// Allowed reports whether traffic to or from serverName may flow. The
// denylist always wins.
func (p *Policy) Allowed(serverName string) bool {
name := normalise(serverName)
if name == "" {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
if _, denied := p.denylist[name]; denied {
return false
}
if len(p.allowlist) == 0 {
return true
}
_, allowed := p.allowlist[name]
return allowed
}
// Lists returns copies of the current allow and deny lists, for the admin
// API and the dashboard.
func (p *Policy) Lists() (allowlist, denylist []string) {
p.mu.RLock()
defer p.mu.RUnlock()
return setToSlice(p.allowlist), setToSlice(p.denylist)
}
func toSet(names []string) map[string]struct{} {
out := make(map[string]struct{}, len(names))
for _, n := range names {
if n = normalise(n); n != "" {
out[n] = struct{}{}
}
}
return out
}
func setToSlice(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for name := range set {
out = append(out, name)
}
return out
}
// normalise lower-cases a server name. Matrix server names are
// case-sensitive in theory, but every real deployment uses a DNS name,
// and treating "Example.com" as a different server from "example.com"
// would turn a typo in the allowlist into an open door.
func normalise(serverName string) string {
return strings.ToLower(strings.TrimSpace(serverName))
}