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,132 @@
|
||||
// Package httputil holds the HTTP plumbing shared by every Warp API: the
|
||||
// Matrix error envelope, JSON helpers, CORS and middleware.
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Matrix error codes used so far. The full list lives in the Client-Server
|
||||
// API spec, section "Standard error response"; codes are added as the
|
||||
// endpoints that return them are implemented.
|
||||
const (
|
||||
ErrCodeForbidden = "M_FORBIDDEN"
|
||||
ErrCodeUnknownToken = "M_UNKNOWN_TOKEN"
|
||||
ErrCodeMissingToken = "M_MISSING_TOKEN"
|
||||
ErrCodeBadJSON = "M_BAD_JSON"
|
||||
ErrCodeNotJSON = "M_NOT_JSON"
|
||||
ErrCodeNotFound = "M_NOT_FOUND"
|
||||
ErrCodeLimitExceeded = "M_LIMIT_EXCEEDED"
|
||||
ErrCodeUnknown = "M_UNKNOWN"
|
||||
ErrCodeUnrecognized = "M_UNRECOGNIZED"
|
||||
ErrCodeUnauthorized = "M_UNAUTHORIZED"
|
||||
ErrCodeMissingParam = "M_MISSING_PARAM"
|
||||
ErrCodeInvalidParam = "M_INVALID_PARAM"
|
||||
ErrCodeTooLarge = "M_TOO_LARGE"
|
||||
ErrCodeUnsupportedVer = "M_UNSUPPORTED_ROOM_VERSION"
|
||||
)
|
||||
|
||||
// MatrixError is the standard Matrix error envelope. Every non-2xx response
|
||||
// from a /_matrix endpoint is one of these, serialised as
|
||||
//
|
||||
// {"errcode": "M_FORBIDDEN", "error": "..."}
|
||||
//
|
||||
// It implements error so handlers can return it up the stack.
|
||||
type MatrixError struct {
|
||||
Code string `json:"errcode"`
|
||||
Message string `json:"error"`
|
||||
|
||||
// RetryAfterMS accompanies M_LIMIT_EXCEEDED.
|
||||
RetryAfterMS int64 `json:"retry_after_ms,omitempty"`
|
||||
|
||||
// SoftLogout accompanies M_UNKNOWN_TOKEN when the client may keep its
|
||||
// encryption state and re-login.
|
||||
SoftLogout bool `json:"soft_logout,omitempty"`
|
||||
|
||||
// status is the HTTP status to send; not part of the JSON body.
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *MatrixError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// StatusCode is the HTTP status this error is sent with.
|
||||
func (e *MatrixError) StatusCode() int {
|
||||
if e.status == 0 {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
return e.status
|
||||
}
|
||||
|
||||
// NewError builds an arbitrary Matrix error.
|
||||
func NewError(status int, code, format string, args ...any) *MatrixError {
|
||||
return &MatrixError{Code: code, Message: fmt.Sprintf(format, args...), status: status}
|
||||
}
|
||||
|
||||
// Forbidden is M_FORBIDDEN (403). Federation-disabled rejections use it.
|
||||
func Forbidden(format string, args ...any) *MatrixError {
|
||||
return NewError(http.StatusForbidden, ErrCodeForbidden, format, args...)
|
||||
}
|
||||
|
||||
// NotFound is M_NOT_FOUND (404) for a resource that could exist but does not.
|
||||
func NotFound(format string, args ...any) *MatrixError {
|
||||
return NewError(http.StatusNotFound, ErrCodeNotFound, format, args...)
|
||||
}
|
||||
|
||||
// Unrecognized is M_UNRECOGNIZED (404), the spec's answer for an endpoint
|
||||
// this server does not implement at all.
|
||||
func Unrecognized() *MatrixError {
|
||||
return NewError(http.StatusNotFound, ErrCodeUnrecognized, "Unrecognized request")
|
||||
}
|
||||
|
||||
// MethodNotAllowed is M_UNRECOGNIZED (405) for a known path, wrong verb.
|
||||
func MethodNotAllowed() *MatrixError {
|
||||
return NewError(http.StatusMethodNotAllowed, ErrCodeUnrecognized, "Unrecognized request")
|
||||
}
|
||||
|
||||
// NotJSON is M_NOT_JSON (400): the body was not valid JSON at all.
|
||||
func NotJSON() *MatrixError {
|
||||
return NewError(http.StatusBadRequest, ErrCodeNotJSON, "Content not JSON")
|
||||
}
|
||||
|
||||
// BadJSON is M_BAD_JSON (400): valid JSON, wrong shape.
|
||||
func BadJSON(format string, args ...any) *MatrixError {
|
||||
return NewError(http.StatusBadRequest, ErrCodeBadJSON, format, args...)
|
||||
}
|
||||
|
||||
// MissingParam is M_MISSING_PARAM (400).
|
||||
func MissingParam(format string, args ...any) *MatrixError {
|
||||
return NewError(http.StatusBadRequest, ErrCodeMissingParam, format, args...)
|
||||
}
|
||||
|
||||
// InvalidParam is M_INVALID_PARAM (400).
|
||||
func InvalidParam(format string, args ...any) *MatrixError {
|
||||
return NewError(http.StatusBadRequest, ErrCodeInvalidParam, format, args...)
|
||||
}
|
||||
|
||||
// MissingToken is M_MISSING_TOKEN (401).
|
||||
func MissingToken() *MatrixError {
|
||||
return NewError(http.StatusUnauthorized, ErrCodeMissingToken, "Missing access token")
|
||||
}
|
||||
|
||||
// UnknownToken is M_UNKNOWN_TOKEN (401).
|
||||
func UnknownToken(softLogout bool) *MatrixError {
|
||||
err := NewError(http.StatusUnauthorized, ErrCodeUnknownToken, "Unrecognised access token")
|
||||
err.SoftLogout = softLogout
|
||||
return err
|
||||
}
|
||||
|
||||
// LimitExceeded is M_LIMIT_EXCEEDED (429).
|
||||
func LimitExceeded(retryAfterMS int64) *MatrixError {
|
||||
err := NewError(http.StatusTooManyRequests, ErrCodeLimitExceeded, "Too many requests")
|
||||
err.RetryAfterMS = retryAfterMS
|
||||
return err
|
||||
}
|
||||
|
||||
// InternalError is M_UNKNOWN (500). The message is deliberately vague: the
|
||||
// detail belongs in the log, not in the response.
|
||||
func InternalError() *MatrixError {
|
||||
return NewError(http.StatusInternalServerError, ErrCodeUnknown, "Internal server error")
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MaxRequestBody caps a request body at 1 MiB. Endpoints that accept larger
|
||||
// payloads (media upload) override it explicitly.
|
||||
const MaxRequestBody = 1 << 20
|
||||
|
||||
type loggerKey struct{}
|
||||
|
||||
// WithLogger attaches a request-scoped logger to the context.
|
||||
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, loggerKey{}, l)
|
||||
}
|
||||
|
||||
// LoggerFrom returns the request-scoped logger, or the default one.
|
||||
func LoggerFrom(ctx context.Context) *slog.Logger {
|
||||
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok && l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// WriteJSON writes v as JSON with the given status. Matrix requires
|
||||
// application/json on every response, including errors.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
slog.Error("marshalling response failed", "error", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"errcode":"M_UNKNOWN","error":"Internal server error"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
slog.Debug("writing response failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteError sends err as a Matrix error envelope. Anything that is not a
|
||||
// *MatrixError is logged in full and reported as M_UNKNOWN, so internal
|
||||
// details never leak to the client.
|
||||
func WriteError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var merr *MatrixError
|
||||
if !errors.As(err, &merr) {
|
||||
LoggerFrom(r.Context()).Error("unhandled handler error",
|
||||
"error", err, "method", r.Method, "path", r.URL.Path)
|
||||
merr = InternalError()
|
||||
}
|
||||
if merr.status >= 500 {
|
||||
LoggerFrom(r.Context()).Error("request failed",
|
||||
"errcode", merr.Code, "error", merr.Message,
|
||||
"method", r.Method, "path", r.URL.Path)
|
||||
}
|
||||
WriteJSON(w, merr.StatusCode(), merr)
|
||||
}
|
||||
|
||||
// DecodeJSON reads a JSON request body into v, enforcing MaxRequestBody and
|
||||
// mapping a missing, oversized or malformed body onto the right Matrix error.
|
||||
func DecodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
|
||||
body := http.MaxBytesReader(w, r.Body, MaxRequestBody)
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return NewError(http.StatusRequestEntityTooLarge, ErrCodeTooLarge, "Request body too large")
|
||||
}
|
||||
return NotJSON()
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return NotJSON()
|
||||
}
|
||||
if err := json.Unmarshal(data, v); err != nil {
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
return BadJSON("Invalid value for field %q", typeErr.Field)
|
||||
}
|
||||
return NotJSON()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handler is an http.Handler whose errors are turned into Matrix envelopes by
|
||||
// Wrap, so handlers can `return httputil.Forbidden(...)` instead of writing
|
||||
// the response themselves.
|
||||
type Handler func(http.ResponseWriter, *http.Request) error
|
||||
|
||||
// Wrap adapts a Handler to http.Handler.
|
||||
func Wrap(h Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h(w, r); err != nil {
|
||||
WriteError(w, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Middleware wraps an http.Handler.
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
// Chain applies middleware so that the first element is the outermost.
|
||||
func Chain(h http.Handler, mw ...Middleware) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ResponseRecorder captures status and size for logging and metrics.
|
||||
type ResponseRecorder struct {
|
||||
http.ResponseWriter
|
||||
Status int
|
||||
Bytes int
|
||||
}
|
||||
|
||||
// WriteHeader records the status before passing it on.
|
||||
func (r *ResponseRecorder) WriteHeader(status int) {
|
||||
if r.Status == 0 {
|
||||
r.Status = status
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) Write(b []byte) (int, error) {
|
||||
if r.Status == 0 {
|
||||
r.Status = http.StatusOK
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.Bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the underlying writer.
|
||||
func (r *ResponseRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
|
||||
// CORSOptions configures the CORS middleware.
|
||||
type CORSOptions struct {
|
||||
// AllowedOrigins is a list of origins or the single element "*".
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// corsHeaders are the headers the Matrix spec tells a homeserver to allow.
|
||||
const (
|
||||
corsMethods = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
corsHeaders = "X-Requested-With, Content-Type, Authorization, Accept, Origin, If-Match, If-None-Match"
|
||||
corsExpose = "ETag, Location, Retry-After"
|
||||
corsMaxAge = "86400"
|
||||
)
|
||||
|
||||
// CORS answers preflight requests and adds the access-control headers to
|
||||
// every response. The Matrix spec expects homeservers to allow all origins;
|
||||
// a narrower list is honoured for deployments that only serve their own
|
||||
// first-party clients.
|
||||
func CORS(opts CORSOptions) Middleware {
|
||||
allowAll := len(opts.AllowedOrigins) == 0 || slices.Contains(opts.AllowedOrigins, "*")
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
switch {
|
||||
case allowAll:
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
case origin != "" && slices.Contains(opts.AllowedOrigins, origin):
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
default:
|
||||
w.Header().Add("Vary", "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", corsMethods)
|
||||
w.Header().Set("Access-Control-Allow-Headers", corsHeaders)
|
||||
w.Header().Set("Access-Control-Expose-Headers", corsExpose)
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Max-Age", corsMaxAge)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequestLogger logs one line per request and puts a request-scoped logger
|
||||
// on the context.
|
||||
func RequestLogger(base *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &ResponseRecorder{ResponseWriter: w}
|
||||
|
||||
l := base.With("method", r.Method, "path", r.URL.Path)
|
||||
next.ServeHTTP(rec, r.WithContext(WithLogger(r.Context(), l)))
|
||||
|
||||
status := rec.Status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
switch {
|
||||
case status >= 500:
|
||||
level = slog.LevelError
|
||||
case status >= 400:
|
||||
level = slog.LevelWarn
|
||||
case isNoise(r.URL.Path):
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
l.LogAttrs(r.Context(), level, "request",
|
||||
slog.Int("status", status),
|
||||
slog.Int("bytes", rec.Bytes),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
slog.String("remote", remoteIP(r)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isNoise keeps health checks and metrics scrapes out of the info log.
|
||||
func isNoise(path string) bool {
|
||||
return path == "/healthz" || path == "/metrics"
|
||||
}
|
||||
|
||||
func remoteIP(r *http.Request) string {
|
||||
host := r.RemoteAddr
|
||||
if i := strings.LastIndex(host, ":"); i > 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
// Recoverer turns a panic in a handler into a logged M_UNKNOWN response so
|
||||
// one bad request cannot take the process down.
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
if v == http.ErrAbortHandler {
|
||||
panic(v)
|
||||
}
|
||||
LoggerFrom(r.Context()).Error("panic in handler",
|
||||
"panic", v, "method", r.Method, "path", r.URL.Path)
|
||||
WriteJSON(w, http.StatusInternalServerError, InternalError())
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NoCache marks a response as uncacheable; used for /_matrix/client/versions
|
||||
// and the well-known documents during development.
|
||||
func NoCache(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
// CacheFor sets a public max-age, used for the well-known documents.
|
||||
func CacheFor(w http.ResponseWriter, d time.Duration) {
|
||||
w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(int(d.Seconds())))
|
||||
}
|
||||
|
||||
// NotFoundHandler answers anything unrouted with M_UNRECOGNIZED, which is
|
||||
// what the Matrix spec requires for unimplemented endpoints.
|
||||
func NotFoundHandler() http.Handler {
|
||||
return Wrap(func(http.ResponseWriter, *http.Request) error {
|
||||
return Unrecognized()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Instrumenter wraps a handler with per-route instrumentation. The route is
|
||||
// the ServeMux pattern the handler is registered under; it is passed
|
||||
// explicitly because the pattern ServeMux records on a request is not
|
||||
// visible to middleware wrapping the mux from the outside.
|
||||
type Instrumenter func(route string, h http.Handler) http.Handler
|
||||
|
||||
// Router is a thin layer over http.ServeMux that applies an Instrumenter to
|
||||
// every route and knows how to answer unrouted requests the Matrix way.
|
||||
//
|
||||
// Patterns use Go 1.22 syntax, method included:
|
||||
//
|
||||
// r.HandleFunc("GET /_matrix/client/versions", versions)
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
instrument Instrumenter
|
||||
}
|
||||
|
||||
// NewRouter returns a router. A nil Instrumenter means no instrumentation.
|
||||
func NewRouter(instrument Instrumenter) *Router {
|
||||
if instrument == nil {
|
||||
instrument = func(_ string, h http.Handler) http.Handler { return h }
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
r := &Router{mux: mux, instrument: instrument}
|
||||
// Anything that matches no other pattern is M_UNRECOGNIZED, per the
|
||||
// Client-Server API's "Unsupported endpoints" rule.
|
||||
r.Handle("/", NotFoundHandler())
|
||||
return r
|
||||
}
|
||||
|
||||
// Handle registers a handler for a pattern.
|
||||
func (r *Router) Handle(pattern string, h http.Handler) {
|
||||
r.mux.Handle(pattern, r.instrument(pattern, h))
|
||||
}
|
||||
|
||||
// HandleFunc registers an error-returning handler for a pattern.
|
||||
func (r *Router) HandleFunc(pattern string, h Handler) {
|
||||
r.Handle(pattern, Wrap(h))
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.mux.ServeHTTP(w, req)
|
||||
}
|
||||
Reference in new issue
Block a user