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>
151 lines
4.5 KiB
Go
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))
|
|
}
|