Phase 9: application services and admin completion
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

Implements PLAN.md Phase 9. internal/appservice reads registration files
(appservice.config_files), matches namespaces the way Synapse does
(anchored at the start), and delivers events to each service as
transactions read from the roomserver's event stream at a cursor kept in
appservice_state. A transaction in flight is recorded, so after a restart
it is resent under the same ID; failures back off from 2s to 5m, and a
successful ping cuts the backoff short.

In the Client-Server API an as_token acts as the service's sender or, with
?user_id=, as any existing user in its namespace. Services register and
log in their users with m.login.application_service; exclusive user and
alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown
aliases and users in a namespace are asked of the service first. The
ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED
and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when
rate_limited is false, skip the rate limiter.

Users can change their password (POST /account/password) and deactivate
their account (POST /account/deactivate), both behind the password.
Deactivation revokes tokens, deletes devices, optionally erases the
profile and leaves every room through a new userapi.AccountObserver.

The admin API is complete: user detail, the admin flag, deactivation and
reactivation, password resets, devices, media quotas
(media.max_user_storage_bytes plus per-user overrides), registration
tokens with use limits and expiry (registration.require_token), server
notices (internal/servernotices, server_notices.*), an editor for the
federation allow/deny lists persisted in server_settings, a delivery view
of the application services, and an audit log of every change.

Migration 0011 adds the registration token, server notice room, media
quota, audit log and appservice cursor tables. Policy lists are now
returned sorted. The Complement entrypoint loads the registrations
Complement mounts in /complement/appservice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-17 12:58:28 +02:00
1 parent b507753172
commit a94794b866
69 files changed
+6922 -124

No files matched your search

+158 -22
View File
@@ -8,21 +8,26 @@
// 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.
// 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"
@@ -41,6 +46,13 @@ type AdminAPI struct {
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.
@@ -60,11 +72,31 @@ type Options struct {
// 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 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.
@@ -83,15 +115,41 @@ func New(opts Options) (*AdminAPI, error) {
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,
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
}
@@ -103,14 +161,39 @@ func (a *AdminAPI) RegisterRoutes(r *httputil.Router) {
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.
@@ -214,6 +297,10 @@ 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.
@@ -224,14 +311,65 @@ func (a *AdminAPI) handleGetFederation(w http.ResponseWriter, _ *http.Request, _
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,
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.
@@ -243,8 +381,7 @@ func (a *AdminAPI) handlePutFederation(w http.ResponseWriter, r *http.Request, a
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)
a.audit(r.Context(), auth, auditFederationSwitch, "", map[string]any{"enabled": enabled})
return a.handleGetFederation(w, r, auth)
}
@@ -266,8 +403,7 @@ func (a *AdminAPI) handlePutRegistration(w http.ResponseWriter, r *http.Request,
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)
a.audit(r.Context(), auth, auditRegistrationSwitch, "", map[string]any{"enabled": enabled})
return a.handleGetRegistration(w, r, auth)
}