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, ®); err != nil { return nil, fmt.Errorf("parse application service registration: %w", err) } if err := reg.validate(serverName); err != nil { return nil, err } return ®, 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 }