Files
Warp/server/internal/ratelimit/ratelimit.go
T
tajniak81andClaude Opus 5 6004bb01de
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 1: accounts and sessions
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>
2026-10-15 18:18:37 +02:00

165 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}