niko_trust/internal/server/ratelimit.go
Niko Marmeladkov 3bf13fa488 Public SDK packages, proxy-aware rate limits, service login recipe
- internal/{address,identity,protocol,tce,transport,verify} -> pkg/ so
  external Go projects can import the verified core; invariant tests
  updated for the new paths
- Config.TrustProxy: key rate limiting by X-Forwarded-For when the relay
  sits behind a reverse proxy (off by default, header never trusted
  otherwise)
- examples/service + examples/approve: complete passwordless login round
  trip (mint request -> wallet approves -> local verify), run live in CI
- docs/SERVICE-GUIDE.md: the integration recipe
2026-08-26 12:49:54 +03:00

91 lines
2.1 KiB
Go

package server
import (
"net"
"net/http"
"sync"
"time"
)
// ipLimiter is a fixed-window per-client rate limiter keyed by remote IP. It is
// deliberately minimal: no bursting, no warm-up, just a hard cap per window to
// keep an open relay from being flooded.
type ipLimiter struct {
mu sync.Mutex
limit int
window time.Duration
hits map[string]int
reset map[string]time.Time
}
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
return &ipLimiter{
limit: limit,
window: window,
hits: make(map[string]int),
reset: make(map[string]time.Time),
}
}
// allow reports whether the client may proceed, incrementing its windowed count.
func (l *ipLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
until, ok := l.reset[ip]
if !ok || now.After(until) {
l.hits[ip] = 0
l.reset[ip] = now.Add(l.window)
} else if now.Sub(until) > l.window*2 {
// Lazy cleanup of long-idle entries.
delete(l.hits, ip)
delete(l.reset, ip)
l.hits[ip] = 0
l.reset[ip] = now.Add(l.window)
}
if l.hits[ip] >= l.limit {
return false
}
l.hits[ip]++
return true
}
// clientIP returns the request's remote IP, stripping a port if present.
// With trustProxy set (Config.TrustProxy) the left-most X-Forwarded-For
// entry wins: the relay sits behind a reverse proxy and every socket would
// otherwise share the proxy's address. The header is attacker-controlled,
// which is exactly why trusting it is an explicit operator choice.
func clientIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := indexByte(xff, ','); i >= 0 {
xff = xff[:i]
}
return trimSpace(xff)
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func indexByte(s string, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}