// 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 }