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 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
5.1 KiB
Go
165 lines
5.1 KiB
Go
// Package ratelimit is the per-endpoint rate limiting the Client-Server API
|
||
// needs: a token bucket per caller, answered with M_LIMIT_EXCEEDED and a
|
||
// retry_after_ms the client is expected to honour.
|
||
//
|
||
// Each rate-limited route gets its own Limiter, so a client hammering
|
||
// /login cannot use up the budget of /profile. Buckets are keyed per caller
|
||
// within a limiter; the key is supplied by the caller (the client address
|
||
// for unauthenticated endpoints, the user ID once a request is
|
||
// authenticated).
|
||
package ratelimit
|
||
|
||
import (
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||
)
|
||
|
||
// Policy is one bucket's shape: Burst requests may arrive back to back, and
|
||
// one slot returns every Interval.
|
||
type Policy struct {
|
||
Burst int
|
||
Interval time.Duration
|
||
}
|
||
|
||
// Limiter is a set of token buckets, one per key.
|
||
//
|
||
// The implementation stores a single timestamp per key — the moment the
|
||
// bucket would next be completely full — rather than a count and a refill
|
||
// time. That is the whole state a leaky bucket needs: how far that moment is
|
||
// in the future says how many slots are in use, and the arithmetic cannot
|
||
// drift the way a periodically refilled counter can.
|
||
type Limiter struct {
|
||
policy Policy
|
||
|
||
// span is Burst × Interval: the longest the "full again" moment can be
|
||
// pushed into the future, which is what makes the bucket finite.
|
||
span time.Duration
|
||
|
||
now func() time.Time
|
||
|
||
mu sync.Mutex
|
||
fullAt map[string]time.Time
|
||
sweptAt time.Time
|
||
disabled bool
|
||
}
|
||
|
||
// sweepInterval is how often idle buckets are collected. Without it the map
|
||
// would keep one entry per address that ever connected.
|
||
const sweepInterval = time.Minute
|
||
|
||
// New builds a limiter. A policy with a non-positive burst or interval
|
||
// disables it, so that a limiter is always safe to call even when the
|
||
// configuration turned rate limiting off.
|
||
func New(policy Policy) *Limiter {
|
||
l := &Limiter{policy: policy, now: time.Now, fullAt: map[string]time.Time{}}
|
||
if policy.Burst <= 0 || policy.Interval <= 0 {
|
||
l.disabled = true
|
||
return l
|
||
}
|
||
l.span = time.Duration(policy.Burst) * policy.Interval
|
||
return l
|
||
}
|
||
|
||
// Disabled returns a limiter that allows everything, for a deployment with
|
||
// rate limiting switched off.
|
||
func Disabled() *Limiter { return &Limiter{disabled: true} }
|
||
|
||
// Allow records one request against key. It returns how long the caller
|
||
// should wait; a zero duration means the request may proceed.
|
||
func (l *Limiter) Allow(key string) time.Duration {
|
||
if l.disabled {
|
||
return 0
|
||
}
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
|
||
now := l.now()
|
||
l.sweep(now)
|
||
|
||
// An unseen or long-idle bucket starts full, which is the same as
|
||
// "would be completely full as of now".
|
||
fullAt := l.fullAt[key]
|
||
if fullAt.Before(now) {
|
||
fullAt = now
|
||
}
|
||
|
||
// Taking a slot pushes the "full again" moment one interval further
|
||
// out. It is allowed as long as that keeps it within the bucket's span;
|
||
// beyond that, every slot is in use and the overshoot is exactly how
|
||
// long the caller has to wait for the next one.
|
||
next := fullAt.Add(l.policy.Interval)
|
||
if overshoot := next.Sub(now) - l.span; overshoot > 0 {
|
||
return overshoot
|
||
}
|
||
l.fullAt[key] = next
|
||
return 0
|
||
}
|
||
|
||
// sweep drops buckets that have been full — and therefore idle — for a
|
||
// while. It runs at most once per sweepInterval and under the caller's lock,
|
||
// so it costs nothing on a busy server beyond one comparison per request.
|
||
func (l *Limiter) sweep(now time.Time) {
|
||
if now.Sub(l.sweptAt) < sweepInterval {
|
||
return
|
||
}
|
||
l.sweptAt = now
|
||
for key, fullAt := range l.fullAt {
|
||
if fullAt.Before(now) {
|
||
delete(l.fullAt, key)
|
||
}
|
||
}
|
||
}
|
||
|
||
// KeyFunc derives the bucket key for a request.
|
||
type KeyFunc func(*http.Request) string
|
||
|
||
// Middleware rejects requests over the limit with M_LIMIT_EXCEEDED.
|
||
func (l *Limiter) Middleware(key KeyFunc) httputil.Middleware {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if retryAfter := l.Allow(key(r)); retryAfter > 0 {
|
||
// Rounded up: a client that waits exactly retry_after_ms
|
||
// and is told to wait again learns nothing useful.
|
||
ms := retryAfter.Milliseconds()
|
||
if retryAfter%time.Millisecond != 0 {
|
||
ms++
|
||
}
|
||
httputil.WriteError(w, r, httputil.LimitExceeded(ms))
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ClientIP returns the address a request came from, as a rate limiting key.
|
||
//
|
||
// trustForwardedFor takes the left-most X-Forwarded-For entry instead of the
|
||
// socket address. It must only be turned on behind a proxy that overwrites
|
||
// the header: anyone can send it, and a spoofable key is no key at all.
|
||
func ClientIP(trustForwardedFor bool) KeyFunc {
|
||
return func(r *http.Request) string {
|
||
if trustForwardedFor {
|
||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||
first, _, _ := strings.Cut(forwarded, ",")
|
||
if first = strings.TrimSpace(first); first != "" {
|
||
return first
|
||
}
|
||
}
|
||
}
|
||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||
if err != nil {
|
||
// RemoteAddr is not host:port for a unix socket or a synthetic
|
||
// request; use it whole rather than losing the distinction.
|
||
return r.RemoteAddr
|
||
}
|
||
return host
|
||
}
|
||
}
|