Files
Behavision/server/internal/api/throttle.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

115 lines
3.2 KiB
Go

package api
import (
"net"
"net/http"
"sync"
"time"
)
// Throttle limits failed sign-in attempts.
//
// bcrypt at cost 12 already makes each guess cost ~250 ms, but that is a
// per-attempt cost, not a per-attacker one: a hundred parallel guesses is a
// hundred parallel bcrypts on a 2 vCPU box, which is both a brute force and a
// denial of service on the machine every shop depends on.
//
// Only FAILURES count. A busy shop where staff sign in all morning is not an
// attack, and a limiter that cannot tell the difference gets switched off.
//
// In memory, not in Postgres: this is one process, and a lockout table would
// add a write to the very path an attacker is trying to flood.
type Throttle struct {
// Max failures within Window before refusing.
Max int
Window time.Duration
mu sync.Mutex
hits map[string][]time.Time
now func() time.Time
}
func NewThrottle(max int, window time.Duration) *Throttle {
return &Throttle{
Max: max, Window: window,
hits: make(map[string][]time.Time),
now: func() time.Time { return time.Now() },
}
}
// Allow reports whether a key may attempt again, without recording anything.
func (t *Throttle) Allow(key string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.live(key)) < t.Max
}
// Fail records a failed attempt.
func (t *Throttle) Fail(key string) {
t.mu.Lock()
defer t.mu.Unlock()
t.hits[key] = append(t.live(key), t.now())
}
// Reset clears a key after a success, so one forgotten password in the morning
// does not lock somebody out at lunchtime.
func (t *Throttle) Reset(key string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.hits, key)
}
// live returns the still-relevant attempts and prunes the rest. Pruning on read
// is what keeps the map from growing forever without a sweeper goroutine —
// every key that stops being touched stops existing the next time it is.
func (t *Throttle) live(key string) []time.Time {
cutoff := t.now().Add(-t.Window)
kept := t.hits[key][:0]
for _, at := range t.hits[key] {
if at.After(cutoff) {
kept = append(kept, at)
}
}
if len(kept) == 0 {
delete(t.hits, key)
return nil
}
t.hits[key] = kept
return kept
}
// Sweep drops keys with nothing live left. Called on a timer so an attacker
// spraying a million distinct addresses cannot grow the map without bound
// between requests for those same addresses.
func (t *Throttle) Sweep() {
t.mu.Lock()
defer t.mu.Unlock()
for k := range t.hits {
t.live(k)
}
}
// clientIP prefers the proxy's forwarded address because Traefik terminates
// TLS in front of this, so RemoteAddr is always the proxy.
//
// Trusting X-Forwarded-For is only safe BECAUSE nothing reaches this port
// except through that proxy; exposed directly, a client sets the header itself
// and defeats the limiter. If the listener ever becomes reachable, this must
// change with it.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// Left-most is the original client; the rest are proxies.
for i := 0; i < len(fwd); i++ {
if fwd[i] == ',' {
return trim(fwd[:i])
}
}
return trim(fwd)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}