mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
Daemon naming convention: standalone binary is graphd. The library package (engine, indexer, storage, resolvers) keeps the import path github.com/luxfi/graph for use by the unified luxfi/explorer binary and external consumers. - cmd/graphd/ renamed (was cmd/graph/) - Dockerfile builds /usr/local/bin/graphd - Makefile bin/graphd target - .github/workflows/docker.yml multi-arch + hanzo ARC runners - .github/workflows/ci.yml hanzo runner - .github/actionlint.yaml register custom runner labels
113 lines
2.4 KiB
Go
113 lines
2.4 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// rateLimiter implements a per-IP token bucket rate limiter.
|
|
// Stdlib only, no external dependencies.
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string]*bucket
|
|
rate float64 // tokens per second
|
|
burst int // max tokens
|
|
}
|
|
|
|
type bucket struct {
|
|
tokens float64
|
|
lastSeen time.Time
|
|
}
|
|
|
|
func newRateLimiter(rate float64, burst int) *rateLimiter {
|
|
rl := &rateLimiter{
|
|
buckets: make(map[string]*bucket),
|
|
rate: rate,
|
|
burst: burst,
|
|
}
|
|
go rl.cleanup()
|
|
return rl
|
|
}
|
|
|
|
// allow checks whether the given IP has tokens remaining.
|
|
func (rl *rateLimiter) allow(ip string) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
b, ok := rl.buckets[ip]
|
|
now := time.Now()
|
|
if !ok {
|
|
rl.buckets[ip] = &bucket{tokens: float64(rl.burst) - 1, lastSeen: now}
|
|
return true
|
|
}
|
|
|
|
elapsed := now.Sub(b.lastSeen).Seconds()
|
|
b.tokens = math.Min(float64(rl.burst), b.tokens+elapsed*rl.rate)
|
|
b.lastSeen = now
|
|
|
|
if b.tokens < 1 {
|
|
return false
|
|
}
|
|
b.tokens--
|
|
return true
|
|
}
|
|
|
|
// cleanup evicts stale entries every 60 seconds.
|
|
func (rl *rateLimiter) cleanup() {
|
|
for {
|
|
time.Sleep(60 * time.Second)
|
|
rl.mu.Lock()
|
|
cutoff := time.Now().Add(-3 * time.Minute)
|
|
for ip, b := range rl.buckets {
|
|
if b.lastSeen.Before(cutoff) {
|
|
delete(rl.buckets, ip)
|
|
}
|
|
}
|
|
rl.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// rateLimitMiddleware wraps a handler with per-IP rate limiting.
|
|
// Exempts /health and /ready paths.
|
|
func rateLimitMiddleware(rl *rateLimiter, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Exempt health/readiness probes
|
|
if r.URL.Path == "/health" || r.URL.Path == "/ready" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
ip := clientIP(r)
|
|
if !rl.allow(ip) {
|
|
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// clientIP extracts the client IP from the request.
|
|
// Prefers X-Forwarded-For (first entry) since we sit behind hanzoai/ingress.
|
|
func clientIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
// First IP in the chain is the original client
|
|
for i := 0; i < len(xff); i++ {
|
|
if xff[i] == ',' {
|
|
return xff[:i]
|
|
}
|
|
}
|
|
return xff
|
|
}
|
|
if xff := r.Header.Get("X-Real-Ip"); xff != "" {
|
|
return xff
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|