Phase 0: bootstrap the Warp homeserver
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Scaffolds the server module and everything Phase 0 of PLAN.md calls for:
a binary that boots, serves its discovery endpoints and nothing else yet.

- config: one YAML file plus per-field WARP_* environment overrides,
  unknown fields rejected, validation that reports every problem at once
- logging and metrics: slog, a Prometheus registry, per-route HTTP
  instrumentation labelled by pattern rather than path
- httputil: the Matrix error envelope, error-returning handlers, CORS,
  request logging, panic recovery, and a router over http.ServeMux
- endpoints: /_matrix/client/versions, /.well-known/matrix/client,
  /.well-known/matrix/server (gated), /healthz, /metrics, and
  M_UNRECOGNIZED for everything else
- storage: driver registry, shared database/sql base, checksummed
  migration runner, sqlite and postgres drivers, pocketbase stub
- settings: server_settings table behind a cached service, so the
  federation and registration switches change without a restart
- federation: the gate that makes federation a runtime mode
- cmd/warp: serve, migrate, generate-keys, version
- Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests,
  race on Linux, storage tests against PostgreSQL, lint, boot smoke test),
  Dockerfile, example config and docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 17:01:13 +02:00
commit b4fb60a8ab
50 files changed
+5672

No files matched your search

+9
View File
@@ -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
+159
View File
@@ -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"
+32
View File
@@ -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
+55
View File
@@ -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"]
+98
View File
@@ -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
+414
View File
@@ -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?
+137
View File
@@ -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 <key-id> <unpadded-base64-seed>`. `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/
```
+14
View File
@@ -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.
+8
View File
@@ -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.
+83
View File
@@ -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 <path>`
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://<server_name>` | 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.
+113
View File
@@ -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.
+7
View File
@@ -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)
+67
View File
@@ -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
+36
View File
@@ -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
)
+108
View File
@@ -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=
+51
View File
@@ -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
}
+311
View File
@@ -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://<server_name>.
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://<server_name> 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
}
+163
View File
@@ -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)
}
}
+105
View File
@@ -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
}
+22
View File
@@ -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
}
+150
View File
@@ -0,0 +1,150 @@
// Package federation holds the server-to-server surface. In Phase 0 that
// is only the gate: the piece that makes federation a runtime mode rather
// than a build-time one.
//
// Every federation route is registered through Gate.Protect, so flipping
// federation.enabled through the admin API takes effect on the next request
// without re-registering routes or restarting the process. The transports
// that the switch also has to start and stop (outbound transaction queue,
// key fetcher, remote media) arrive in Phase 5 as federation.Module.
package federation
import (
"net/http"
"strings"
"sync"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// Gate reports whether federation is currently live. The callback reads the
// settings service, so the answer tracks the runtime switch.
type Gate struct {
enabled func() bool
policy *Policy
}
// NewGate builds a gate. A nil callback means permanently disabled, which
// is the safe default for a half-wired server.
func NewGate(enabled func() bool, policy *Policy) *Gate {
if enabled == nil {
enabled = func() bool { return false }
}
if policy == nil {
policy = NewPolicy(nil, nil)
}
return &Gate{enabled: enabled, policy: policy}
}
// Enabled reports the live state of the switch.
func (g *Gate) Enabled() bool { return g.enabled() }
// Policy returns the allow/deny list filter.
func (g *Gate) Policy() *Policy { return g.policy }
// Protect wraps a federation handler so that it answers M_UNRECOGNIZED
// while federation is off. The spec's answer for an endpoint a server does
// not implement is a 404, and from the outside a federation-disabled Warp
// is exactly that: a server without a federation API.
func (g *Gate) Protect(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !g.enabled() {
httputil.WriteError(w, r, httputil.Unrecognized())
return
}
h.ServeHTTP(w, r)
})
}
// ProtectFunc is Protect for an error-returning handler.
func (g *Gate) ProtectFunc(h httputil.Handler) http.Handler {
return g.Protect(httputil.Wrap(h))
}
// RejectClientRequest is the error a Client-Server endpoint returns when it
// would need federation to answer: joining a remote room, inviting a remote
// user, querying a remote directory. The message is deliberately explicit —
// "M_FORBIDDEN" alone sends operators hunting through power levels.
func RejectClientRequest(action string) error {
return httputil.Forbidden("Federation is disabled on this server; %s is not possible", action)
}
// Policy filters remote servers by name. An empty allowlist means "any
// server not on the denylist"; a non-empty one means "only these".
type Policy struct {
mu sync.RWMutex
allowlist map[string]struct{}
denylist map[string]struct{}
}
// NewPolicy builds a policy from config lists.
func NewPolicy(allowlist, denylist []string) *Policy {
p := &Policy{}
p.Replace(allowlist, denylist)
return p
}
// Replace swaps both lists atomically; the admin API edits them at runtime.
func (p *Policy) Replace(allowlist, denylist []string) {
allow := toSet(allowlist)
deny := toSet(denylist)
p.mu.Lock()
defer p.mu.Unlock()
p.allowlist, p.denylist = allow, deny
}
// Allowed reports whether traffic to or from serverName may flow. The
// denylist always wins.
func (p *Policy) Allowed(serverName string) bool {
name := normalise(serverName)
if name == "" {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
if _, denied := p.denylist[name]; denied {
return false
}
if len(p.allowlist) == 0 {
return true
}
_, allowed := p.allowlist[name]
return allowed
}
// Lists returns copies of the current allow and deny lists, for the admin
// API and the dashboard.
func (p *Policy) Lists() (allowlist, denylist []string) {
p.mu.RLock()
defer p.mu.RUnlock()
return setToSlice(p.allowlist), setToSlice(p.denylist)
}
func toSet(names []string) map[string]struct{} {
out := make(map[string]struct{}, len(names))
for _, n := range names {
if n = normalise(n); n != "" {
out[n] = struct{}{}
}
}
return out
}
func setToSlice(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for name := range set {
out = append(out, name)
}
return out
}
// normalise lower-cases a server name. Matrix server names are
// case-sensitive in theory, but every real deployment uses a DNS name,
// and treating "Example.com" as a different server from "example.com"
// would turn a typo in the allowlist into an open door.
func normalise(serverName string) string {
return strings.ToLower(strings.TrimSpace(serverName))
}
+97
View File
@@ -0,0 +1,97 @@
package federation_test
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/dariuszjaniak/warp/server/internal/federation"
)
func TestGateFollowsTheSwitch(t *testing.T) {
var enabled atomic.Bool
gate := federation.NewGate(enabled.Load, nil)
handler := gate.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_matrix/federation/v1/version", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 while federation is off", rec.Code)
}
enabled.Store(true)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_matrix/federation/v1/version", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 once federation is on", rec.Code)
}
}
func TestNilCallbackMeansDisabled(t *testing.T) {
gate := federation.NewGate(nil, nil)
if gate.Enabled() {
t.Error("a gate without a switch must be closed")
}
}
func TestPolicy(t *testing.T) {
cases := []struct {
name string
allowlist []string
denylist []string
server string
want bool
}{
{"empty lists allow anything", nil, nil, "matrix.org", true},
{"denylist blocks", nil, []string{"evil.example"}, "evil.example", false},
{"denylist is case insensitive", nil, []string{"evil.example"}, "Evil.Example", false},
{"allowlist admits members", []string{"a.example"}, nil, "a.example", true},
{"allowlist excludes everyone else", []string{"a.example"}, nil, "b.example", false},
{"denylist wins over allowlist", []string{"a.example"}, []string{"a.example"}, "a.example", false},
{"empty server name is refused", nil, nil, "", false},
{"whitespace is trimmed", []string{"a.example"}, nil, " a.example ", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := federation.NewPolicy(tc.allowlist, tc.denylist)
if got := p.Allowed(tc.server); got != tc.want {
t.Errorf("Allowed(%q) = %v, want %v", tc.server, got, tc.want)
}
})
}
}
func TestPolicyReplace(t *testing.T) {
p := federation.NewPolicy([]string{"a.example"}, nil)
if !p.Allowed("a.example") {
t.Fatal("a.example should be allowed")
}
p.Replace(nil, []string{"a.example"})
if p.Allowed("a.example") {
t.Error("after Replace, a.example should be denied")
}
allow, deny := p.Lists()
if len(allow) != 0 || len(deny) != 1 || deny[0] != "a.example" {
t.Errorf("Lists() = %v, %v", allow, deny)
}
}
func TestRejectClientRequestExplainsItself(t *testing.T) {
err := federation.RejectClientRequest("joining a room on another server")
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
for _, want := range []string{"M_FORBIDDEN", "Federation is disabled", "joining a room"} {
if !strings.Contains(msg, want) {
t.Errorf("error %q should mention %q", msg, want)
}
}
}
+132
View File
@@ -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")
}
+104
View File
@@ -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)
}
})
}
+179
View File
@@ -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()
})
}
+50
View File
@@ -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)
}
+52
View File
@@ -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
}
+182
View File
@@ -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:<id>" 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 <key-id> <unpadded base64 seed>
//
// 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 <key-id> <base64-seed>\"", 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)
}
+147
View File
@@ -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")
}
}
+111
View File
@@ -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())
})
}
+193
View File
@@ -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)
}
+200
View File
@@ -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()
}
+127
View File
@@ -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
}
+258
View File
@@ -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 <file>` 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)
}
+350
View File
@@ -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)
}
}
@@ -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"
)
@@ -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
}
@@ -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)
}
}
@@ -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)
@@ -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
);
@@ -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
}
@@ -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
);
+136
View File
@@ -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
}
@@ -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
}
@@ -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)
}
+127
View File
@@ -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
}
+149
View File
@@ -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)
}
})
}
}
+13
View File
@@ -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.
+13
View File
@@ -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.
+94
View File
@@ -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://<server_name>. 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