| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- package handlers
- import (
- "fmt"
- "log/slog"
- "net/http"
- "time"
- "github.com/mk6i/open-oscar-server/server/webapi/types"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // rateLimitStatusCode is the Web AIM API envelope code for a rate-limited
- // request. The AIM web client swallows 430 on the IM path so that the rateLimit
- // event owns the user-facing message instead of a generic send failure alert.
- const rateLimitStatusCode = 430
- // minRetryAfter floors the Retry-After hint sent with a rate-limited response.
- // The computed wait can round down to nothing when a class is barely over its
- // limit, and a hint of zero invites an immediate retry.
- const minRetryAfter = 1 * time.Second
- // SessionHandlerFunc is the session-aware handler shape that
- // AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
- type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *state.WebAPISession)
- // RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
- // food group.
- //
- // Such routes are limited by OSCAR itself: OSCAR charges the session's shared
- // per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
- // a user cannot dodge a limit by switching transports. Routes that reach no food
- // group are not limited here; edge rate limiting (a reverse proxy keyed by client
- // IP) is expected to cover the unauthenticated login/asset endpoints and the
- // authenticated bookkeeping ones.
- //
- // It lives alongside the handlers (rather than in the middleware package) so that
- // its rejection can be encoded through the same SendResponse path the handlers
- // use, honoring the request's JSON/JSONP/XML/AMF format.
- //
- // It only enforces the limit (the 430 rejection). Telling the client its status
- // changed is the job of OServiceService.MonitorRateLimits.
- type RateLimitMiddleware struct {
- snacRateLimits wire.SNACRateLimits
- logger *slog.Logger
- }
- // NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
- // same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
- func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
- return &RateLimitMiddleware{
- snacRateLimits: snacRateLimits,
- logger: logger,
- }
- }
- // OSCAR returns middleware that charges one unit against the OSCAR rate class
- // mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
- // HTTP counterpart of the TOC server's per-command rate check.
- //
- // A SNAC with no rate class mapping is allowed through, since refusing traffic
- // because the server's own table is incomplete would be worse than not limiting
- // it.
- func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
- return func(next SessionHandlerFunc) SessionHandlerFunc {
- return func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
- ctx := r.Context()
- rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
- if !ok {
- l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
- "foodgroup", wire.FoodGroupName(foodGroup),
- "subgroup", wire.SubGroupName(foodGroup, subGroup))
- next(w, r, session)
- return
- }
- sess := session.OSCARSession.Session()
- status := sess.EvaluateRateLimit(time.Now(), rateClassID)
- // Disconnect is rejected alongside Limited: EvaluateRateLimit has
- // already closed the account's OSCAR session by the time it returns,
- // so there is nothing left for the handler to act on. That close also
- // invalidates the aimsid (GetSession stops resolving a session whose
- // OSCAR instance is closed), so every subsequent request is turned
- // away at RequireSession rather than reaching here again.
- if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
- l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
- "foodgroup", wire.FoodGroupName(foodGroup),
- "subgroup", wire.SubGroupName(foodGroup, subGroup),
- "status", rateLimitStatusName(status))
- // A disconnected session has no aimsid left to retry with, so
- // there is no wait to advertise.
- var retryAfter time.Duration
- if status == wire.RateLimitStatusLimited {
- retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
- }
- l.sendRateLimited(w, r, retryAfter)
- return
- }
- next(w, r, session)
- }
- }
- }
- // retryAfterFor returns how long the client must wait for its next request on
- // this class to clear the limit.
- //
- // OSCAR's limiter has no fixed window: it tracks a moving average of the gap
- // between requests, and a request lifts the limit only once that average climbs
- // back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
- // lands the new average exactly on ClearLevel gives
- //
- // elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
- //
- // A flat hint cannot work here, because a rejected request is still charged: a
- // client retrying on a fixed interval drives the average toward that interval, so
- // any hint below the class's ClearLevel holds the average just under the bar and
- // the client stays limited forever. The production ICBM class clears at 5100ms,
- // which a 5s hint would do exactly.
- func retryAfterFor(rcs state.RateClassState) time.Duration {
- neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
- // Retry-After carries whole seconds, so round up: a hint that is short by a
- // fraction of a second reproduces the same never-clears loop. A class barely
- // over its limit can compute to no wait at all, hence the floor.
- return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
- }
- // seedRateLimitAlert raises the client's rate limit alert when a session starts
- // on an account that is already rate limited.
- //
- // The monitor broadcasts transitions, not current state, so a session signing on
- // mid-limit missed the one that raised the alert — and the client's alert is
- // sticky, so the eventual "clear" would arrive with nothing to dismiss. An OSCAR
- // client learns the current state from the rate params it gets at handshake; this
- // is the Web API's equivalent.
- //
- // Only the limited state is seeded: alert is a warning the user cannot act on,
- // and seeding clear would render nothing.
- func seedRateLimitAlert(session *state.WebAPISession, classID wire.RateLimitClassID) {
- if classID == 0 {
- return
- }
- status := session.OSCARSession.Session().RateLimitStates()[classID-1].CurrentStatus
- if status != wire.RateLimitStatusLimited {
- return
- }
- session.EventQueue.Push(types.EventTypeRateLimit, types.RateLimitEvent{
- Classes: []types.RateLimitClass{
- {
- ID: int(classID),
- Status: rateLimitStatusName(status),
- },
- },
- })
- }
- // rateLimitStatusName maps an OSCAR rate limit status onto the status string the
- // web client switches on. It returns "" for a status the client does not know.
- func rateLimitStatusName(status wire.RateLimitStatus) string {
- switch status {
- case wire.RateLimitStatusClear:
- return "clear"
- case wire.RateLimitStatusAlert:
- return "warn"
- case wire.RateLimitStatusLimited:
- return "limit"
- case wire.RateLimitStatusDisconnect:
- return "disconnect"
- default:
- return ""
- }
- }
- // sendRateLimited writes a rate limit rejection. The transport status is 200 and
- // the rejection lives entirely in the Web AIM API envelope's own rate limit code.
- //
- // The transport status is deliberately not 429: the AIM client's WIM request layer
- // (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
- // routed to their error handlers, which synthesize a generic "request failed"
- // result and never look at the body, so the envelope's 430 — which the client
- // swallows on the IM path in favor of the rateLimit event — would go unread and the
- // user would see a generic send failure instead.
- //
- // The body is encoded via SendResponse, so it honors the request's format
- // (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
- // the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
- // normal handler response would.
- //
- // A retryAfter of zero sends no Retry-After header, for the rejections that have
- // nothing to retry.
- func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
- if retryAfter > 0 {
- w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = rateLimitStatusCode
- resp.Response.StatusText = "rate limit exceeded"
- SendResponse(w, r, resp, l.logger)
- }
|