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,150 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package federation_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
)
|
||||
|
||||
func TestGateFollowsTheSwitch(t *testing.T) {
|
||||
var enabled atomic.Bool
|
||||
gate := federation.NewGate(enabled.Load, nil)
|
||||
|
||||
handler := gate.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_matrix/federation/v1/version", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 while federation is off", rec.Code)
|
||||
}
|
||||
|
||||
enabled.Store(true)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_matrix/federation/v1/version", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 once federation is on", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilCallbackMeansDisabled(t *testing.T) {
|
||||
gate := federation.NewGate(nil, nil)
|
||||
if gate.Enabled() {
|
||||
t.Error("a gate without a switch must be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
allowlist []string
|
||||
denylist []string
|
||||
server string
|
||||
want bool
|
||||
}{
|
||||
{"empty lists allow anything", nil, nil, "matrix.org", true},
|
||||
{"denylist blocks", nil, []string{"evil.example"}, "evil.example", false},
|
||||
{"denylist is case insensitive", nil, []string{"evil.example"}, "Evil.Example", false},
|
||||
{"allowlist admits members", []string{"a.example"}, nil, "a.example", true},
|
||||
{"allowlist excludes everyone else", []string{"a.example"}, nil, "b.example", false},
|
||||
{"denylist wins over allowlist", []string{"a.example"}, []string{"a.example"}, "a.example", false},
|
||||
{"empty server name is refused", nil, nil, "", false},
|
||||
{"whitespace is trimmed", []string{"a.example"}, nil, " a.example ", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := federation.NewPolicy(tc.allowlist, tc.denylist)
|
||||
if got := p.Allowed(tc.server); got != tc.want {
|
||||
t.Errorf("Allowed(%q) = %v, want %v", tc.server, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyReplace(t *testing.T) {
|
||||
p := federation.NewPolicy([]string{"a.example"}, nil)
|
||||
if !p.Allowed("a.example") {
|
||||
t.Fatal("a.example should be allowed")
|
||||
}
|
||||
|
||||
p.Replace(nil, []string{"a.example"})
|
||||
if p.Allowed("a.example") {
|
||||
t.Error("after Replace, a.example should be denied")
|
||||
}
|
||||
|
||||
allow, deny := p.Lists()
|
||||
if len(allow) != 0 || len(deny) != 1 || deny[0] != "a.example" {
|
||||
t.Errorf("Lists() = %v, %v", allow, deny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectClientRequestExplainsItself(t *testing.T) {
|
||||
err := federation.RejectClientRequest("joining a room on another server")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"M_FORBIDDEN", "Federation is disabled", "joining a room"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error %q should mention %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user