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