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,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()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user