Phase 0: bootstrap the Warp homeserver
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

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:
tajniak81andClaude Opus 5 committed 2026-10-15 17:01:13 +02:00
commit b4fb60a8ab
50 files changed
+5672

No files matched your search

+150
View File
@@ -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))
}
+97
View File
@@ -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)
}
}
}