package http import ( "io" "net/http" "net/netip" "strconv" "strings" "sync/atomic" "time" "tug/internal/platform/ratelimit" ) type Rate = ratelimit.Rate type LimitConfig struct { Authorize, LoginForm, Password, Invite, Callback, SSOLogin, LoginClaim Rate Entries int TokenSlots, SigninSlots, MaxWaiters int SlotWait time.Duration } var DefaultLimits = LimitConfig{ Authorize: Rate{Burst: 20, Every: 6 * time.Second}, LoginForm: Rate{Burst: 20, Every: 6 * time.Second}, Password: Rate{Burst: 10, Every: 10 * time.Second}, Invite: Rate{Burst: 10, Every: 10 * time.Second}, Callback: Rate{Burst: 20, Every: 6 * time.Second}, SSOLogin: Rate{Burst: 30, Every: 3 * time.Second}, LoginClaim: Rate{Burst: 20, Every: 6 * time.Second}, Entries: 20000, TokenSlots: 8, SigninSlots: 8, MaxWaiters: 64, SlotWait: 10 * time.Second, } type rateClass int const ( rateNone rateClass = iota rateAuthorize rateLoginForm ratePassword rateInvite rateCallback rateSSOLogin rateLoginClaim ) type limitKey struct { class rateClass prefix netip.Prefix } type gate struct { slots chan struct{} waiting atomic.Int64 } type Limits struct { cfg LimitConfig table *ratelimit.Table[limitKey] token *gate signin *gate } func NewLimits(cfg LimitConfig) *Limits { return newLimits(cfg, time.Now) } func newLimits(cfg LimitConfig, now func() time.Time) *Limits { return &Limits{cfg: cfg, table: ratelimit.New[limitKey](cfg.Entries, now), token: newGate(cfg.TokenSlots), signin: newGate(cfg.SigninSlots)} } type RouterOption func(*router) func WithLimits(l *Limits) RouterOption { return func(rt *router) { rt.limits = l } } func (l *Limits) rate(c rateClass) Rate { switch c { case rateAuthorize: return l.cfg.Authorize case rateLoginForm: return l.cfg.LoginForm case ratePassword: return l.cfg.Password case rateInvite: return l.cfg.Invite case rateCallback: return l.cfg.Callback case rateSSOLogin: return l.cfg.SSOLogin case rateLoginClaim: return l.cfg.LoginClaim } return Rate{} } func classify(class hostClass, method, p string) rateClass { get, post := method == http.MethodGet, method == http.MethodPost switch class { case classIDP: if get && strings.HasPrefix(p, "/auth/") { return rateAuthorize } case classAccount, classAdmin: switch { case get && p == "/oidc/callback": return rateCallback case post && p == "/sudo": return ratePassword case class == classAdmin: case get && p == "/login/claim": return rateLoginClaim case p == "/login" && get, post && (p == "/login/google" || p == "/google/link"): return rateLoginForm case get && p == "/link/google/callback": return rateCallback case post && (p == "/login" || p == "/password"): return ratePassword case (get || post) && strings.HasPrefix(p, "/invite/"): return rateInvite } case classApp: switch { case get && p == "/.sso/login": return rateSSOLogin case get && p == "/.sso/callback": return rateCallback } } return rateNone } func clientPrefix(r *http.Request) netip.Prefix { return ratelimit.Prefix(ClientOf(r.Context()).Addr) } func (l *Limits) size() int { return l.table.Len() } func retryAfter(d time.Duration) string { s := int64((d + time.Second - 1) / time.Second) return strconv.FormatInt(max(s, 1), 10) } func (l *Limits) admit(w http.ResponseWriter, r *http.Request, class hostClass, page ErrorPage, o Observer) (func(), bool) { if c := classify(class, r.Method, r.URL.Path); c != rateNone { if wait, ok := l.table.Take(limitKey{class: c, prefix: clientPrefix(r)}, l.rate(c)); !ok { o.Limited(c.name()) w.Header().Set("Retry-After", retryAfter(wait)) page(w, r, http.StatusTooManyRequests) return nil, false } } g := l.gate(class, r.Method, r.URL.Path) if g == nil { return func() {}, true } if l.acquire(r, g, o) { return func() { <-g.slots }, true } o.Refused(l.gateName(g)) w.Header().Set("Retry-After", "1") if g == l.token { h := w.Header() h.Set("Content-Type", "application/json") h.Set("Cache-Control", "no-store") w.WriteHeader(http.StatusServiceUnavailable) io.WriteString(w, tokenBusy) return nil, false } page(w, busyRequest(r), http.StatusServiceUnavailable) return nil, false } const tokenBusy = `{"error":"temporarily_unavailable","error_description":"The server is busy. Try again in a moment."}` func newGate(n int) *gate { if n <= 0 { return nil } return &gate{slots: make(chan struct{}, n)} } func (l *Limits) gate(class hostClass, method, p string) *gate { get, post := method == http.MethodGet, method == http.MethodPost switch { case class == classIDP && post && p == "/token": return l.token case class == classAccount && post && (p == "/login" || p == "/login/google" || p == "/google/link"), class == classAccount && get && p == "/link/google/callback", (class == classAccount || class == classAdmin) && get && p == "/oidc/callback", class == classApp && get && p == "/.sso/callback": return l.signin } return nil } func (l *Limits) acquire(r *http.Request, g *gate, o Observer) bool { select { case g.slots <- struct{}{}: return true default: } if g.waiting.Add(1) > int64(l.cfg.MaxWaiters) { g.waiting.Add(-1) return false } o.Waited(l.gateName(g)) defer g.waiting.Add(-1) t := time.NewTimer(l.cfg.SlotWait) defer t.Stop() select { case g.slots <- struct{}{}: return true case <-t.C: case <-r.Context().Done(): } return false }