Files
Warp/server/internal/adminapi/adminapi.go
T
tajniak81andClaude Opus 5.5 6aaf554336
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 5: federation
Implements PLAN.md Phase 5: the Server-Server API. Warp joins rooms on
other servers and lets their users join its rooms, invites across servers
with the invitee's server countersigning, rejects remote invites (locally
if the inviter is unreachable), and exchanges events, typing, receipts and
to-device messages with other homeservers — all behind the runtime switch,
which now starts and stops real outbound traffic.

The protocol lives in internal/federation with no knowledge of rooms:
X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping
the dial address and the TLS name apart, a signed client that refuses
every request while the switch is off or the policy denies the
destination, and a key ring that caches other servers' keys for at most a
week, with optional notaries.

The roomserver takes events from elsewhere through the spec's intake:
signatures and hashes (a hash mismatch keeps the redacted form), the rules
against the event's own auth events, against the state before it — with
/get_missing_events for small gaps and /state_ids for wide ones — and
against the current state, where a failure is a soft failure. Events held
without their state are outliers: a column on roomserver_events, because
the current state of a room joined over federation is almost all
outliers, and excluded from every timeline query. Forks are resolved at
the room's head. Membership across servers is make_join/send_join,
make_leave/send_leave and countersigned invites, in both directions;
m.room.server_acl is enforced on everything another server sends.
History before a remote join is backfilled when /messages pages past it,
stored at negative stream positions.

internal/federationapi is the running component: the inbound endpoints,
and an outbound queue that persists what it owes each destination, sends
it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to
six hours, survives restarts, and drops events persisted while federation
is off rather than delivering a backlog in one burst. Its Module is what
the switch starts and stops.

Also: a TLS listener for port 8448, trusted key servers, key validity and
an outbound CA bundle in the config; per-destination health and backoff
reset in the admin API; remote profiles, aliases and directories in the
Client-Server API; a Complement federation profile. See
docs/federation.md, including its known gaps (no partial-state joins, no
key rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:20 +02:00

352 lines
12 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/roomserver"
"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
rooms *roomserver.API
settings *settings.Service
federation *federation.Gate
database storage.Database
fedStatus FederationStatus
log *slog.Logger
}
// Options configure the component.
type Options struct {
ServerName string
Users *userapi.API
// Rooms is the roomserver component. A server that started without a
// signing key has none, and the room endpoints then report that rather
// than failing on the first call.
Rooms *roomserver.API
Settings *settings.Service
Federation *federation.Gate
// FederationStatus reports on the federation module. Nil on a server
// without one, and the destinations endpoints then say so.
FederationStatus FederationStatus
// 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,
rooms: opts.Rooms,
settings: opts.Settings,
federation: opts.Federation,
database: opts.Database,
fedStatus: opts.FederationStatus,
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+"/federation/destinations", a.handleGetDestinations)
a.handle(r, "POST "+Prefix+"/federation/destinations/{serverName}/reset", a.handleResetDestination)
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)
a.handle(r, "GET "+Prefix+"/rooms", a.handleGetRooms)
a.handle(r, "GET "+Prefix+"/rooms/{roomID}", a.handleGetRoom)
a.handle(r, "DELETE "+Prefix+"/rooms/{roomID}", a.handleDeleteRoom)
a.handle(r, "POST "+Prefix+"/rooms/{roomID}/force_leave", a.handleForceLeave)
}
// 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"`
Rooms int `json:"rooms"`
PublishedRooms int `json:"published_rooms"`
Events int `json:"events"`
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()
}
if a.rooms != nil {
roomStats, err := a.rooms.Stats(r.Context())
if err != nil {
return err
}
resp.Rooms = roomStats.Rooms
resp.PublishedRooms = roomStats.Published
resp.Events = roomStats.Events
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}