Nothing ever set the child's working directory, so it took the parent's - and an app started by double-clicking its bundle is handed "/", not anywhere useful. On macOS the symptom was `python: No module named behavision` repeating forever, because the dev engine is invoked as `-m behavision` and that resolves against the working directory. The same app launched from a terminal inside the repo worked perfectly, which is exactly the shape of a bug that survives every test a developer runs. It only appeared when the app was started the way a user starts one. Config.EngineDir, empty meaning the install root, set by both launchers - the desktop app and the headless agent, which had identical code and the identical omission. It matters beyond this case: the shipped Windows engine is a one-folder PyInstaller build whose relative paths should resolve beside itself rather than beside Explorer's idea of a current directory. Verified by double-clicking the bundle with nothing in the environment: engine up on 8010 (401, gated), w600k_r50 on CoreML, gallery 5/5 embeddings usable and none stranded, both office cameras connected and streaming, and head office reporting cameras 2/2 one heartbeat later. Two things that showed up while proving it, both the product being honest rather than faults: - The camera at .121 was genuinely unreachable for several minutes, and last_error said so in words an installer can act on - "cannot reach 192.168.1.121:554 - No route" - rather than `connected: false`. That field was added yesterday for precisely this. - Head office briefly showed cameras 0/1 against a local 2/2. That is a 60-second heartbeat, not a disagreement; the next one read 2/2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
213 lines
7.1 KiB
Go
213 lines
7.1 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"`
|
|
// 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"`
|
|
|
|
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
|
|
}
|