aboutsummaryrefslogtreecommitdiff
path: root/internal/game/login_throttle.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/login_throttle.go')
-rw-r--r--internal/game/login_throttle.go114
1 files changed, 114 insertions, 0 deletions
diff --git a/internal/game/login_throttle.go b/internal/game/login_throttle.go
new file mode 100644
index 0000000..b819188
--- /dev/null
+++ b/internal/game/login_throttle.go
@@ -0,0 +1,114 @@
+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<<uint(shift))) * time.Millisecond
+ if d > 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)
+ }
+}