// 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 built the skeleton PLAN.md section 7 asks for: the two runtime // switches, user listing and creation, and the dashboard counters. Phase 9 // completed it: full user management, registration tokens, server notices, // media quotas, the federation allow/deny list editor, the application // service status view, and an audit log of every change made through it. package adminapi import ( "errors" "log/slog" "net/http" "time" "github.com/dariuszjaniak/warp/server/internal/appservice" "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/mediaapi" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/servernotices" "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 now func() time.Time media *mediaapi.API appservices *appservice.API notices *servernotices.API tokens *userapi.RegistrationTokens adminStore storage.AdminStore } // 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 its // admin store for the audit log; nothing else here writes to it // directly. Database storage.Database // Media is the media repository, for the quota endpoints. Nil leaves // them answering 404. Media *mediaapi.API // AppServices is the application service component, for the status // view. Nil means none are registered. AppServices *appservice.API // Notices sends server notices. Nil means server notices are not // configured, and the endpoint says so. Notices *servernotices.API // RegistrationTokens manages the tokens made through the admin API. // Nil builds one on the Database's admin store. RegistrationTokens *userapi.RegistrationTokens Logger *slog.Logger // Now is the clock, for the audit log. Now func() time.Time } // 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() } if opts.Now == nil { opts.Now = time.Now } var adminStore storage.AdminStore if opts.Database != nil { adminStore = opts.Database.Admin() } if opts.RegistrationTokens == nil { now := opts.Now opts.RegistrationTokens = userapi.NewRegistrationTokens(userapi.RegistrationTokensOptions{ Store: adminStore, NowMS: func() int64 { return now().UnixMilli() }, }) } if opts.AppServices == nil { var err error if opts.AppServices, err = appservice.New(appservice.Options{Logger: opts.Logger}); err != nil { return nil, err } } 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, now: opts.Now, media: opts.Media, appservices: opts.AppServices, notices: opts.Notices, tokens: opts.RegistrationTokens, adminStore: adminStore, }, 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, "PUT "+Prefix+"/federation/lists", a.handlePutFederationLists) a.handle(r, "DELETE "+Prefix+"/federation/lists", a.handleDeleteFederationLists) a.handle(r, "GET "+Prefix+"/users", a.handleGetUsers) a.handle(r, "POST "+Prefix+"/users", a.handlePostUsers) a.handle(r, "GET "+Prefix+"/stats", a.handleGetStats) // One user. {userID} is a full user ID, escaped in the path. a.handle(r, "GET "+Prefix+"/users/{userID}", a.handleGetUser) a.handle(r, "PUT "+Prefix+"/users/{userID}/admin", a.handlePutUserAdmin) a.handle(r, "POST "+Prefix+"/users/{userID}/deactivate", a.handleDeactivateUser) a.handle(r, "POST "+Prefix+"/users/{userID}/reactivate", a.handleReactivateUser) a.handle(r, "POST "+Prefix+"/users/{userID}/password", a.handleResetPassword) a.handle(r, "GET "+Prefix+"/users/{userID}/devices", a.handleGetUserDevices) a.handle(r, "DELETE "+Prefix+"/users/{userID}/devices", a.handleDeleteUserDevices) a.handle(r, "DELETE "+Prefix+"/users/{userID}/devices/{deviceID}", a.handleDeleteUserDevice) a.handle(r, "GET "+Prefix+"/users/{userID}/media", a.handleGetUserMedia) a.handle(r, "PUT "+Prefix+"/users/{userID}/media/quota", a.handlePutMediaQuota) a.handle(r, "DELETE "+Prefix+"/users/{userID}/media/quota", a.handleDeleteMediaQuota) 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) a.handle(r, "GET "+Prefix+"/registration_tokens", a.handleGetRegistrationTokens) a.handle(r, "POST "+Prefix+"/registration_tokens", a.handlePostRegistrationToken) a.handle(r, "GET "+Prefix+"/registration_tokens/{token}", a.handleGetRegistrationToken) a.handle(r, "PUT "+Prefix+"/registration_tokens/{token}", a.handlePutRegistrationToken) a.handle(r, "DELETE "+Prefix+"/registration_tokens/{token}", a.handleDeleteRegistrationToken) a.handle(r, "POST "+Prefix+"/server_notices", a.handlePostServerNotice) a.handle(r, "GET "+Prefix+"/appservices", a.handleGetAppServices) a.handle(r, "GET "+Prefix+"/audit_log", a.handleGetAuditLog) } // 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"` // ListsEdited is true when the lists were set through the admin API // and replace the config file's. ListsEdited bool `json:"lists_edited"` } // 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(), }, Allowlist: allowlist, Denylist: denylist, ListsEdited: a.settings.FederationLists().Edited, }) return nil } // federationListsRequest is the PUT /federation/lists body. type federationListsRequest struct { Allowlist *[]string `json:"allowlist"` Denylist *[]string `json:"denylist"` } // handlePutFederationLists implements PUT /_warp/admin/v1/federation/lists. // // Both lists are replaced together, and both have to be in the body: a // panel that sent one and had the other silently kept would be one bug // away from leaving a server it meant to cut off on the allowlist. They // take effect at once, on the next request in either direction, and are // kept across restarts in place of the config file's. func (a *AdminAPI) handlePutFederationLists(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { var req federationListsRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if req.Allowlist == nil || req.Denylist == nil { return httputil.MissingParam("allowlist and denylist are both required (send [] for an empty list)") } for _, list := range [][]string{*req.Allowlist, *req.Denylist} { for _, name := range list { if _, err := federation.ParseServerName(name); err != nil { return httputil.InvalidParam("%q is not a server name", name) } } } if err := a.settings.SetFederationLists(r.Context(), *req.Allowlist, *req.Denylist); err != nil { return err } lists := a.settings.FederationLists() a.federation.Policy().Replace(lists.Allowlist, lists.Denylist) a.audit(r.Context(), auth, auditFederationLists, "", map[string]any{ "allowlist": lists.Allowlist, "denylist": lists.Denylist, }) return a.handleGetFederation(w, r, auth) } // handleDeleteFederationLists implements DELETE // /_warp/admin/v1/federation/lists: forget the edited lists and go back to // the config file's. func (a *AdminAPI) handleDeleteFederationLists(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { lists, err := a.settings.ResetFederationLists(r.Context()) if err != nil { return err } a.federation.Policy().Replace(lists.Allowlist, lists.Denylist) a.audit(r.Context(), auth, auditFederationReset, "", nil) return a.handleGetFederation(w, r, auth) } // 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.audit(r.Context(), auth, auditFederationSwitch, "", map[string]any{"enabled": enabled}) 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.audit(r.Context(), auth, auditRegistrationSwitch, "", map[string]any{"enabled": enabled}) 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 }