Files
Warp/server/internal/adminapi/adminapi.go
T
tajniak81andClaude Opus 5 6004bb01de
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 1: accounts and sessions
Implements PLAN.md Phase 1. A client can now register, log in, manage its
devices and set its profile; an operator can create accounts and flip the
runtime switches. There are still no rooms and no real /sync.

userapi: accounts, argon2id passwords in PHC format, devices, access and
refresh tokens stored as SHA-256 hashes, account data and the admin flag.
Its storage interface is storage.UserStore, implemented once in sqlstore
with `?` placeholders; the postgres driver supplies a rebind function and
unique-violation recognition rather than keeping a second copy of thirty
statements. Timestamps in these tables are int64 milliseconds, the unit the
Client-Server API uses on the wire.

Client-Server API: register (user-interactive auth with m.login.dummy and
optional registration tokens), register/available, token validity, login
with m.login.password, logout, logout/all, refresh, account/whoami,
capabilities, devices/* with password-gated deletion, profile/*, global and
per-room account data.

GET /sync is a deliberate placeholder: it answers a valid token with an
empty but well-formed response and refuses a revoked one. Phase 1 has no
rooms, so that is the whole truth, and it is what makes session revocation
observable. Phase 4 replaces it with the syncapi.

Auth middleware resolves a bearer token (header or the legacy query
parameter) to a device and user, answering an expired token with the soft
logout the spec asks for. Rate limiting is a token bucket per endpoint class
keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms.

adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix
token, GET/PUT /federation and /registration driving internal/settings and
reporting a config-pinned switch as a 409, GET/POST /users, GET /stats.

warp create-account [--admin] creates the first account straight in the
database, so a new install with registration off has a way in.

Complement is wired up for the federation=off profile: an image meeting its
contract, an entrypoint that configures Warp entirely from WARP_*
variables, and a runner that builds, clones and executes the target set
(TestRegistration, TestLogin, TestDeviceManagement). It has not been run
yet -- there is no container runtime on the development machine -- so the
three tests' assertions were read from the Complement sources and pinned as
httptest tests instead, and CI gained a job that runs the real thing.

Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern,
meant for the built binary, also matched the directory server/cmd/warp and
silently excluded the entire command package from the repository. The
binary patterns are now anchored to the repository root, and cmd/warp is
committed for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 18:18:37 +02:00

317 lines
11 KiB
Go

// Package adminapi serves /_warp/admin/v1: the API behind the admin panel.
//
// It is deliberately not part of the Matrix spec. Everything a client needs
// goes through the Client-Server API; this is the operator's surface, and
// the only thing in Warp that is ours rather than Matrix's.
//
// Authentication is a normal Matrix access token belonging to an account
// with the admin flag, so the panel logs in the way any client does and
// there is no second credential to manage.
//
// Phase 1 is the skeleton PLAN.md section 7 asks for: the two runtime
// switches, user listing and creation, and the dashboard counters. Full user
// management, registration tokens, the federation allow/deny list editor and
// the audit log arrive in Phase 9.
package adminapi
import (
"errors"
"log/slog"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Prefix is the path every admin endpoint lives under.
const Prefix = "/_warp/admin/v1"
// AdminAPI serves the admin endpoints.
type AdminAPI struct {
serverName string
users *userapi.API
settings *settings.Service
federation *federation.Gate
database storage.Database
log *slog.Logger
}
// Options configure the component.
type Options struct {
ServerName string
Users *userapi.API
Settings *settings.Service
Federation *federation.Gate
// Database is used for the driver name the dashboard shows and for the
// migration state; the admin API does not write to it directly.
Database storage.Database
Logger *slog.Logger
}
// New builds the component.
func New(opts Options) (*AdminAPI, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("adminapi: ServerName must not be empty")
case opts.Users == nil:
return nil, errors.New("adminapi: Users must not be nil")
case opts.Settings == nil:
return nil, errors.New("adminapi: Settings must not be nil")
}
if opts.Federation == nil {
opts.Federation = federation.NewGate(nil, nil)
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
return &AdminAPI{
serverName: opts.ServerName,
users: opts.Users,
settings: opts.Settings,
federation: opts.Federation,
database: opts.Database,
log: opts.Logger,
}, nil
}
// RegisterRoutes adds the admin endpoints to the router.
func (a *AdminAPI) RegisterRoutes(r *httputil.Router) {
a.handle(r, "GET "+Prefix+"/federation", a.handleGetFederation)
a.handle(r, "PUT "+Prefix+"/federation", a.handlePutFederation)
a.handle(r, "GET "+Prefix+"/registration", a.handleGetRegistration)
a.handle(r, "PUT "+Prefix+"/registration", a.handlePutRegistration)
a.handle(r, "GET "+Prefix+"/users", a.handleGetUsers)
a.handle(r, "POST "+Prefix+"/users", a.handlePostUsers)
a.handle(r, "GET "+Prefix+"/stats", a.handleGetStats)
}
// adminHandler is a handler that has already been shown to be an admin.
type adminHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
// handle registers an admin-only endpoint.
//
// Admin endpoints are not rate limited. They are reachable only with an
// admin token, and an operator bulk-loading users through the panel is
// exactly the traffic a limiter would get in the way of.
func (a *AdminAPI) handle(r *httputil.Router, pattern string, h adminHandler) {
r.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) error {
auth, err := a.requireAdmin(req)
if err != nil {
return err
}
return h(w, req, auth)
})
}
// requireAdmin resolves the access token and insists on the admin flag.
//
// A non-admin token gets M_FORBIDDEN rather than a 404: the caller is a
// legitimate user of this server who is not allowed here, and pretending
// the endpoint does not exist would only make the panel harder to debug.
func (a *AdminAPI) requireAdmin(r *http.Request) (userapi.Authenticated, error) {
token := accessTokenFrom(r)
if token == "" {
return userapi.Authenticated{}, httputil.MissingToken()
}
auth, err := a.users.Authenticate(r.Context(), token)
switch {
case err == nil:
case errors.Is(err, userapi.ErrNoSuchToken):
return userapi.Authenticated{}, httputil.UnknownToken(false)
case errors.Is(err, userapi.ErrTokenExpired):
return userapi.Authenticated{}, httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return userapi.Authenticated{}, httputil.UserDeactivated()
default:
return userapi.Authenticated{}, err
}
if !auth.Admin {
return userapi.Authenticated{}, httputil.Forbidden("This endpoint requires a server administrator")
}
return auth, nil
}
// accessTokenFrom reads the bearer token. The admin panel is a normal
// Matrix client, so it sends the header form; the query parameter is not
// accepted here, because an admin token in a URL ends up in access logs.
func accessTokenFrom(r *http.Request) string {
header := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(header) <= len(prefix) || !equalFoldASCII(header[:len(prefix)], prefix) {
return ""
}
return header[len(prefix):]
}
// equalFoldASCII compares two ASCII strings case-insensitively. The scheme
// of an Authorization header is case-insensitive per RFC 9110.
func equalFoldASCII(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := range len(a) {
x, y := a[i], b[i]
if 'A' <= x && x <= 'Z' {
x += 'a' - 'A'
}
if 'A' <= y && y <= 'Z' {
y += 'a' - 'A'
}
if x != y {
return false
}
}
return true
}
// switchResponse is the shape both runtime switches report.
type switchResponse struct {
Enabled bool `json:"enabled"`
// Toggleable is false when the config file pinned the setting, in
// which case the panel shows the switch greyed out rather than letting
// an operator flip something that will bounce.
Toggleable bool `json:"toggleable"`
}
// switchRequest is the body of either PUT.
type switchRequest struct {
Enabled *bool `json:"enabled"`
}
// federationResponse adds the allow and deny lists to the switch.
type federationResponse struct {
switchResponse
Allowlist []string `json:"allowlist"`
Denylist []string `json:"denylist"`
}
// handleGetFederation implements GET /_warp/admin/v1/federation.
func (a *AdminAPI) handleGetFederation(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
allowlist, denylist := a.federation.Policy().Lists()
httputil.WriteJSON(w, http.StatusOK, federationResponse{
switchResponse: switchResponse{
Enabled: a.settings.FederationEnabled(),
Toggleable: a.settings.FederationToggleable(),
},
// Editing these is Phase 9; they are reported now because the
// dashboard has to show which servers a running Warp will talk to.
Allowlist: allowlist,
Denylist: denylist,
})
return nil
}
// handlePutFederation implements PUT /_warp/admin/v1/federation, the switch
// PLAN.md section 3 describes: no restart, the gate picks it up on the next
// request.
func (a *AdminAPI) handlePutFederation(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
enabled, err := decodeSwitch(w, r)
if err != nil {
return err
}
if err := a.settings.SetFederationEnabled(r.Context(), enabled); err != nil {
return toggleError(err, "federation")
}
a.log.Info("federation switch changed through the admin API",
"enabled", enabled, "by", auth.UserID)
return a.handleGetFederation(w, r, auth)
}
// handleGetRegistration implements GET /_warp/admin/v1/registration.
func (a *AdminAPI) handleGetRegistration(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, switchResponse{
Enabled: a.settings.RegistrationEnabled(),
Toggleable: a.settings.RegistrationToggleable(),
})
return nil
}
// handlePutRegistration implements PUT /_warp/admin/v1/registration.
func (a *AdminAPI) handlePutRegistration(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
enabled, err := decodeSwitch(w, r)
if err != nil {
return err
}
if err := a.settings.SetRegistrationEnabled(r.Context(), enabled); err != nil {
return toggleError(err, "registration")
}
a.log.Info("registration switch changed through the admin API",
"enabled", enabled, "by", auth.UserID)
return a.handleGetRegistration(w, r, auth)
}
// decodeSwitch reads {"enabled": bool}. The field is a pointer so that a
// body without it is a missing parameter rather than a silent "false".
func decodeSwitch(w http.ResponseWriter, r *http.Request) (bool, error) {
var req switchRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return false, err
}
if req.Enabled == nil {
return false, httputil.MissingParam("enabled is required")
}
return *req.Enabled, nil
}
// toggleError turns a refused toggle into the right answer: a setting the
// config file pinned is a 409, because the request was well formed and the
// server's configuration is what stands in the way.
func toggleError(err error, name string) error {
if errors.Is(err, settings.ErrNotToggleable) {
return httputil.NewError(http.StatusConflict, httputil.ErrCodeForbidden,
"The %s switch is pinned by the configuration file (%s.allow_runtime_toggle is false)",
name, name)
}
return err
}
// statsResponse is the dashboard payload: PLAN.md section 8.1 lists users,
// rooms, events, federation status, storage driver and version as the first
// thing the panel shows. The room and event counters join it in Phase 3,
// when there is something to count.
type statsResponse struct {
ServerName string `json:"server_name"`
Version string `json:"version"`
Database string `json:"database"`
Users int `json:"users"`
Admins int `json:"admins"`
Deactivated int `json:"deactivated"`
Devices int `json:"devices"`
FederationEnabled bool `json:"federation_enabled"`
RegistrationEnabled bool `json:"registration_enabled"`
}
// handleGetStats implements GET /_warp/admin/v1/stats.
func (a *AdminAPI) handleGetStats(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
stats, err := a.users.Stats(r.Context())
if err != nil {
return err
}
resp := statsResponse{
ServerName: a.serverName,
Version: buildinfo.Version,
Users: stats.Users,
Admins: stats.Admins,
Deactivated: stats.Deactivated,
Devices: stats.Devices,
FederationEnabled: a.settings.FederationEnabled(),
RegistrationEnabled: a.settings.RegistrationEnabled(),
}
if a.database != nil {
resp.Database = a.database.Driver()
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}