// Package federation holds the server-to-server surface. In Phase 0 that // is only the gate: the piece that makes federation a runtime mode rather // than a build-time one. // // Every federation route is registered through Gate.Protect, so flipping // federation.enabled through the admin API takes effect on the next request // without re-registering routes or restarting the process. The transports // that the switch also has to start and stop (outbound transaction queue, // key fetcher, remote media) arrive in Phase 5 as federation.Module. package federation import ( "net/http" "strings" "sync" "github.com/dariuszjaniak/warp/server/internal/httputil" ) // Gate reports whether federation is currently live. The callback reads the // settings service, so the answer tracks the runtime switch. type Gate struct { enabled func() bool policy *Policy } // NewGate builds a gate. A nil callback means permanently disabled, which // is the safe default for a half-wired server. func NewGate(enabled func() bool, policy *Policy) *Gate { if enabled == nil { enabled = func() bool { return false } } if policy == nil { policy = NewPolicy(nil, nil) } return &Gate{enabled: enabled, policy: policy} } // Enabled reports the live state of the switch. func (g *Gate) Enabled() bool { return g.enabled() } // Policy returns the allow/deny list filter. func (g *Gate) Policy() *Policy { return g.policy } // Protect wraps a federation handler so that it answers M_UNRECOGNIZED // while federation is off. The spec's answer for an endpoint a server does // not implement is a 404, and from the outside a federation-disabled Warp // is exactly that: a server without a federation API. func (g *Gate) Protect(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !g.enabled() { httputil.WriteError(w, r, httputil.Unrecognized()) return } h.ServeHTTP(w, r) }) } // ProtectFunc is Protect for an error-returning handler. func (g *Gate) ProtectFunc(h httputil.Handler) http.Handler { return g.Protect(httputil.Wrap(h)) } // RejectClientRequest is the error a Client-Server endpoint returns when it // would need federation to answer: joining a remote room, inviting a remote // user, querying a remote directory. The message is deliberately explicit — // "M_FORBIDDEN" alone sends operators hunting through power levels. func RejectClientRequest(action string) error { return httputil.Forbidden("Federation is disabled on this server; %s is not possible", action) } // Policy filters remote servers by name. An empty allowlist means "any // server not on the denylist"; a non-empty one means "only these". type Policy struct { mu sync.RWMutex allowlist map[string]struct{} denylist map[string]struct{} } // NewPolicy builds a policy from config lists. func NewPolicy(allowlist, denylist []string) *Policy { p := &Policy{} p.Replace(allowlist, denylist) return p } // Replace swaps both lists atomically; the admin API edits them at runtime. func (p *Policy) Replace(allowlist, denylist []string) { allow := toSet(allowlist) deny := toSet(denylist) p.mu.Lock() defer p.mu.Unlock() p.allowlist, p.denylist = allow, deny } // Allowed reports whether traffic to or from serverName may flow. The // denylist always wins. func (p *Policy) Allowed(serverName string) bool { name := normalise(serverName) if name == "" { return false } p.mu.RLock() defer p.mu.RUnlock() if _, denied := p.denylist[name]; denied { return false } if len(p.allowlist) == 0 { return true } _, allowed := p.allowlist[name] return allowed } // Lists returns copies of the current allow and deny lists, for the admin // API and the dashboard. func (p *Policy) Lists() (allowlist, denylist []string) { p.mu.RLock() defer p.mu.RUnlock() return setToSlice(p.allowlist), setToSlice(p.denylist) } func toSet(names []string) map[string]struct{} { out := make(map[string]struct{}, len(names)) for _, n := range names { if n = normalise(n); n != "" { out[n] = struct{}{} } } return out } func setToSlice(set map[string]struct{}) []string { out := make([]string, 0, len(set)) for name := range set { out = append(out, name) } return out } // normalise lower-cases a server name. Matrix server names are // case-sensitive in theory, but every real deployment uses a DNS name, // and treating "Example.com" as a different server from "example.com" // would turn a typo in the allowlist into an open door. func normalise(serverName string) string { return strings.ToLower(strings.TrimSpace(serverName)) }