package quotas import ( "context" "encoding/hex" "fmt" "math" "strconv" "sync" "time" "github.com/armon/go-metrics" log "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-uuid" "github.com/hashicorp/vault/helper/metricsutil" "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/sethvargo/go-limiter" "github.com/sethvargo/go-limiter/httplimit" "github.com/sethvargo/go-limiter/memorystore" ) const ( // DefaultRateLimitPurgeInterval defines the default purge interval used by a // RateLimitQuota to remove stale client rate limiters. DefaultRateLimitPurgeInterval = time.Minute // DefaultRateLimitStaleAge defines the default stale age of a client limiter. DefaultRateLimitStaleAge = 3 * time.Minute // EnvVaultEnableRateLimitAuditLogging is used to enable audit logging of // requests that get rejected due to rate limit quota violations. EnvVaultEnableRateLimitAuditLogging = "VAULT_ENABLE_RATE_LIMIT_AUDIT_LOGGING" ) // Ensure that RateLimitQuota implements the Quota interface var _ Quota = (*RateLimitQuota)(nil) // RateLimitQuota represents the quota rule properties that is used to limit the // number of requests in a given interval for a namespace or mount. type RateLimitQuota struct { // ID is the identifier of the quota ID string `json:"id"` // Type of quota this represents Type Type `json:"type"` // Name of the quota rule Name string `json:"name"` // NamespacePath is the path of the namespace to which this quota is // applicable. NamespacePath string `json:"namespace_path"` // MountPath is the path of the mount to which this quota is applicable MountPath string `json:"mount_path"` // Role is the role on an auth mount to apply the quota to upon /login requests // Not applicable for use with path suffixes Role string `json:"role"` // PathSuffix is the path suffix to which this quota is applicable PathSuffix string `json:"path_suffix"` // Rate defines the number of requests allowed per Interval. Rate float64 `json:"rate"` // Interval defines the duration to which rate limiting is applied. Interval time.Duration `json:"interval"` // BlockInterval defines the duration during which all requests are blocked for // a given client. This interval is enforced only if non-zero and a client // reaches the rate limit. BlockInterval time.Duration `json:"block_interval"` lock *sync.RWMutex store limiter.Store logger log.Logger metricSink *metricsutil.ClusterMetricSink purgeInterval time.Duration staleAge time.Duration blockedClients sync.Map purgeBlocked bool closePurgeBlockedCh chan struct{} } // NewRateLimitQuota creates a quota checker for imposing limits on the number // of requests in a given interval. An interval time duration of zero may be // provided, which will default to 1s when initialized. An optional block // duration may be provided, where if set, when a client reaches the rate limit, // subsequent requests will fail until the block duration has passed. func NewRateLimitQuota(name, nsPath, mountPath, pathSuffix, role string, rate float64, interval, block time.Duration) *RateLimitQuota { id, err := uuid.GenerateUUID() if err != nil { // Fall back to generating with a hash of the name, later in initialize id = "" } return &RateLimitQuota{ Name: name, ID: id, Type: TypeRateLimit, NamespacePath: nsPath, MountPath: mountPath, Role: role, PathSuffix: pathSuffix, Rate: rate, Interval: interval, BlockInterval: block, purgeInterval: DefaultRateLimitPurgeInterval, staleAge: DefaultRateLimitStaleAge, } } func (q *RateLimitQuota) Clone() Quota { rlq := &RateLimitQuota{ ID: q.ID, Name: q.Name, MountPath: q.MountPath, Role: q.Role, Type: q.Type, NamespacePath: q.NamespacePath, PathSuffix: q.PathSuffix, BlockInterval: q.BlockInterval, Rate: q.Rate, Interval: q.Interval, } return rlq } // initialize ensures the namespace and max requests are initialized, sets the ID // if it's currently empty, sets the purge interval and stale age to default // values, and finally starts the client purge go routine if it has been started // already. Note, initialize will reset the internal rateQuotas mapping. func (rlq *RateLimitQuota) initialize(logger log.Logger, ms *metricsutil.ClusterMetricSink) error { if rlq.lock == nil { rlq.lock = new(sync.RWMutex) } rlq.lock.Lock() defer rlq.lock.Unlock() // Memdb requires a non-empty value for indexing if rlq.NamespacePath == "" { rlq.NamespacePath = "root" } if rlq.Interval == 0 { rlq.Interval = time.Second } if rlq.Rate <= 0 { return fmt.Errorf("invalid rate: %v", rlq.Rate) } if rlq.BlockInterval < 0 { return fmt.Errorf("invalid block interval: %v", rlq.BlockInterval) } if logger != nil { rlq.logger = logger } if rlq.metricSink == nil { rlq.metricSink = ms } if rlq.ID == "" { // A lease which was created with a blank ID may have been persisted // to storage already (this is the case up to release 1.6.2.) // So, performance standby nodes could call initialize() on their copy // of the lease; for consistency we need to generate an ID that is // deterministic. That ensures later invalidation removes the original // lease from the memdb, instead of creating a duplicate. rlq.ID = hex.EncodeToString(cryptoutil.Blake2b256Hash(rlq.Name)) } // Set purgeInterval if coming from a previous version where purgeInterval was // not defined. if rlq.purgeInterval == 0 { rlq.purgeInterval = DefaultRateLimitPurgeInterval } // Set staleAge if coming from a previous version where staleAge was not defined. if rlq.staleAge == 0 { rlq.staleAge = DefaultRateLimitStaleAge } rlStore, err := memorystore.New(&memorystore.Config{ Tokens: uint64(math.Round(rlq.Rate)), // allow 'rlq.Rate' number of requests per 'Interval' Interval: rlq.Interval, // time interval in which to enforce rate limiting SweepInterval: rlq.purgeInterval, // how often stale clients are removed SweepMinTTL: rlq.staleAge, // how long since the last request a client is considered stale }) if err != nil { return err } rlq.store = rlStore rlq.blockedClients = sync.Map{} if rlq.BlockInterval > 0 && !rlq.purgeBlocked { rlq.purgeBlocked = true rlq.closePurgeBlockedCh = make(chan struct{}) go rlq.purgeBlockedClients() } return nil } // purgeBlockedClients performs a blocking process where every purgeInterval // duration, we look at all blocked clients to potentially remove from the blocked // clients map. // // A blocked client will only be removed if the current time minus the time the // client was blocked at is greater than or equal to the block duration. The loop // will continue to run indefinitely until a value is sent on the closePurgeBlockedCh // in which we stop the ticker and return. func (rlq *RateLimitQuota) purgeBlockedClients() { rlq.lock.RLock() ticker := time.NewTicker(rlq.purgeInterval) rlq.lock.RUnlock() for { select { case t := <-ticker.C: rlq.blockedClients.Range(func(key, value interface{}) bool { blockedAt := value.(time.Time) if t.Sub(blockedAt) >= rlq.BlockInterval { rlq.blockedClients.Delete(key) } return true }) case <-rlq.closePurgeBlockedCh: ticker.Stop() rlq.lock.Lock() rlq.purgeBlocked = false rlq.lock.Unlock() return } } } func (rlq *RateLimitQuota) getPurgeBlocked() bool { rlq.lock.RLock() defer rlq.lock.RUnlock() return rlq.purgeBlocked } func (rlq *RateLimitQuota) numBlockedClients() int { rlq.lock.RLock() defer rlq.lock.RUnlock() size := 0 rlq.blockedClients.Range(func(_, _ interface{}) bool { size++ return true }) return size } // quotaID returns the identifier of the quota rule func (rlq *RateLimitQuota) quotaID() string { return rlq.ID } // QuotaName returns the name of the quota rule func (rlq *RateLimitQuota) QuotaName() string { return rlq.Name } // allow decides if the request is allowed by the quota. An error will be // returned if the request ID or address is empty. If the path is exempt, the // quota will not be evaluated. Otherwise, the client rate limiter is retrieved // by address and the rate limit quota is checked against that limiter. func (rlq *RateLimitQuota) allow(ctx context.Context, req *Request) (Response, error) { resp := Response{ Headers: make(map[string]string), } if req.ClientAddress == "" { return resp, fmt.Errorf("missing request client address in quota request") } var retryAfter string defer func() { if !resp.Allowed { resp.Headers[httplimit.HeaderRetryAfter] = retryAfter rlq.metricSink.IncrCounterWithLabels([]string{"quota", "rate_limit", "violation"}, 1, []metrics.Label{{"name", rlq.Name}}) } }() // Check if the client is currently blocked and if so, deny the request. Note, // we cannot simply rely on the presence of the client in the map as the timing // of purging blocked clients may not yield a false negative. In other words, // a client may no longer be considered blocked whereas the purging interval // has yet to run. if v, ok := rlq.blockedClients.Load(req.ClientAddress); ok { blockedAt := v.(time.Time) if time.Since(blockedAt) >= rlq.BlockInterval { // allow the request and remove the blocked client rlq.blockedClients.Delete(req.ClientAddress) } else { // deny the request and return early resp.Allowed = false retryAfter = strconv.Itoa(int(time.Until(blockedAt.Add(rlq.BlockInterval)).Seconds())) return resp, nil } } limit, remaining, reset, allow, err := rlq.store.Take(ctx, req.ClientAddress) if err != nil { return resp, err } resp.Allowed = allow resp.Headers[httplimit.HeaderRateLimitLimit] = strconv.FormatUint(limit, 10) resp.Headers[httplimit.HeaderRateLimitRemaining] = strconv.FormatUint(remaining, 10) resp.Headers[httplimit.HeaderRateLimitReset] = strconv.Itoa(int(time.Until(time.Unix(0, int64(reset))).Seconds())) retryAfter = resp.Headers[httplimit.HeaderRateLimitReset] // If the request is not allowed (i.e. rate limit threshold reached) and blocking // is enabled, we add the client to the set of blocked clients. if !resp.Allowed && rlq.purgeBlocked { blockedAt := time.Now() retryAfter = strconv.Itoa(int(time.Until(blockedAt.Add(rlq.BlockInterval)).Seconds())) rlq.blockedClients.Store(req.ClientAddress, blockedAt) } return resp, nil } // close stops the current running client purge loop. // It should be called with the write lock held. func (rlq *RateLimitQuota) close(ctx context.Context) error { if rlq.purgeBlocked { close(rlq.closePurgeBlockedCh) } if rlq.store != nil { return rlq.store.Close(ctx) } return nil } func (rlq *RateLimitQuota) handleRemount(mountpath, nspath string) { rlq.MountPath = mountpath rlq.NamespacePath = nspath }