mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 06:39:08 +02:00
170 lines
4.5 KiB
Go
170 lines
4.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"net/netip"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
|
)
|
|
|
|
const (
|
|
credentialFailureLimit = 5
|
|
credentialFailureWindow = 5 * time.Minute
|
|
credentialBlockDuration = 15 * time.Minute
|
|
credentialCheckInterval = 6 * time.Second
|
|
credentialCheckBurst = 5
|
|
credentialMaxSources = 16384
|
|
credentialMaxServices = 4096
|
|
credentialCleanupInterval = time.Minute
|
|
)
|
|
|
|
type credentialServiceKey struct {
|
|
accountID types.AccountID
|
|
serviceID types.ServiceID
|
|
}
|
|
|
|
type credentialSourceKey struct {
|
|
service credentialServiceKey
|
|
ip netip.Addr
|
|
}
|
|
|
|
type credentialSource struct {
|
|
failures []time.Time
|
|
pending int
|
|
expiresAt time.Time
|
|
blockedUntil time.Time
|
|
}
|
|
|
|
type credentialService struct {
|
|
limiter *rate.Limiter
|
|
lastUsed time.Time
|
|
}
|
|
|
|
type credentialOutcome string
|
|
|
|
const (
|
|
credentialUnavailable credentialOutcome = "unavailable"
|
|
credentialRejected credentialOutcome = "rejected"
|
|
credentialAccepted credentialOutcome = "accepted"
|
|
)
|
|
|
|
// State is local to this proxy process. Active blocks are never evicted to
|
|
// make room for a new source; exhausting capacity denies new checks.
|
|
type credentialLimiter struct {
|
|
mu sync.Mutex
|
|
now func() time.Time
|
|
sources map[credentialSourceKey]*credentialSource
|
|
services map[credentialServiceKey]*credentialService
|
|
nextCleanup time.Time
|
|
}
|
|
|
|
func newCredentialLimiter() *credentialLimiter {
|
|
return &credentialLimiter{
|
|
now: time.Now,
|
|
sources: make(map[credentialSourceKey]*credentialSource),
|
|
services: make(map[credentialServiceKey]*credentialService),
|
|
}
|
|
}
|
|
|
|
func (l *credentialLimiter) begin(key credentialSourceKey) (*credentialSource, time.Duration) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := l.now()
|
|
l.cleanup(now)
|
|
source := l.sources[key]
|
|
if source != nil {
|
|
if now.Before(source.blockedUntil) {
|
|
return nil, source.blockedUntil.Sub(now)
|
|
}
|
|
if source.pending == 0 && !now.Before(source.expiresAt) {
|
|
*source = credentialSource{}
|
|
}
|
|
source.expireFailures(now)
|
|
// Reserve the failure budget before verification so concurrent guesses
|
|
// cannot all pass a check against the same completed failure count.
|
|
if len(source.failures)+source.pending >= credentialFailureLimit {
|
|
return nil, time.Second
|
|
}
|
|
} else if len(l.sources) >= credentialMaxSources {
|
|
return nil, credentialCleanupInterval
|
|
}
|
|
if retry := l.allowService(key.service, now); retry > 0 {
|
|
return nil, retry
|
|
}
|
|
if source == nil {
|
|
source = &credentialSource{}
|
|
l.sources[key] = source
|
|
}
|
|
if source.expiresAt.IsZero() {
|
|
source.expiresAt = now.Add(credentialFailureWindow)
|
|
}
|
|
source.pending++
|
|
return source, 0
|
|
}
|
|
|
|
func (l *credentialLimiter) allowService(key credentialServiceKey, now time.Time) time.Duration {
|
|
service := l.services[key]
|
|
if service == nil {
|
|
if len(l.services) >= credentialMaxServices {
|
|
return credentialCleanupInterval
|
|
}
|
|
service = &credentialService{limiter: rate.NewLimiter(rate.Every(credentialCheckInterval), credentialCheckBurst)}
|
|
l.services[key] = service
|
|
}
|
|
service.lastUsed = now
|
|
if service.limiter.AllowN(now, 1) {
|
|
return 0
|
|
}
|
|
return max(time.Nanosecond, time.Duration((1-service.limiter.TokensAt(now))*float64(credentialCheckInterval)))
|
|
}
|
|
|
|
func (l *credentialLimiter) finish(source *credentialSource, outcome credentialOutcome) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
source.pending--
|
|
now := l.now()
|
|
source.expireFailures(now)
|
|
switch outcome {
|
|
case credentialRejected:
|
|
source.failures = append(source.failures, now)
|
|
source.expiresAt = now.Add(credentialFailureWindow)
|
|
if len(source.failures) >= credentialFailureLimit && source.blockedUntil.IsZero() {
|
|
source.blockedUntil = now.Add(credentialBlockDuration)
|
|
source.expiresAt = source.blockedUntil
|
|
}
|
|
case credentialAccepted:
|
|
if !now.Before(source.blockedUntil) {
|
|
source.failures = nil
|
|
source.expiresAt = now.Add(credentialFailureWindow)
|
|
}
|
|
case credentialUnavailable:
|
|
// Transport failures consume the service budget, but are not bad guesses.
|
|
}
|
|
}
|
|
|
|
func (s *credentialSource) expireFailures(now time.Time) {
|
|
for len(s.failures) > 0 && !now.Before(s.failures[0].Add(credentialFailureWindow)) {
|
|
s.failures = s.failures[1:]
|
|
}
|
|
}
|
|
|
|
func (l *credentialLimiter) cleanup(now time.Time) {
|
|
if now.Before(l.nextCleanup) {
|
|
return
|
|
}
|
|
l.nextCleanup = now.Add(credentialCleanupInterval)
|
|
for key, source := range l.sources {
|
|
if source.pending == 0 && !now.Before(source.expiresAt) {
|
|
delete(l.sources, key)
|
|
}
|
|
}
|
|
for key, service := range l.services {
|
|
if now.Sub(service.lastUsed) >= credentialBlockDuration {
|
|
delete(l.services, key)
|
|
}
|
|
}
|
|
}
|