Files
Warp/server/internal/appservice/registration.go
T
tajniak81andClaude Opus 5.5 a94794b866
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 9: application services and admin completion
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>
2026-10-17 12:58:28 +02:00

179 lines
5.6 KiB
Go

package appservice
import (
"errors"
"fmt"
"net/url"
"os"
"regexp"
"strings"
"gopkg.in/yaml.v3"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Registration is one application service's registration file, the YAML
// document the Application Service API defines and every bridge writes.
//
// Unknown keys are ignored rather than refused, unlike Warp's own config
// file: registration files are written by the bridges, which add their own
// and unstable keys freely (push_ephemeral, the MSC3202 ones), and a bridge
// upgrade that adds one must not stop the homeserver from starting.
type Registration struct {
// ID names the application service. It is what the ping endpoint is
// addressed by and what its delivery cursor is stored under, so
// changing it starts the service over from the top of the stream.
ID string `yaml:"id"`
// URL is where transactions and queries are sent. Empty (or null in
// the file) is a service that receives nothing, and only uses the
// Client-Server API.
URL string `yaml:"url"`
// ASToken is what the service authenticates to this server with;
// HSToken is what this server authenticates to the service with.
ASToken string `yaml:"as_token"`
HSToken string `yaml:"hs_token"`
// SenderLocalpart is the localpart of the service's own user, which it
// acts as when it does not ask to act as somebody else.
SenderLocalpart string `yaml:"sender_localpart"`
Namespaces Namespaces `yaml:"namespaces"`
// RateLimited is whether the users the service acts as are rate
// limited. Absent is true, as the spec has it. The sender itself never
// is.
RateLimited *bool `yaml:"rate_limited"`
// Protocols are the third-party protocols the service provides.
Protocols []string `yaml:"protocols"`
// path is the file the registration was read from, for messages.
path string
}
// Namespaces are the users, aliases and rooms a service is interested in.
type Namespaces struct {
Users []Namespace `yaml:"users"`
Aliases []Namespace `yaml:"aliases"`
Rooms []Namespace `yaml:"rooms"`
}
// Namespace is one regular expression and whether the service claims what
// it matches for itself alone.
type Namespace struct {
Exclusive bool `yaml:"exclusive"`
Regex string `yaml:"regex"`
re *regexp.Regexp
}
// Matches reports whether an ID is in the namespace.
func (n Namespace) Matches(id string) bool { return n.re != nil && n.re.MatchString(id) }
// LoadRegistration reads and validates a registration file.
func LoadRegistration(path, serverName string) (*Registration, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read application service registration: %w", err)
}
reg, err := ParseRegistration(data, serverName)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
reg.path = path
return reg, nil
}
// ParseRegistration parses and validates a registration document.
func ParseRegistration(data []byte, serverName string) (*Registration, error) {
var reg Registration
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("parse application service registration: %w", err)
}
if err := reg.validate(serverName); err != nil {
return nil, err
}
return &reg, nil
}
// validate checks a registration and compiles its namespaces, reporting
// every problem rather than the first.
func (r *Registration) validate(serverName string) error {
var errs []error
bad := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if strings.TrimSpace(r.ID) == "" {
bad("id: must not be empty")
}
if r.ASToken == "" {
bad("as_token: must not be empty")
}
if r.HSToken == "" {
bad("hs_token: must not be empty")
}
if r.ASToken != "" && r.ASToken == r.HSToken {
// Either side could then impersonate the other.
bad("as_token and hs_token must differ")
}
if r.URL != "" {
u, err := url.Parse(r.URL)
switch {
case err != nil:
bad("url: %v", err)
case u.Scheme != "http" && u.Scheme != "https":
bad("url: must be an http(s) URL, got %q", r.URL)
case u.Host == "":
bad("url: has no host")
}
}
r.SenderLocalpart = userapi.NormaliseLocalpart(r.SenderLocalpart)
if err := userapi.ValidateLocalpart(r.SenderLocalpart, serverName); err != nil {
bad("sender_localpart: %v", err)
}
for _, group := range []struct {
name string
list []Namespace
}{
{"users", r.Namespaces.Users},
{"aliases", r.Namespaces.Aliases},
{"rooms", r.Namespaces.Rooms},
} {
for i := range group.list {
ns := &group.list[i]
if ns.Regex == "" {
bad("namespaces.%s[%d].regex: must not be empty", group.name, i)
continue
}
re, err := compileNamespace(ns.Regex)
if err != nil {
bad("namespaces.%s[%d].regex: %v", group.name, i, err)
continue
}
ns.re = re
}
}
return errors.Join(errs...)
}
// compileNamespace compiles a namespace regular expression, anchored at
// the start.
//
// The spec does not say how a namespace matches, and the registration
// files in the wild are written against Synapse, which anchors at the
// start and not at the end — so "@_irc_" matches every user whose ID
// begins with it. Anchoring both ends would quietly match nothing for
// those files; anchoring neither would let "@bot" match "@notabot".
func compileNamespace(expr string) (*regexp.Regexp, error) {
return regexp.Compile(`^(?:` + expr + `)`)
}
// RateLimitedUsers reports whether the users the service acts as are rate
// limited.
func (r *Registration) RateLimitedUsers() bool { return r.RateLimited == nil || *r.RateLimited }
// Path is the file the registration came from, or "".
func (r *Registration) Path() string { return r.path }