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

+72
View File
@@ -0,0 +1,72 @@
package setup
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/dariuszjaniak/warp/server/internal/appservice"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// loadAppServices reads the registration files the config names.
func loadAppServices(cfg *config.Config) (*appservice.Registry, error) {
regs := make([]*appservice.Registration, 0, len(cfg.AppService.ConfigFiles))
for _, path := range cfg.AppService.ConfigFiles {
reg, err := appservice.LoadRegistration(path, cfg.Global.ServerName)
if err != nil {
return nil, err
}
regs = append(regs, reg)
}
return appservice.NewRegistry(cfg.Global.ServerName, regs)
}
// ensureAppServiceSenders creates the sender user of every application
// service that does not have one yet. A service acts as its sender
// whenever it does not ask to act as somebody else, so the account has to
// exist before the first request; it has no password, because nobody logs
// in as it.
func ensureAppServiceSenders(ctx context.Context, users *userapi.API, registry *appservice.Registry, log *slog.Logger) error {
for _, svc := range registry.Services() {
_, err := users.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: svc.SenderLocalpart})
switch {
case err == nil:
log.Info("created the sender of an application service", "appservice", svc.ID, "user_id", svc.SenderUserID())
case errors.Is(err, userapi.ErrUserExists):
default:
return fmt.Errorf("create the sender of application service %q: %w", svc.ID, err)
}
}
return nil
}
// roomLeaver is the roomserver's side of a deactivation: the user leaves
// every room they are in, rejects every invite and withdraws every knock.
// A room that refuses is logged and skipped; the account is already
// deactivated, and one stuck room must not keep the user in all the
// others.
type roomLeaver struct {
rooms *roomserver.API
log *slog.Logger
}
// AccountDeactivated implements userapi.AccountObserver.
func (l roomLeaver) AccountDeactivated(ctx context.Context, localpart string) {
userID := "@" + localpart + ":" + l.rooms.ServerName()
memberships, err := l.rooms.UserMemberships(ctx, userID,
[]string{event.MembershipJoin, event.MembershipInvite, event.MembershipKnock})
if err != nil {
l.log.Error("could not list the rooms of a deactivated user", "user_id", userID, "error", err)
return
}
for _, m := range memberships {
if err := l.rooms.LeaveRoom(ctx, m.RoomID, userID, ""); err != nil {
l.log.Warn("a deactivated user could not leave a room", "user_id", userID, "room_id", m.RoomID, "error", err)
}
}
}
+711
View File
@@ -0,0 +1,711 @@
package setup_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/setup"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// The Phase 9 tests: application services, account management and the
// completed admin API, through the whole stack.
// adminToken creates an admin account and returns a token for it.
func adminToken(t *testing.T, srv *setup.Server, localpart string) string {
t.Helper()
account, err := srv.Users().CreateAccount(t.Context(), userapi.NewAccountRequest{
Localpart: localpart, Password: "sUp3rs3kr1t", Admin: true,
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
session, err := srv.Users().CreateSession(t.Context(), userapi.SessionRequest{Localpart: account.Localpart})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return session.AccessToken
}
// errcode reads the Matrix error code of a response.
func errcode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
code, _ := decode[map[string]any](t, rec)["errcode"].(string)
return code
}
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code, what string) {
t.Helper()
if rec.Code != status || errcode(t, rec) != code {
t.Errorf("%s: %d %s, want %d %s", what, rec.Code, rec.Body.String(), status, code)
}
}
func adminPath(parts ...string) string {
return "/_warp/admin/v1/" + strings.Join(parts, "/")
}
// fakeBridge is an application service: it records the transactions it is
// sent, and answers user and alias queries by creating what was asked for,
// through the homeserver's own API, the way a bridge does.
type fakeBridge struct {
t *testing.T
srv *setup.Server
asToken string
mu sync.Mutex
txnIDs []string
events []map[string]any
pings int
}
func (b *fakeBridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer hs-bridge" {
http.Error(w, `{"errcode":"M_FORBIDDEN"}`, http.StatusForbidden)
return
}
switch {
case strings.HasPrefix(r.URL.Path, "/_matrix/app/v1/transactions/"):
var body struct {
Events []map[string]any `json:"events"`
}
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
b.mu.Lock()
b.txnIDs = append(b.txnIDs, strings.TrimPrefix(r.URL.Path, "/_matrix/app/v1/transactions/"))
b.events = append(b.events, body.Events...)
b.mu.Unlock()
_, _ = w.Write([]byte(`{}`))
case strings.HasPrefix(r.URL.Path, "/_matrix/app/v1/users/"):
userID, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/_matrix/app/v1/users/"))
localpart := strings.TrimPrefix(strings.Split(userID, ":")[0], "@")
rec := post(b.t, b.srv, "/_matrix/client/v3/register", map[string]any{
"type": "m.login.application_service", "username": localpart, "inhibit_login": true,
}, b.asToken)
if rec.Code != http.StatusOK {
http.Error(w, rec.Body.String(), http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(`{}`))
case strings.HasPrefix(r.URL.Path, "/_matrix/app/v1/rooms/"):
alias, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/_matrix/app/v1/rooms/"))
localpart := strings.TrimPrefix(strings.Split(alias, ":")[0], "#")
rec := post(b.t, b.srv, "/_matrix/client/v3/createRoom", map[string]any{
"preset": "public_chat", "room_alias_name": localpart,
}, b.asToken)
if rec.Code != http.StatusOK {
http.Error(w, rec.Body.String(), http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(`{}`))
case r.URL.Path == "/_matrix/app/v1/ping":
b.mu.Lock()
b.pings++
b.mu.Unlock()
_, _ = w.Write([]byte(`{}`))
default:
http.NotFound(w, r)
}
}
// eventIDs lists the event IDs the bridge has been sent, of one type.
func (b *fakeBridge) eventIDs(eventType string) []string {
b.mu.Lock()
defer b.mu.Unlock()
var out []string
for _, e := range b.events {
if e["type"] == eventType {
id, _ := e["event_id"].(string)
out = append(out, id)
}
}
return out
}
const bridgeRegistration = `
id: bridge
url: %s
as_token: as-bridge
hs_token: hs-bridge
sender_localpart: bridgebot
rate_limited: false
namespaces:
users:
- exclusive: true
regex: '@_bridge_.*:warp\.example'
aliases:
- exclusive: true
regex: '#_bridge_.*:warp\.example'
`
// newBridgedServer starts a server with the fake bridge registered.
func newBridgedServer(t *testing.T, tweak func(*config.Config)) (*setup.Server, *fakeBridge) {
t.Helper()
bridge := &fakeBridge{t: t, asToken: "as-bridge"}
hs := httptest.NewServer(bridge)
t.Cleanup(hs.Close)
regPath := filepath.Join(t.TempDir(), "bridge.yaml")
if err := os.WriteFile(regPath, []byte(strings.Replace(bridgeRegistration, "%s", hs.URL, 1)), 0o600); err != nil {
t.Fatal(err)
}
srv := newSigningServer(t, func(c *config.Config) {
c.AppService.ConfigFiles = []string{regPath}
c.AppService.Timeout = 5 * time.Second
if tweak != nil {
tweak(c)
}
})
bridge.srv = srv
return srv, bridge
}
func waitForAppServices(t *testing.T, srv *setup.Server) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
if err := srv.AppServices().WaitIdle(ctx); err != nil {
t.Fatal(err)
}
}
func TestAppServicesThroughTheAPI(t *testing.T) {
srv, bridge := newBridgedServer(t, nil)
const as = "as-bridge"
// The sender exists from the start, and is who the token is.
who := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", as), "whoami as the sender")
if who["user_id"] != "@bridgebot:warp.example" {
t.Errorf("whoami = %v", who)
}
// Registering a puppet: the service's token, no password, no flow.
reg := mustOK(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
"type": "m.login.application_service", "username": "_bridge_alice", "inhibit_login": true,
}, as), "register a puppet")
if reg["user_id"] != "@_bridge_alice:warp.example" || reg["access_token"] != nil {
t.Errorf("register = %v", reg)
}
expectError(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
"type": "m.login.application_service", "username": "carol",
}, as), http.StatusBadRequest, "M_EXCLUSIVE", "register outside the namespace")
expectError(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
"type": "m.login.application_service", "username": "_bridge_x",
}, "nope"), http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "register with a bad token")
// Nobody else may have a name in the exclusive namespace.
expectError(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
"username": "_bridge_bob", "password": "sUp3rs3kr1t",
}, ""), http.StatusBadRequest, "M_EXCLUSIVE", "a user registering in the namespace")
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/register/available?username=_bridge_bob", ""),
http.StatusBadRequest, "M_EXCLUSIVE", "register/available in the namespace")
// Acting as a puppet.
asAlice := "?user_id=" + url.QueryEscape("@_bridge_alice:warp.example")
who = mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami"+asAlice, as), "whoami as a puppet")
if who["user_id"] != "@_bridge_alice:warp.example" {
t.Errorf("whoami = %v", who)
}
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami?user_id=%40carol%3Awarp.example", as),
http.StatusForbidden, "M_FORBIDDEN", "acting as a user outside the namespace")
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami?user_id=%40_bridge_nobody%3Awarp.example", as),
http.StatusForbidden, "M_FORBIDDEN", "acting as a puppet that does not exist")
// A real user talks to the puppet; the bridge hears about it.
alice := registerThrough(t, srv, "alice")
roomID := createPublicRoom(t, srv, alice)
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/invite",
map[string]any{"user_id": "@_bridge_alice:warp.example"}, alice), "invite the puppet")
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join"+asAlice,
map[string]any{}, as), "the puppet joins")
msgID := sendText(t, srv, alice, roomID, "t1", map[string]any{"msgtype": "m.text", "body": "hello bridge"})
// A room the bridge has nothing to do with stays private.
otherRoom := createPublicRoom(t, srv, alice)
privateID := sendText(t, srv, alice, otherRoom, "t2", map[string]any{"msgtype": "m.text", "body": "not for you"})
waitForAppServices(t, srv)
messages := bridge.eventIDs("m.room.message")
if !contains(messages, msgID) || contains(messages, privateID) {
t.Errorf("the bridge was sent %v; want %s and not %s", messages, msgID, privateID)
}
// Inviting a puppet that does not exist yet asks the bridge, which
// registers it.
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/invite",
map[string]any{"user_id": "@_bridge_carol:warp.example"}, alice), "invite a puppet on demand")
if ok, _ := srv.Users().AccountExists(t.Context(), "_bridge_carol"); !ok {
t.Error("the bridge was not asked to create the puppet")
}
// Aliases: reserved for the bridge, and created by it on demand.
expectError(t, put(t, srv, "/_matrix/client/v3/directory/room/"+url.PathEscape("#_bridge_mine:warp.example"),
map[string]any{"room_id": roomID}, alice), http.StatusBadRequest, "M_EXCLUSIVE", "an alias in the namespace")
expectError(t, post(t, srv, "/_matrix/client/v3/createRoom", map[string]any{"room_alias_name": "_bridge_x"}, alice),
http.StatusBadRequest, "M_EXCLUSIVE", "createRoom with an alias in the namespace")
resolved := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/directory/room/"+url.PathEscape("#_bridge_portal:warp.example"), ""),
"resolve a portal alias")
if id, _ := resolved["room_id"].(string); !strings.HasPrefix(id, "!") {
t.Errorf("resolved = %v", resolved)
}
// Logging in as a puppet, with the service's token as the credential.
login := mustOK(t, post(t, srv, "/_matrix/client/v3/login", map[string]any{
"type": "m.login.application_service",
"identifier": map[string]any{"type": "m.id.user", "user": "_bridge_alice"},
}, as), "application service login")
if login["user_id"] != "@_bridge_alice:warp.example" || login["access_token"] == nil {
t.Errorf("login = %v", login)
}
expectError(t, post(t, srv, "/_matrix/client/v3/login", map[string]any{
"type": "m.login.application_service",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
}, as), http.StatusForbidden, "M_FORBIDDEN", "application service login outside the namespace")
flows := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/login", ""), "login flows")
if !strings.Contains(mustJSON(t, flows), "m.login.application_service") {
t.Errorf("login flows = %v", flows)
}
// The ping.
ping := mustOK(t, post(t, srv, "/_matrix/client/v1/appservice/bridge/ping",
map[string]any{"transaction_id": "p1"}, as), "ping")
if _, ok := ping["duration_ms"]; !ok {
t.Errorf("ping = %v", ping)
}
expectError(t, post(t, srv, "/_matrix/client/v1/appservice/bridge/ping", map[string]any{}, alice),
http.StatusForbidden, "M_FORBIDDEN", "a user asking for the ping")
// The admin view.
root := adminToken(t, srv, "root")
list := mustOK(t, getWithToken(t, srv, adminPath("appservices"), root), "GET /appservices")
services, _ := list["appservices"].([]any)
if len(services) != 1 {
t.Fatalf("appservices = %v", list)
}
svc := services[0].(map[string]any)
delivery, _ := svc["delivery"].(map[string]any)
if svc["id"] != "bridge" || svc["sender"] != "@bridgebot:warp.example" || delivery == nil ||
delivery["failures"] != float64(0) || strings.Contains(mustJSON(t, list), "as-bridge") {
t.Errorf("appservice = %v", svc)
}
}
func mustJSON(t *testing.T, v any) string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// passwordAuth answers a UIA challenge with a password stage.
func passwordAuth(t *testing.T, rec *httptest.ResponseRecorder, user, password string) map[string]any {
t.Helper()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("want a UIA challenge, got %d %s", rec.Code, rec.Body.String())
}
return map[string]any{
"type": "m.login.password",
"session": decode[map[string]any](t, rec)["session"],
"identifier": map[string]any{"type": "m.id.user", "user": user},
"password": password,
}
}
func login(t *testing.T, srv *setup.Server, user, password string) *httptest.ResponseRecorder {
t.Helper()
return post(t, srv, "/_matrix/client/v3/login", map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": user},
"password": password,
}, "")
}
func TestPasswordChangeAndDeactivation(t *testing.T) {
srv := newSigningServer(t, nil)
alice := registerThrough(t, srv, "alice")
other := decode[map[string]any](t, login(t, srv, "alice", "sUp3rs3kr1t"))["access_token"].(string)
caps := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/capabilities", alice), "capabilities")
if !strings.Contains(mustJSON(t, caps), `"m.change_password":{"enabled":true}`) {
t.Errorf("capabilities = %v", caps)
}
// Changing the password asks for the old one first.
body := map[string]any{"new_password": "n3w-s3cr3t"}
rec := post(t, srv, "/_matrix/client/v3/account/password", body, alice)
body["auth"] = passwordAuth(t, rec, "alice", "wrong")
if rec := post(t, srv, "/_matrix/client/v3/account/password", body, alice); rec.Code != http.StatusUnauthorized {
t.Fatalf("a wrong password: %d %s", rec.Code, rec.Body.String())
}
body["auth"] = passwordAuth(t, post(t, srv, "/_matrix/client/v3/account/password", map[string]any{"new_password": "x"}, alice),
"alice", "sUp3rs3kr1t")
mustOK(t, post(t, srv, "/_matrix/client/v3/account/password", body, alice), "change password")
// The old password is gone, the other session with it; this one stays.
expectError(t, login(t, srv, "alice", "sUp3rs3kr1t"), http.StatusForbidden, "M_FORBIDDEN", "the old password")
mustOK(t, login(t, srv, "alice", "n3w-s3cr3t"), "the new password")
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", other),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "another session after the change")
mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice), "this session after the change")
// Deactivation: same re-authentication, then the user is gone from
// their rooms and cannot log in again.
bob := registerThrough(t, srv, "bob")
roomID := createPublicRoom(t, srv, bob)
mustOK(t, post(t, srv, "/_matrix/client/v3/join/"+url.PathEscape(roomID), map[string]any{}, alice), "join")
mustOK(t, put(t, srv, "/_matrix/client/v3/profile/@alice:warp.example/displayname",
map[string]any{"displayname": "Alice"}, alice), "set displayname")
deactivate := map[string]any{"erase": true}
deactivate["auth"] = passwordAuth(t, post(t, srv, "/_matrix/client/v3/account/deactivate", deactivate, alice),
"alice", "n3w-s3cr3t")
resp := mustOK(t, post(t, srv, "/_matrix/client/v3/account/deactivate", deactivate, alice), "deactivate")
if resp["id_server_unbind_result"] != "no-support" {
t.Errorf("deactivate = %v", resp)
}
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "a token of a deactivated account")
expectError(t, login(t, srv, "alice", "n3w-s3cr3t"), http.StatusForbidden, "M_USER_DEACTIVATED", "logging in when deactivated")
members := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/joined_members", bob), "joined_members")
if joined, _ := members["joined"].(map[string]any); joined["@alice:warp.example"] != nil {
t.Errorf("a deactivated user is still in the room: %v", members)
}
profile := getWithToken(t, srv, "/_matrix/client/v3/profile/@alice:warp.example", bob)
if strings.Contains(profile.Body.String(), "Alice") {
t.Errorf("an erased profile = %s", profile.Body.String())
}
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/register/available?username=alice", ""),
http.StatusBadRequest, "M_USER_IN_USE", "the name of a deactivated account")
}
func TestAdminUserManagement(t *testing.T) {
srv := newSigningServer(t, func(c *config.Config) {
c.Media.BasePath = t.TempDir()
c.Media.MaxUserStorageBytes = 1000
})
root := adminToken(t, srv, "root")
alice := registerThrough(t, srv, "alice")
aliceID := url.PathEscape("@alice:warp.example")
user := mustOK(t, getWithToken(t, srv, adminPath("users", aliceID), root), "GET user")
if user["user_id"] != "@alice:warp.example" || user["devices"] != float64(1) || user["joined_rooms"] != float64(0) {
t.Errorf("user = %v", user)
}
if media, _ := user["media"].(map[string]any); media["quota_bytes"] != float64(1000) || media["custom_quota"] != false {
t.Errorf("media = %v", user["media"])
}
expectError(t, getWithToken(t, srv, adminPath("users", url.PathEscape("@nobody:warp.example")), root),
http.StatusNotFound, "M_NOT_FOUND", "an unknown user")
expectError(t, getWithToken(t, srv, adminPath("users", url.PathEscape("@x:elsewhere")), root),
http.StatusBadRequest, "M_INVALID_PARAM", "a user of another server")
expectError(t, getWithToken(t, srv, adminPath("users", aliceID), alice),
http.StatusForbidden, "M_FORBIDDEN", "a non-admin")
// The admin flag, and the one change an admin may not make to themselves.
if got := mustOK(t, put(t, srv, adminPath("users", aliceID, "admin"), map[string]any{"admin": true}, root), "grant"); got["admin"] != true {
t.Errorf("grant = %v", got)
}
mustOK(t, getWithToken(t, srv, adminPath("stats"), alice), "alice is an admin now")
mustOK(t, put(t, srv, adminPath("users", aliceID, "admin"), map[string]any{"admin": false}, root), "revoke")
expectError(t, put(t, srv, adminPath("users", url.PathEscape("@root:warp.example"), "admin"), map[string]any{"admin": false}, root),
http.StatusForbidden, "M_FORBIDDEN", "demoting yourself")
expectError(t, post(t, srv, adminPath("users", url.PathEscape("@root:warp.example"), "deactivate"), map[string]any{}, root),
http.StatusForbidden, "M_FORBIDDEN", "deactivating yourself")
// Devices.
devices := mustOK(t, getWithToken(t, srv, adminPath("users", aliceID, "devices"), root), "devices")
list, _ := devices["devices"].([]any)
if len(list) != 1 {
t.Fatalf("devices = %v", devices)
}
deviceID := list[0].(map[string]any)["device_id"].(string)
expectError(t, sendJSON(t, srv, http.MethodDelete, adminPath("users", aliceID, "devices", "NOPE"), nil, root),
http.StatusNotFound, "M_NOT_FOUND", "deleting an unknown device")
mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("users", aliceID, "devices", deviceID), nil, root), "delete device")
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "the deleted device")
// A password reset logs everything out by default.
alice = decode[map[string]any](t, login(t, srv, "alice", "sUp3rs3kr1t"))["access_token"].(string)
mustOK(t, post(t, srv, adminPath("users", aliceID, "password"), map[string]any{"password": "r3s3t-pw"}, root), "reset password")
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "a session after the reset")
alice = decode[map[string]any](t, login(t, srv, "alice", "r3s3t-pw"))["access_token"].(string)
// Media quota: the configured one, then one of her own.
if rec := send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", make([]byte, 600), alice, nil); rec.Code != http.StatusOK {
t.Fatalf("first upload: %d %s", rec.Code, rec.Body.String())
}
expectError(t, send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", make([]byte, 600), alice, nil),
http.StatusRequestEntityTooLarge, "M_TOO_LARGE", "an upload over the quota")
quota := mustOK(t, put(t, srv, adminPath("users", aliceID, "media", "quota"), map[string]any{"max_bytes": 0}, root), "lift the quota")
if quota["quota_bytes"] != float64(0) || quota["custom_quota"] != true || quota["bytes"] != float64(600) {
t.Errorf("quota = %v", quota)
}
if rec := send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", make([]byte, 600), alice, nil); rec.Code != http.StatusOK {
t.Errorf("upload with no limit: %d %s", rec.Code, rec.Body.String())
}
quota = mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("users", aliceID, "media", "quota"), nil, root), "reset the quota")
if quota["quota_bytes"] != float64(1000) || quota["custom_quota"] != false || quota["uploads"] != float64(2) {
t.Errorf("quota = %v", quota)
}
// Deactivation and reactivation.
user = mustOK(t, post(t, srv, adminPath("users", aliceID, "deactivate"), map[string]any{"erase": false}, root), "deactivate")
if user["deactivated"] != true || user["devices"] != float64(0) {
t.Errorf("deactivated user = %v", user)
}
expectError(t, login(t, srv, "alice", "r3s3t-pw"), http.StatusForbidden, "M_USER_DEACTIVATED", "logging in when deactivated")
user = mustOK(t, post(t, srv, adminPath("users", aliceID, "reactivate"), map[string]any{}, root), "reactivate")
if user["deactivated"] != false {
t.Errorf("reactivated user = %v", user)
}
mustOK(t, login(t, srv, "alice", "r3s3t-pw"), "logging in after reactivation")
// Every change is in the audit log, newest first.
logResp := mustOK(t, getWithToken(t, srv, adminPath("audit_log")+"?actor="+url.QueryEscape("@root:warp.example"), root), "audit log")
var actions []string
for _, e := range logResp["entries"].([]any) {
entry := e.(map[string]any)
actions = append(actions, entry["action"].(string))
if entry["actor"] != "@root:warp.example" {
t.Errorf("entry = %v", entry)
}
}
want := []string{"user.reactivate", "user.deactivate", "user.media_quota", "user.media_quota",
"user.password", "user.device_delete", "user.admin", "user.admin"}
if strings.Join(actions, ",") != strings.Join(want, ",") {
t.Errorf("audit actions = %v, want %v", actions, want)
}
page := mustOK(t, getWithToken(t, srv, adminPath("audit_log")+"?limit=2", root), "a page of the audit log")
if entries, _ := page["entries"].([]any); len(entries) != 2 || page["next_before"] == nil {
t.Errorf("page = %v", page)
}
}
func TestRegistrationTokensThroughTheAdminAPI(t *testing.T) {
srv := newSigningServer(t, func(c *config.Config) { c.Registration.RequireToken = true })
root := adminToken(t, srv, "root")
created := httptestCreated(t, post(t, srv, adminPath("registration_tokens"),
map[string]any{"token": "invite-1", "uses_allowed": 1}, root))
if created["token"] != "invite-1" || created["uses_allowed"] != float64(1) || created["valid"] != true || created["expiry_time_ms"] != nil {
t.Errorf("created = %v", created)
}
generated := httptestCreated(t, post(t, srv, adminPath("registration_tokens"), map[string]any{"length": 24}, root))
if tok, _ := generated["token"].(string); len(tok) != 24 || generated["uses_allowed"] != nil {
t.Errorf("generated = %v", generated)
}
expectError(t, post(t, srv, adminPath("registration_tokens"), map[string]any{"token": "invite-1"}, root),
http.StatusBadRequest, "M_INVALID_PARAM", "a duplicate token")
expectError(t, post(t, srv, adminPath("registration_tokens"), map[string]any{"token": "not valid!"}, root),
http.StatusBadRequest, "M_INVALID_PARAM", "a malformed token")
validity := func() any {
return mustOK(t, getWithToken(t, srv, "/_matrix/client/v1/register/m.login.registration_token/validity?token=invite-1", ""),
"validity")["valid"]
}
if validity() != true {
t.Error("a fresh token is not valid")
}
// Signing up with it spends its one use.
register := func(username string) *httptest.ResponseRecorder {
body := map[string]any{"username": username, "password": "sUp3rs3kr1t"}
rec := post(t, srv, "/_matrix/client/v3/register", body, "")
session := decode[map[string]any](t, rec)["session"]
body["auth"] = map[string]any{"type": "m.login.registration_token", "token": "invite-1", "session": session}
rec = post(t, srv, "/_matrix/client/v3/register", body, "")
if rec.Code != http.StatusUnauthorized {
return rec
}
body["auth"] = map[string]any{"type": "m.login.dummy", "session": session}
return post(t, srv, "/_matrix/client/v3/register", body, "")
}
mustOK(t, register("alice"), "register with the token")
if validity() != false {
t.Error("a used-up token is still valid")
}
if rec := register("bob"); rec.Code == http.StatusOK {
t.Errorf("a used-up token registered bob: %s", rec.Body.String())
}
got := mustOK(t, getWithToken(t, srv, adminPath("registration_tokens", "invite-1"), root), "GET token")
if got["completed"] != float64(1) || got["valid"] != false {
t.Errorf("token = %v", got)
}
updated := mustOK(t, put(t, srv, adminPath("registration_tokens", "invite-1"), map[string]any{"uses_allowed": nil}, root), "lift the limit")
if updated["uses_allowed"] != nil || updated["valid"] != true || updated["completed"] != float64(1) {
t.Errorf("updated = %v", updated)
}
mustOK(t, register("bob"), "register after the limit was lifted")
list := mustOK(t, getWithToken(t, srv, adminPath("registration_tokens"), root), "list tokens")
if tokens, _ := list["registration_tokens"].([]any); len(tokens) != 2 || list["required"] != true {
t.Errorf("list = %v", list)
}
mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("registration_tokens", "invite-1"), nil, root), "delete")
expectError(t, getWithToken(t, srv, adminPath("registration_tokens", "invite-1"), root),
http.StatusNotFound, "M_NOT_FOUND", "a deleted token")
if validity() != false {
t.Error("a deleted token is still valid")
}
}
func httptestCreated(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d %s", rec.Code, rec.Body.String())
}
return decode[map[string]any](t, rec)
}
// noticeSync is the part of a sync the server notice test reads.
type noticeSync struct {
Rooms struct {
Invite map[string]json.RawMessage `json:"invite"`
Join map[string]struct {
AccountData struct {
Events []struct {
Type string `json:"type"`
Content json.RawMessage `json:"content"`
} `json:"events"`
} `json:"account_data"`
Timeline struct {
Events []struct {
Sender string `json:"sender"`
Content map[string]any `json:"content"`
} `json:"events"`
} `json:"timeline"`
} `json:"join"`
} `json:"rooms"`
}
func TestServerNotices(t *testing.T) {
srv := newSigningServer(t, func(c *config.Config) { c.ServerNotices.Enabled = true })
root := adminToken(t, srv, "root")
alice := registerThrough(t, srv, "alice")
notice := func(body string) map[string]any {
return mustOK(t, post(t, srv, adminPath("server_notices"), map[string]any{
"user_id": "@alice:warp.example",
"content": map[string]any{"body": body},
}, root), "send a notice")
}
first := notice("Maintenance tonight")
roomID, _ := first["room_id"].(string)
var s noticeSync
rec := getWithToken(t, srv, "/_matrix/client/v3/sync?timeout=0", alice)
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil {
t.Fatal(err)
}
if _, ok := s.Rooms.Invite[roomID]; !ok {
t.Fatalf("alice has no invite to %s: %s", roomID, rec.Body.String())
}
// A second notice goes to the same room.
if second := notice("Done"); second["room_id"] != roomID {
t.Errorf("second notice went to %v, want %s", second["room_id"], roomID)
}
mustOK(t, post(t, srv, "/_matrix/client/v3/join/"+url.PathEscape(roomID), map[string]any{}, alice), "join the notice room")
rec = getWithToken(t, srv, "/_matrix/client/v3/sync?timeout=0", alice)
s = noticeSync{}
_ = json.Unmarshal(rec.Body.Bytes(), &s)
joined := s.Rooms.Join[roomID]
var bodies []string
for _, e := range joined.Timeline.Events {
if body, _ := e.Content["body"].(string); body != "" {
bodies = append(bodies, body)
if e.Sender != "@server-notices:warp.example" || e.Content["msgtype"] != "m.text" {
t.Errorf("notice event = %+v", e)
}
}
}
if strings.Join(bodies, "|") != "Maintenance tonight|Done" {
t.Errorf("notices = %v (%s)", bodies, rec.Body.String())
}
var tagged bool
for _, e := range joined.AccountData.Events {
if e.Type == "m.tag" && strings.Contains(string(e.Content), "m.server_notice") {
tagged = true
}
}
if !tagged {
t.Errorf("the notice room has no m.server_notice tag: %s", rec.Body.String())
}
// The user cannot answer, and a user who leaves is invited back.
expectError(t, put(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/x",
map[string]any{"msgtype": "m.text", "body": "hi"}, alice), http.StatusForbidden, "M_FORBIDDEN", "replying to a notice")
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", map[string]any{}, alice), "leave")
notice("Again")
if membership, _ := srv.Rooms().Membership(t.Context(), roomID, "@alice:warp.example"); membership != "invite" {
t.Errorf("after leaving, membership = %q", membership)
}
expectError(t, post(t, srv, adminPath("server_notices"), map[string]any{
"user_id": "@nobody:warp.example", "content": map[string]any{"body": "x"},
}, root), http.StatusNotFound, "M_NOT_FOUND", "a notice to nobody")
expectError(t, post(t, srv, adminPath("server_notices"), map[string]any{
"user_id": "@alice:warp.example", "content": map[string]any{},
}, root), http.StatusBadRequest, "M_MISSING_PARAM", "a notice without a body")
}
func TestServerNoticesOffByDefault(t *testing.T) {
srv := newSigningServer(t, nil)
root := adminToken(t, srv, "root")
expectError(t, post(t, srv, adminPath("server_notices"), map[string]any{
"user_id": "@root:warp.example", "content": map[string]any{"body": "x"},
}, root), http.StatusConflict, "M_FORBIDDEN", "server notices when they are off")
if ok, _ := srv.Users().AccountExists(t.Context(), "server-notices"); ok {
t.Error("the notices user exists although notices are off")
}
}
func TestFederationListsThroughTheAdminAPI(t *testing.T) {
srv := newSigningServer(t, func(c *config.Config) { c.Federation.Denylist = []string{"bad.example"} })
root := adminToken(t, srv, "root")
got := mustOK(t, getWithToken(t, srv, adminPath("federation"), root), "GET federation")
if got["lists_edited"] != false || mustJSON(t, got["denylist"]) != `["bad.example"]` {
t.Errorf("federation = %v", got)
}
got = mustOK(t, put(t, srv, adminPath("federation", "lists"), map[string]any{
"allowlist": []string{"friend.example", "Other.Example"}, "denylist": []string{},
}, root), "PUT lists")
if got["lists_edited"] != true || mustJSON(t, got["allowlist"]) != `["friend.example","other.example"]` ||
mustJSON(t, got["denylist"]) != `[]` {
t.Errorf("after PUT = %v", got)
}
expectError(t, put(t, srv, adminPath("federation", "lists"), map[string]any{"allowlist": []string{}}, root),
http.StatusBadRequest, "M_MISSING_PARAM", "only one list")
expectError(t, put(t, srv, adminPath("federation", "lists"), map[string]any{
"allowlist": []string{"not a name"}, "denylist": []string{},
}, root), http.StatusBadRequest, "M_INVALID_PARAM", "a bad server name")
got = mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("federation", "lists"), nil, root), "DELETE lists")
if got["lists_edited"] != false || mustJSON(t, got["allowlist"]) != `[]` || mustJSON(t, got["denylist"]) != `["bad.example"]` {
t.Errorf("after DELETE = %v", got)
}
}
+120 -10
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/dariuszjaniak/warp/server/internal/adminapi"
"github.com/dariuszjaniak/warp/server/internal/appservice"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
@@ -27,6 +28,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/metrics"
"github.com/dariuszjaniak/warp/server/internal/pushserver"
"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/storage/drivers" // link in the storage drivers
@@ -52,7 +54,12 @@ type Server struct {
push *pushserver.API
media *mediaapi.API
clientAPI *clientapi.ClientAPI
adminAPI *adminapi.AdminAPI
appservices *appservice.API
notices *servernotices.API
regTokens *userapi.RegistrationTokens
adminAPI *adminapi.AdminAPI
// The federation components. All nil when the server started without
// a signing key, because nothing that federates can work without one.
@@ -151,6 +158,8 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
FederationAllowRuntimeToggle: cfg.Federation.AllowRuntimeToggle,
RegistrationEnabled: cfg.Registration.Enabled,
RegistrationAllowRuntimeToggle: cfg.Registration.AllowRuntimeToggle,
FederationAllowlist: cfg.Federation.Allowlist,
FederationDenylist: cfg.Federation.Denylist,
}, log)
if err != nil {
_ = s.closeDB()
@@ -158,9 +167,12 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
}
s.settings = settingsService
// The lists in force are the settings service's: the config file's,
// unless an admin has replaced them through the admin API.
lists := settingsService.FederationLists()
s.federation = federation.NewGate(
settingsService.FederationEnabled,
federation.NewPolicy(cfg.Federation.Allowlist, cfg.Federation.Denylist),
federation.NewPolicy(lists.Allowlist, lists.Denylist),
)
// The switch starts and stops the federation module. The gate and the
// client read the switch on every request and need no telling.
@@ -192,6 +204,29 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
return nil, err
}
// Registration tokens come from the config file and from the admin
// API; the sign-up flow and the admin endpoints share one view of both.
s.regTokens = userapi.NewRegistrationTokens(userapi.RegistrationTokensOptions{
Store: s.db.Admin(),
Static: cfg.Registration.Tokens,
Required: cfg.Registration.RequireToken,
NowMS: func() int64 { return time.Now().UnixMilli() },
})
// Application services are read from their registration files now,
// so that a bad one stops the server before it listens. Their senders
// are created here too: a service acts as its sender from its first
// request.
registry, err := loadAppServices(cfg)
if err != nil {
_ = s.closeDB()
return nil, err
}
if err := ensureAppServiceSenders(ctx, s.users, registry, log); err != nil {
_ = s.closeDB()
return nil, err
}
// The media repository needs no key: uploads and downloads of local
// media are not signed. Fetching other servers' media is, and is
// handed the federation client below once there is one.
@@ -342,6 +377,47 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
}
s.sync.SetOutbound(federationapi.NewEDUs(queue, s.rooms))
// Application services read the event stream from a cursor of
// their own, and only need waking when it moves: they are one
// more listener on the emit stage.
s.appservices, err = appservice.New(appservice.Options{
Registry: registry,
Store: s.db.AppServices(),
Rooms: s.rooms,
Timeout: cfg.AppService.Timeout,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
emit.add(s.appservices)
// Deactivating an account leaves its rooms, which only the
// roomserver can do.
s.users.SetAccountObserver(roomLeaver{rooms: s.rooms, log: log})
// Server notices are room messages, so they come with the rooms.
if cfg.ServerNotices.Enabled {
s.notices, err = servernotices.New(servernotices.Options{
Localpart: cfg.ServerNotices.Localpart,
DisplayName: cfg.ServerNotices.DisplayName,
RoomName: cfg.ServerNotices.RoomName,
Accounts: s.users,
Rooms: s.rooms,
Store: s.db.Admin(),
WakeUser: notifier.NotifyUser,
Logger: log,
})
if err == nil {
err = s.notices.EnsureUser(ctx)
}
if err != nil {
_ = s.closeDB()
return nil, err
}
}
s.fedAPI, err = federationapi.NewServer(federationapi.ServerOptions{
ServerName: cfg.Global.ServerName,
Gate: s.federation,
@@ -360,6 +436,16 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
}
} else {
log.Warn("started without a signing key: rooms, /sync and end-to-end encryption are unavailable")
// Without rooms there is no stream to deliver; the registry still
// lets the services use the Client-Server API.
if s.appservices, err = appservice.New(appservice.Options{
Registry: registry,
Timeout: cfg.AppService.Timeout,
Logger: log,
}); err != nil {
_ = s.closeDB()
return nil, err
}
}
// The interfaces below are left nil rather than set to a nil pointer
@@ -386,6 +472,8 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
Federation: s.federation,
RemoteProfiles: remoteProfiles,
Registration: cfg.Registration,
RegistrationTokens: s.regTokens,
AppServices: s.appservices,
RateLimit: cfg.ClientAPI.RateLimit,
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
Logger: log,
@@ -396,20 +484,30 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
}
s.adminAPI, err = adminapi.New(adminapi.Options{
ServerName: cfg.Global.ServerName,
Users: s.users,
Rooms: s.rooms,
Settings: settingsService,
Federation: s.federation,
FederationStatus: fedStatus,
Database: s.db,
Logger: log,
ServerName: cfg.Global.ServerName,
Users: s.users,
Rooms: s.rooms,
Settings: settingsService,
Federation: s.federation,
FederationStatus: fedStatus,
Database: s.db,
Media: s.media,
AppServices: s.appservices,
Notices: s.notices,
RegistrationTokens: s.regTokens,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
// Delivery to application services starts with the server rather
// than with Run, like the push worker: it only reads the database and
// talks to the services, and tests that drive the handler directly
// need it running.
s.appservices.Start(ctx)
s.handler = httputil.Chain(s.routes(),
httputil.Recoverer,
httputil.RequestLogger(log),
@@ -477,6 +575,13 @@ func (s *Server) Keys() *keyserver.API { return s.keys }
// signing key.
func (s *Server) Push() *pushserver.API { return s.push }
// AppServices exposes the application service component.
func (s *Server) AppServices() *appservice.API { return s.appservices }
// ServerNotices exposes the server notice sender, or nil when server
// notices are off or the server has no signing key.
func (s *Server) ServerNotices() *servernotices.API { return s.notices }
// Media exposes the media repository.
func (s *Server) Media() *mediaapi.API { return s.media }
@@ -624,6 +729,11 @@ func (s *Server) Close() error {
// to close.
s.push.Close()
}
if s.appservices != nil {
// So do the application service workers, which move their
// cursors.
s.appservices.Close()
}
return s.closeDB()
}