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
202 lines
6.5 KiB
Go
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
|
|
}
|