From b4fb60a8abcf3d940a2f6eace5dad11c5baa868f Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:01:13 +0200 Subject: [PATCH] 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 --- .gitattributes | 9 + .github/workflows/ci.yml | 159 +++++++ .gitignore | 32 ++ Dockerfile | 55 +++ Makefile | 98 +++++ PLAN.md | 414 ++++++++++++++++++ README.md | 137 ++++++ apps/README.md | 14 + docs/README.md | 8 + docs/configuration.md | 83 ++++ docs/development.md | 113 +++++ go.work | 7 + server/.golangci.yml | 67 +++ server/go.mod | 36 ++ server/go.sum | 108 +++++ server/internal/buildinfo/buildinfo.go | 51 +++ server/internal/config/config.go | 311 +++++++++++++ server/internal/config/config_test.go | 163 +++++++ server/internal/config/env.go | 105 +++++ server/internal/config/yaml.go | 22 + server/internal/federation/gate.go | 150 +++++++ server/internal/federation/gate_test.go | 97 ++++ server/internal/httputil/errors.go | 132 ++++++ server/internal/httputil/json.go | 104 +++++ server/internal/httputil/middleware.go | 179 ++++++++ server/internal/httputil/router.go | 50 +++ server/internal/logging/logging.go | 52 +++ server/internal/matrix/signing/key.go | 182 ++++++++ server/internal/matrix/signing/key_test.go | 147 +++++++ server/internal/metrics/metrics.go | 111 +++++ server/internal/settings/settings.go | 193 ++++++++ server/internal/settings/settings_test.go | 200 +++++++++ server/internal/setup/routes.go | 127 ++++++ server/internal/setup/server.go | 258 +++++++++++ server/internal/setup/server_test.go | 350 +++++++++++++++ server/internal/storage/drivers/drivers.go | 14 + .../internal/storage/migrations/migrations.go | 236 ++++++++++ .../storage/migrations/migrations_test.go | 175 ++++++++ .../internal/storage/pocketbase/pocketbase.go | 79 ++++ .../migrations/0001_server_settings.sql | 7 + server/internal/storage/postgres/postgres.go | 85 ++++ .../migrations/0001_server_settings.sql | 7 + server/internal/storage/sqlite/sqlite.go | 136 ++++++ server/internal/storage/sqlstore/settings.go | 89 ++++ server/internal/storage/sqlstore/sqlstore.go | 124 ++++++ server/internal/storage/storage.go | 127 ++++++ server/internal/storage/storage_test.go | 149 +++++++ server/test/complement/README.md | 13 + server/test/vectors/README.md | 13 + warp.yaml.example | 94 ++++ 50 files changed, 5672 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 apps/README.md create mode 100644 docs/README.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 go.work create mode 100644 server/.golangci.yml create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/buildinfo/buildinfo.go create mode 100644 server/internal/config/config.go create mode 100644 server/internal/config/config_test.go create mode 100644 server/internal/config/env.go create mode 100644 server/internal/config/yaml.go create mode 100644 server/internal/federation/gate.go create mode 100644 server/internal/federation/gate_test.go create mode 100644 server/internal/httputil/errors.go create mode 100644 server/internal/httputil/json.go create mode 100644 server/internal/httputil/middleware.go create mode 100644 server/internal/httputil/router.go create mode 100644 server/internal/logging/logging.go create mode 100644 server/internal/matrix/signing/key.go create mode 100644 server/internal/matrix/signing/key_test.go create mode 100644 server/internal/metrics/metrics.go create mode 100644 server/internal/settings/settings.go create mode 100644 server/internal/settings/settings_test.go create mode 100644 server/internal/setup/routes.go create mode 100644 server/internal/setup/server.go create mode 100644 server/internal/setup/server_test.go create mode 100644 server/internal/storage/drivers/drivers.go create mode 100644 server/internal/storage/migrations/migrations.go create mode 100644 server/internal/storage/migrations/migrations_test.go create mode 100644 server/internal/storage/pocketbase/pocketbase.go create mode 100644 server/internal/storage/postgres/migrations/0001_server_settings.sql create mode 100644 server/internal/storage/postgres/postgres.go create mode 100644 server/internal/storage/sqlite/migrations/0001_server_settings.sql create mode 100644 server/internal/storage/sqlite/sqlite.go create mode 100644 server/internal/storage/sqlstore/settings.go create mode 100644 server/internal/storage/sqlstore/sqlstore.go create mode 100644 server/internal/storage/storage.go create mode 100644 server/internal/storage/storage_test.go create mode 100644 server/test/complement/README.md create mode 100644 server/test/vectors/README.md create mode 100644 warp.yaml.example diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6c5d44e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Normalise to LF in the repository; the toolchain, the container build and +# CI all expect it, and a CRLF .sql or .sh file breaks them on Linux. +* text=auto eol=lf + +*.png binary +*.jpg binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0cb3c7b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,159 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.26" + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: server/go.sum + + - name: Verify formatting + if: runner.os == 'Linux' + working-directory: server + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Verify go.mod is tidy + if: runner.os == 'Linux' + working-directory: server + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Vet + working-directory: server + run: go vet ./... + + - name: Test + working-directory: server + run: go test -count=1 ./... + + - name: Test with the race detector + if: runner.os == 'Linux' + working-directory: server + run: go test -race -count=1 ./... + + - name: Build + working-directory: server + run: go build ./... + + postgres: + name: Storage tests on PostgreSQL + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: warp + POSTGRES_PASSWORD: warp + POSTGRES_DB: warp_test + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: server/go.sum + + - name: Storage tests (sqlite + postgres) + working-directory: server + env: + WARP_TEST_POSTGRES_DSN: postgres://warp:warp@localhost:5432/warp_test?sslmode=disable + run: go test -count=1 ./internal/storage/... + + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: server/go.sum + + - uses: golangci/golangci-lint-action@v8 + with: + version: v2.14.0 + working-directory: server + + smoke: + name: Smoke test the binary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: server/go.sum + + - name: Build + run: make build + + - name: Generate a signing key and boot the server + run: | + set -euo pipefail + sed 's/^ server_name: .*/ server_name: ci.example/' warp.yaml.example > warp.yaml + ./bin/warp version + ./bin/warp generate-keys + ./bin/warp migrate + ./bin/warp serve & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8008/healthz >/dev/null 2>&1; then + break + fi + sleep 1 + done + + curl -fsS http://127.0.0.1:8008/healthz | tee /dev/stderr | grep -q '"status":"ok"' + curl -fsS http://127.0.0.1:8008/_matrix/client/versions | grep -q '"versions"' + curl -fsS http://127.0.0.1:8008/.well-known/matrix/client | grep -q 'm.homeserver' + curl -fsS http://127.0.0.1:8008/metrics | grep -q warp_build_info + + # Federation is off by default, so its surface must be absent. + status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8008/.well-known/matrix/server)" + test "$status" = "404" + + # Graceful shutdown: SIGTERM must stop the process cleanly. + kill -TERM "$server_pid" + wait "$server_pid" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27a1b4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Build output +/bin/ +/dist/ +warp +warp.exe + +# Local configuration and state +/warp.yaml +/keys/ +*.db +*.db-shm +*.db-wal +/pb_data/ + +# Go +/go.work.sum +*.test +*.out +coverage.* + +# Frontend (apps/*) +node_modules/ +.pnpm-store/ +dist/ +.vite/ + +# Editors and OS +.DS_Store +Thumbs.db +.idea/ +.vscode/* +!.vscode/extensions.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e869ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Warp homeserver — single binary, no cgo. +# +# docker build -t warp . +# docker run -p 8008:8008 -v warp-data:/data \ +# -e WARP_SERVER_NAME=warp.example warp + +FROM golang:1.26-alpine AS build + +WORKDIR /src + +# Dependencies first, so the module cache survives source changes. +COPY server/go.mod server/go.sum ./server/ +RUN cd server && go mod download + +COPY server ./server + +ARG VERSION=0.0.0-dev +ARG COMMIT=unknown +ARG BUILD_DATE=unknown + +ENV CGO_ENABLED=0 +RUN cd server && go build -trimpath \ + -ldflags "-s -w \ + -X github.com/dariuszjaniak/warp/server/internal/buildinfo.Version=${VERSION} \ + -X github.com/dariuszjaniak/warp/server/internal/buildinfo.Commit=${COMMIT} \ + -X github.com/dariuszjaniak/warp/server/internal/buildinfo.BuildDate=${BUILD_DATE}" \ + -o /out/warp ./cmd/warp + + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata curl && \ + adduser -D -u 10001 -h /data warp + +COPY --from=build /out/warp /usr/local/bin/warp +COPY warp.yaml.example /etc/warp/warp.yaml.example + +# Database file, signing key and media live here; mount a volume on it. +WORKDIR /data +RUN chown warp:warp /data +USER warp + +ENV WARP_LISTEN_ADDRESS=":8008" \ + WARP_PRIVATE_KEY="/data/signing.key" \ + WARP_DB_DRIVER="sqlite" \ + WARP_DB_CONNECTION_STRING="file:/data/warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)" \ + WARP_LOG_FORMAT="json" + +EXPOSE 8008 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8008/healthz || exit 1 + +ENTRYPOINT ["warp"] +CMD ["serve"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac21c23 --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +# Warp — build, test and lint. +# +# Everything Go lives in ./server; the frontend packages (apps/*) get their +# own targets as they are created. + +SHELL := /bin/sh +GO ?= go +BIN_DIR := bin +BINARY := warp +SERVER_DIR := server + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +PKG := github.com/dariuszjaniak/warp/server/internal/buildinfo +LDFLAGS := -X $(PKG).Version=$(VERSION) -X $(PKG).Commit=$(COMMIT) -X $(PKG).BuildDate=$(DATE) + +# Windows builds need the .exe suffix for `make run` to work from MSYS. +ifeq ($(OS),Windows_NT) +EXE := .exe +else +EXE := +endif + +.DEFAULT_GOAL := build + +.PHONY: help +help: ## List the targets + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +.PHONY: build +build: ## Build the warp binary into bin/ + cd $(SERVER_DIR) && $(GO) build -ldflags "$(LDFLAGS)" -o ../$(BIN_DIR)/$(BINARY)$(EXE) ./cmd/warp + +.PHONY: run +run: build ## Build and run the server + ./$(BIN_DIR)/$(BINARY)$(EXE) serve + +.PHONY: test +test: ## Run the unit tests (sqlite only) + cd $(SERVER_DIR) && $(GO) test ./... + +.PHONY: test-race +test-race: ## Run the tests with the race detector (needs cgo) + cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) test -race ./... + +.PHONY: test-postgres +test-postgres: ## Run the storage tests against PostgreSQL too + @test -n "$(WARP_TEST_POSTGRES_DSN)" || \ + { echo "set WARP_TEST_POSTGRES_DSN, e.g. postgres://warp:warp@localhost/warp_test?sslmode=disable"; exit 1; } + cd $(SERVER_DIR) && WARP_TEST_POSTGRES_DSN="$(WARP_TEST_POSTGRES_DSN)" $(GO) test ./internal/storage/... + +.PHONY: cover +cover: ## Run the tests and write coverage.out / coverage.html + cd $(SERVER_DIR) && $(GO) test -coverprofile=../coverage.out -covermode=count ./... + $(GO) tool cover -html=coverage.out -o coverage.html + @echo "wrote coverage.html" + +.PHONY: fmt +fmt: ## Format the Go code + cd $(SERVER_DIR) && $(GO) fmt ./... + +.PHONY: vet +vet: ## Run go vet + cd $(SERVER_DIR) && $(GO) vet ./... + +.PHONY: lint +lint: ## Run golangci-lint + cd $(SERVER_DIR) && golangci-lint run + +.PHONY: tidy +tidy: ## Tidy the Go module files + cd $(SERVER_DIR) && $(GO) mod tidy + +.PHONY: check +check: vet test ## What CI runs on every push (minus the linter) + +.PHONY: generate-keys +generate-keys: build ## Create the ed25519 signing key + ./$(BIN_DIR)/$(BINARY)$(EXE) generate-keys + +.PHONY: migrate +migrate: build ## Apply pending database migrations + ./$(BIN_DIR)/$(BINARY)$(EXE) migrate + +.PHONY: complement +complement: ## Run the Complement suite (Phase 1 onwards) + @echo "Complement is wired up in Phase 1, once registration and login exist." + @exit 1 + +.PHONY: docker +docker: ## Build the container image + docker build -t warp:$(VERSION) . + +.PHONY: clean +clean: ## Remove build output + rm -rf $(BIN_DIR) coverage.out coverage.html diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..915caf4 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,414 @@ +# Warp — a Matrix homeserver and client suite in Go + +Project plan. Warp is a from-scratch [Matrix](https://spec.matrix.org) homeserver +written in Go, taking architectural lessons from +[Dendrite](https://github.com/element-hq/dendrite) without copying its code, +plus a family of first-party clients (admin panel, web, desktop, mobile). + +--- + +## 1. Goals and non-goals + +**Goals** + +- Spec-conformant homeserver: Client-Server API, Server-Server (federation) API, + Application Service API, Push Gateway client side. +- **Federation is optional.** Warp must run as a fully isolated, private + homeserver with federation switched off, and be switchable at runtime. +- Single binary, single process ("monolith"). Dendrite's polylith mode was + deprecated for good reason — the internal component boundaries stay, the + network hops go. +- Three storage drivers: **PostgreSQL** (production), **SQLite** (dev / + single-user), **PocketBase** (all-in-one appliance deployments). +- Pass [Complement](https://github.com/matrix-org/complement) (the official + integration test suite, itself written in Go). +- Federate with Synapse, Dendrite, Conduit/Conduwuit/Tuwunel when enabled. +- First-party clients: admin web panel, web client, desktop client, Android + app (iOS later), all sharing as much code as possible. + +**Non-goals (for now)** + +- Identity server, SSO/OIDC provider (delegate to MAS later), VoIP/TURN + provisioning, legacy Sytest. + +--- + +## 2. Key decisions + +| Decision | Choice | Why | +|---|---|---| +| Go version | 1.26, `net/http` with pattern routing (`GET /_matrix/client/v3/...`) | No router dependency needed since Go 1.22. | +| Event core (canonical JSON, hashing, ed25519 signing, auth rules, state resolution v2) | **Own implementation**, tested against `gomatrixserverlib` test vectors and Complement | This is the heart of a homeserver and the point of the project. Fallback: import `gomatrixserverlib` if it stalls the schedule. | +| Internal messaging | Go interfaces + channels, one process | Dendrite embeds NATS JetStream; we don't need durable internal queues until we do. Design the producer/consumer interfaces so NATS can be dropped in later. | +| Storage | `database/sql` + hand-written SQL behind a per-component `Storage` interface; drivers: `pgx` (Postgres), `modernc.org/sqlite` (pure Go SQLite), PocketBase (embedded `core.App`, see §4) | Same dual-backend model as Dendrite plus one; no ORM. | +| Migrations | Embedded SQL files, per component, `embed.FS` | Simple, reviewable. | +| Config | Single YAML file + env overrides; a small set of settings (federation on/off, registration on/off) also mutable at runtime via admin API and persisted in DB | | +| Logging / metrics / tracing | `log/slog`, Prometheus `/metrics`, OpenTelemetry (later) | | +| Room versions | Implement the latest stable room version first (the one `m.room.create` defaults to in the current spec), then backfill older versions as needed for federation | Old room versions matter only when joining old rooms elsewhere. | +| Sync | `/sync` v3 first; Simplified Sliding Sync (MSC4186) as a later phase | Element X and our own mobile app want sliding sync; classic clients require `/sync`. | +| Frontend stack (admin + web client) | Vue 3 + TypeScript + Vite + Pinia; **Tailwind CSS** with a hand-rolled component set in `apps/ui` (Headless UI / Radix Vue for accessible primitives: dialogs, menus, listboxes) | Same UI code runs in the browser and inside the Wails desktop shell; Tailwind keeps the bundle small and the design system ours. | +| Desktop shell | **Wails v3** (Go host + webview) reusing the Vue web client | Pure-Go UI toolkits (Fyne/Gio) would mean a second UI codebase. | +| Mobile | Flutter + `matrix_dart_sdk` (famedly) with vodozemac for E2EE | Mature SDK; same codebase later ships to iOS. | +| Client protocol/crypto in Go | `mautrix-go` (+ `goolm` pure-Go Olm/Megolm) for the desktop host and any Go-side tooling; `matrix-js-sdk` + `matrix-sdk-crypto-wasm` in the browser | Do not write our own client-side crypto. | + +--- + +## 3. Federation switch + +`federation.enabled` is a first-class mode, not an afterthought. + +```yaml +federation: + enabled: false # default off for a new install + allowlist: [] # if non-empty, only these server names may federate + denylist: [] + allow_runtime_toggle: true +``` + +**When disabled:** + +| Area | Behaviour | +|---|---| +| Routes | `/_matrix/federation/*`, `/_matrix/key/*`, `/.well-known/matrix/server` are not registered (404). | +| Outbound | Transaction queue, key fetcher, remote media fetch, remote `keys/query`, remote profile/directory lookups are not started. | +| Client API | `/join` with `server_name` for a room not known locally → `M_FORBIDDEN` with a clear message; inviting `@user:otherserver` → `M_FORBIDDEN`; `publicRooms?server=` → `M_FORBIDDEN`. | +| Room IDs / event IDs | Unchanged — still `!id:warp.example`. Rooms created offline federate normally if federation is later enabled. | +| Signing | Events are still signed with the server key; only publication/verification of *remote* keys is skipped. | + +**Runtime toggle:** `PUT /_warp/admin/v1/federation {"enabled": true}` (and the +admin panel switch). Implemented as a `federation.Module` with `Start(ctx)` / +`Stop()`; the HTTP mux delegates federation routes through a gate that checks +the live flag, so no restart is required. The setting is persisted in the +`server_settings` table and overrides the YAML value on boot if +`allow_runtime_toggle` is true. + +**Allowlist mode** is the middle ground for "federate only with my other +servers": all remote traffic is filtered by server name at the auth layer and in +the outbound queue. + +--- + +## 4. Storage drivers + +All components talk to storage through Go interfaces (e.g. +`roomserver.Database`, `userapi.Database`). Each driver implements them. + +| Driver | Engine | Target use | Notes | +|---|---|---|---| +| `postgres` | PostgreSQL 15+ via `pgx` | Production, multi-user, large rooms | Primary; Complement runs against this. | +| `sqlite` | `modernc.org/sqlite` (no cgo), WAL mode | Dev, tests, single-user | Serialised writer; fine up to a few hundred users. | +| `pocketbase` | Embedded [PocketBase](https://pocketbase.io) (`core.App`) on its own SQLite file | "Appliance" deployments: one binary that also provides admin auth, file storage (local/S3) for media, and realtime for the admin panel | Records are stored via PocketBase collections; heavier than raw SQLite and its record model is a poor fit for the event DAG, so **the roomserver/syncapi tables bypass PocketBase's collection API and use its underlying `dbx` connection directly**. PocketBase adds value for `mediaapi` (its filesystem abstraction) and for admin-panel auth. Marked experimental until it passes the same test suite as `sqlite`. | + +Rules: + +- One SQL dialect per driver, kept in `internal/storage/{postgres,sqlite,pocketbase}`. +- Every storage test runs against all three drivers (`go test -tags=storage_all`). +- Migrations are per-driver; `warp migrate` runs them. + +--- + +## 5. Server architecture + +``` + +--------------------------------------------------+ + clients ------> | clientapi (C-S REST handlers, auth, ratelimit) | + +--------------------------------------------------+ + other HS -----> | federationapi [gated by federation.enabled] | + +-------------+-------------+----------------------+ + | userapi | roomserver | syncapi | + | accounts | event DAG | read model / streams| + | devices | auth rules | /sync, /messages | + | tokens | state res | filters, tokens | + | push rules | redactions | | + +-------------+-------------+----------------------+ + | keyserver | mediaapi | appservice | + | device keys| upload/dl | registration, txns | + | OTKs, x-sig| thumbnails | | + +-------------+-------------+----------------------+ + | adminapi (/_warp/admin/*) + embedded admin SPA | + +--------------------------------------------------+ + | internal/matrix: event, canonicaljson, signing, | + | roomversion, auth, stateres | + +--------------------------------------------------+ + | storage: postgres | sqlite | pocketbase | + +--------------------------------------------------+ +``` + +**Data-flow principle (borrowed from Dendrite):** the roomserver is the single +writer for room events. Every event — local or federated — goes in through the +roomserver, which authorises it, resolves state, persists it, and then emits an +"output event" that the syncapi, federationapi (outbound, if enabled), +appservice, and push consumers subscribe to. Nothing else writes room state. + +--- + +## 6. Repository layout (monorepo) + +``` +Warp/ +├── go.work Go workspace tying the Go modules together +├── server/ Go module: the homeserver +│ ├── cmd/warp/ serve, generate-keys, create-account, migrate +│ ├── internal/ +│ │ ├── config/ +│ │ ├── httputil/ +│ │ ├── matrix/ canonicaljson, event, signing, roomversion, auth, stateres +│ │ ├── clientapi/ federationapi/ roomserver/ syncapi/ userapi/ +│ │ ├── keyserver/ mediaapi/ appservice/ pushserver/ adminapi/ +│ │ └── storage/{postgres,sqlite,pocketbase,migrations}/ +│ ├── web/admin/ build output of apps/admin, embedded via embed.FS +│ └── test/{complement,vectors}/ +├── apps/ +│ ├── ui/ shared Vue 3 component library + Matrix client store (Pinia) +│ ├── admin/ admin web panel (Vue) — built into server/web/admin +│ ├── web/ web client (Vue SPA) + Go host (`cmd/warp-web`) +│ ├── desktop/ Wails v3 app: Go host (mautrix-go) + apps/web frontend +│ └── mobile/ Flutter app: Android now, iOS later +├── docs/ +├── Dockerfile +├── Makefile +└── warp.yaml.example +``` + +Frontend packages are managed with `pnpm` workspaces (`apps/ui`, `apps/admin`, +`apps/web`); Go modules with `go.work` (`server`, `apps/web`, `apps/desktop`). + +--- + +## 7. Server phases + +Each phase ends with something runnable and a Complement subset passing. + +### Phase 0 — Bootstrap (1 week) + +- `go mod init`, layout above, `Makefile` (build/test/lint/complement), `golangci-lint`, GitHub Actions CI. +- Config loading, `slog` logging, graceful shutdown, `/healthz`, `/metrics`. +- HTTP server skeleton with Matrix error envelope (`errcode`, `error`), CORS, + `GET /_matrix/client/versions`, `/.well-known/matrix/client` (and `/server` + only when federation is enabled). +- Storage interfaces + migrations runner; `sqlite` driver first, `postgres` + second, `pocketbase` stub compiled but returning `ErrNotImplemented`. +- `warp generate-keys` (ed25519 signing key file). +- `server_settings` table and the runtime-settings service (federation flag, + registration flag). + +### Phase 1 — Accounts & sessions (1–2 weeks) + +- **userapi**: users, passwords (argon2id), devices, access tokens, + refresh tokens, account data, admin flag. +- C-S endpoints: `register` (UIA with `m.login.dummy` / registration token), + `login` (`m.login.password`), `logout`, `logout/all`, `account/whoami`, + `devices/*`, `profile/*`, `user/{id}/account_data/*`, `capabilities`. +- Auth middleware, per-endpoint rate limiting (`M_LIMIT_EXCEEDED`). +- `warp create-account [--admin]` CLI. +- **adminapi v1** skeleton: admin-only auth, `GET/PUT /federation`, + `GET/PUT /registration`, `GET /users`, `POST /users`, `GET /stats`. +- Complement: `TestRegistration`, `TestLogin`, `TestDeviceManagement`. + +### Phase 2 — Event core (2–3 weeks, the hard part) + +Pure library code under `internal/matrix`, no HTTP, no DB. + +- Canonical JSON, event/PDU model, hashing, redaction algorithm. +- ed25519 signing and verification, key IDs. +- Room version registry. +- Authorisation rules (full spec list). +- State resolution v2. + +### Phase 3 — Rooms, local only (2–3 weeks) + +- **roomserver** storage and input pipeline (validate → auth → resolve → + persist → emit). +- C-S: `createRoom`, `join`, `leave`, `invite`, `kick`, `ban`, + `send/{type}/{txnId}`, `state/*`, `redact`, `rooms/{id}/event/{id}`, + `members`, `joined_members`, aliases, `publicRooms`. +- Federation-disabled behaviours from §3 enforced here (remote joins/invites + rejected). +- adminapi: room list, room detail, purge room, force-leave. +- Complement: `TestRoomCreate`, `TestRoomState`, `TestMembership*`, `TestRedactions`. + +### Phase 4 — Sync (2 weeks) + +- **syncapi** read model, stream tokens, `GET /sync`, `/messages`, + `/context`, filters, lazy-loading members. +- EDUs: typing, receipts, presence (config flag), `to_device`. +- Complement: `TestSync*`, `TestMessagesPagination`, `TestTyping`, `TestReceipts`. + +→ **Milestone M1**: Element Web works against Warp with federation off. + The admin panel and web client (§8) can now be developed against a real server. + +### Phase 5 — Federation (3–4 weeks) + +- `federation.Module` with `Start/Stop`, route gate, allow/deny lists. +- Server keys, discovery, `X-Matrix` auth, `/send`, joins/leaves/invites, + `/state`, `/state_ids`, `/backfill`, `/get_missing_events`, queries. +- Outbound queue with backoff, blacklist, persistence. +- Complement federation tests. + +→ **Milestone M2**: Warp joins `#matrix:matrix.org`; toggling federation off + at runtime cleanly stops all remote traffic and back on resumes it. + +### Phase 6 — Media (1 week) + +- `mediaapi`, thumbnails, authenticated media, remote media cache (federation + only). PocketBase driver uses PocketBase's filesystem abstraction (local/S3). + +### Phase 7 — E2EE support (2 weeks) + +- `keyserver`: device keys, OTKs, fallback keys, cross-signing, device list + tracking (local always; federated only when enabled), key backup, + `sendToDevice`. + +### Phase 8 — Push & notifications (1 week) + +- Push rules, pushers, notification counts, push gateway client. Needed by the + mobile app. + +### Phase 9 — Application services & admin completion (1–2 weeks) + +- AS registration, namespaces, transactions, masquerading. +- adminapi: full user management (deactivate, reset password, devices, admin + flag), server notices, media quota, registration tokens, federation + allow/deny list editing, audit log. + +### Phase 10 — Hardening & ops + +- Simplified Sliding Sync (MSC4186), spaces, threads, search. +- PocketBase driver to parity with `sqlite`. +- Tracing, dashboards, Dockerfile, compose/Helm examples, load testing. + +--- + +## 8. Client sub-projects + +All clients speak plain Matrix C-S API, so they also work against Synapse — and +Element works against Warp. Nothing proprietary in the wire protocol except +the `/_warp/admin/*` API used by the admin panel. + +### 8.1 Admin web panel — Go + Vue (`apps/admin`) + +- **Stack**: Vue 3 + TS + Vite + Pinia + Tailwind CSS, components from + `apps/ui`. Built assets embedded into the `warp` binary and + served at `/_warp/admin/`; no separate process. +- **Backend**: `server/internal/adminapi` (Go). Auth = Matrix login of a user + with the admin flag; the panel uses the normal access token. +- **Features by priority**: + 1. Dashboard: users, rooms, events/min, federation status, storage driver, version. + 2. Federation switch, allow/deny list editor, per-server health (queue depth, backoff). + 3. Users: list/search, create, deactivate, reset password, devices, admin flag, registration tokens. + 4. Rooms: list/search, members, state viewer, purge, force-leave, alias management. + 5. Media: usage per user, quotas, purge remote media. + 6. Settings: registration on/off, rate limits, server notices, appservice registrations. + 7. Audit log of admin actions. +- **Starts**: after server Phase 1 (dashboard + users + federation switch), + grows with each server phase. + +### 8.2 Web client — Go + Vue (`apps/web`) + +- **Frontend**: Vue 3 SPA in `apps/web/frontend`, built on the shared + `apps/ui` component library and a Pinia-based Matrix store wrapping + `matrix-js-sdk` (sync, rooms, timeline) with `matrix-sdk-crypto-wasm` for E2EE. + Go is not a good fit for in-browser Matrix crypto; the Rust-Wasm crypto is + what Element uses and is audited. +- **Go host** (`apps/web/cmd/warp-web`): serves the SPA, injects runtime config + (homeserver URL, branding), handles `/.well-known` for client discovery, + optional reverse proxy to the homeserver to avoid CORS. Can also be embedded + into the `warp` binary behind `web_client.enabled: true`. +- **Feature order**: login/register → room list + timeline → send text/media → + read receipts, typing → invites/joins, room settings → E2EE (device + verification, key backup) → threads, reactions, spaces → voice messages, + calls (much later). +- **Starts**: after server M1. + +### 8.3 Desktop client — Go (`apps/desktop`) + +- **Stack**: Wails v3. The UI is the same Vue frontend as `apps/web` (imported + as a workspace package), so web and desktop share every screen. +- **Go side**: `mautrix-go` client with `goolm` (pure Go Olm/Megolm, no cgo) + for E2EE, local encrypted SQLite store for sync state and keys, OS keychain + for the access token, native notifications, tray icon, auto-update. + Rationale: on desktop, doing sync + crypto in Go instead of in the webview + gives a persistent local store, background sync, and lets the frontend stay + thin — the Vue store talks to Go via Wails bindings instead of + `matrix-js-sdk`. The store therefore has two backends (`js-sdk` in the + browser, `wails` on desktop) behind one interface. +- **Targets**: Windows, macOS, Linux. +- **Starts**: once the web client has a stable room list + timeline. + +### 8.4 Android app — Flutter (`apps/mobile`) + +- **Stack**: Flutter + `matrix_dart_sdk` (famedly) + `flutter_vodozemac` for + E2EE, `flutter_secure_storage`, FCM/UnifiedPush for push (via the server's + push gateway support from Phase 8), Riverpod for state. +- **Feature order**: login → room list → timeline + send → push notifications + → E2EE + verification → media → calls (later). +- Uses Simplified Sliding Sync when the server has it (Phase 10) for fast + startup; falls back to `/sync`. +- **Starts**: after server Phase 8 (push) for a useful MVP; UI work can begin + after M1. + +### 8.5 iPhone app — future + +- Same Flutter codebase; work items are APNs push (needs a push gateway such as + Sygnal, or a Warp-native one), iOS-specific secure storage and keychain, + App Store review. Deferred until the Android app is stable. + +--- + +## 9. Testing strategy + +| Level | Tool | What | +|---|---|---| +| Unit | `go test`, table-driven | `internal/matrix` (100% of spec examples); storage against all three drivers | +| Component | `httptest` | Each API package with a fake roomserver/userapi; federation gate tests (routes 404 when disabled, resume when enabled) | +| Integration | **Complement** (Docker) | Two profiles: `federation=off` (C-S tests only) and `federation=on`; track pass counts as the primary progress metric | +| Interop | Manual + scripted | Federate a dev instance with a Synapse in Docker; join `#matrix:matrix.org` | +| Fuzz | `go test -fuzz` | Canonical JSON, event parsing, X-Matrix header parsing, federation PDU intake | +| Frontend | Vitest + Playwright | `apps/ui` components; end-to-end login/send/receive against a Warp container | +| Mobile | Flutter `test` + `integration_test` | Against the same Warp container | + +--- + +## 10. Milestones + +| # | Milestone | Definition of done | +|---|---|---| +| M1 | "Hello Matrix" | Element Web can register, log in, create a room, send messages, see them after reload — with federation **off** (server Phases 0–4). Admin panel shows dashboard, users, federation switch. | +| M2 | "On the network" | Federation toggled on from the admin panel; Warp joins a room on matrix.org; Synapse users join a Warp room; toggling off stops remote traffic cleanly (Phase 5). | +| M3 | "Daily driver" | Warp web client: E2EE rooms with cross-signing and key backup, images/files, push (Phases 6–8). Desktop client runs the same UI via Wails. | +| M4 | "Pocket server" | Android app with push and E2EE; bridges (mautrix) run via the AS API; sliding sync; PocketBase driver at parity (Phases 9–10). | +| M5 | "Everywhere" | iOS build of the Flutter app in TestFlight. | + +--- + +## 11. Reference material + +- Spec: https://spec.matrix.org/latest/ — start with *Room Versions* and the + *Server-Server API* appendices. +- Dendrite (architecture reference; read, don't paste): https://github.com/element-hq/dendrite +- `gomatrixserverlib` (test vectors): https://github.com/matrix-org/gomatrixserverlib +- Complement: https://github.com/matrix-org/complement +- Federation tester: https://federationtester.matrix.org +- PocketBase as a Go framework: https://pocketbase.io/docs/go-overview/ +- `mautrix-go` + `goolm`: https://github.com/mautrix/go +- `matrix-js-sdk` / `matrix-sdk-crypto-wasm`: https://github.com/matrix-org/matrix-js-sdk +- Wails v3: https://v3alpha.wails.io +- `matrix_dart_sdk`: https://github.com/famedly/matrix-dart-sdk +- Synapse admin API (feature checklist for our admin panel): https://element-hq.github.io/synapse/latest/usage/administration/admin_api/ + +--- + +## 12. Open questions + +1. **State storage model**: Dendrite's snapshot + block NIDs vs. per-event + full-state rows. Decide in Phase 3 after a spike with a 10k-event room. +2. **Room version breadth** for federation compatibility (v6+ probably enough). +3. **Presence** default on/off. +4. **PocketBase scope**: full storage driver as specified, or only media + filesystem + admin auth on top of the `sqlite` driver? The latter is far + less work for most of the benefit. Decide after the `sqlite` driver is done. +5. **Desktop sync engine**: Go (`mautrix-go`) as planned, or keep + `matrix-js-sdk` in the webview for maximum code sharing with web? Prototype + both in a week and pick. +6. **Push gateway for mobile**: run Sygnal, or build a small Warp-native + FCM/APNs gateway in Go? diff --git a/README.md b/README.md new file mode 100644 index 0000000..23886ed --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Warp + +A [Matrix](https://spec.matrix.org) homeserver written from scratch in Go, +plus a family of first-party clients. Federation is optional and can be +switched on and off at runtime. + +The full design and roadmap live in [PLAN.md](PLAN.md). This README covers +what exists today. + +## Status + +**Phase 0 — bootstrap. Complete.** There is a server binary that boots, +serves its discovery endpoints and nothing else yet: no accounts, no rooms, +no sync. Those are Phases 1 to 4. + +What works now: + +| | | +|---|---| +| `GET /_matrix/client/versions` | spec versions this build claims | +| `GET /.well-known/matrix/client` | client discovery | +| `GET /.well-known/matrix/server` | server discovery — **only while federation is on** | +| `GET /healthz` | liveness, including a database ping | +| `GET /metrics` | Prometheus | +| anything else under `/_matrix/` | `404 M_UNRECOGNIZED`, as the spec requires | + +Also in place: YAML + environment configuration, structured logging, +graceful shutdown, the storage interfaces with a working SQLite and +PostgreSQL driver (and a PocketBase stub), the embedded migration runner, +the `server_settings` table with the runtime federation and registration +switches, and `warp generate-keys`. + +## Quick start + +Requires Go 1.26 or newer. No cgo, no C compiler, no database server: the +default SQLite driver is pure Go. + +```sh +cp warp.yaml.example warp.yaml +# edit global.server_name + +make build +./bin/warp generate-keys +./bin/warp serve +``` + +Then: + +```sh +curl localhost:8008/healthz +curl localhost:8008/_matrix/client/versions +``` + +Without `make`, the same from the `server` directory: + +```sh +go build -o ../bin/warp ./cmd/warp +``` + +## Configuration + +One YAML file, every field overridable from the environment. See +[warp.yaml.example](warp.yaml.example) — each setting lists its `WARP_*` +variable. With no config file at all the server runs on defaults plus the +environment, which is how the container image is meant to be used: + +```sh +docker build -t warp . +docker run -p 8008:8008 -v warp-data:/data -e WARP_SERVER_NAME=warp.example warp +``` + +An unknown field in the config file is an error, not a warning: a typo in a +security-relevant setting should never be silently ignored. + +## Commands + +| Command | What it does | +|---|---| +| `warp serve` | run the homeserver | +| `warp migrate [--status]` | apply (or list) pending database migrations | +| `warp generate-keys [--output path] [--key-id id]` | create the ed25519 signing key | +| `warp version` | version and compiled-in storage drivers | + +The signing key is written as a single line, the same format Synapse uses: +`ed25519 `. `generate-keys` refuses to +overwrite an existing key — losing it would invalidate every event this +server has ever signed. + +## Federation switch + +Federation is off in a fresh install. While it is off, the federation +surface does not answer at all; `/.well-known/matrix/server` returns +`404 M_UNRECOGNIZED`, exactly as it would on a server that has no +federation API. + +The switch is stored in the `server_settings` table and cached in memory, +so flipping it takes effect on the next request without a restart. In +Phase 0 the only thing behind the gate is the delegation document; the +outbound queue, key fetcher and remote media fetch join it in Phase 5. Set +`federation.allow_runtime_toggle: false` to pin the switch to the config +file. + +## Development + +```sh +make test # unit tests (SQLite) +make lint # golangci-lint +make check # vet + test, what CI runs +make cover # coverage.html + +# the storage tests against PostgreSQL as well +WARP_TEST_POSTGRES_DSN='postgres://warp:warp@localhost/warp_test?sslmode=disable' make test-postgres +``` + +`make test-race` needs cgo and therefore a C toolchain; CI runs it on +Linux. + +## Layout + +``` +server/ the homeserver (Go module) + cmd/warp/ the binary: serve, migrate, generate-keys, version + internal/ + config/ YAML + env configuration + logging/ metrics/ slog and Prometheus setup + httputil/ Matrix error envelope, CORS, router, middleware + federation/ the federation gate and allow/deny policy + settings/ runtime settings service (the switches) + setup/ wiring: storage, routes, graceful shutdown + matrix/signing/ ed25519 server keys + storage/ interfaces + driver registry + migrations/ embedded migration runner + sqlstore/ shared database/sql implementation + sqlite/ postgres/ pocketbase/ +apps/ clients: ui, admin, web, desktop, mobile (Phase 1+) +docs/ +``` diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..4bc3354 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,14 @@ +# Clients + +Nothing here yet. The client sub-projects start once the server can do +something worth talking to: + +| Directory | What | Starts after | +|---|---|---| +| `ui/` | shared Vue 3 + Tailwind component library and Matrix store | server Phase 1 | +| `admin/` | admin web panel, embedded into the `warp` binary | server Phase 1 | +| `web/` | web client (Vue SPA) + Go host | server M1 (Phase 4) | +| `desktop/` | Wails v3 shell reusing the web frontend | once web has a timeline | +| `mobile/` | Flutter app, Android first | server Phase 8 (push) | + +See PLAN.md section 8 for the stack decisions behind each one. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8a618cf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Documentation + +| Document | What | +|---|---| +| [configuration.md](configuration.md) | every setting, and its environment variable | +| [development.md](development.md) | building, testing, adding a migration | + +The design and roadmap are in [PLAN.md](../PLAN.md) at the repository root. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4b2904f --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,83 @@ +# Configuration + +Warp reads one YAML file. Every setting can also come from the +environment, and the environment wins. With no file at all the defaults +plus `WARP_*` variables are used, which is how the container image runs. + +Where the file comes from, in order: + +1. `--config ` +2. `$WARP_CONFIG` +3. `./warp.yaml`, if it exists +4. nothing — defaults and environment only + +An unknown field is a fatal error. A typo in a security-relevant setting +must not be ignored. + +List-valued variables are comma-separated. A single `-` clears a list: +`WARP_CORS_ALLOWED_ORIGINS=-`. + +## Settings + +| YAML | Environment | Default | Notes | +|---|---|---|---| +| `global.server_name` | `WARP_SERVER_NAME` | — | Required. Part of every ID; cannot be changed later. | +| `global.private_key` | `WARP_PRIVATE_KEY` | `./keys/signing.key` | Written by `warp generate-keys`. | +| `global.well_known.client_base_url` | `WARP_WELL_KNOWN_CLIENT_BASE_URL` | `https://` | Advertised to clients. | +| `global.well_known.identity_server_base_url` | `WARP_WELL_KNOWN_IDENTITY_SERVER_BASE_URL` | empty | Omitted from the document when empty. | +| `global.well_known.server_address` | `WARP_WELL_KNOWN_SERVER_ADDRESS` | empty | `host:port` for other homeservers. Empty = no delegation, other servers fall back to SRV/A. | +| `listen.address` | `WARP_LISTEN_ADDRESS` | `:8008` | | +| `listen.read_header_timeout` | `WARP_READ_HEADER_TIMEOUT` | `10s` | | +| `listen.read_timeout` | `WARP_READ_TIMEOUT` | `60s` | | +| `listen.write_timeout` | `WARP_WRITE_TIMEOUT` | `120s` | Raise before long-polling `/sync` lands in Phase 4. | +| `listen.idle_timeout` | `WARP_IDLE_TIMEOUT` | `120s` | | +| `listen.max_header_bytes` | `WARP_MAX_HEADER_BYTES` | `1048576` | | +| `listen.shutdown_timeout` | `WARP_SHUTDOWN_TIMEOUT` | `30s` | Grace period for in-flight requests. | +| `database.driver` | `WARP_DB_DRIVER` | `sqlite` | `sqlite`, `postgres`, `pocketbase` (not implemented). | +| `database.connection_string` | `WARP_DB_CONNECTION_STRING` | SQLite file in the working directory | | +| `database.max_open_conns` | `WARP_DB_MAX_OPEN_CONNS` | `10` | Ignored by SQLite: writes are serialised onto one connection. | +| `database.max_idle_conns` | `WARP_DB_MAX_IDLE_CONNS` | `5` | | +| `database.conn_max_lifetime` | `WARP_DB_CONN_MAX_LIFETIME` | `1h` | | +| `database.auto_migrate` | `WARP_DB_AUTO_MIGRATE` | `true` | `false` makes startup fail while migrations are pending. | +| `client_api.cors_allowed_origins` | `WARP_CORS_ALLOWED_ORIGINS` | `["*"]` | The spec expects any origin. | +| `federation.enabled` | `WARP_FEDERATION_ENABLED` | `false` | | +| `federation.allowlist` | `WARP_FEDERATION_ALLOWLIST` | `[]` | Non-empty = federate only with these. | +| `federation.denylist` | `WARP_FEDERATION_DENYLIST` | `[]` | Always wins over the allowlist. | +| `federation.allow_runtime_toggle` | `WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE` | `true` | See below. | +| `registration.enabled` | `WARP_REGISTRATION_ENABLED` | `false` | | +| `registration.allow_runtime_toggle` | `WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE` | `true` | | +| `logging.level` | `WARP_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error`. | +| `logging.format` | `WARP_LOG_FORMAT` | `text` | `text` or `json`. | +| `logging.add_source` | `WARP_LOG_ADD_SOURCE` | `false` | File and line in every record. | +| `metrics.enabled` | `WARP_METRICS_ENABLED` | `true` | | +| `metrics.path` | `WARP_METRICS_PATH` | `/metrics` | Served on the same listener. | + +## Settings that change at runtime + +`federation.enabled` and `registration.enabled` are also stored in the +`server_settings` database table, which is what the admin API writes to. + +- With `allow_runtime_toggle: true` (the default) the stored value wins + over the config file at boot, and the admin API may change it. The + change takes effect on the next request; no restart. +- With `allow_runtime_toggle: false` the config file is authoritative and + the admin API refuses to change the setting. Any previously stored value + is kept but ignored, so re-enabling the toggle restores the + administrator's last choice. + +Everything else is read once at startup. + +## What federation off actually means + +While `federation.enabled` is false: + +- `/.well-known/matrix/server` answers `404 M_UNRECOGNIZED`, and so will + `/_matrix/federation/*` and `/_matrix/key/*` once they exist. +- Nothing outbound runs — in Phase 0 there is nothing outbound yet; from + Phase 5 the transaction queue, key fetcher and remote media fetch stay + stopped. +- Client-Server endpoints that would need a remote server answer + `M_FORBIDDEN` with a message saying federation is disabled. +- Events are still signed with the server key, and room and event IDs are + unchanged. A room created while federation is off federates normally + once it is switched on. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..dbe8609 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,113 @@ +# Development + +## Prerequisites + +- Go 1.26+. Nothing else for the default build: the SQLite driver is pure + Go and `CGO_ENABLED=0` works everywhere. +- A C toolchain only if you want `go test -race`. +- PostgreSQL only to run the storage tests against it. +- `golangci-lint` v2 for `make lint`: + `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest` + +## Everyday commands + +```sh +make build # bin/warp +make test # go test ./... in server/ +make check # vet + test +make lint +make cover # coverage.html +make clean +``` + +Each one is a two-line shell command if you would rather not use `make`; +look at the [Makefile](../Makefile). + +## Running a server locally + +```sh +cp warp.yaml.example warp.yaml # set global.server_name +make build +./bin/warp generate-keys +./bin/warp serve +``` + +The SQLite file, the signing key and (later) the media store are written +relative to the working directory. They are in `.gitignore`. + +For a throwaway server with no config file at all: + +```sh +WARP_SERVER_NAME=dev.local WARP_PRIVATE_KEY=./keys/dev.key ./bin/warp generate-keys +WARP_SERVER_NAME=dev.local WARP_PRIVATE_KEY=./keys/dev.key ./bin/warp serve +``` + +## Adding a database migration + +1. Write the same change twice, once per SQL dialect: + `server/internal/storage/sqlite/migrations/NNNN_name.sql` and + `server/internal/storage/postgres/migrations/NNNN_name.sql`. + Use the next free number; `NNNN` is zero-padded to four digits. +2. The files are embedded with `embed.FS`, so a rebuild picks them up. +3. `./bin/warp migrate --status` lists what would run, `./bin/warp migrate` + applies it. A server started with `database.auto_migrate: true` (the + default) runs them itself. + +Rules the runner enforces: + +- One transaction per migration. A failure rolls that file back entirely + and leaves the following ones unapplied. +- A migration that has already been applied and whose file has since + changed is an error. Write a new migration instead of editing an old + one; the checksum of every applied file is recorded. +- A database that has migrations this binary does not know about refuses + to start, so an old binary cannot run against a newer schema. + +## Adding an endpoint + +Routes are registered in `server/internal/setup/routes.go` on an +`httputil.Router`, which is `http.ServeMux` plus per-route metrics: + +```go +r.HandleFunc("GET /_matrix/client/v3/profile/{userID}/displayname", s.handleGetDisplayName) +``` + +Handlers have the signature `func(http.ResponseWriter, *http.Request) error` +and return Matrix errors directly: + +```go +func (s *Server) handleThing(w http.ResponseWriter, r *http.Request) error { + if somethingWrong { + return httputil.Forbidden("You are not allowed to do that") + } + httputil.WriteJSON(w, http.StatusOK, response{}) + return nil +} +``` + +Anything returned that is not a `*httputil.MatrixError` is logged in full +and reported to the client as `M_UNKNOWN`, so internal detail never leaks. + +Endpoints that belong to the federation surface are registered through the +gate instead, so they disappear when federation is switched off: + +```go +r.Handle("GET /_matrix/federation/v1/version", s.federation.ProtectFunc(s.handleFederationVersion)) +``` + +A Client-Server endpoint that would need federation to answer returns +`federation.RejectClientRequest("...")`, which is an `M_FORBIDDEN` that +says why. + +## Tests + +- Unit tests live next to the code. `internal/setup` holds the HTTP-level + tests; they build a whole server on an in-memory SQLite database. +- Storage tests run against every driver that is available: + SQLite always, PostgreSQL when `WARP_TEST_POSTGRES_DSN` is set. +- CI runs `gofmt -l`, `go mod tidy` (as a diff check), `go vet`, the tests + on Linux and Windows, the race detector on Linux, `golangci-lint`, and a + smoke test that boots the real binary and curls it. + +Complement, the official integration suite, is wired up in Phase 1 — there +is nothing it can test before registration exists. diff --git a/go.work b/go.work new file mode 100644 index 0000000..6609e38 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.26.4 + +use ./server + +// The client sub-projects join the workspace as they are created: +// use ./apps/web (Go host for the web client, Phase 8.2) +// use ./apps/desktop (Wails v3 shell, Phase 8.3) diff --git a/server/.golangci.yml b/server/.golangci.yml new file mode 100644 index 0000000..825c5c8 --- /dev/null +++ b/server/.golangci.yml @@ -0,0 +1,67 @@ +# golangci-lint v2 configuration. +# +# golangci-lint run (from ./server, or `make lint` from the root) +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: none + enable: + - bodyclose # a leaked response body is a leaked connection + - errcheck + - govet + - ineffassign + - misspell + - nilerr + - revive + - rowserrcheck # rows.Err() after a database/sql loop + - sqlclosecheck + - staticcheck + - unconvert + - unused + + settings: + errcheck: + # Deliberate fire-and-forget writes are marked with `_ =`; these are + # the ones where even that is noise. + exclude-functions: + - (net/http.ResponseWriter).Write + govet: + # `shadow` is left off on purpose: it fires on every idiomatic + # `if err := f(); err != nil` inside a function that already has an + # err, which is noise rather than signal. + enable-all: false + revive: + rules: + - name: exported + arguments: + - disableStutteringCheck + - name: package-comments + - name: indent-error-flow + - name: errorf + - name: context-as-argument + - name: receiver-naming + - name: unreachable-code + + exclusions: + presets: + - comments + - std-error-handling + rules: + # Tests may shadow and may ignore errors from helpers. + - path: _test\.go + linters: + - errcheck + - govet + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/dariuszjaniak/warp diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..c7781ac --- /dev/null +++ b/server/go.mod @@ -0,0 +1,36 @@ +module github.com/dariuszjaniak/warp/server + +go 1.26.4 + +require ( + github.com/jackc/pgx/v5 v5.11.0 + github.com/prometheus/client_golang v1.25.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.60.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..52277ea --- /dev/null +++ b/server/go.sum @@ -0,0 +1,108 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/internal/buildinfo/buildinfo.go b/server/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..822b2e6 --- /dev/null +++ b/server/internal/buildinfo/buildinfo.go @@ -0,0 +1,51 @@ +// Package buildinfo carries version information stamped in at link time. +package buildinfo + +import ( + "fmt" + "runtime" + "runtime/debug" +) + +// Set with -ldflags "-X github.com/dariuszjaniak/warp/server/internal/buildinfo.Version=..." +var ( + Version = "0.0.0-dev" + Commit = "" + BuildDate = "" +) + +func init() { + if Commit != "" { + return + } + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + Commit = s.Value + case "vcs.time": + BuildDate = s.Value + } + } +} + +// UserAgent is the value sent on outbound federation requests. +func UserAgent() string { + return fmt.Sprintf("Warp/%s (%s %s)", Version, runtime.GOOS, runtime.GOARCH) +} + +// String renders the full version line for `warp version` and startup logs. +func String() string { + s := Version + if Commit != "" { + if len(Commit) > 12 { + s += " (" + Commit[:12] + ")" + } else { + s += " (" + Commit + ")" + } + } + return s +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..2c27202 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,311 @@ +// Package config loads and validates the Warp server configuration: a single +// YAML file, overridable per-field by WARP_* environment variables. +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// Config is the whole server configuration. +type Config struct { + // Version is the config schema version; bumped only on breaking changes. + Version int `yaml:"version"` + + Global Global `yaml:"global"` + Listen Listen `yaml:"listen"` + Database Database `yaml:"database"` + ClientAPI ClientAPI `yaml:"client_api"` + Federation Federation `yaml:"federation"` + Registration Registration `yaml:"registration"` + Logging Logging `yaml:"logging"` + Metrics Metrics `yaml:"metrics"` + + // path is the file this config was read from, if any. + path string +} + +// Global holds identity settings shared by every component. +type Global struct { + // ServerName is the Matrix server name, e.g. "warp.example". It becomes + // part of every user, room and event ID and cannot be changed later. + ServerName string `yaml:"server_name" env:"WARP_SERVER_NAME"` + + // PrivateKeyPath points at the ed25519 signing key written by + // `warp generate-keys`. + PrivateKeyPath string `yaml:"private_key" env:"WARP_PRIVATE_KEY"` + + WellKnown WellKnown `yaml:"well_known"` +} + +// WellKnown configures the delegation documents served under /.well-known. +type WellKnown struct { + // ClientBaseURL is advertised to clients as m.homeserver.base_url. It + // defaults to https://. + ClientBaseURL string `yaml:"client_base_url" env:"WARP_WELL_KNOWN_CLIENT_BASE_URL"` + + // IdentityServerBaseURL is optional and advertised as m.identity_server. + IdentityServerBaseURL string `yaml:"identity_server_base_url" env:"WARP_WELL_KNOWN_IDENTITY_SERVER_BASE_URL"` + + // ServerAddress is advertised to other homeservers as "m.server", e.g. + // "warp.example:8448". Only served while federation is enabled; empty + // means /.well-known/matrix/server returns 404 and other servers fall + // back to SRV/A record resolution. + ServerAddress string `yaml:"server_address" env:"WARP_WELL_KNOWN_SERVER_ADDRESS"` +} + +// Listen configures the single HTTP listener. +type Listen struct { + Address string `yaml:"address" env:"WARP_LISTEN_ADDRESS"` + ReadHeaderTimeout time.Duration `yaml:"read_header_timeout" env:"WARP_READ_HEADER_TIMEOUT"` + ReadTimeout time.Duration `yaml:"read_timeout" env:"WARP_READ_TIMEOUT"` + WriteTimeout time.Duration `yaml:"write_timeout" env:"WARP_WRITE_TIMEOUT"` + IdleTimeout time.Duration `yaml:"idle_timeout" env:"WARP_IDLE_TIMEOUT"` + MaxHeaderBytes int `yaml:"max_header_bytes" env:"WARP_MAX_HEADER_BYTES"` + + // ShutdownTimeout bounds graceful shutdown before connections are cut. + ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"WARP_SHUTDOWN_TIMEOUT"` +} + +// Database selects and configures the storage driver. +type Database struct { + // Driver is one of "sqlite", "postgres", "pocketbase". + Driver string `yaml:"driver" env:"WARP_DB_DRIVER"` + + // ConnectionString is driver specific: a DSN for postgres, a file URI + // for sqlite, a directory for pocketbase. + ConnectionString string `yaml:"connection_string" env:"WARP_DB_CONNECTION_STRING"` + + MaxOpenConns int `yaml:"max_open_conns" env:"WARP_DB_MAX_OPEN_CONNS"` + MaxIdleConns int `yaml:"max_idle_conns" env:"WARP_DB_MAX_IDLE_CONNS"` + ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime" env:"WARP_DB_CONN_MAX_LIFETIME"` + + // AutoMigrate runs pending migrations on startup. Turn it off to gate + // schema changes behind an explicit `warp migrate`. + AutoMigrate bool `yaml:"auto_migrate" env:"WARP_DB_AUTO_MIGRATE"` +} + +// ClientAPI configures the Client-Server API surface. +type ClientAPI struct { + // CORSAllowedOrigins lists origins allowed to call the C-S API from a + // browser. The Matrix spec expects "*"; narrow it if the only client is + // your own web app on a known origin. + CORSAllowedOrigins []string `yaml:"cors_allowed_origins" env:"WARP_CORS_ALLOWED_ORIGINS"` +} + +// Federation controls the server-to-server surface. See PLAN.md section 3: it +// is a first-class mode, not an afterthought. +type Federation struct { + Enabled bool `yaml:"enabled" env:"WARP_FEDERATION_ENABLED"` + + // Allowlist, when non-empty, restricts federation to these server names. + Allowlist []string `yaml:"allowlist" env:"WARP_FEDERATION_ALLOWLIST"` + + // Denylist blocks these server names even in allowlist mode. + Denylist []string `yaml:"denylist" env:"WARP_FEDERATION_DENYLIST"` + + // AllowRuntimeToggle lets the admin API flip Enabled without a restart. + // When true the persisted setting wins over the value above on boot. + AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE"` +} + +// Registration controls open registration. +type Registration struct { + Enabled bool `yaml:"enabled" env:"WARP_REGISTRATION_ENABLED"` + AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE"` +} + +// Logging configures slog. +type Logging struct { + Level string `yaml:"level" env:"WARP_LOG_LEVEL"` + Format string `yaml:"format" env:"WARP_LOG_FORMAT"` + AddSource bool `yaml:"add_source" env:"WARP_LOG_ADD_SOURCE"` +} + +// Metrics configures the Prometheus endpoint. +type Metrics struct { + Enabled bool `yaml:"enabled" env:"WARP_METRICS_ENABLED"` + Path string `yaml:"path" env:"WARP_METRICS_PATH"` +} + +// Path returns the file this config was loaded from, or "" for defaults. +func (c *Config) Path() string { return c.path } + +// Default returns a config with every optional field populated. Only +// global.server_name is left empty: there is no sane default for it. +func Default() *Config { + return &Config{ + Version: 1, + Global: Global{ + PrivateKeyPath: "./keys/signing.key", + }, + Listen: Listen{ + Address: ":8008", + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, + ShutdownTimeout: 30 * time.Second, + }, + Database: Database{ + Driver: "sqlite", + ConnectionString: "file:warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)", + MaxOpenConns: 10, + MaxIdleConns: 5, + ConnMaxLifetime: time.Hour, + AutoMigrate: true, + }, + ClientAPI: ClientAPI{ + CORSAllowedOrigins: []string{"*"}, + }, + Federation: Federation{ + Enabled: false, + AllowRuntimeToggle: true, + }, + Registration: Registration{ + Enabled: false, + AllowRuntimeToggle: true, + }, + Logging: Logging{Level: "info", Format: "text"}, + Metrics: Metrics{Enabled: true, Path: "/metrics"}, + } +} + +// ClientBaseURL returns the URL advertised to clients, falling back to +// https:// when not configured. +func (c *Config) ClientBaseURL() string { + if c.Global.WellKnown.ClientBaseURL != "" { + return strings.TrimRight(c.Global.WellKnown.ClientBaseURL, "/") + } + return "https://" + c.Global.ServerName +} + +// Validate reports every problem it finds, not just the first. +func (c *Config) Validate() error { + var errs []error + bad := func(format string, args ...any) { + errs = append(errs, fmt.Errorf(format, args...)) + } + + if c.Version != 1 { + bad("version: unsupported config version %d (expected 1)", c.Version) + } + if err := validateServerName(c.Global.ServerName); err != nil { + bad("global.server_name: %w", err) + } + if c.Global.PrivateKeyPath == "" { + bad("global.private_key: must not be empty") + } + for _, u := range []struct{ name, raw string }{ + {"global.well_known.client_base_url", c.Global.WellKnown.ClientBaseURL}, + {"global.well_known.identity_server_base_url", c.Global.WellKnown.IdentityServerBaseURL}, + } { + if u.raw == "" { + continue + } + parsed, err := url.Parse(u.raw) + if err != nil { + bad("%s: %w", u.name, err) + } else if parsed.Scheme != "http" && parsed.Scheme != "https" { + bad("%s: must be an http(s) URL, got %q", u.name, u.raw) + } + } + if c.Listen.Address == "" { + bad("listen.address: must not be empty") + } + if c.Listen.ShutdownTimeout <= 0 { + bad("listen.shutdown_timeout: must be positive") + } + + switch c.Database.Driver { + case "sqlite", "postgres", "pocketbase": + case "": + bad("database.driver: must not be empty") + default: + bad("database.driver: unknown driver %q (want sqlite, postgres or pocketbase)", c.Database.Driver) + } + if c.Database.ConnectionString == "" { + bad("database.connection_string: must not be empty") + } + if c.Database.MaxOpenConns < 0 { + bad("database.max_open_conns: must not be negative") + } + + switch strings.ToLower(c.Logging.Level) { + case "debug", "info", "warn", "warning", "error": + default: + bad("logging.level: unknown level %q", c.Logging.Level) + } + switch strings.ToLower(c.Logging.Format) { + case "text", "json": + default: + bad("logging.format: unknown format %q (want text or json)", c.Logging.Format) + } + + if c.Metrics.Enabled && !strings.HasPrefix(c.Metrics.Path, "/") { + bad("metrics.path: must start with /") + } + + for i, s := range c.Federation.Allowlist { + if err := validateServerName(s); err != nil { + bad("federation.allowlist[%d]: %w", i, err) + } + } + for i, s := range c.Federation.Denylist { + if err := validateServerName(s); err != nil { + bad("federation.denylist[%d]: %w", i, err) + } + } + + return errors.Join(errs...) +} + +// validateServerName applies a cheap sanity check. The full grammar (IPv6 +// literals, ports, IDNA) lands with the event core in Phase 2. +func validateServerName(s string) error { + switch { + case s == "": + return errors.New("must not be empty") + case strings.ContainsAny(s, " \t\r\n"): + return errors.New("must not contain whitespace") + case strings.Contains(s, "/"): + return errors.New("must be a host[:port], not a URL") + case strings.HasPrefix(s, ":"), strings.HasSuffix(s, ":"): + return fmt.Errorf("malformed host:port %q", s) + } + return nil +} + +// Load reads path, applies environment overrides and validates the result. +// An empty path yields the defaults plus environment overrides, which is how +// container deployments configured purely through env vars start up. +func Load(path string) (*Config, error) { + cfg := Default() + if path != "" { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + if err := unmarshalStrict(data, cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + cfg.path = abs + } + if err := ApplyEnv(cfg, os.Getenv); err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration:\n%w", err) + } + return cfg, nil +} diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go new file mode 100644 index 0000000..2786ef3 --- /dev/null +++ b/server/internal/config/config_test.go @@ -0,0 +1,163 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeTemp(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "warp.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadAppliesDefaults(t *testing.T) { + path := writeTemp(t, "version: 1\nglobal:\n server_name: warp.example\n") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got, want := cfg.Listen.Address, ":8008"; got != want { + t.Errorf("listen.address = %q, want %q", got, want) + } + if got, want := cfg.Database.Driver, "sqlite"; got != want { + t.Errorf("database.driver = %q, want %q", got, want) + } + if cfg.Federation.Enabled { + t.Error("federation must default to disabled") + } + if !cfg.Federation.AllowRuntimeToggle { + t.Error("federation.allow_runtime_toggle must default to true") + } + if got, want := cfg.ClientBaseURL(), "https://warp.example"; got != want { + t.Errorf("ClientBaseURL() = %q, want %q", got, want) + } + if cfg.Path() == "" { + t.Error("Path() must record the file the config came from") + } +} + +func TestLoadRejectsUnknownFields(t *testing.T) { + path := writeTemp(t, "version: 1\nglobal:\n server_name: warp.example\n srever_name: typo\n") + + _, err := Load(path) + if err == nil { + t.Fatal("expected an error for an unknown field") + } + if !strings.Contains(err.Error(), "srever_name") { + t.Errorf("error should name the offending field, got: %v", err) + } +} + +func TestValidateCollectsEveryProblem(t *testing.T) { + cfg := Default() + cfg.Global.ServerName = "" + cfg.Database.Driver = "mysql" + cfg.Logging.Level = "chatty" + + err := cfg.Validate() + if err == nil { + t.Fatal("expected validation to fail") + } + for _, want := range []string{"global.server_name", "database.driver", "logging.level"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %s, got: %v", want, err) + } + } +} + +func TestApplyEnvOverrides(t *testing.T) { + cfg := Default() + cfg.Global.ServerName = "from-yaml.example" + cfg.Federation.Allowlist = []string{"a.example"} + + env := map[string]string{ + "WARP_SERVER_NAME": "from-env.example", + "WARP_FEDERATION_ENABLED": "true", + "WARP_FEDERATION_ALLOWLIST": "b.example, c.example", + "WARP_DB_MAX_OPEN_CONNS": "42", + "WARP_SHUTDOWN_TIMEOUT": "5s", + "WARP_WELL_KNOWN_CLIENT_BASE_URL": "https://matrix.example", + } + if err := ApplyEnv(cfg, func(k string) string { return env[k] }); err != nil { + t.Fatalf("ApplyEnv: %v", err) + } + + if got, want := cfg.Global.ServerName, "from-env.example"; got != want { + t.Errorf("server_name = %q, want %q", got, want) + } + if !cfg.Federation.Enabled { + t.Error("federation.enabled should have been overridden to true") + } + if got, want := strings.Join(cfg.Federation.Allowlist, ","), "b.example,c.example"; got != want { + t.Errorf("allowlist = %q, want %q", got, want) + } + if got, want := cfg.Database.MaxOpenConns, 42; got != want { + t.Errorf("max_open_conns = %d, want %d", got, want) + } + if got, want := cfg.Listen.ShutdownTimeout, 5*time.Second; got != want { + t.Errorf("shutdown_timeout = %s, want %s", got, want) + } + if got, want := cfg.ClientBaseURL(), "https://matrix.example"; got != want { + t.Errorf("ClientBaseURL() = %q, want %q", got, want) + } +} + +func TestApplyEnvRejectsMalformedValues(t *testing.T) { + cfg := Default() + err := ApplyEnv(cfg, func(k string) string { + if k == "WARP_FEDERATION_ENABLED" { + return "yes-please" + } + return "" + }) + if err == nil || !strings.Contains(err.Error(), "WARP_FEDERATION_ENABLED") { + t.Fatalf("expected an error naming the variable, got: %v", err) + } +} + +func TestApplyEnvEmptyListSentinel(t *testing.T) { + cfg := Default() + cfg.ClientAPI.CORSAllowedOrigins = []string{"*"} + if err := ApplyEnv(cfg, func(k string) string { + if k == "WARP_CORS_ALLOWED_ORIGINS" { + return "-" + } + return "" + }); err != nil { + t.Fatalf("ApplyEnv: %v", err) + } + if len(cfg.ClientAPI.CORSAllowedOrigins) != 0 { + t.Errorf("expected the list to be cleared, got %v", cfg.ClientAPI.CORSAllowedOrigins) + } +} + +// TestExampleConfigIsValid keeps warp.yaml.example honest: it must parse +// with unknown fields rejected and pass validation as shipped. +func TestExampleConfigIsValid(t *testing.T) { + path := filepath.Join("..", "..", "..", "warp.yaml.example") + if _, err := os.Stat(path); err != nil { + t.Skipf("example config not found: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("warp.yaml.example does not load: %v", err) + } + if cfg.Federation.Enabled { + t.Error("the shipped example must keep federation off") + } + if cfg.Registration.Enabled { + t.Error("the shipped example must keep registration off") + } + if cfg.Database.Driver != "sqlite" { + t.Errorf("example driver = %q, want sqlite", cfg.Database.Driver) + } +} diff --git a/server/internal/config/env.go b/server/internal/config/env.go new file mode 100644 index 0000000..0273ba7 --- /dev/null +++ b/server/internal/config/env.go @@ -0,0 +1,105 @@ +package config + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "time" +) + +// durationType is compared against to tell a Duration from a plain int64. +var durationType = reflect.TypeOf(time.Duration(0)) + +// ApplyEnv overwrites every field carrying an `env` tag whose variable is set +// to a non-empty value. lookup is usually os.Getenv; tests pass a fake. +// +// Supported kinds: string, bool, all ints, time.Duration and []string +// (comma-separated, whitespace trimmed, empty elements dropped). +func ApplyEnv(cfg *Config, lookup func(string) string) error { + return applyEnv(reflect.ValueOf(cfg).Elem(), lookup) +} + +func applyEnv(v reflect.Value, lookup func(string) string) error { + t := v.Type() + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + fv := v.Field(i) + + if fv.Kind() == reflect.Struct && field.Type != durationType { + if err := applyEnv(fv, lookup); err != nil { + return err + } + continue + } + + name := field.Tag.Get("env") + if name == "" { + continue + } + raw := strings.TrimSpace(lookup(name)) + if raw == "" { + continue + } + if err := setFromString(fv, raw); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + return nil +} + +func setFromString(fv reflect.Value, raw string) error { + if fv.Type() == durationType { + d, err := time.ParseDuration(raw) + if err != nil { + return fmt.Errorf("invalid duration %q", raw) + } + fv.SetInt(int64(d)) + return nil + } + + switch fv.Kind() { + case reflect.String: + fv.SetString(raw) + case reflect.Bool: + b, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("invalid boolean %q (want true or false)", raw) + } + fv.SetBool(b) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid integer %q", raw) + } + fv.SetInt(n) + case reflect.Slice: + if fv.Type().Elem().Kind() != reflect.String { + return fmt.Errorf("unsupported slice type %s", fv.Type()) + } + fv.Set(reflect.ValueOf(splitList(raw))) + default: + return fmt.Errorf("unsupported field type %s", fv.Type()) + } + return nil +} + +// splitList parses a comma-separated env value. A lone "-" clears the list, +// which is the only way to turn a YAML list back into an empty one from the +// environment. +func splitList(raw string) []string { + if raw == "-" { + return []string{} + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/server/internal/config/yaml.go b/server/internal/config/yaml.go new file mode 100644 index 0000000..92a9152 --- /dev/null +++ b/server/internal/config/yaml.go @@ -0,0 +1,22 @@ +package config + +import ( + "bytes" + + "gopkg.in/yaml.v3" +) + +// unmarshalStrict decodes YAML into v and rejects unknown fields, so a typo +// in warp.yaml fails the boot instead of being silently ignored. +func unmarshalStrict(data []byte, v any) error { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(v); err != nil { + // An empty file decodes to io.EOF; treat it as "all defaults". + if err.Error() == "EOF" { + return nil + } + return err + } + return nil +} diff --git a/server/internal/federation/gate.go b/server/internal/federation/gate.go new file mode 100644 index 0000000..a2d8479 --- /dev/null +++ b/server/internal/federation/gate.go @@ -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)) +} diff --git a/server/internal/federation/gate_test.go b/server/internal/federation/gate_test.go new file mode 100644 index 0000000..3b16158 --- /dev/null +++ b/server/internal/federation/gate_test.go @@ -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) + } + } +} diff --git a/server/internal/httputil/errors.go b/server/internal/httputil/errors.go new file mode 100644 index 0000000..1f7da99 --- /dev/null +++ b/server/internal/httputil/errors.go @@ -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") +} diff --git a/server/internal/httputil/json.go b/server/internal/httputil/json.go new file mode 100644 index 0000000..d2ebac2 --- /dev/null +++ b/server/internal/httputil/json.go @@ -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) + } + }) +} diff --git a/server/internal/httputil/middleware.go b/server/internal/httputil/middleware.go new file mode 100644 index 0000000..043650c --- /dev/null +++ b/server/internal/httputil/middleware.go @@ -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() + }) +} diff --git a/server/internal/httputil/router.go b/server/internal/httputil/router.go new file mode 100644 index 0000000..2a3c2f6 --- /dev/null +++ b/server/internal/httputil/router.go @@ -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) +} diff --git a/server/internal/logging/logging.go b/server/internal/logging/logging.go new file mode 100644 index 0000000..b5e0bea --- /dev/null +++ b/server/internal/logging/logging.go @@ -0,0 +1,52 @@ +// Package logging configures the process-wide slog logger. +package logging + +import ( + "fmt" + "io" + "log/slog" + "strings" +) + +// Options mirrors the logging section of the config file. +type Options struct { + Level string + Format string + AddSource bool +} + +// ParseLevel maps a config string onto a slog level. +func ParseLevel(s string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug, nil + case "", "info": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("unknown log level %q", s) + } +} + +// New builds a logger writing to w. It does not install it as the default. +func New(w io.Writer, opts Options) (*slog.Logger, error) { + level, err := ParseLevel(opts.Level) + if err != nil { + return nil, err + } + handlerOpts := &slog.HandlerOptions{Level: level, AddSource: opts.AddSource} + + var h slog.Handler + switch strings.ToLower(strings.TrimSpace(opts.Format)) { + case "", "text": + h = slog.NewTextHandler(w, handlerOpts) + case "json": + h = slog.NewJSONHandler(w, handlerOpts) + default: + return nil, fmt.Errorf("unknown log format %q", opts.Format) + } + return slog.New(h), nil +} diff --git a/server/internal/matrix/signing/key.go b/server/internal/matrix/signing/key.go new file mode 100644 index 0000000..f9b1bc9 --- /dev/null +++ b/server/internal/matrix/signing/key.go @@ -0,0 +1,182 @@ +// Package signing handles the server's ed25519 signing keys: generating +// them, reading and writing the on-disk format, and naming them. +// +// Event signing itself (canonical JSON, hashes, signature blocks) arrives +// with the event core in Phase 2; this package deliberately stops at key +// material so that `warp generate-keys` can exist from day one. +package signing + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// KeyIDPrefix is the only signing algorithm Matrix defines today. +const KeyIDPrefix = "ed25519" + +// FileMode is the permission a freshly written key file gets. On Windows it +// is advisory; the warning in Load covers the Unix case. +const FileMode os.FileMode = 0o600 + +// KeyPair is a server signing key and the key ID that names it in a +// signature block ("ed25519:abc123"). +type KeyPair struct { + // ID is the part after the algorithm, e.g. "a_RVfN". + ID string + + Private ed25519.PrivateKey + Public ed25519.PublicKey +} + +// KeyID returns the full "ed25519:" identifier. +func (k *KeyPair) KeyID() string { return KeyIDPrefix + ":" + k.ID } + +// PublicBase64 returns the unpadded base64 public key, the encoding Matrix +// uses on the wire. +func (k *KeyPair) PublicBase64() string { + return base64.RawStdEncoding.EncodeToString(k.Public) +} + +// Generate creates a new key. An empty id gets a random one. +func Generate(id string) (*KeyPair, error) { + if id == "" { + var err error + if id, err = RandomKeyID(); err != nil { + return nil, err + } + } + if err := ValidateKeyID(id); err != nil { + return nil, err + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ed25519 key: %w", err) + } + return &KeyPair{ID: id, Private: priv, Public: pub}, nil +} + +// keyIDAlphabet is deliberately narrow: the spec allows [a-zA-Z0-9_] and +// mixed case key IDs are a nuisance to type. +const keyIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + +// RandomKeyID returns an identifier of the "a_xxxxx" shape Synapse uses, so +// operators recognise it. +func RandomKeyID() (string, error) { + buf := make([]byte, 5) + if _, err := io.ReadFull(rand.Reader, buf); err != nil { + return "", fmt.Errorf("read randomness: %w", err) + } + out := make([]byte, 0, len(buf)+2) + out = append(out, 'a', '_') + for _, b := range buf { + out = append(out, keyIDAlphabet[int(b)%len(keyIDAlphabet)]) + } + return string(out), nil +} + +// ValidateKeyID checks the identifier against the grammar in the spec. +func ValidateKeyID(id string) error { + if id == "" { + return errors.New("key ID must not be empty") + } + if strings.Contains(id, ":") { + return fmt.Errorf("key ID %q must not contain a colon; pass the part after %q", id, KeyIDPrefix+":") + } + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + default: + return fmt.Errorf("key ID %q contains %q; only [a-zA-Z0-9_] are allowed", id, r) + } + } + return nil +} + +// Save writes the key to path in the one-line interchange format shared with +// Synapse: +// +// ed25519 +// +// It refuses to clobber an existing file: a lost signing key means every +// event this server ever signed stops verifying. +func (k *KeyPair) Save(path string) error { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create key directory: %w", err) + } + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, FileMode) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("%s already exists; refusing to overwrite a signing key", path) + } + return err + } + defer f.Close() + + seed := base64.RawStdEncoding.EncodeToString(k.Private.Seed()) + if _, err := fmt.Fprintf(f, "%s %s %s\n", KeyIDPrefix, k.ID, seed); err != nil { + return fmt.Errorf("write key: %w", err) + } + return f.Close() +} + +// Load reads a key file written by Save. Lines starting with '#' and blank +// lines are ignored; the first key line wins. Older keys (a file with +// several entries) will matter once key rotation lands in Phase 5. +func Load(path string) (*KeyPair, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read signing key: %w", err) + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return parseKeyLine(line, path) + } + return nil, fmt.Errorf("%s contains no signing key", path) +} + +func parseKeyLine(line, path string) (*KeyPair, error) { + parts := strings.Fields(line) + if len(parts) != 3 { + return nil, fmt.Errorf("%s: malformed key line, want \"ed25519 \"", path) + } + if parts[0] != KeyIDPrefix { + return nil, fmt.Errorf("%s: unsupported key algorithm %q", path, parts[0]) + } + if err := ValidateKeyID(parts[1]); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + seed, err := decodeBase64(parts[2]) + if err != nil { + return nil, fmt.Errorf("%s: decode seed: %w", path, err) + } + if len(seed) != ed25519.SeedSize { + return nil, fmt.Errorf("%s: seed is %d bytes, want %d", path, len(seed), ed25519.SeedSize) + } + priv := ed25519.NewKeyFromSeed(seed) + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("%s: unexpected public key type", path) + } + return &KeyPair{ID: parts[1], Private: priv, Public: pub}, nil +} + +// decodeBase64 accepts both padded and unpadded standard base64, because +// keys get copied between implementations by hand. +func decodeBase64(s string) ([]byte, error) { + if strings.HasSuffix(s, "=") { + return base64.StdEncoding.DecodeString(s) + } + return base64.RawStdEncoding.DecodeString(s) +} diff --git a/server/internal/matrix/signing/key_test.go b/server/internal/matrix/signing/key_test.go new file mode 100644 index 0000000..c09c0db --- /dev/null +++ b/server/internal/matrix/signing/key_test.go @@ -0,0 +1,147 @@ +package signing + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "keys", "signing.key") + + key, err := Generate("") + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !strings.HasPrefix(key.ID, "a_") { + t.Errorf("generated key ID %q should look like a_xxxxx", key.ID) + } + if got, want := key.KeyID(), "ed25519:"+key.ID; got != want { + t.Errorf("KeyID() = %q, want %q", got, want) + } + if err := key.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.ID != key.ID { + t.Errorf("key ID = %q, want %q", loaded.ID, key.ID) + } + if !loaded.Private.Equal(key.Private) { + t.Error("loaded private key differs from the generated one") + } + if loaded.PublicBase64() != key.PublicBase64() { + t.Error("loaded public key differs from the generated one") + } + + msg := []byte("the quick brown fox") + if !ed25519.Verify(loaded.Public, msg, ed25519.Sign(loaded.Private, msg)) { + t.Error("loaded key does not sign and verify") + } +} + +func TestSaveRefusesToOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "signing.key") + key, err := Generate("test") + if err != nil { + t.Fatal(err) + } + if err := key.Save(path); err != nil { + t.Fatal(err) + } + err = key.Save(path) + if err == nil { + t.Fatal("expected the second Save to fail") + } + if !strings.Contains(err.Error(), "refusing to overwrite") { + t.Errorf("error should explain itself, got: %v", err) + } +} + +func TestSaveFormat(t *testing.T) { + path := filepath.Join(t.TempDir(), "signing.key") + key, err := Generate("abc") + if err != nil { + t.Fatal(err) + } + if err := key.Save(path); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(string(data)) + if len(fields) != 3 || fields[0] != "ed25519" || fields[1] != "abc" { + t.Fatalf("unexpected key file contents: %q", string(data)) + } + if strings.Contains(fields[2], "=") { + t.Error("the seed should be unpadded base64") + } +} + +func TestLoadRejectsGarbage(t *testing.T) { + dir := t.TempDir() + cases := map[string]string{ + "empty": "", + "only a comment": "# nothing here\n", + "wrong field count": "ed25519 abc\n", + "wrong algorithm": "rsa abc AAAA\n", + "bad key id": "ed25519 a:b AAAA\n", + "short seed": "ed25519 abc AAAA\n", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(dir, strings.ReplaceAll(name, " ", "_")+".key") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatalf("expected Load to reject %q", body) + } + }) + } +} + +func TestValidateKeyID(t *testing.T) { + for _, ok := range []string{"a_abc", "ABC123", "x_1"} { + if err := ValidateKeyID(ok); err != nil { + t.Errorf("ValidateKeyID(%q) = %v, want nil", ok, err) + } + } + for _, bad := range []string{"", "ed25519:abc", "a-b", "a b", "ä"} { + if err := ValidateKeyID(bad); err == nil { + t.Errorf("ValidateKeyID(%q) = nil, want an error", bad) + } + } +} + +func TestLoadIgnoresCommentsAndBlankLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "signing.key") + key, err := Generate("real") + if err != nil { + t.Fatal(err) + } + if err := key.Save(path); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append([]byte("# warp signing key\n\n"), body...), 0o600); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.ID != "real" { + t.Errorf("key ID = %q, want %q", loaded.ID, "real") + } +} diff --git a/server/internal/metrics/metrics.go b/server/internal/metrics/metrics.go new file mode 100644 index 0000000..77ee44b --- /dev/null +++ b/server/internal/metrics/metrics.go @@ -0,0 +1,111 @@ +// Package metrics owns the Prometheus registry and the HTTP instrumentation. +package metrics + +import ( + "net/http" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/dariuszjaniak/warp/server/internal/buildinfo" + "github.com/dariuszjaniak/warp/server/internal/httputil" +) + +// Namespace prefixes every metric Warp exports. +const Namespace = "warp" + +// Registry is a Prometheus registry plus the collectors Warp always exports. +// Components register their own collectors on it at construction time. +type Registry struct { + *prometheus.Registry + + httpRequests *prometheus.CounterVec + httpDuration *prometheus.HistogramVec + httpInFlight prometheus.Gauge +} + +// NewRegistry builds a registry with the Go runtime, process and build-info +// collectors already registered. +func NewRegistry() *Registry { + reg := prometheus.NewRegistry() + reg.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + + buildInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Name: "build_info", + Help: "Warp build information; the value is always 1.", + }, []string{"version", "commit"}) + buildInfo.WithLabelValues(buildinfo.Version, buildinfo.Commit).Set(1) + reg.MustRegister(buildInfo) + + r := &Registry{ + Registry: reg, + httpRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: "http", + Name: "requests_total", + Help: "Total HTTP requests by route, method and status.", + }, []string{"route", "method", "status"}), + httpDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: "http", + Name: "request_duration_seconds", + Help: "HTTP request duration by route and method.", + Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30}, + }, []string{"route", "method"}), + httpInFlight: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: "http", + Name: "requests_in_flight", + Help: "HTTP requests currently being served.", + }), + } + reg.MustRegister(r.httpRequests, r.httpDuration, r.httpInFlight) + return r +} + +// Handler serves the metrics endpoint. +func (r *Registry) Handler() http.Handler { + return promhttp.HandlerFor(r.Registry, promhttp.HandlerOpts{ + ErrorHandling: promhttp.HTTPErrorOnError, + }) +} + +// InFlight tracks concurrent requests. It is a global middleware: the +// gauge carries no route label. +func (r *Registry) InFlight() httputil.Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.httpInFlight.Inc() + defer r.httpInFlight.Dec() + next.ServeHTTP(w, req) + }) + } +} + +// Instrument wraps one route's handler. The label is the route pattern it +// was registered under, not the request path, so per-user and per-room URLs +// cannot explode the metric cardinality. It is passed to the router as an +// httputil.Instrumenter, which is why it takes the route explicitly: the +// pattern ServeMux records on the request is not visible to an outer +// middleware. +func (r *Registry) Instrument(route string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + start := time.Now() + rec := &httputil.ResponseRecorder{ResponseWriter: w} + next.ServeHTTP(rec, req) + + status := rec.Status + if status == 0 { + status = http.StatusOK + } + r.httpRequests.WithLabelValues(route, req.Method, strconv.Itoa(status)).Inc() + r.httpDuration.WithLabelValues(route, req.Method).Observe(time.Since(start).Seconds()) + }) +} diff --git a/server/internal/settings/settings.go b/server/internal/settings/settings.go new file mode 100644 index 0000000..fdd826e --- /dev/null +++ b/server/internal/settings/settings.go @@ -0,0 +1,193 @@ +// Package settings is the runtime-settings service: the small set of +// switches an administrator can flip without restarting the server. +// +// Each setting has a value in the config file and, optionally, a value in +// the server_settings table. The stored value wins when the config allows +// the setting to be toggled at runtime; otherwise the file is authoritative +// and the stored value is ignored (but kept, so that re-enabling the toggle +// restores the administrator's last choice). +// +// Readers get a cached value — these are consulted on every federated +// request — and components that must react to a change subscribe. +package settings + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "sync" + + "github.com/dariuszjaniak/warp/server/internal/storage" +) + +// Setting keys as stored in server_settings. +const ( + KeyFederationEnabled = "federation.enabled" + KeyRegistrationEnabled = "registration.enabled" +) + +// ErrNotToggleable is returned when a setting is pinned by the config file. +var ErrNotToggleable = errors.New("setting is not toggleable at runtime") + +// Defaults come from the config file. +type Defaults struct { + FederationEnabled bool + FederationAllowRuntimeToggle bool + + RegistrationEnabled bool + RegistrationAllowRuntimeToggle bool +} + +// Snapshot is a consistent view of every runtime setting. +type Snapshot struct { + FederationEnabled bool + RegistrationEnabled bool +} + +// Service caches the runtime settings and persists changes. +type Service struct { + store storage.SettingsStore + log *slog.Logger + defaults Defaults + + mu sync.RWMutex + current Snapshot + listeners []func(Snapshot) + + // writeMu serialises Set calls; see set. + writeMu sync.Mutex +} + +// New loads the persisted settings and returns a service. A stored value +// that cannot be parsed is logged and ignored rather than failing the boot: +// an unreadable switch should not keep the server down. +func New(ctx context.Context, store storage.SettingsStore, defaults Defaults, log *slog.Logger) (*Service, error) { + if log == nil { + log = slog.Default() + } + s := &Service{ + store: store, + log: log, + defaults: defaults, + current: Snapshot{ + FederationEnabled: defaults.FederationEnabled, + RegistrationEnabled: defaults.RegistrationEnabled, + }, + } + + stored, err := store.GetAll(ctx) + if err != nil { + return nil, fmt.Errorf("load server settings: %w", err) + } + if defaults.FederationAllowRuntimeToggle { + s.current.FederationEnabled = s.boolOr(stored, KeyFederationEnabled, defaults.FederationEnabled) + } + if defaults.RegistrationAllowRuntimeToggle { + s.current.RegistrationEnabled = s.boolOr(stored, KeyRegistrationEnabled, defaults.RegistrationEnabled) + } + + s.log.Info("runtime settings loaded", + "federation_enabled", s.current.FederationEnabled, + "registration_enabled", s.current.RegistrationEnabled) + return s, nil +} + +func (s *Service) boolOr(stored map[string]string, key string, fallback bool) bool { + raw, ok := stored[key] + if !ok { + return fallback + } + v, err := strconv.ParseBool(raw) + if err != nil { + s.log.Warn("ignoring unparseable stored setting", "key", key, "value", raw, "error", err) + return fallback + } + return v +} + +// Snapshot returns the current values. +func (s *Service) Snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current +} + +// FederationEnabled reports whether the federation surface is live. It is +// called on every federation request, so it only takes a read lock. +func (s *Service) FederationEnabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.FederationEnabled +} + +// RegistrationEnabled reports whether open registration is accepted. +func (s *Service) RegistrationEnabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.RegistrationEnabled +} + +// FederationToggleable reports whether SetFederationEnabled is allowed. +func (s *Service) FederationToggleable() bool { return s.defaults.FederationAllowRuntimeToggle } + +// RegistrationToggleable reports whether SetRegistrationEnabled is allowed. +func (s *Service) RegistrationToggleable() bool { return s.defaults.RegistrationAllowRuntimeToggle } + +// SetFederationEnabled persists and applies the federation switch. +func (s *Service) SetFederationEnabled(ctx context.Context, enabled bool) error { + if !s.defaults.FederationAllowRuntimeToggle { + return fmt.Errorf("federation.enabled: %w (federation.allow_runtime_toggle is false)", ErrNotToggleable) + } + return s.set(ctx, KeyFederationEnabled, enabled, func(snap *Snapshot) { snap.FederationEnabled = enabled }) +} + +// SetRegistrationEnabled persists and applies the registration switch. +func (s *Service) SetRegistrationEnabled(ctx context.Context, enabled bool) error { + if !s.defaults.RegistrationAllowRuntimeToggle { + return fmt.Errorf("registration.enabled: %w (registration.allow_runtime_toggle is false)", ErrNotToggleable) + } + return s.set(ctx, KeyRegistrationEnabled, enabled, func(snap *Snapshot) { snap.RegistrationEnabled = enabled }) +} + +// set writes the store first: if persistence fails the in-memory value must +// not change, or a restart would silently undo what the admin API reported +// as done. +func (s *Service) set(ctx context.Context, key string, value bool, apply func(*Snapshot)) error { + // writeMu serialises the whole write path, so two concurrent toggles + // cannot persist in one order and notify subscribers in another. + s.writeMu.Lock() + defer s.writeMu.Unlock() + + if err := s.store.Set(ctx, key, strconv.FormatBool(value)); err != nil { + return err + } + + s.mu.Lock() + before := s.current + apply(&s.current) + after := s.current + listeners := make([]func(Snapshot), len(s.listeners)) + copy(listeners, s.listeners) + s.mu.Unlock() + + if before == after { + return nil + } + s.log.Info("runtime setting changed", "key", key, "value", value) + // Listeners are called outside s.mu: a federation module starting or + // stopping can take a while and must not block readers. + for _, fn := range listeners { + fn(after) + } + return nil +} + +// Subscribe registers fn to be called after any setting changes. It is +// called with the new snapshot, never concurrently with itself. +func (s *Service) Subscribe(fn func(Snapshot)) { + s.mu.Lock() + defer s.mu.Unlock() + s.listeners = append(s.listeners, fn) +} diff --git a/server/internal/settings/settings_test.go b/server/internal/settings/settings_test.go new file mode 100644 index 0000000..0391e87 --- /dev/null +++ b/server/internal/settings/settings_test.go @@ -0,0 +1,200 @@ +package settings_test + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/dariuszjaniak/warp/server/internal/settings" +) + +// memStore is an in-memory storage.SettingsStore. +type memStore struct { + mu sync.Mutex + values map[string]string + failSet error +} + +func newMemStore(initial map[string]string) *memStore { + values := map[string]string{} + for k, v := range initial { + values[k] = v + } + return &memStore{values: values} +} + +func (m *memStore) Get(_ context.Context, key string) (string, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.values[key] + return v, ok, nil +} + +func (m *memStore) GetAll(context.Context) (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := map[string]string{} + for k, v := range m.values { + out[k] = v + } + return out, nil +} + +func (m *memStore) Set(_ context.Context, key, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.failSet != nil { + return m.failSet + } + m.values[key] = value + return nil +} + +func (m *memStore) Delete(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.values, key) + return nil +} + +func togglableDefaults() settings.Defaults { + return settings.Defaults{ + FederationEnabled: false, + FederationAllowRuntimeToggle: true, + RegistrationEnabled: false, + RegistrationAllowRuntimeToggle: true, + } +} + +func TestStoredValueOverridesConfig(t *testing.T) { + store := newMemStore(map[string]string{settings.KeyFederationEnabled: "true"}) + + svc, err := settings.New(t.Context(), store, togglableDefaults(), nil) + if err != nil { + t.Fatalf("New: %v", err) + } + if !svc.FederationEnabled() { + t.Error("the persisted value should win over the config default") + } +} + +func TestPinnedSettingIgnoresStoredValue(t *testing.T) { + store := newMemStore(map[string]string{settings.KeyFederationEnabled: "true"}) + defaults := togglableDefaults() + defaults.FederationAllowRuntimeToggle = false + + svc, err := settings.New(t.Context(), store, defaults, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + if svc.FederationEnabled() { + t.Error("with allow_runtime_toggle false the config file is authoritative") + } + if err := svc.SetFederationEnabled(t.Context(), true); !errors.Is(err, settings.ErrNotToggleable) { + t.Errorf("SetFederationEnabled = %v, want ErrNotToggleable", err) + } + if _, found, _ := store.Get(t.Context(), settings.KeyFederationEnabled); !found { + t.Error("the stored value should be kept for when the toggle is re-enabled") + } +} + +func TestUnparseableStoredValueFallsBackToConfig(t *testing.T) { + store := newMemStore(map[string]string{settings.KeyRegistrationEnabled: "maybe"}) + defaults := togglableDefaults() + defaults.RegistrationEnabled = true + + svc, err := settings.New(t.Context(), store, defaults, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + if !svc.RegistrationEnabled() { + t.Error("a corrupt stored value must fall back to the config value, not to false") + } +} + +func TestSetPersistsAndNotifies(t *testing.T) { + store := newMemStore(nil) + svc, err := settings.New(t.Context(), store, togglableDefaults(), nil) + if err != nil { + t.Fatal(err) + } + + var got []settings.Snapshot + svc.Subscribe(func(s settings.Snapshot) { got = append(got, s) }) + + if err := svc.SetFederationEnabled(t.Context(), true); err != nil { + t.Fatalf("SetFederationEnabled: %v", err) + } + if !svc.FederationEnabled() { + t.Error("the cached value was not updated") + } + if v, _, _ := store.Get(t.Context(), settings.KeyFederationEnabled); v != "true" { + t.Errorf("stored value = %q, want \"true\"", v) + } + if len(got) != 1 || !got[0].FederationEnabled { + t.Fatalf("subscriber saw %v, want one snapshot with federation on", got) + } + + // Setting the same value again changes nothing and must not notify. + if err := svc.SetFederationEnabled(t.Context(), true); err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("a no-op change notified subscribers: %v", got) + } +} + +func TestSetKeepsCacheOnStoreFailure(t *testing.T) { + store := newMemStore(nil) + store.failSet = errors.New("disk on fire") + + svc, err := settings.New(t.Context(), store, togglableDefaults(), nil) + if err != nil { + t.Fatal(err) + } + if err := svc.SetFederationEnabled(t.Context(), true); err == nil { + t.Fatal("expected the store failure to surface") + } + if svc.FederationEnabled() { + t.Error("the cached value must not change when persistence fails") + } +} + +func TestSnapshotIsConsistent(t *testing.T) { + store := newMemStore(map[string]string{ + settings.KeyFederationEnabled: "true", + settings.KeyRegistrationEnabled: "true", + }) + svc, err := settings.New(t.Context(), store, togglableDefaults(), nil) + if err != nil { + t.Fatal(err) + } + snap := svc.Snapshot() + if !snap.FederationEnabled || !snap.RegistrationEnabled { + t.Errorf("Snapshot() = %+v, want both enabled", snap) + } +} + +func TestConcurrentReadsAndWrites(t *testing.T) { + store := newMemStore(nil) + svc, err := settings.New(t.Context(), store, togglableDefaults(), nil) + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for i := range 8 { + wg.Add(1) + go func() { + defer wg.Done() + if i%2 == 0 { + _ = svc.SetFederationEnabled(t.Context(), i%4 == 0) + return + } + _ = svc.FederationEnabled() + _ = svc.Snapshot() + }() + } + wg.Wait() +} diff --git a/server/internal/setup/routes.go b/server/internal/setup/routes.go new file mode 100644 index 0000000..5f16d55 --- /dev/null +++ b/server/internal/setup/routes.go @@ -0,0 +1,127 @@ +package setup + +import ( + "net/http" + "time" + + "github.com/dariuszjaniak/warp/server/internal/buildinfo" + "github.com/dariuszjaniak/warp/server/internal/httputil" +) + +// SpecVersions is what GET /_matrix/client/versions reports. +// +// Keep it honest: a version belongs here once the endpoints a client +// actually uses from it work. Phase 0 serves only /versions and +// /.well-known, so the list stays minimal and grows as the C-S API lands in +// Phases 1 to 4. +var SpecVersions = []string{"v1.13", "v1.14"} + +// UnstableFeatures advertises MSC support. Simplified Sliding Sync +// (MSC4186) is Phase 10. +var UnstableFeatures = map[string]bool{} + +type versionsResponse struct { + Versions []string `json:"versions"` + UnstableFeatures map[string]bool `json:"unstable_features,omitempty"` +} + +// wellKnownClient is the /.well-known/matrix/client document. +type wellKnownClient struct { + Homeserver wellKnownBaseURL `json:"m.homeserver"` + IdentityServer *wellKnownBaseURL `json:"m.identity_server,omitempty"` +} + +type wellKnownBaseURL struct { + BaseURL string `json:"base_url"` +} + +type wellKnownServer struct { + Server string `json:"m.server"` +} + +type healthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Database string `json:"database"` + Error string `json:"error,omitempty"` +} + +// routes registers every endpoint Phase 0 serves. +func (s *Server) routes() *httputil.Router { + r := httputil.NewRouter(s.metrics.Instrument) + + r.HandleFunc("GET /_matrix/client/versions", s.handleVersions) + r.HandleFunc("GET /.well-known/matrix/client", s.handleWellKnownClient) + + // The server-side delegation document is part of the federation + // surface, so it goes through the gate and disappears with it. + r.Handle("GET /.well-known/matrix/server", s.federation.ProtectFunc(s.handleWellKnownServer)) + + r.HandleFunc("GET /healthz", s.handleHealth) + if s.cfg.Metrics.Enabled { + r.Handle("GET "+s.cfg.Metrics.Path, s.metrics.Handler()) + } + return r +} + +// handleVersions implements GET /_matrix/client/versions. +func (s *Server) handleVersions(w http.ResponseWriter, _ *http.Request) error { + httputil.CacheFor(w, time.Hour) + httputil.WriteJSON(w, http.StatusOK, versionsResponse{ + Versions: SpecVersions, + UnstableFeatures: UnstableFeatures, + }) + return nil +} + +// handleWellKnownClient implements GET /.well-known/matrix/client, the +// document a client fetches to turn "@me:warp.example" into an API URL. +func (s *Server) handleWellKnownClient(w http.ResponseWriter, _ *http.Request) error { + doc := wellKnownClient{ + Homeserver: wellKnownBaseURL{BaseURL: s.cfg.ClientBaseURL()}, + } + if url := s.cfg.Global.WellKnown.IdentityServerBaseURL; url != "" { + doc.IdentityServer = &wellKnownBaseURL{BaseURL: url} + } + httputil.CacheFor(w, time.Hour) + httputil.WriteJSON(w, http.StatusOK, doc) + return nil +} + +// handleWellKnownServer implements GET /.well-known/matrix/server. With no +// address configured there is nothing to delegate and the right answer is +// 404: other servers then fall back to SRV and A record resolution. +func (s *Server) handleWellKnownServer(w http.ResponseWriter, _ *http.Request) error { + address := s.cfg.Global.WellKnown.ServerAddress + if address == "" { + return httputil.NotFound("No server delegation is configured") + } + httputil.CacheFor(w, time.Hour) + httputil.WriteJSON(w, http.StatusOK, wellKnownServer{Server: address}) + return nil +} + +// handleHealth implements GET /healthz: a liveness and readiness check in +// one. It reports 503 when the database is unreachable, which is what a +// load balancer needs to stop sending traffic here. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) error { + resp := healthResponse{ + Status: "ok", + Version: buildinfo.Version, + Database: s.db.Driver(), + } + status := http.StatusOK + + ctx, cancel := contextWithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.db.Ping(ctx); err != nil { + resp.Status = "unavailable" + resp.Error = "database unreachable" + status = http.StatusServiceUnavailable + httputil.LoggerFrom(r.Context()).Error("health check failed", "error", err) + } + + httputil.NoCache(w) + httputil.WriteJSON(w, status, resp) + return nil +} diff --git a/server/internal/setup/server.go b/server/internal/setup/server.go new file mode 100644 index 0000000..bdeff1e --- /dev/null +++ b/server/internal/setup/server.go @@ -0,0 +1,258 @@ +// Package setup wires the components together into a running server: it +// opens storage, loads the signing key, builds the runtime-settings service +// and the HTTP stack, and owns the graceful shutdown. +package setup + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "sync/atomic" + "time" + + "github.com/dariuszjaniak/warp/server/internal/buildinfo" + "github.com/dariuszjaniak/warp/server/internal/config" + "github.com/dariuszjaniak/warp/server/internal/federation" + "github.com/dariuszjaniak/warp/server/internal/httputil" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" + "github.com/dariuszjaniak/warp/server/internal/metrics" + "github.com/dariuszjaniak/warp/server/internal/settings" + "github.com/dariuszjaniak/warp/server/internal/storage" + _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers +) + +// Server is a configured, not yet listening homeserver. +type Server struct { + cfg *config.Config + log *slog.Logger + + db storage.Database + settings *settings.Service + metrics *metrics.Registry + federation *federation.Gate + signingKey *signing.KeyPair + + handler http.Handler + + // addr is the address Run is listening on; empty until then. + addr atomic.Pointer[string] + + // ownsDB is false when the caller supplied the database and will close + // it itself. + ownsDB bool +} + +// Options are the knobs tests and commands need beyond the config file. +type Options struct { + // Logger receives everything the server logs. + Logger *slog.Logger + + // Database, when set, is used instead of opening one from the config. + // Tests pass an in-memory SQLite database this way. + Database storage.Database + + // SkipSigningKey starts the server without loading the signing key. + // Only tests and `warp migrate` use it: a real server must be able to + // sign. + SkipSigningKey bool +} + +// New builds the server: storage, migrations, settings, routes. It does not +// listen; call Run for that. +func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error) { + log := opts.Logger + if log == nil { + log = slog.Default() + } + + s := &Server{cfg: cfg, log: log, metrics: metrics.NewRegistry()} + + if opts.Database != nil { + s.db = opts.Database + } else { + db, err := storage.Open(ctx, storage.Config{ + Driver: cfg.Database.Driver, + ConnectionString: cfg.Database.ConnectionString, + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + }, log) + if err != nil { + return nil, err + } + s.db, s.ownsDB = db, true + } + + if err := s.prepareSchema(ctx); err != nil { + _ = s.closeDB() + return nil, err + } + + if !opts.SkipSigningKey { + key, err := signing.Load(cfg.Global.PrivateKeyPath) + if err != nil { + _ = s.closeDB() + return nil, fmt.Errorf("%w\nrun `warp generate-keys --config ` to create one", err) + } + s.signingKey = key + log.Info("loaded signing key", "key_id", key.KeyID(), "path", cfg.Global.PrivateKeyPath) + } + + settingsService, err := settings.New(ctx, s.db.Settings(), settings.Defaults{ + FederationEnabled: cfg.Federation.Enabled, + FederationAllowRuntimeToggle: cfg.Federation.AllowRuntimeToggle, + RegistrationEnabled: cfg.Registration.Enabled, + RegistrationAllowRuntimeToggle: cfg.Registration.AllowRuntimeToggle, + }, log) + if err != nil { + _ = s.closeDB() + return nil, err + } + s.settings = settingsService + + s.federation = federation.NewGate( + settingsService.FederationEnabled, + federation.NewPolicy(cfg.Federation.Allowlist, cfg.Federation.Denylist), + ) + // Phase 5 replaces this with starting and stopping the federation + // module; for now it is the audit trail for a runtime toggle. + settingsService.Subscribe(func(snap settings.Snapshot) { + log.Info("federation switch applied", "enabled", snap.FederationEnabled) + }) + + s.handler = httputil.Chain(s.routes(), + httputil.Recoverer, + httputil.RequestLogger(log), + s.metrics.InFlight(), + httputil.CORS(httputil.CORSOptions{AllowedOrigins: cfg.ClientAPI.CORSAllowedOrigins}), + ) + return s, nil +} + +// prepareSchema migrates the database, or refuses to start when migrations +// are pending and auto-migration is off. +func (s *Server) prepareSchema(ctx context.Context) error { + if s.cfg.Database.AutoMigrate { + if err := s.db.Migrate(ctx); err != nil { + return fmt.Errorf("migrate database: %w", err) + } + return nil + } + pending, err := s.db.PendingMigrations(ctx) + if err != nil { + return fmt.Errorf("check migrations: %w", err) + } + if len(pending) > 0 { + return fmt.Errorf("database has %d pending migration(s): %v; run `warp migrate`", len(pending), pending) + } + return nil +} + +// Addr reports the address Run is listening on, or "" before it starts. +// Tests and `warp serve` on port 0 use it to find the chosen port. +func (s *Server) Addr() string { + if p := s.addr.Load(); p != nil { + return *p + } + return "" +} + +// Handler exposes the fully wrapped HTTP handler, for tests. +func (s *Server) Handler() http.Handler { return s.handler } + +// Settings exposes the runtime-settings service; the admin API in Phase 1 +// is its first user. +func (s *Server) Settings() *settings.Service { return s.settings } + +// Database exposes the storage handle. +func (s *Server) Database() storage.Database { return s.db } + +// SigningKey returns the server signing key, or nil when it was skipped. +func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey } + +// Run listens and serves until ctx is cancelled, then shuts down +// gracefully: it stops accepting connections and gives in-flight requests +// up to listen.shutdown_timeout to finish. +func (s *Server) Run(ctx context.Context) error { + listener, err := net.Listen("tcp", s.cfg.Listen.Address) + if err != nil { + return fmt.Errorf("listen on %s: %w", s.cfg.Listen.Address, err) + } + + addr := listener.Addr().String() + s.addr.Store(&addr) + defer s.addr.Store(nil) + + httpServer := &http.Server{ + Handler: s.handler, + ReadHeaderTimeout: s.cfg.Listen.ReadHeaderTimeout, + ReadTimeout: s.cfg.Listen.ReadTimeout, + WriteTimeout: s.cfg.Listen.WriteTimeout, + IdleTimeout: s.cfg.Listen.IdleTimeout, + MaxHeaderBytes: s.cfg.Listen.MaxHeaderBytes, + ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug), + BaseContext: func(net.Listener) context.Context { return ctx }, + } + + s.log.Info("warp is listening", + "address", addr, + "server_name", s.cfg.Global.ServerName, + "version", buildinfo.String(), + "database", s.db.Driver(), + "federation", s.settings.FederationEnabled(), + "registration", s.settings.RegistrationEnabled(), + ) + + errCh := make(chan error, 1) + go func() { + err := httpServer.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + errCh <- err + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + + s.log.Info("shutting down", "timeout", s.cfg.Listen.ShutdownTimeout) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.cfg.Listen.ShutdownTimeout) + defer cancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + s.log.Warn("graceful shutdown timed out; closing connections", "error", err) + _ = httpServer.Close() + } + // Serve has returned by now; surface anything it reported. + if err := <-errCh; err != nil { + return err + } + s.log.Info("stopped") + return nil +} + +// Close releases the resources New acquired. +func (s *Server) Close() error { + return s.closeDB() +} + +func (s *Server) closeDB() error { + if s.db == nil || !s.ownsDB { + return nil + } + err := s.db.Close() + s.db = nil + return err +} + +// contextWithTimeout is a thin wrapper so handlers do not import context +// just for a deadline. +func contextWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, d) +} diff --git a/server/internal/setup/server_test.go b/server/internal/setup/server_test.go new file mode 100644 index 0000000..97ce3a9 --- /dev/null +++ b/server/internal/setup/server_test.go @@ -0,0 +1,350 @@ +package setup_test + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/dariuszjaniak/warp/server/internal/config" + "github.com/dariuszjaniak/warp/server/internal/setup" +) + +// newTestServer builds a server on a throwaway SQLite database. +func newTestServer(t *testing.T, tweak func(*config.Config)) *setup.Server { + t.Helper() + + cfg := config.Default() + cfg.Global.ServerName = "warp.example" + cfg.Database.ConnectionString = "file:" + t.Name() + "?mode=memory&cache=shared" + cfg.Metrics.Enabled = true + if tweak != nil { + tweak(cfg) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("test config is invalid: %v", err) + } + + srv, err := setup.New(t.Context(), cfg, setup.Options{ + Logger: slog.New(slog.DiscardHandler), + SkipSigningKey: true, + }) + if err != nil { + t.Fatalf("setup.New: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + return srv +} + +func get(t *testing.T, srv *setup.Server, method, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} + +func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T { + t.Helper() + var out T + body, err := io.ReadAll(rec.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("response is not JSON (%s): %v", body, err) + } + return out +} + +func TestVersions(t *testing.T) { + srv := newTestServer(t, nil) + rec := get(t, srv, http.MethodGet, "/_matrix/client/versions") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + body := decode[struct { + Versions []string `json:"versions"` + }](t, rec) + if len(body.Versions) == 0 { + t.Error("versions must not be empty") + } + for _, v := range body.Versions { + if !strings.HasPrefix(v, "v") && !strings.HasPrefix(v, "r") { + t.Errorf("version %q does not look like a spec version", v) + } + } +} + +func TestWellKnownClient(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { + c.Global.WellKnown.IdentityServerBaseURL = "https://identity.example" + }) + rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := decode[map[string]map[string]string](t, rec) + if got, want := body["m.homeserver"]["base_url"], "https://warp.example"; got != want { + t.Errorf("base_url = %q, want %q", got, want) + } + if got, want := body["m.identity_server"]["base_url"], "https://identity.example"; got != want { + t.Errorf("identity server = %q, want %q", got, want) + } +} + +func TestWellKnownClientOmitsIdentityServerByDefault(t *testing.T) { + srv := newTestServer(t, nil) + rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client") + + body := decode[map[string]any](t, rec) + if _, present := body["m.identity_server"]; present { + t.Error("m.identity_server must be omitted when it is not configured") + } +} + +// TestFederationGate is the Phase 0 proof of PLAN.md section 3: the +// federation surface is absent while the switch is off, appears when it is +// flipped at runtime, and disappears again — with no restart. +func TestFederationGate(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { + c.Global.WellKnown.ServerAddress = "warp.example:8448" + }) + + rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server") + if rec.Code != http.StatusNotFound { + t.Fatalf("with federation off, status = %d, want 404", rec.Code) + } + if code := decode[map[string]string](t, rec)["errcode"]; code != "M_UNRECOGNIZED" { + t.Errorf("errcode = %q, want M_UNRECOGNIZED", code) + } + + if err := srv.Settings().SetFederationEnabled(t.Context(), true); err != nil { + t.Fatalf("SetFederationEnabled: %v", err) + } + + rec = get(t, srv, http.MethodGet, "/.well-known/matrix/server") + if rec.Code != http.StatusOK { + t.Fatalf("with federation on, status = %d, want 200", rec.Code) + } + if got, want := decode[map[string]string](t, rec)["m.server"], "warp.example:8448"; got != want { + t.Errorf("m.server = %q, want %q", got, want) + } + + if err := srv.Settings().SetFederationEnabled(t.Context(), false); err != nil { + t.Fatalf("SetFederationEnabled: %v", err) + } + if rec = get(t, srv, http.MethodGet, "/.well-known/matrix/server"); rec.Code != http.StatusNotFound { + t.Errorf("after switching off again, status = %d, want 404", rec.Code) + } +} + +func TestWellKnownServerWithoutDelegation(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { c.Federation.Enabled = true }) + + rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 when no delegation is configured", rec.Code) + } + if code := decode[map[string]string](t, rec)["errcode"]; code != "M_NOT_FOUND" { + t.Errorf("errcode = %q, want M_NOT_FOUND", code) + } +} + +func TestUnknownMatrixEndpoint(t *testing.T) { + srv := newTestServer(t, nil) + + rec := get(t, srv, http.MethodGet, "/_matrix/client/v3/rooms/!a:b/messages") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + body := decode[map[string]string](t, rec) + if body["errcode"] != "M_UNRECOGNIZED" { + t.Errorf("errcode = %q, want M_UNRECOGNIZED", body["errcode"]) + } + if body["error"] == "" { + t.Error("the error envelope must carry a human-readable message") + } +} + +func TestCORSPreflight(t *testing.T) { + srv := newTestServer(t, nil) + + req := httptest.NewRequest(http.MethodOptions, "/_matrix/client/versions", nil) + req.Header.Set("Origin", "https://app.example") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("preflight status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want *", got) + } + for _, header := range []string{"Authorization", "Content-Type", "X-Requested-With"} { + if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), header) { + t.Errorf("Access-Control-Allow-Headers is missing %s", header) + } + } +} + +func TestCORSRestrictedOrigins(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { + c.ClientAPI.CORSAllowedOrigins = []string{"https://app.example"} + }) + + req := httptest.NewRequest(http.MethodGet, "/_matrix/client/versions", nil) + req.Header.Set("Origin", "https://app.example") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if got, want := rec.Header().Get("Access-Control-Allow-Origin"), "https://app.example"; got != want { + t.Errorf("allowed origin = %q, want %q", got, want) + } + + req = httptest.NewRequest(http.MethodGet, "/_matrix/client/versions", nil) + req.Header.Set("Origin", "https://evil.example") + rec = httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("unlisted origin got %q, want no allow-origin header", got) + } +} + +func TestHealthz(t *testing.T) { + srv := newTestServer(t, nil) + rec := get(t, srv, http.MethodGet, "/healthz") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := decode[map[string]string](t, rec) + if body["status"] != "ok" { + t.Errorf("status = %q, want ok", body["status"]) + } + if body["database"] != "sqlite" { + t.Errorf("database = %q, want sqlite", body["database"]) + } +} + +func TestMetrics(t *testing.T) { + srv := newTestServer(t, nil) + + // Generate one request so the counters exist. + get(t, srv, http.MethodGet, "/_matrix/client/versions") + + rec := get(t, srv, http.MethodGet, "/metrics") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"warp_build_info", "warp_http_requests_total"} { + if !strings.Contains(body, want) { + t.Errorf("metrics output is missing %s", want) + } + } + // The route label must be the pattern, not the path, or the metric + // explodes once rooms and users appear in URLs. + if !strings.Contains(body, `route="GET /_matrix/client/versions"`) { + t.Error("requests are not labelled with their route pattern") + } +} + +func TestMetricsCanBeDisabled(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { c.Metrics.Enabled = false }) + + if rec := get(t, srv, http.MethodGet, "/metrics"); rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 when metrics are off", rec.Code) + } +} + +func TestPendingMigrationsBlockStartup(t *testing.T) { + cfg := config.Default() + cfg.Global.ServerName = "warp.example" + cfg.Database.ConnectionString = "file:" + t.Name() + "?mode=memory&cache=shared" + cfg.Database.AutoMigrate = false + + _, err := setup.New(t.Context(), cfg, setup.Options{ + Logger: slog.New(slog.DiscardHandler), + SkipSigningKey: true, + }) + if err == nil { + t.Fatal("expected startup to refuse a database with pending migrations") + } + if !strings.Contains(err.Error(), "warp migrate") { + t.Errorf("the error should say how to fix it, got: %v", err) + } +} + +// TestRunServesAndShutsDownGracefully covers the lifecycle the signal +// handler drives: Run listens, serves, and returns cleanly once its +// context is cancelled. +func TestRunServesAndShutsDownGracefully(t *testing.T) { + srv := newTestServer(t, func(c *config.Config) { + c.Listen.Address = "127.0.0.1:0" + c.Listen.ShutdownTimeout = 5 * time.Second + }) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- srv.Run(ctx) }() + + var addr string + for range 100 { + if addr = srv.Addr(); addr != "" { + break + } + time.Sleep(10 * time.Millisecond) + } + if addr == "" { + cancel() + t.Fatal("the server never started listening") + } + + resp, err := http.Get("http://" + addr + "/healthz") + if err != nil { + cancel() + t.Fatalf("GET /healthz: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("Run returned %v, want nil after a cancelled context", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after its context was cancelled") + } + + if resp, err := http.Get("http://" + addr + "/healthz"); err == nil { + _ = resp.Body.Close() + t.Error("the listener is still accepting connections after shutdown") + } +} + +func TestRunReportsListenFailure(t *testing.T) { + // Not a valid "host:port", so net.Listen fails before blocking. + srv := newTestServer(t, func(c *config.Config) { c.Listen.Address = "nonsense" }) + + err := srv.Run(t.Context()) + if err == nil { + t.Fatal("expected Run to fail on an unusable address") + } + if !strings.Contains(err.Error(), "listen on") { + t.Errorf("error should name the address, got: %v", err) + } +} diff --git a/server/internal/storage/drivers/drivers.go b/server/internal/storage/drivers/drivers.go new file mode 100644 index 0000000..340b419 --- /dev/null +++ b/server/internal/storage/drivers/drivers.go @@ -0,0 +1,14 @@ +// Package drivers links every storage driver into the binary. Importing it +// for its side effects is what makes storage.Open able to find them: +// +// import _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" +// +// It exists so that commands and tests have one import to remember, and so +// that the storage package itself never imports a database engine. +package drivers + +import ( + _ "github.com/dariuszjaniak/warp/server/internal/storage/pocketbase" + _ "github.com/dariuszjaniak/warp/server/internal/storage/postgres" + _ "github.com/dariuszjaniak/warp/server/internal/storage/sqlite" +) diff --git a/server/internal/storage/migrations/migrations.go b/server/internal/storage/migrations/migrations.go new file mode 100644 index 0000000..b471d9d --- /dev/null +++ b/server/internal/storage/migrations/migrations.go @@ -0,0 +1,236 @@ +// Package migrations runs embedded SQL migrations against any +// database/sql driver. +// +// Migrations live next to their driver as files named NNNN_description.sql +// and are applied in version order inside a transaction each, recorded in a +// schema_migrations table. A migration that has already run and whose file +// has since changed is an error, not a silent no-op. +package migrations + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "log/slog" + "path" + "slices" + "strconv" + "strings" + "time" +) + +// Migration is one SQL file. +type Migration struct { + Version int + Name string + SQL string + Checksum string +} + +// Applied is a row of schema_migrations. +type Applied struct { + Version int + Name string + Checksum string + AppliedAt time.Time +} + +// Dialect carries the handful of statements that differ per database. Each +// driver package supplies one; the runner itself stays dialect-agnostic. +type Dialect struct { + // Name is used in log lines and errors, e.g. "sqlite". + Name string + + // CreateTable creates schema_migrations if it does not exist, with + // columns (version, name, checksum, applied_at). + CreateTable string + + // SelectApplied reads every row, ordered by version. + SelectApplied string + + // InsertApplied records one migration. Its placeholders bind + // (version, name, checksum, applied_at) in that order. + InsertApplied string +} + +// Load reads every NNNN_*.sql file in dir of fsys. +func Load(fsys fs.FS, dir string) ([]Migration, error) { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return nil, fmt.Errorf("read migrations directory %s: %w", dir, err) + } + + var out []Migration + seen := map[int]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + version, name, err := parseFilename(e.Name()) + if err != nil { + return nil, err + } + if prev, dup := seen[version]; dup { + return nil, fmt.Errorf("duplicate migration version %d: %s and %s", version, prev, e.Name()) + } + seen[version] = e.Name() + + body, err := fs.ReadFile(fsys, path.Join(dir, e.Name())) + if err != nil { + return nil, fmt.Errorf("read migration %s: %w", e.Name(), err) + } + sum := sha256.Sum256(body) + out = append(out, Migration{ + Version: version, + Name: name, + SQL: string(body), + Checksum: hex.EncodeToString(sum[:]), + }) + } + slices.SortFunc(out, func(a, b Migration) int { return a.Version - b.Version }) + return out, nil +} + +// parseFilename splits "0001_server_settings.sql" into 1 and +// "server_settings". +func parseFilename(filename string) (int, string, error) { + base := strings.TrimSuffix(filename, ".sql") + prefix, name, ok := strings.Cut(base, "_") + if !ok { + return 0, "", fmt.Errorf("migration %s: expected NNNN_description.sql", filename) + } + version, err := strconv.Atoi(prefix) + if err != nil { + return 0, "", fmt.Errorf("migration %s: %q is not a version number", filename, prefix) + } + if version <= 0 { + return 0, "", fmt.Errorf("migration %s: version must be positive", filename) + } + return version, name, nil +} + +// Status lists the migrations already applied. +func Status(ctx context.Context, db *sql.DB, d Dialect) ([]Applied, error) { + if _, err := db.ExecContext(ctx, d.CreateTable); err != nil { + return nil, fmt.Errorf("create schema_migrations: %w", err) + } + rows, err := db.QueryContext(ctx, d.SelectApplied) + if err != nil { + return nil, fmt.Errorf("read schema_migrations: %w", err) + } + defer rows.Close() + + var out []Applied + for rows.Next() { + var a Applied + if err := rows.Scan(&a.Version, &a.Name, &a.Checksum, &a.AppliedAt); err != nil { + return nil, fmt.Errorf("scan schema_migrations: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} + +// Run applies every migration that has not run yet and returns how many it +// applied. It is safe to call on every boot. +func Run(ctx context.Context, db *sql.DB, d Dialect, ms []Migration, log *slog.Logger) (int, error) { + if log == nil { + log = slog.Default() + } + applied, err := Status(ctx, db, d) + if err != nil { + return 0, err + } + known := make(map[int]Applied, len(applied)) + for _, a := range applied { + known[a.Version] = a + } + + count := 0 + for _, m := range ms { + if prev, ok := known[m.Version]; ok { + if prev.Checksum != m.Checksum { + return count, fmt.Errorf( + "migration %04d_%s has changed since it was applied (recorded %s, file %s); "+ + "write a new migration instead of editing an applied one", + m.Version, m.Name, short(prev.Checksum), short(m.Checksum)) + } + continue + } + if err := apply(ctx, db, d, m); err != nil { + return count, err + } + log.Info("applied migration", "version", m.Version, "name", m.Name) + count++ + } + return count, nil +} + +func apply(ctx context.Context, db *sql.DB, d Dialect, m Migration) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %04d_%s: %w", m.Version, m.Name, err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, m.SQL); err != nil { + return fmt.Errorf("migration %04d_%s: %w", m.Version, m.Name, err) + } + if _, err := tx.ExecContext(ctx, d.InsertApplied, m.Version, m.Name, m.Checksum, time.Now().UTC()); err != nil { + return fmt.Errorf("record migration %04d_%s: %w", m.Version, m.Name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %04d_%s: %w", m.Version, m.Name, err) + } + return nil +} + +// Pending reports the migrations in ms that have not been applied yet. +func Pending(ctx context.Context, db *sql.DB, d Dialect, ms []Migration) ([]Migration, error) { + applied, err := Status(ctx, db, d) + if err != nil { + return nil, err + } + done := make(map[int]struct{}, len(applied)) + for _, a := range applied { + done[a.Version] = struct{}{} + } + var out []Migration + for _, m := range ms { + if _, ok := done[m.Version]; !ok { + out = append(out, m) + } + } + return out, nil +} + +// ErrDirtySchema is returned when the recorded schema is ahead of the +// binary: an older warp must not run against a newer database. +var ErrDirtySchema = errors.New("database schema is newer than this binary") + +// CheckNotAhead fails when the database records a migration this binary does +// not know about. +func CheckNotAhead(applied []Applied, ms []Migration) error { + highest := 0 + for _, m := range ms { + highest = max(highest, m.Version) + } + for _, a := range applied { + if a.Version > highest { + return fmt.Errorf("%w: database is at %04d_%s, this binary knows up to %04d", + ErrDirtySchema, a.Version, a.Name, highest) + } + } + return nil +} + +func short(checksum string) string { + if len(checksum) > 8 { + return checksum[:8] + } + return checksum +} diff --git a/server/internal/storage/migrations/migrations_test.go b/server/internal/storage/migrations/migrations_test.go new file mode 100644 index 0000000..5c16396 --- /dev/null +++ b/server/internal/storage/migrations/migrations_test.go @@ -0,0 +1,175 @@ +package migrations_test + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + _ "modernc.org/sqlite" + + "github.com/dariuszjaniak/warp/server/internal/storage/migrations" +) + +var dialect = migrations.Dialect{ + Name: "sqlite", + CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, name TEXT NOT NULL, + checksum TEXT NOT NULL, applied_at TIMESTAMP NOT NULL)`, + SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`, + InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`, +} + +func openDB(t *testing.T) *sql.DB { + t.Helper() + path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db")) + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func fsWith(files map[string]string) fstest.MapFS { + out := fstest.MapFS{} + for name, body := range files { + out["migrations/"+name] = &fstest.MapFile{Data: []byte(body)} + } + return out +} + +func TestLoadOrdersByVersionAndParsesNames(t *testing.T) { + ms, err := migrations.Load(fsWith(map[string]string{ + "0002_devices.sql": "CREATE TABLE devices (id TEXT);", + "0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT);", + "notes.txt": "ignored", + }), "migrations") + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(ms) != 2 { + t.Fatalf("loaded %d migrations, want 2", len(ms)) + } + if ms[0].Version != 1 || ms[0].Name != "server_settings" { + t.Errorf("first migration = %d/%s", ms[0].Version, ms[0].Name) + } + if ms[1].Version != 2 || ms[1].Name != "devices" { + t.Errorf("second migration = %d/%s", ms[1].Version, ms[1].Name) + } + if ms[0].Checksum == "" || ms[0].Checksum == ms[1].Checksum { + t.Error("each migration needs its own checksum") + } +} + +func TestLoadRejectsBadFilenames(t *testing.T) { + for _, name := range []string{"nope.sql", "abc_x.sql", "0000_zero.sql"} { + if _, err := migrations.Load(fsWith(map[string]string{name: "SELECT 1;"}), "migrations"); err == nil { + t.Errorf("Load(%s) = nil error, want a complaint", name) + } + } +} + +func TestRunIsIdempotent(t *testing.T) { + db := openDB(t) + fsys := fsWith(map[string]string{ + "0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);", + }) + ms, err := migrations.Load(fsys, "migrations") + if err != nil { + t.Fatal(err) + } + + n, err := migrations.Run(t.Context(), db, dialect, ms, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + if n != 1 { + t.Errorf("applied %d migrations, want 1", n) + } + + n, err = migrations.Run(t.Context(), db, dialect, ms, nil) + if err != nil { + t.Fatalf("second Run: %v", err) + } + if n != 0 { + t.Errorf("second Run applied %d migrations, want 0", n) + } +} + +func TestRunDetectsEditedMigration(t *testing.T) { + db := openDB(t) + before, err := migrations.Load(fsWith(map[string]string{ + "0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);", + }), "migrations") + if err != nil { + t.Fatal(err) + } + if _, err := migrations.Run(t.Context(), db, dialect, before, nil); err != nil { + t.Fatal(err) + } + + after, err := migrations.Load(fsWith(map[string]string{ + "0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY, v TEXT);", + }), "migrations") + if err != nil { + t.Fatal(err) + } + _, err = migrations.Run(t.Context(), db, dialect, after, nil) + if err == nil { + t.Fatal("expected an error for an edited migration") + } + if !strings.Contains(err.Error(), "has changed since it was applied") { + t.Errorf("unhelpful error: %v", err) + } +} + +func TestRunRollsBackFailedMigration(t *testing.T) { + db := openDB(t) + ms, err := migrations.Load(fsWith(map[string]string{ + "0001_ok.sql": "CREATE TABLE a (k TEXT);", + "0002_broken.sql": "CREATE TABLE b (k TEXT); THIS IS NOT SQL;", + }), "migrations") + if err != nil { + t.Fatal(err) + } + + n, err := migrations.Run(t.Context(), db, dialect, ms, nil) + if err == nil { + t.Fatal("expected the broken migration to fail") + } + if n != 1 { + t.Errorf("applied %d migrations before failing, want 1", n) + } + + // The failed migration must leave nothing behind. + var count int + if err := db.QueryRowContext(t.Context(), + `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'b'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Error("table from the failed migration was not rolled back") + } + + pending, err := migrations.Pending(t.Context(), db, dialect, ms) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].Version != 2 { + t.Errorf("pending = %v, want just migration 2", pending) + } +} + +func TestCheckNotAhead(t *testing.T) { + applied := []migrations.Applied{{Version: 7, Name: "future"}} + ms := []migrations.Migration{{Version: 1, Name: "server_settings"}} + + if err := migrations.CheckNotAhead(applied, ms); err == nil { + t.Fatal("expected a dirty-schema error") + } + if err := migrations.CheckNotAhead(nil, ms); err != nil { + t.Errorf("fresh database must be fine, got %v", err) + } +} diff --git a/server/internal/storage/pocketbase/pocketbase.go b/server/internal/storage/pocketbase/pocketbase.go new file mode 100644 index 0000000..31df58d --- /dev/null +++ b/server/internal/storage/pocketbase/pocketbase.go @@ -0,0 +1,79 @@ +// Package pocketbase is the placeholder for the PocketBase storage driver. +// +// The plan (PLAN.md section 4) is an appliance deployment: one binary that +// also brings admin auth, a filesystem abstraction for media (local or S3) +// and realtime for the admin panel. The event DAG is a poor fit for +// PocketBase's record model, so the roomserver and syncapi tables will use +// its underlying dbx connection directly rather than its collection API. +// +// Until that work starts (Phase 10) the driver is compiled in, registered +// and fails loudly, so that `database.driver: pocketbase` is a clear error +// instead of an unknown-driver one, and so the interface stays honest: +// every method the other drivers implement exists here too. +package pocketbase + +import ( + "context" + "fmt" + "log/slog" + + "github.com/dariuszjaniak/warp/server/internal/storage" +) + +func init() { + storage.Register(storage.DriverPocketBase, Open) +} + +// Database is the not-yet-implemented PocketBase driver. +type Database struct{} + +// Open always fails with storage.ErrNotImplemented. +func Open(context.Context, storage.Config, *slog.Logger) (storage.Database, error) { + return nil, fmt.Errorf( + "pocketbase driver is not implemented yet (planned for Phase 10); use %q or %q: %w", + storage.DriverSQLite, storage.DriverPostgres, storage.ErrNotImplemented) +} + +// Driver implements storage.Database. +func (*Database) Driver() string { return storage.DriverPocketBase } + +// Ping implements storage.Database. +func (*Database) Ping(context.Context) error { return storage.ErrNotImplemented } + +// Migrate implements storage.Database. +func (*Database) Migrate(context.Context) error { return storage.ErrNotImplemented } + +// PendingMigrations implements storage.Database. +func (*Database) PendingMigrations(context.Context) ([]string, error) { + return nil, storage.ErrNotImplemented +} + +// Settings implements storage.Database. +func (*Database) Settings() storage.SettingsStore { return notImplementedSettings{} } + +// Close implements storage.Database. +func (*Database) Close() error { return nil } + +// notImplementedSettings satisfies storage.SettingsStore by refusing every +// call, so a partially wired-up PocketBase deployment cannot silently lose +// an admin's federation switch. +type notImplementedSettings struct{} + +func (notImplementedSettings) Get(context.Context, string) (string, bool, error) { + return "", false, storage.ErrNotImplemented +} + +func (notImplementedSettings) GetAll(context.Context) (map[string]string, error) { + return nil, storage.ErrNotImplemented +} + +func (notImplementedSettings) Set(context.Context, string, string) error { + return storage.ErrNotImplemented +} + +func (notImplementedSettings) Delete(context.Context, string) error { + return storage.ErrNotImplemented +} + +// Compile-time proof that the stub keeps up with the interface. +var _ storage.Database = (*Database)(nil) diff --git a/server/internal/storage/postgres/migrations/0001_server_settings.sql b/server/internal/storage/postgres/migrations/0001_server_settings.sql new file mode 100644 index 0000000..26498fd --- /dev/null +++ b/server/internal/storage/postgres/migrations/0001_server_settings.sql @@ -0,0 +1,7 @@ +-- Runtime-mutable server settings (federation and registration switches). +-- Values are stored as text; the settings service parses and caches them. +CREATE TABLE IF NOT EXISTS server_settings ( + setting_key TEXT NOT NULL PRIMARY KEY, + setting_value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); diff --git a/server/internal/storage/postgres/postgres.go b/server/internal/storage/postgres/postgres.go new file mode 100644 index 0000000..0534f67 --- /dev/null +++ b/server/internal/storage/postgres/postgres.go @@ -0,0 +1,85 @@ +// Package postgres implements the storage interfaces on PostgreSQL 15+ via +// pgx's database/sql driver. This is the production backend and the one +// Complement runs against. +package postgres + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log/slog" + + _ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" database/sql driver + + "github.com/dariuszjaniak/warp/server/internal/storage" + "github.com/dariuszjaniak/warp/server/internal/storage/migrations" + "github.com/dariuszjaniak/warp/server/internal/storage/sqlstore" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +func init() { + storage.Register(storage.DriverPostgres, Open) +} + +var dialect = migrations.Dialect{ + Name: storage.DriverPostgres, + CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL + )`, + SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`, + InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, $4)`, +} + +var settingsQueries = sqlstore.SettingsQueries{ + Get: `SELECT setting_value FROM server_settings WHERE setting_key = $1`, + GetAll: `SELECT setting_key, setting_value FROM server_settings`, + Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES ($1, $2, $3) + ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`, + Delete: `DELETE FROM server_settings WHERE setting_key = $1`, +} + +// Database is the PostgreSQL implementation of storage.Database. +type Database struct { + *sqlstore.Store +} + +// Open connects to the DSN in cfg.ConnectionString, e.g. +// "postgres://warp:secret@localhost/warp?sslmode=disable". +func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) { + if cfg.ConnectionString == "" { + return nil, fmt.Errorf("postgres: connection_string must not be empty") + } + db, err := sql.Open("pgx", cfg.ConnectionString) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("postgres: connect: %w", err) + } + + store, err := sqlstore.New(sqlstore.Options{ + DriverName: storage.DriverPostgres, + DB: db, + Dialect: dialect, + MigrationsFS: migrationFiles, + MigrationsDir: "migrations", + Settings: settingsQueries, + Logger: log, + }) + if err != nil { + _ = db.Close() + return nil, err + } + return &Database{Store: store}, nil +} diff --git a/server/internal/storage/sqlite/migrations/0001_server_settings.sql b/server/internal/storage/sqlite/migrations/0001_server_settings.sql new file mode 100644 index 0000000..51b1e37 --- /dev/null +++ b/server/internal/storage/sqlite/migrations/0001_server_settings.sql @@ -0,0 +1,7 @@ +-- Runtime-mutable server settings (federation and registration switches). +-- Values are stored as text; the settings service parses and caches them. +CREATE TABLE IF NOT EXISTS server_settings ( + setting_key TEXT NOT NULL PRIMARY KEY, + setting_value TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL +); diff --git a/server/internal/storage/sqlite/sqlite.go b/server/internal/storage/sqlite/sqlite.go new file mode 100644 index 0000000..38e9b87 --- /dev/null +++ b/server/internal/storage/sqlite/sqlite.go @@ -0,0 +1,136 @@ +// Package sqlite implements the storage interfaces on modernc.org/sqlite, +// a pure-Go SQLite with no cgo. It is the development and single-user +// driver; PostgreSQL is the production one. +package sqlite + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log/slog" + "net/url" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" // registers the "sqlite" database/sql driver + + "github.com/dariuszjaniak/warp/server/internal/storage" + "github.com/dariuszjaniak/warp/server/internal/storage/migrations" + "github.com/dariuszjaniak/warp/server/internal/storage/sqlstore" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +func init() { + storage.Register(storage.DriverSQLite, Open) +} + +// dialect is the migration bookkeeping for SQLite. +var dialect = migrations.Dialect{ + Name: storage.DriverSQLite, + CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMP NOT NULL + )`, + SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`, + InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`, +} + +var settingsQueries = sqlstore.SettingsQueries{ + Get: `SELECT setting_value FROM server_settings WHERE setting_key = ?`, + GetAll: `SELECT setting_key, setting_value FROM server_settings`, + Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES (?, ?, ?) + ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`, + Delete: `DELETE FROM server_settings WHERE setting_key = ?`, +} + +// Database is the SQLite implementation of storage.Database. +type Database struct { + *sqlstore.Store +} + +// Open connects to the SQLite file named by cfg.ConnectionString. +// +// The connection string is a modernc.org/sqlite DSN: "file:warp.db" with +// optional "_pragma=..." parameters. The parent directory is created if it +// does not exist, because a missing directory is the single most common +// first-run failure. +// +// Writes are serialised onto one connection: SQLite allows a single writer, +// and letting database/sql open several write connections turns that into +// SQLITE_BUSY under concurrency rather than a queue. +func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) { + dsn := cfg.ConnectionString + if dsn == "" { + return nil, fmt.Errorf("sqlite: connection_string must not be empty") + } + if err := ensureParentDir(dsn); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("sqlite: connect: %w", err) + } + + store, err := sqlstore.New(sqlstore.Options{ + DriverName: storage.DriverSQLite, + DB: db, + Dialect: dialect, + MigrationsFS: migrationFiles, + MigrationsDir: "migrations", + Settings: settingsQueries, + Logger: log, + }) + if err != nil { + _ = db.Close() + return nil, err + } + return &Database{Store: store}, nil +} + +// ensureParentDir creates the directory holding the database file. In-memory +// databases (":memory:" or "mode=memory") have no directory. +func ensureParentDir(dsn string) error { + path := filePathFromDSN(dsn) + if path == "" { + return nil + } + dir := filepath.Dir(path) + if dir == "" || dir == "." { + return nil + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("sqlite: create database directory %s: %w", dir, err) + } + return nil +} + +// filePathFromDSN extracts the filesystem path from a SQLite DSN, or "" for +// an in-memory database. +func filePathFromDSN(dsn string) string { + path := strings.TrimPrefix(dsn, "file:") + if i := strings.IndexAny(path, "?"); i >= 0 { + path = path[:i] + } + if unescaped, err := url.PathUnescape(path); err == nil { + path = unescaped + } + if path == "" || path == ":memory:" || strings.Contains(dsn, "mode=memory") { + return "" + } + return path +} diff --git a/server/internal/storage/sqlstore/settings.go b/server/internal/storage/sqlstore/settings.go new file mode 100644 index 0000000..51a90c5 --- /dev/null +++ b/server/internal/storage/sqlstore/settings.go @@ -0,0 +1,89 @@ +package sqlstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// SettingsQueries are the four statements the settings store needs. Each +// driver writes them in its own placeholder style and upsert syntax. +type SettingsQueries struct { + // Get binds (key) and selects setting_value. + Get string + + // GetAll selects (setting_key, setting_value). + GetAll string + + // Upsert binds (key, value, updated_at) and overwrites on conflict. + Upsert string + + // Delete binds (key). + Delete string +} + +// Settings is the server_settings key/value store. It is the persistence +// behind the runtime federation and registration switches; the in-memory +// cache lives one layer up, in the settings service. +type Settings struct { + db *sql.DB + q SettingsQueries +} + +// NewSettings builds a settings store. +func NewSettings(db *sql.DB, q SettingsQueries) *Settings { + return &Settings{db: db, q: q} +} + +// Get returns the stored value for key; the boolean reports presence. +func (s *Settings) Get(ctx context.Context, key string) (string, bool, error) { + var value string + err := s.db.QueryRowContext(ctx, s.q.Get, key).Scan(&value) + switch { + case errors.Is(err, sql.ErrNoRows): + return "", false, nil + case err != nil: + return "", false, fmt.Errorf("get setting %q: %w", key, err) + } + return value, true, nil +} + +// GetAll returns every stored setting. +func (s *Settings) GetAll(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, s.q.GetAll) + if err != nil { + return nil, fmt.Errorf("list settings: %w", err) + } + defer rows.Close() + + out := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("scan setting: %w", err) + } + out[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list settings: %w", err) + } + return out, nil +} + +// Set writes key, overwriting any previous value. +func (s *Settings) Set(ctx context.Context, key, value string) error { + if _, err := s.db.ExecContext(ctx, s.q.Upsert, key, value, time.Now().UTC()); err != nil { + return fmt.Errorf("set setting %q: %w", key, err) + } + return nil +} + +// Delete removes key. Removing an absent key is not an error. +func (s *Settings) Delete(ctx context.Context, key string) error { + if _, err := s.db.ExecContext(ctx, s.q.Delete, key); err != nil { + return fmt.Errorf("delete setting %q: %w", key, err) + } + return nil +} diff --git a/server/internal/storage/sqlstore/sqlstore.go b/server/internal/storage/sqlstore/sqlstore.go new file mode 100644 index 0000000..8f9a9d9 --- /dev/null +++ b/server/internal/storage/sqlstore/sqlstore.go @@ -0,0 +1,124 @@ +// Package sqlstore holds the parts of a storage driver that are identical +// for every database/sql backend: the migration plumbing and the +// server_settings table. The sqlite and postgres drivers differ only in +// their DSN handling, placeholder style and upsert syntax, all of which +// they pass in here. +package sqlstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "log/slog" + + "github.com/dariuszjaniak/warp/server/internal/storage" + "github.com/dariuszjaniak/warp/server/internal/storage/migrations" +) + +// Options describe one database/sql backend. +type Options struct { + // DriverName is the Warp driver name, e.g. "sqlite". + DriverName string + + // DB is the already-opened pool. + DB *sql.DB + + // Dialect supplies the migration bookkeeping statements. + Dialect migrations.Dialect + + // MigrationsFS and MigrationsDir locate the embedded .sql files. + MigrationsFS fs.FS + MigrationsDir string + + // Settings holds the statements for the server_settings table. + Settings SettingsQueries + + // Logger is used for migration progress. + Logger *slog.Logger +} + +// Store is the shared implementation of storage.Database. Drivers embed it +// and add their own component stores as those are built. +type Store struct { + opts Options + db *sql.DB + log *slog.Logger + settings *Settings +} + +// New builds a Store. It does not touch the database. +func New(opts Options) (*Store, error) { + if opts.DB == nil { + return nil, errors.New("sqlstore: DB must not be nil") + } + if opts.Logger == nil { + opts.Logger = slog.Default() + } + return &Store{ + opts: opts, + db: opts.DB, + log: opts.Logger, + settings: NewSettings(opts.DB, opts.Settings), + }, nil +} + +// DB exposes the pool for driver-specific code and tests. +func (s *Store) DB() *sql.DB { return s.db } + +// Driver implements storage.Database. +func (s *Store) Driver() string { return s.opts.DriverName } + +// Ping implements storage.Database. +func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } + +// Close implements storage.Database. +func (s *Store) Close() error { return s.db.Close() } + +// Settings implements storage.Database. +func (s *Store) Settings() storage.SettingsStore { return s.settings } + +// Migrate implements storage.Database. +func (s *Store) Migrate(ctx context.Context) error { + ms, err := s.migrations() + if err != nil { + return err + } + applied, err := migrations.Status(ctx, s.db, s.opts.Dialect) + if err != nil { + return err + } + if err := migrations.CheckNotAhead(applied, ms); err != nil { + return err + } + n, err := migrations.Run(ctx, s.db, s.opts.Dialect, ms, s.log) + if err != nil { + return err + } + if n == 0 { + s.log.Debug("database schema is up to date", "migrations", len(ms)) + } + return nil +} + +// PendingMigrations implements storage.Database. +func (s *Store) PendingMigrations(ctx context.Context) ([]string, error) { + ms, err := s.migrations() + if err != nil { + return nil, err + } + pending, err := migrations.Pending(ctx, s.db, s.opts.Dialect, ms) + if err != nil { + return nil, err + } + out := make([]string, 0, len(pending)) + for _, m := range pending { + out = append(out, fmt.Sprintf("%04d_%s", m.Version, m.Name)) + } + return out, nil +} + +func (s *Store) migrations() ([]migrations.Migration, error) { + return migrations.Load(s.opts.MigrationsFS, s.opts.MigrationsDir) +} diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go new file mode 100644 index 0000000..10c719d --- /dev/null +++ b/server/internal/storage/storage.go @@ -0,0 +1,127 @@ +// Package storage defines the interfaces every Warp storage driver +// implements, plus the registry that turns a config into a Database. +// +// Drivers live in sub-packages (sqlite, postgres, pocketbase) and register +// themselves from an init function, the database/sql way. Nothing in this +// package imports a driver, so components can depend on the interfaces +// without pulling in every database engine. +package storage + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "slices" + "sync" + "time" +) + +// Driver names. These are the values accepted by database.driver in the +// config file. +const ( + DriverSQLite = "sqlite" + DriverPostgres = "postgres" + DriverPocketBase = "pocketbase" +) + +// ErrNotImplemented is returned by a driver that is compiled in but not +// finished — currently PocketBase. +var ErrNotImplemented = errors.New("not implemented by this storage driver") + +// ErrNoSuchSetting is returned by SettingsStore.Get for an unknown key. +var ErrNoSuchSetting = errors.New("no such setting") + +// Config is the subset of the file configuration a driver needs. +type Config struct { + Driver string + ConnectionString string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration +} + +// Database is the handle every component gets. Component-specific stores +// (userapi, roomserver, syncapi...) hang off it as they are implemented. +type Database interface { + // Driver reports which driver this is, for logs and admin output. + Driver() string + + // Ping checks the connection is alive; used by /healthz. + Ping(ctx context.Context) error + + // Migrate applies pending migrations. + Migrate(ctx context.Context) error + + // PendingMigrations lists migrations that Migrate would apply. + PendingMigrations(ctx context.Context) ([]string, error) + + // Settings is the server_settings key/value store backing the runtime + // settings service (federation and registration switches). + Settings() SettingsStore + + // Close releases the connection pool. + Close() error +} + +// SettingsStore persists the handful of settings that are mutable at +// runtime through the admin API. +type SettingsStore interface { + // Get returns the raw value for key. The boolean reports presence. + Get(ctx context.Context, key string) (string, bool, error) + + // GetAll returns every setting. + GetAll(ctx context.Context) (map[string]string, error) + + // Set writes key, overwriting any previous value. + Set(ctx context.Context, key, value string) error + + // Delete removes key, reverting it to its configured default. Deleting + // a key that is not there is not an error. + Delete(ctx context.Context, key string) error +} + +// OpenFunc opens a database for a driver. +type OpenFunc func(ctx context.Context, cfg Config, log *slog.Logger) (Database, error) + +var ( + registryMu sync.RWMutex + registry = map[string]OpenFunc{} +) + +// Register makes a driver available to Open. It panics on a duplicate +// registration, which can only be a programming error. +func Register(name string, open OpenFunc) { + registryMu.Lock() + defer registryMu.Unlock() + if _, dup := registry[name]; dup { + panic("storage: driver " + name + " registered twice") + } + registry[name] = open +} + +// Drivers lists the registered driver names, sorted. +func Drivers() []string { + registryMu.RLock() + defer registryMu.RUnlock() + return slices.Sorted(maps.Keys(registry)) +} + +// Open connects using the driver named in cfg. +func Open(ctx context.Context, cfg Config, log *slog.Logger) (Database, error) { + registryMu.RLock() + open, ok := registry[cfg.Driver] + registryMu.RUnlock() + if !ok { + return nil, fmt.Errorf("storage: unknown driver %q (compiled in: %v)", cfg.Driver, Drivers()) + } + if log == nil { + log = slog.Default() + } + db, err := open(ctx, cfg, log.With("driver", cfg.Driver)) + if err != nil { + return nil, fmt.Errorf("storage: open %s: %w", cfg.Driver, err) + } + return db, nil +} diff --git a/server/internal/storage/storage_test.go b/server/internal/storage/storage_test.go new file mode 100644 index 0000000..7ab7334 --- /dev/null +++ b/server/internal/storage/storage_test.go @@ -0,0 +1,149 @@ +package storage_test + +import ( + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/dariuszjaniak/warp/server/internal/storage" + _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" +) + +// openTestDB opens each driver that can run without external services. The +// postgres driver joins in when WARP_TEST_POSTGRES_DSN is set, which is how +// CI runs the same tests against both engines. +func openTestDB(t *testing.T, driver string) storage.Database { + t.Helper() + + cfg := storage.Config{Driver: driver, MaxOpenConns: 4, MaxIdleConns: 2} + switch driver { + case storage.DriverSQLite: + path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db")) + cfg.ConnectionString = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" + case storage.DriverPostgres: + dsn := os.Getenv("WARP_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set WARP_TEST_POSTGRES_DSN to test the postgres driver") + } + cfg.ConnectionString = dsn + default: + t.Fatalf("no test setup for driver %q", driver) + } + + db, err := storage.Open(t.Context(), cfg, nil) + if err != nil { + t.Fatalf("Open(%s): %v", driver, err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// testDrivers are run by every storage test. +var testDrivers = []string{storage.DriverSQLite, storage.DriverPostgres} + +func TestDriversRegistered(t *testing.T) { + got := storage.Drivers() + for _, want := range []string{storage.DriverSQLite, storage.DriverPostgres, storage.DriverPocketBase} { + if !slices.Contains(got, want) { + t.Errorf("driver %q is not registered (have %v)", want, got) + } + } +} + +func TestOpenUnknownDriver(t *testing.T) { + _, err := storage.Open(t.Context(), storage.Config{Driver: "mysql"}, nil) + if err == nil { + t.Fatal("expected an error for an unknown driver") + } +} + +func TestPocketBaseIsNotImplemented(t *testing.T) { + _, err := storage.Open(t.Context(), storage.Config{ + Driver: storage.DriverPocketBase, + ConnectionString: "./pb_data", + }, nil) + if !errors.Is(err, storage.ErrNotImplemented) { + t.Fatalf("expected ErrNotImplemented, got %v", err) + } +} + +func TestMigrateAndSettings(t *testing.T) { + for _, driver := range testDrivers { + t.Run(driver, func(t *testing.T) { + ctx := t.Context() + db := openTestDB(t, driver) + + pending, err := db.PendingMigrations(ctx) + if err != nil { + t.Fatalf("PendingMigrations: %v", err) + } + if len(pending) == 0 { + t.Skip("database already migrated by a previous run") + } + + if err := db.Migrate(ctx); err != nil { + t.Fatalf("Migrate: %v", err) + } + // Migrating twice must be a no-op, not an error: it runs on + // every boot. + if err := db.Migrate(ctx); err != nil { + t.Fatalf("second Migrate: %v", err) + } + pending, err = db.PendingMigrations(ctx) + if err != nil { + t.Fatalf("PendingMigrations: %v", err) + } + if len(pending) != 0 { + t.Errorf("after Migrate, still pending: %v", pending) + } + + settings := db.Settings() + if _, found, err := settings.Get(ctx, "federation.enabled"); err != nil { + t.Fatalf("Get: %v", err) + } else if found { + t.Fatal("expected no stored value on a fresh database") + } + + if err := settings.Set(ctx, "federation.enabled", "true"); err != nil { + t.Fatalf("Set: %v", err) + } + if err := settings.Set(ctx, "federation.enabled", "false"); err != nil { + t.Fatalf("Set (overwrite): %v", err) + } + value, found, err := settings.Get(ctx, "federation.enabled") + if err != nil || !found { + t.Fatalf("Get after Set: value=%q found=%v err=%v", value, found, err) + } + if value != "false" { + t.Errorf("value = %q, want %q (the upsert must overwrite)", value, "false") + } + + all, err := settings.GetAll(ctx) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if all["federation.enabled"] != "false" { + t.Errorf("GetAll = %v", all) + } + + if err := settings.Delete(ctx, "federation.enabled"); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := settings.Delete(ctx, "federation.enabled"); err != nil { + t.Errorf("deleting an absent key must not fail: %v", err) + } + if _, found, _ := settings.Get(ctx, "federation.enabled"); found { + t.Error("value survived Delete") + } + + if err := db.Ping(ctx); err != nil { + t.Errorf("Ping: %v", err) + } + if db.Driver() != driver { + t.Errorf("Driver() = %q, want %q", db.Driver(), driver) + } + }) + } +} diff --git a/server/test/complement/README.md b/server/test/complement/README.md new file mode 100644 index 0000000..9866a24 --- /dev/null +++ b/server/test/complement/README.md @@ -0,0 +1,13 @@ +# Complement + +[Complement](https://github.com/matrix-org/complement) is the official +Matrix integration suite and the project's primary progress metric. + +It is wired up in Phase 1, once registration and login exist — before that +there is nothing for it to test. Two profiles are planned, per PLAN.md +section 9: `federation=off` (Client-Server tests only) and +`federation=on`. + +What will live here: the Dockerfile Complement boots, the image entrypoint +that generates a signing key and a config per test homeserver, and the +list of tests expected to pass. diff --git a/server/test/vectors/README.md b/server/test/vectors/README.md new file mode 100644 index 0000000..561229a --- /dev/null +++ b/server/test/vectors/README.md @@ -0,0 +1,13 @@ +# Test vectors + +Spec examples and vectors used by the event core in Phase 2: canonical +JSON, event hashing, the redaction algorithm, signatures and state +resolution. + +Sources: + +- the Matrix specification's own examples (canonical JSON, signing) +- [gomatrixserverlib](https://github.com/matrix-org/gomatrixserverlib) + test vectors — the data, as a conformance check, not its code + +Each file keeps a note of where it came from. diff --git a/warp.yaml.example b/warp.yaml.example new file mode 100644 index 0000000..a6b661e --- /dev/null +++ b/warp.yaml.example @@ -0,0 +1,94 @@ +# Warp homeserver configuration. +# +# Copy to warp.yaml and edit. Every field below is optional except +# global.server_name; the values shown are the defaults. +# +# Any field can also be set from the environment, which wins over this +# file. The variable name is on the right of each line below, e.g. +# WARP_SERVER_NAME=warp.example WARP_FEDERATION_ENABLED=true warp serve +# List values are comma-separated; a single "-" clears a list. + +version: 1 + +global: + # The Matrix server name. It appears in every user, room and event ID + # and CANNOT be changed after the first event is written. WARP_SERVER_NAME + server_name: warp.example + + # ed25519 signing key, created by `warp generate-keys`. WARP_PRIVATE_KEY + private_key: ./keys/signing.key + + well_known: + # Advertised to clients as m.homeserver.base_url. + # Defaults to https://. WARP_WELL_KNOWN_CLIENT_BASE_URL + client_base_url: "" + + # Optional identity server. WARP_WELL_KNOWN_IDENTITY_SERVER_BASE_URL + identity_server_base_url: "" + + # Advertised to other homeservers as m.server, e.g. "warp.example:8448". + # Only served while federation is enabled; empty means other servers + # fall back to SRV/A resolution. WARP_WELL_KNOWN_SERVER_ADDRESS + server_address: "" + +listen: + address: ":8008" # WARP_LISTEN_ADDRESS + read_header_timeout: 10s # WARP_READ_HEADER_TIMEOUT + read_timeout: 60s # WARP_READ_TIMEOUT + write_timeout: 120s # WARP_WRITE_TIMEOUT + idle_timeout: 120s # WARP_IDLE_TIMEOUT + max_header_bytes: 1048576 # WARP_MAX_HEADER_BYTES + shutdown_timeout: 30s # WARP_SHUTDOWN_TIMEOUT + +database: + # sqlite (dev, single user), postgres (production) or pocketbase + # (appliance; not implemented yet). WARP_DB_DRIVER + driver: sqlite + + # sqlite: file:warp.db?_pragma=journal_mode(WAL) + # postgres: postgres://warp:secret@localhost/warp?sslmode=disable + # WARP_DB_CONNECTION_STRING + connection_string: "file:warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)" + + max_open_conns: 10 # WARP_DB_MAX_OPEN_CONNS (ignored by sqlite: + max_idle_conns: 5 # WARP_DB_MAX_IDLE_CONNS one writer only) + conn_max_lifetime: 1h # WARP_DB_CONN_MAX_LIFETIME + + # Apply pending migrations on startup. Set false to gate schema changes + # behind an explicit `warp migrate`. WARP_DB_AUTO_MIGRATE + auto_migrate: true + +client_api: + # The Matrix spec expects a homeserver to allow any origin. Narrow this + # if only your own web client talks to it. WARP_CORS_ALLOWED_ORIGINS + cors_allowed_origins: ["*"] + +federation: + # Off by default: a new install is a private homeserver until you say + # otherwise. WARP_FEDERATION_ENABLED + enabled: false + + # Non-empty = federate ONLY with these servers. + # WARP_FEDERATION_ALLOWLIST + allowlist: [] + + # Always blocked, even if listed above. WARP_FEDERATION_DENYLIST + denylist: [] + + # Let the admin API flip `enabled` without a restart. When true, the + # stored setting overrides the value above on boot. + # WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE + allow_runtime_toggle: true + +registration: + enabled: false # WARP_REGISTRATION_ENABLED + allow_runtime_toggle: true # WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE + +logging: + level: info # debug, info, warn, error WARP_LOG_LEVEL + format: text # text or json WARP_LOG_FORMAT + add_source: false # WARP_LOG_ADD_SOURCE + +metrics: + enabled: true # WARP_METRICS_ENABLED + path: /metrics # WARP_METRICS_PATH