package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// rateLimiter is a small in-memory fixed-window rate limiter, used both to
// blunt brute-force/credential-stuffing against the web login (counting
// failed attempts per key) and to bound how often the anonymous git-HTTP
// clone endpoints can be hit per source IP (counting every request). It is
// intentionally process-local: the web UI runs as a single replica (see
// deploy/k8s/deployment.yaml), so there's no shared-state requirement, and
// a restart clearing the counters is an acceptable, fail-open-on-restart
// trade-off.
type rateLimiter struct {
mu sync.Mutex
hits map[string]*hitWindow
max int
window time.Duration
}
type hitWindow struct {
count int
reset time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
l := &rateLimiter{hits: make(map[string]*hitWindow), max: max, window: window}
go l.gcLoop()
return l
}
// allowed reports whether an attempt for key may proceed right now. It does
// not record anything — call record separately, so a caller that only wants
// to count failures (e.g. login) can skip it on success.
func (l *rateLimiter) allowed(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
w := l.hits[key]
if w == nil || time.Now().After(w.reset) {
return true
}
return w.count < l.max
}
func (l *rateLimiter) record(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if w := l.hits[key]; w != nil && !now.After(w.reset) {
w.count++
return
}
l.hits[key] = &hitWindow{count: 1, reset: now.Add(l.window)}
}
// reset clears the counter for key, e.g. after a successful login so the
// window doesn't linger against a user who has proven who they are.
func (l *rateLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
func (l *rateLimiter) gcLoop() {
for range time.Tick(l.window) {
l.mu.Lock()
now := time.Now()
for k, w := range l.hits {
if now.After(w.reset) {
delete(l.hits, k)
}
}
l.mu.Unlock()
}
}
// clientIP extracts the caller's IP for rate-limiting. Behind the ingress
// (Traefik) the real client address arrives in X-Real-IP or as the last hop
// of X-Forwarded-For — the last entry is the one our own trusted proxy
// appended, so it can't be spoofed by the client the way the left-most
// (client-supplied) entry can. Falls back to the transport RemoteAddr when
// no proxy header is present (direct connection in dev).
func clientIP(r *http.Request) string {
if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
return xr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if last := strings.TrimSpace(parts[len(parts)-1]); last != "" {
return last
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}