Files
Behavision/agent/pkg/config/config.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

202 lines
6.5 KiB
Go

// Package config holds the agent's own settings: which tenant and site this
// install belongs to, how to reach the broker, and how to launch the engine.
//
// Kept separate from the engine's YAML on purpose. That file describes
// recognition — thresholds, cameras, gates — and is edited by whoever tunes a
// site. This one describes identity and connectivity, is written by the
// installer and the login flow, and holds a secret.
package config
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// protectedPrefix marks a value that went through DPAPI, so a config written
// on Windows is never mistaken for a plaintext dev one and vice versa.
const protectedPrefix = "dpapi:"
// Config is the agent's on-disk settings.
type Config struct {
// Tenant identity. The server keys everything on these.
ClientID string `json:"client_id"`
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
// Broker.
BrokerURL string `json:"broker_url"`
BrokerUsername string `json:"broker_username"`
BrokerPassword string `json:"broker_password"` // protected at rest
// Pins the broker's issuer. Empty uses the system roots, which is what a
// Let's Encrypt certificate needs; a private CA is pinned by path.
BrokerCAFile string `json:"broker_ca_file"`
// Engine process.
EngineExe string `json:"engine_exe"`
EngineArgs []string `json:"engine_args"`
APIBase string `json:"api_base"`
APIUser string `json:"api_user"`
APIPassword string `json:"api_password"` // protected at rest
// Session, so a shop PC that reboots overnight is not a login every
// morning. Protected at rest like every other secret here.
SessionToken string `json:"session_token"`
SessionRefresh string `json:"session_refresh"`
SessionEmail string `json:"session_email"`
// CloudBase is the server this site reports to; AgentToken is this PC's
// own credential there, issued once at enrolment.
//
// Deliberately not the same secret as BrokerPassword: they authenticate
// different things - one says this site may publish events, the other that
// it may ask the API for something - so rotating either must not break the
// other. Protected at rest like every other secret here.
CloudBase string `json:"cloud_base"`
AgentToken string `json:"agent_token"`
// Standalone marks a PC deliberately run on its own: cameras, recognition
// and the local gallery, with nothing reported to head office.
//
// It exists so that "not linked yet" and "not going to be linked" are
// different states. Without it every install was blocked on an enrolment
// code, so a shop with one PC and no head office could not add a camera at
// all - the software refused to do the thing it is for until a server it
// does not need had issued it a credential.
Standalone bool `json:"standalone"`
// Queue.
SpoolMax int `json:"spool_max"`
path string
}
// Defaults returns a config that runs a locally installed engine.
//
// EngineExe is relative to the install root - the directory holding this
// executable - and names the installed layout: the engine is a PyInstaller
// one-FOLDER build, so it brings its own DLLs and cannot simply sit beside the
// app. Windows filenames are case-insensitive too, so `Behavision.exe` (the
// app) and `behavision.exe` (the engine) could not share a directory even if
// it were tidy to.
func Defaults() Config {
exe := filepath.Join("engine", "behavision")
if runtime.GOOS == "windows" {
exe += ".exe"
}
return Config{
EngineExe: exe,
EngineArgs: []string{"run"},
APIBase: "http://127.0.0.1:8010",
SpoolMax: 50000,
}
}
// Load reads the config, decrypting secrets. A missing file is not an error:
// a fresh install has none until the operator logs in, and failing to start
// because of that would leave them with no UI to log in from.
func Load(path string) (Config, error) {
cfg := Defaults()
cfg.path = path
blob, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return cfg, err
}
if err := json.Unmarshal(blob, &cfg); err != nil {
return cfg, fmt.Errorf("config %s: %w", path, err)
}
cfg.path = path
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
plain, err := reveal(*field)
if err != nil {
// A secret that cannot be decrypted usually means the config was
// copied from another machine - DPAPI is machine-scoped. Blank it
// rather than failing: the operator can log in again, but they
// cannot fix a process that will not start.
*field = ""
continue
}
*field = plain
}
return cfg, nil
}
// Save writes the config atomically, protecting secrets on the way out.
func (c Config) Save(path string) error {
if path == "" {
path = c.path
}
if path == "" {
return fmt.Errorf("config: no path to save to")
}
out := c
out.path = ""
for _, field := range []*string{&out.BrokerPassword, &out.APIPassword,
&out.SessionToken, &out.SessionRefresh, &out.AgentToken} {
hidden, err := conceal(*field)
if err != nil {
return err
}
*field = hidden
}
blob, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
// Temp-then-rename: a crash mid-write must not leave a config that parses
// as valid but is half old and half new.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Configured reports whether this install has been claimed by a tenant yet.
// The UI shows a login screen until it has.
func (c Config) Configured() bool {
return c.ClientID != "" && c.SiteID != "" && c.BrokerURL != ""
}
// SecretsProtected is false on a dev machine, where secrets are stored as-is.
// Surfaced rather than hidden so nobody ships a build believing otherwise.
func SecretsProtected() bool { return protectionAvailable() }
func conceal(plain string) (string, error) {
if plain == "" || !protectionAvailable() {
return plain, nil
}
blob, err := protect([]byte(plain))
if err != nil {
return "", err
}
return protectedPrefix + base64.StdEncoding.EncodeToString(blob), nil
}
func reveal(stored string) (string, error) {
if !strings.HasPrefix(stored, protectedPrefix) {
return stored, nil
}
blob, err := base64.StdEncoding.DecodeString(
strings.TrimPrefix(stored, protectedPrefix))
if err != nil {
return "", err
}
plain, err := unprotect(blob)
if err != nil {
return "", err
}
return string(plain), nil
}