package game import ( "sync" "time" ) // Interactive login throttling. The admin HTTP /login route has its own // limiter; the telnet/TLS/websocket login is the primary credential surface // and needs one too. The policy is an escalating delay keyed on // (account, client IP): a hard lockout is deliberately avoided because // account existence is discoverable and a lockout would be a targeted // griefing tool. The delay runs on the connection goroutine, never the // owner, so a brute-force attempt stalls only its own connection. const ( // loginThrottleMaxDelay caps the escalation so a legitimate player who // fat-fingers a password a few times is inconvenienced, not locked out. loginThrottleMaxDelay = 4 * time.Second // loginThrottleMaxKeys bounds the tracked keys so a source that cycles // account names or addresses cannot grow server memory without bound. loginThrottleMaxKeys = 4096 // loginThrottleEntryTTL is how long an untouched entry is retained. loginThrottleEntryTTL = 10 * time.Minute ) type loginThrottleEntry struct { failures int lastSeen time.Time } type loginThrottle struct { mu sync.Mutex entries map[string]loginThrottleEntry } func newLoginThrottle() *loginThrottle { return &loginThrottle{entries: make(map[string]loginThrottleEntry)} } // ensureLoginThrottle returns the game's login throttle, creating it on first // use. The connection goroutine calls this; sync.Once makes the lazy init // race-free for Game literals that did not go through New. func (g *Game) ensureLoginThrottle() *loginThrottle { g.loginThrottleOnce.Do(func() { g.loginThrottle = newLoginThrottle() }) return g.loginThrottle } // loginThrottleKey combines the attempted account and the client address. // Either may be empty (synthetic connections). func loginThrottleKey(account, ip string) string { return account + "\x00" + ip } // delayFor records a failed attempt and returns the escalating delay the // caller should wait before responding: 0, 250ms, 500ms, 1s, 2s, 4s, 4s... func (t *loginThrottle) delayFor(key string) time.Duration { t.mu.Lock() defer t.mu.Unlock() now := time.Now() if len(t.entries) >= loginThrottleMaxKeys { t.evictLocked(now) } e := t.entries[key] e.failures++ e.lastSeen = now t.entries[key] = e if e.failures < 2 { // The first wrong password is free: a single typo must not cost a // legitimate player any time. return 0 } shift := e.failures - 2 if shift > 4 { // 250ms << 4 == loginThrottleMaxDelay shift = 4 } d := time.Duration(250*int64(1< loginThrottleMaxDelay { d = loginThrottleMaxDelay } return d } // clear forgets a key after a successful login. func (t *loginThrottle) clear(key string) { t.mu.Lock() delete(t.entries, key) t.mu.Unlock() } // evictLocked drops expired entries, then the oldest entry if still at the // cap. Caller holds mu. func (t *loginThrottle) evictLocked(now time.Time) { for k, e := range t.entries { if now.Sub(e.lastSeen) > loginThrottleEntryTTL { delete(t.entries, k) } } if len(t.entries) < loginThrottleMaxKeys { return } oldestKey := "" var oldest time.Time for k, e := range t.entries { if oldestKey == "" || e.lastSeen.Before(oldest) { oldestKey, oldest = k, e.lastSeen } } if oldestKey != "" { delete(t.entries, oldestKey) } }