// 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 ( "crypto/rand" "encoding/base64" "encoding/hex" "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"` // EngineDir is the working directory the engine is launched IN. Empty // means the install root. // // It exists because nothing set it, so the engine inherited whatever // launched the app - and for an app started by double-clicking its // bundle that is "/", not anywhere useful. The symptom on macOS was // `python: No module named behavision` repeating forever: the dev engine // is `-m behavision`, which resolves against the working directory. The // same app started from a terminal in the repo worked, which is exactly // the shape of a bug that survives every test run by a developer. EngineDir string `json:"engine_dir,omitempty"` 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"` // InstallID distinguishes THIS installation from every other one claimed // to the same site. See MQTTClientID. InstallID string `json:"install_id,omitempty"` 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 // Minted on first load and written back, so an installation that predates // this field gets one without anybody doing anything. Best effort: a // read-only config still yields a working id for this run, it is simply // not the same one next time. if cfg.InstallID == "" { cfg.InstallID = newInstallID() _ = cfg.Save(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 } // MQTTClientID names this INSTALLATION, not this site. // // It was `behavision--`, which is the same string on every // computer claimed to one shop. MQTT requires client ids to be unique and a // broker enforces it by disconnecting the older session when a new one // arrives with the same id - so two machines on one site take turns kicking // each other off, forever. Measured on a second Mac claimed to a live shop: // // broker connected / broker connection lost: EOF / broker connected / ... // // The damage is not confined to the new machine. The shop's own till is the // other half of that loop, so somebody signing in on a laptop to look at the // product stops the shop delivering visits - and nothing at either end says // why, because from each side it reads as an unstable network. // // The site stays in the id because it is what a broker log is read by, and // the random half is short for the same reason. `CleanSession(true)` means // there is no session state for a changed id to strand. func (c Config) MQTTClientID() string { id := c.InstallID if id == "" { // A config that could not be written still has to produce a UNIQUE // id, or this falls straight back into the collision it exists to // prevent. Per-run is the right failure: the connection works and the // only cost is a new name in the broker's log after a restart. id = newInstallID() } return "behavision-" + c.ClientID + "-" + c.SiteID + "-" + id } func newInstallID() string { b := make([]byte, 4) if _, err := rand.Read(b); err != nil { return "x" } return hex.EncodeToString(b) }