Address production-readiness review: clearer errors, safer votes, and ops hardening.
Distinguish auth/lookup failures, make votes idempotent on visible questions, bound shutdown, page admin users, LRU throttle, trusted-proxy CIDRs, avatar cleanup, versioned migrations, and session cleanup logging.
This commit is contained in:
+118
-34
@@ -1,22 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultThrottleMaxKeys = 10_000
|
||||
|
||||
// throttle is a sliding-window rate limiter with expired-key eviction and a cap.
|
||||
// throttle is a sliding-window rate limiter with LRU eviction at capacity.
|
||||
type throttle struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
hits map[string]*throttleEntry
|
||||
lru *list.List // front = most recently used
|
||||
limit int
|
||||
window time.Duration
|
||||
maxKeys int
|
||||
rejects atomic.Uint64
|
||||
lastLog time.Time
|
||||
}
|
||||
|
||||
type throttleEntry struct {
|
||||
times []time.Time
|
||||
el *list.Element
|
||||
}
|
||||
|
||||
func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
|
||||
@@ -24,7 +35,8 @@ func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
|
||||
maxKeys = defaultThrottleMaxKeys
|
||||
}
|
||||
return &throttle{
|
||||
hits: map[string][]time.Time{},
|
||||
hits: map[string]*throttleEntry{},
|
||||
lru: list.New(),
|
||||
limit: limit,
|
||||
window: window,
|
||||
maxKeys: maxKeys,
|
||||
@@ -38,45 +50,68 @@ func (t *throttle) allow(key string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
now := time.Now()
|
||||
t.evictExpiredLocked(now)
|
||||
cutoff := now.Add(-t.window)
|
||||
|
||||
xs := pruneTimes(t.hits[key], now.Add(-t.window))
|
||||
if len(xs) >= t.limit {
|
||||
if len(xs) == 0 {
|
||||
delete(t.hits, key)
|
||||
} else {
|
||||
t.hits[key] = xs
|
||||
ent, ok := t.hits[key]
|
||||
if ok {
|
||||
ent.times = pruneTimes(ent.times, cutoff)
|
||||
if len(ent.times) == 0 {
|
||||
t.removeLocked(key, ent)
|
||||
ok = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
if _, exists := t.hits[key]; !exists && len(t.hits) >= t.maxKeys {
|
||||
t.evictExpiredLocked(now)
|
||||
if len(t.hits) >= t.maxKeys {
|
||||
if ok {
|
||||
if len(ent.times) >= t.limit {
|
||||
t.touchLocked(key, ent)
|
||||
return false
|
||||
}
|
||||
ent.times = append(ent.times, now)
|
||||
t.touchLocked(key, ent)
|
||||
return true
|
||||
}
|
||||
t.hits[key] = append(xs, now)
|
||||
|
||||
// New key: make room via LRU if needed.
|
||||
for len(t.hits) >= t.maxKeys {
|
||||
oldest := t.lru.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
oldKey := oldest.Value.(string)
|
||||
t.removeLocked(oldKey, t.hits[oldKey])
|
||||
n := t.rejects.Add(1)
|
||||
if time.Since(t.lastLog) > time.Minute {
|
||||
log.Printf("throttle: LRU evicted key at capacity=%d rejects=%d", t.maxKeys, n)
|
||||
t.lastLog = now
|
||||
}
|
||||
}
|
||||
ent = &throttleEntry{times: []time.Time{now}}
|
||||
ent.el = t.lru.PushFront(key)
|
||||
t.hits[key] = ent
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *throttle) touchLocked(key string, ent *throttleEntry) {
|
||||
if ent.el != nil {
|
||||
t.lru.MoveToFront(ent.el)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *throttle) removeLocked(key string, ent *throttleEntry) {
|
||||
if ent == nil {
|
||||
return
|
||||
}
|
||||
if ent.el != nil {
|
||||
t.lru.Remove(ent.el)
|
||||
}
|
||||
delete(t.hits, key)
|
||||
}
|
||||
|
||||
func (t *throttle) lenKeys() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.hits)
|
||||
}
|
||||
|
||||
func (t *throttle) evictExpiredLocked(now time.Time) {
|
||||
cutoff := now.Add(-t.window)
|
||||
for k, xs := range t.hits {
|
||||
xs = pruneTimes(xs, cutoff)
|
||||
if len(xs) == 0 {
|
||||
delete(t.hits, k)
|
||||
} else {
|
||||
t.hits[k] = xs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time {
|
||||
n := 0
|
||||
for _, ts := range xs {
|
||||
@@ -137,7 +172,16 @@ func (f *failureTracker) record(key string) {
|
||||
f.evictExpiredLocked(now)
|
||||
st := f.fails[key]
|
||||
if st.count == 0 && len(f.fails) >= f.maxKeys {
|
||||
return
|
||||
// Drop an arbitrary expired-or-oldest entry.
|
||||
for k, v := range f.fails {
|
||||
if now.Sub(v.last) > f.window/2 {
|
||||
delete(f.fails, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(f.fails) >= f.maxKeys {
|
||||
return
|
||||
}
|
||||
}
|
||||
st.count++
|
||||
st.last = now
|
||||
@@ -184,18 +228,58 @@ func progressiveDelay(failCount int) time.Duration {
|
||||
}
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.cfg.TrustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer := net.ParseIP(host)
|
||||
if peer == nil || !ipInNets(peer, s.cfg.TrustedProxies) {
|
||||
return host
|
||||
}
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff == "" {
|
||||
return host
|
||||
}
|
||||
parts := strings.Split(xff, ",")
|
||||
// Walk right-to-left; skip trusted hops; first untrusted is the client.
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
p := net.ParseIP(strings.TrimSpace(parts[i]))
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if !ipInNets(p, s.cfg.TrustedProxies) {
|
||||
return p.String()
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func ipInNets(ip net.IP, nets []*net.IPNet) bool {
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCIDRs(raw string) []*net.IPNet {
|
||||
var out []*net.IPNet
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
_, n, err := net.ParseCIDR(part)
|
||||
if err != nil {
|
||||
log.Printf("trusted proxy CIDR ignored %q: %v", part, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func authTooMany(w http.ResponseWriter) {
|
||||
http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user