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
115 lines
3.2 KiB
Go
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
|
|
}
|